Skip to content

About

A headless Chrome sandbox for AI agents: sessions over REST, Puppeteer and Playwright over one secure WebSocket. Resets on every restart. Starter template for velixir.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Steel Browser

Deploy on velixir

Give your AI agents and scripts a real Chrome to drive. Steel Browser is an open-source browser API: start sessions over REST, connect Puppeteer or Playwright over a WebSocket, turn any page into markdown, a screenshot or a PDF with one call, and watch the browser live while it works.

A sandbox, on purpose. Browser sessions, cookies, local storage, downloads and profiles are wiped whenever the app restarts or redeploys, so every boot starts with a clean browser. That is what you want for agents, scraping jobs, testing and disposable browsing. It is not the place to stay signed in to websites for weeks.

Deploy it on velixir.

What you get

  • A real browser on a private URL. Chrome 154 (Google's Chrome for Testing build) at https://your-app.velixir.run, answering only requests that carry your API key.
  • Steel's API. Sessions, scraping to markdown or cleaned HTML, screenshots and PDFs, and file transfer, with an interactive reference at /documentation.
  • Puppeteer and Playwright with nothing to set up. The browser's DevTools Protocol WebSocket is on the same URL, over TLS.
  • A live view. Steel's web UI shows the browser as it works, with the session's details, console logs and Chrome DevTools, and lets you take over the mouse and keyboard.
  • Nothing to look after. All configuration is environment variables, applied on every boot, so a restart comes back ready to use. Only the browser's state resets.
  • Locked by default. Self-hosted Steel has no login, and an open browser on the internet is a free proxy for anyone who finds it. This template checks your key on every request and every WebSocket before anything reaches Steel or Chrome.

Your API key

After the first deploy, open the app's Logs and look for the box that says Steel Browser API key for this boot. That key works straight away.

It changes on every restart and redeploy, because it was generated at startup. To keep one key:

  1. Generate one: openssl rand -hex 32, or 32 or more random characters from a password manager.
  2. On the app's Environment tab, set STEEL_API_KEY to it and tick Treat as secret. Saving redeploys the app, and from then on the key stays the same.

Why not generate a key once and keep it, as other velixir templates do with their secrets? They keep it in Postgres. A sandbox has nowhere to keep one: no database, and a disk that is wiped on every boot. The environment is the one place a key survives a restart. Keys shorter than 16 characters are ignored (the log says so) because a short key on a public URL can be guessed.

Send the key whichever way your client supports:

How Example Suits
steel-api-key header steel-api-key: <key> the Steel SDKs (steelAPIKey, steel_api_key)
Authorization header Authorization: Bearer <key> curl and other HTTP tools
apiKey on the URL wss://your-app.velixir.run/?apiKey=<key> WebSocket clients: Puppeteer, Playwright, agents

In a browser, open https://your-app.velixir.run and sign in with the key. That sets a cookie for the web UI, valid for 7 days or until the key changes.

Connecting

In the examples, replace your-app with your app's name and keep the key in the STEEL_API_KEY environment variable on your side.

Puppeteer

import puppeteer from 'puppeteer-core';

const browser = await puppeteer.connect({
  browserWSEndpoint: `wss://your-app.velixir.run/?apiKey=${process.env.STEEL_API_KEY}`,
});
const page = await browser.newPage();
await page.goto('https://example.com');
await page.screenshot({ path: 'example.png' });
await browser.disconnect();

disconnect() leaves the browser running for the next client. close() shuts Chrome down, and Steel starts a fresh one.

Playwright

import { chromium } from 'playwright';

const browser = await chromium.connectOverCDP(
  `wss://your-app.velixir.run/?apiKey=${process.env.STEEL_API_KEY}`,
);
const context = browser.contexts()[0];
const page = await context.newPage();
await page.goto('https://example.com');
console.log(await page.title());
await browser.close(); // over CDP this disconnects; the browser keeps running

To keep the key out of the URL, pass it as a header instead: puppeteer.connect({ browserWSEndpoint: 'wss://your-app.velixir.run/', headers: { 'steel-api-key': key } }), or chromium.connectOverCDP('wss://your-app.velixir.run/', { headers: { 'steel-api-key': key } }).

The Steel SDK

The official SDKs work for sessions and scraping: point them at your app. (For screenshots and PDFs, see the note under REST below.)

import Steel from 'steel-sdk';
import puppeteer from 'puppeteer-core';

const client = new Steel({
  baseURL: 'https://your-app.velixir.run',
  steelAPIKey: process.env.STEEL_API_KEY,
});

const session = await client.sessions.create();
// session.websocketUrl is wss://your-app.velixir.run/ (add the key to connect)
const browser = await puppeteer.connect({
  browserWSEndpoint: `${session.websocketUrl}?apiKey=${process.env.STEEL_API_KEY}`,
});
// ... drive the page ...
await browser.disconnect();
await client.sessions.release(session.id);

const scraped = await client.scrape({ url: 'https://example.com', format: ['markdown'] });
console.log(scraped.content.markdown);

In Python: Steel(base_url="https://your-app.velixir.run", steel_api_key=os.environ["STEEL_API_KEY"]).

The URLs a session comes back with (websocketUrl, debugUrl, sessionViewerUrl, debuggerUrl) all point at your app's public address.

REST, with curl

# A page as markdown
curl https://your-app.velixir.run/v1/scrape \
  -H "steel-api-key: $STEEL_API_KEY" -H "content-type: application/json" \
  -d '{"url": "https://example.com", "format": ["markdown"]}'

# A full-page screenshot (JPEG) and a PDF
curl https://your-app.velixir.run/v1/screenshot -o page.jpg \
  -H "steel-api-key: $STEEL_API_KEY" -H "content-type: application/json" \
  -d '{"url": "https://example.com", "fullPage": true}'
curl https://your-app.velixir.run/v1/pdf -o page.pdf \
  -H "steel-api-key: $STEEL_API_KEY" -H "content-type: application/json" \
  -d '{"url": "https://example.com"}'

Call /v1/screenshot and /v1/pdf like this rather than through the SDKs' screenshot() and pdf() helpers. The helpers expect Steel's cloud service, which answers with a link to a stored file; a self-hosted Steel answers with the file itself, and the helpers garble it.

Use it with an AI agent

These were each tested against this template, through its key check.

Playwright MCP (Claude Code, Cursor, VS Code and other MCP clients)

Playwright MCP gives any MCP client browser tools (navigate, click, type, read the page, screenshot). Point it at your app instead of a local Chrome:

claude mcp add steel-browser -- npx @playwright/mcp@latest \
  --cdp-endpoint "wss://your-app.velixir.run/?apiKey=YOUR_KEY"

For clients configured with JSON (Cursor's mcp.json, for example):

{
  "mcpServers": {
    "steel-browser": {
      "command": "npx",
      "args": ["@playwright/mcp@latest", "--cdp-endpoint", "wss://your-app.velixir.run/?apiKey=YOUR_KEY"]
    }
  }
}

--cdp-endpoint wss://your-app.velixir.run/ --cdp-header "steel-api-key: YOUR_KEY" works too, if you would rather keep the key out of the URL.

browser-use (Python)

import asyncio, os
from browser_use import Agent, Browser

browser = Browser(cdp_url=f"wss://your-app.velixir.run/?apiKey={os.environ['STEEL_API_KEY']}")
agent = Agent(task="Find the top story on Hacker News", llm=your_llm, browser=browser)
asyncio.run(agent.run())

llm is any chat model browser-use supports (see its docs). Browser(cdp_url="wss://your-app.velixir.run/", headers={"steel-api-key": key}) works as well.

Steel's own MCP server

Steel's MCP server only sends an API key to Steel's cloud service, never to a self-hosted address, so it cannot get past this template's key check. Use Playwright MCP above instead.

The web UI

Open https://your-app.velixir.run and sign in with the key. The web UI shows the current session live, with its details, console logs and Chrome DevTools, and a button to release it. You can click and type into the live view. It works in the browser you signed in with; embedding it in a page on another site does not, because that page does not have the sign-in cookie.

Things to know before you rely on this

It resets on every restart. Redeploying, restarting, saving a change on the Environment tab (which redeploys) and the occasional container restart during platform maintenance all start a clean browser, and change the key unless STEEL_API_KEY is set. Save what you need (screenshots, PDFs, scraped data, downloads) somewhere else as you go.

One browser, one session at a time. Self-hosted Steel runs a single Chrome. Creating a session while another is live takes over the same browser, and the first is marked released. Every client connected over the WebSocket shares that one Chrome and can see the others' tabs. For several agents working in parallel, deploy one app each.

Sessions in one boot share a profile. Releasing a session restarts Chrome with a blank tab, but Steel keeps the same profile until the app restarts, so cookies and local storage from an earlier session can still be there in the next. For a clean slate within a boot, open an isolated context (browser.createBrowserContext() in Puppeteer, browser.newContext() in Playwright), or create the session with a new profile folder: {"userDataDir": "/tmp/profile-<something unique>"}.

Open tabs one at a time. Steel prepares each new tab before it is ready for use (fingerprint, ad blocking, request routing). When several new tabs start loading at the same moment, a page can start before its tab is ready and never finish loading: in testing, a news site stalled until it timed out. Open tabs one after another, or create them and navigate a second later. An agent working in one tab is not affected.

It needs 2 GB of memory. Chrome is most of it. In our tests, counting the memory the system cannot reclaim: about 400 MB with the browser idle, 500 to 600 MB with one encyclopedia article open, about 800 MB with one busy news site (over 900 MB counting cache, so no margin on a 1 GB plan) or three tabs of news and reference sites, and 1.4 to 1.5 GB with five tabs including GitHub. At 2 GB all of that ran with memory to spare; at 1 GB, opening the five tabs crashed two of them. Memory drops back when a session is released. Steel's own share grows over the first few dozen sessions and then levels off: it stayed flat from 40 to 100 sessions in a row. If the app ever does run out of memory, it is restarted, which resets the browser (and changes a generated key), so pick 4 GB for many tabs at once or heavy web apps.

It needs an always-on plan. The Free plan sleeps after 15 idle minutes and would wake with a fresh browser, and a new generated key, every time. The create form only offers plans that fit.

Keep it to one replica. Each replica would be a separate browser with its own sessions, and requests would land on either one.

Web-only egress. Apps on velixir reach the internet on ports 80 and 443 only, so the browser cannot load a site on any other port, and an upstream proxy (proxyUrl) has to listen on one of those two.

Be a good citizen. The browser works from velixir's network. Respect sites' terms and robots.txt, keep your request rate reasonable, and never use it to get into accounts that are not yours: abuse gets the app suspended.

The logs show where the browser goes. Steel logs every page it navigates to, with the full URL, so anyone who can read the app's logs can see them.

The browser's time zone follows its IP address. Steel looks it up at ipinfo.io when Chrome starts and when a session starts. Pass timezone when you create a session to choose one yourself (no lookup is made for that session), and set DEFAULT_TIMEZONE for the fallback.

Headless only, and no Selenium. The container has no display, so asking for a visible browser (headless: false) returns an error. Steel's Selenium mode needs Java and is not included.

Chrome is pinned. The build uses the Chrome for Testing version in package.json. Browsers get security fixes every few weeks, so bump steel.chrome to a current stable version from Chrome for Testing and redeploy now and then.

What this repo is

A thin wrapper, not a fork. None of Steel's code is committed here. The versions are pinned in one place, package.json:

"steel": { "version": "0.5.4-beta", "chrome": "154.0.8037.57" }
File What it does
build-steel.js Downloads the pinned Steel release, builds it the way Steel's own Dockerfile does, and fetches the pinned Chrome (npm run build)
start.js The key, Steel's settings, starting and restarting Steel, and stopping cleanly
gateway.js The public side: checks the key, then forwards HTTP and WebSockets to Steel and Chrome
pages.js The sign-in page and the "starting" page
nixpacks.toml Node 22, Chrome's system libraries and fonts, and the start command

Along the way it fixes three things that break Steel on a hosted platform: Chrome refusing WebSocket connections addressed to a public host name, Chrome reopening every earlier tab each time Steel restarts it (memory grew with every session), and dead Chrome processes piling up by the hundred as sessions come and go (tini now cleans them up).

Upgrading is a version bump. Change steel.version or steel.chrome, commit and deploy. Read Steel's release notes first. Each deploy builds Steel from source, which takes a few minutes.

Configuration

Variable Default Does
STEEL_API_KEY generated at every boot The API key, 16 characters or more
PUBLIC_URL https://<app>.velixir.run Your own domain, if you use one (see below)
DEFAULT_TIMEZONE the container's (UTC) Time zone when the IP lookup fails
SKIP_FINGERPRINT_INJECTION false true turns off Steel's browser fingerprint randomisation
CHROME_ARGS none Extra Chrome flags, separated by spaces
PROXY_URL, DEFAULT_HEADERS, TIMEZONE_SERVICE_URL none Steel's other settings, passed through as documented upstream

The template sets HOST, PORT, DOMAIN, CDP_DOMAIN, USE_SSL, CHROME_EXECUTABLE_PATH and CHROME_HEADLESS for Steel itself: they wire up the key check, the public URLs and the bundled Chrome, so setting them has no effect.

On your own domain

Add the domain to the app, then set PUBLIC_URL to https://browser.example.com and redeploy, so the URLs Steel hands out use it. Use that address for the web UI too: the sign-in cookie belongs to the address you signed in on.

Running it locally

The build downloads Linux builds of Steel's dependencies and Chrome, so the simplest way to run exactly what velixir runs is to build the same image with Nixpacks:

nixpacks build . --name steel-browser
docker run --rm -p 8080:8080 -e PORT=8080 steel-browser

Then open http://localhost:8080 and sign in with the key printed in the output. The build takes a few minutes and needs internet access: GitHub, the npm registry and Google's Chrome downloads.

Deploying

velixir deploy

Routes

Method Path Does
GET /healthz, /health 200 with no key, also while Chrome is starting
GET, POST /login, /logout Sign-in for the web UI
any /v1/... Steel's REST API (reference at /documentation)
WebSocket / The Chrome DevTools Protocol, for Puppeteer, Playwright and agents
GET /ui Steel's web UI
any /devtools/... Chrome DevTools, behind a session's debuggerUrl

Everything except the first two rows needs the key.

Licence

Steel Browser is open source under the Apache License 2.0, by Steel. Chrome for Testing is Google's build of Chrome for automation, downloaded from Google during the build. Its Widevine DRM module is left out, because its licence requires a separate agreement with Google. This wrapper is MIT.

About

A headless Chrome sandbox for AI agents: sessions over REST, Puppeteer and Playwright over one secure WebSocket. Resets on every restart. Starter template for velixir.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages