API-Based Browser Without API Key — A free, open-source headless browser designed for AI agents.
No API keys. No paid services. Just a powerful, self-hosted browser that agents can control via REST API or MCP.
Existing browser tools for agents (Browserbase, Steel Cloud, Bright Data) charge per session or require API keys. Playwright MCP dumps 26+ tools and full accessibility trees, burning agent context windows. steer gives you:
- 5 clean endpoints —
navigate,act,extract,observe,screenshot - 10 actions — click, type, select, scroll, wait, keyboard, hover, upload, dialog
- 93% context reduction — Snapshot+Refs filters DOM to only interactive/meaningful elements
- Two interfaces — REST API for any language, MCP server for Claude Desktop/Cursor
- Zero cost — runs entirely on your own machine
- Low memory — defaults to Firefox (~120MB idle vs Chromium's ~250MB)
npm install
npx playwright install firefox
# Start REST API server
npm run dev
# Or start MCP server (stdio)
npm run mcp:devdocker compose up
# Server running at http://localhost:3010npm install -g steer
npx playwright install firefox
steer # REST API on http://0.0.0.0:3010
steer --mcp # MCP server (stdio transport)
steer --help # Show all optionssteer works with any MCP-compatible client. Below are setup instructions for popular AI tools.
Add to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
Local (stdio):
{
"mcpServers": {
"steer": {
"command": "npx",
"args": ["steer", "--mcp"]
}
}
}Docker (HTTP):
docker compose up -d steer-mcp
# MCP server listening at http://localhost:3011/mcp{
"mcpServers": {
"steer": {
"url": "http://localhost:3011/mcp"
}
}
}Without Docker Compose:
docker build -t steer .
docker run -d -p 3011:3011 --name steer-mcp steer node dist/cli.js --mcp-httpAdd to .claude/settings.json or run claude mcp add:
Local (stdio):
{
"mcpServers": {
"steer": {
"command": "npx",
"args": ["steer", "--mcp"]
}
}
}Docker (HTTP):
{
"mcpServers": {
"steer": {
"url": "http://localhost:3011/mcp"
}
}
}Add to Cursor's MCP settings (Settings > MCP Servers > Add):
Local (stdio):
{
"mcpServers": {
"steer": {
"command": "npx",
"args": ["steer", "--mcp"]
}
}
}Docker (HTTP):
{
"mcpServers": {
"steer": {
"url": "http://localhost:3011/mcp"
}
}
}Add to ~/.codeium/windsurf/mcp_config.json:
{
"mcpServers": {
"steer": {
"command": "npx",
"args": ["steer", "--mcp"]
}
}
}Add to .vscode/mcp.json in your workspace or user settings:
{
"servers": {
"steer": {
"command": "npx",
"args": ["steer", "--mcp"]
}
}
}Add to ~/.gemini/settings.json:
{
"mcpServers": {
"steer": {
"command": "npx",
"args": ["steer", "--mcp"]
}
}
}Add to ~/.codex/config.json:
{
"mcpServers": {
"steer": {
"command": "npx",
"args": ["steer", "--mcp"]
}
}
}steer supports two transports:
| Transport | Use when | Config |
|---|---|---|
| stdio | Client spawns the process | "command": "npx", "args": ["steer", "--mcp"] |
| Streamable HTTP | Server runs separately (Docker, remote) | "url": "http://localhost:3011/mcp" |
For stdio, point your client at npx steer --mcp. For HTTP, start the server first (steer --mcp-http or via Docker) and point your client at the /mcp endpoint.
| Tool | Description |
|---|---|
browser_navigate |
Go to a URL, returns page snapshot with ref IDs |
browser_act |
Perform actions: click, type, select, scroll, wait, keyboard, hover, upload, dialog |
browser_extract |
Get page content as text, markdown, or structured JSON |
browser_observe |
Get accessibility snapshot with interactive element refs |
browser_screenshot |
Capture page as base64 PNG |
All tools accept an optional sessionId. If omitted, a default session is auto-created and reused.
User: "Search for the latest Claude API docs"
Agent: browser_navigate({ url: "https://google.com" })
→ Returns snapshot: r3=textbox "Search", r5=button "Google Search"
Agent: browser_act({ action: "type", ref: "r3", value: "Claude API documentation" })
Agent: browser_act({ action: "keyboard", key: "Enter" })
→ Page navigates to search results
Agent: browser_act({ action: "wait", selector: "#search", state: "visible" })
→ Waits for results to load
Agent: browser_extract({ mode: "markdown" })
→ Returns search results as clean markdown
Base URL: http://localhost:3010
POST /sessions
Request body (all fields optional):
{
"profile": "my-profile",
"viewport": { "width": 1920, "height": 1080 },
"blockResources": ["image", "font", "media"]
}Response (201):
{
"id": "abc123xyz",
"url": "about:blank",
"createdAt": 1707350400000
}GET /sessions
Response:
{
"sessions": [
{
"id": "abc123xyz",
"url": "https://example.com",
"createdAt": 1707350400000,
"lastActivity": 1707350450000
}
]
}DELETE /sessions/:id
Response:
{ "success": true }POST /sessions/:id/navigate
Request body:
{
"url": "https://example.com",
"waitUntil": "domcontentloaded"
}| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | URL to navigate to (must be http/https) |
waitUntil |
string | No | "load", "domcontentloaded", or "networkidle" |
Response:
{
"url": "https://example.com",
"title": "Example Domain",
"snapshot": {
"url": "https://example.com",
"title": "Example Domain",
"refs": [
{ "ref": "r1", "role": "heading", "name": "Example Domain" },
{ "ref": "r2", "role": "link", "name": "More information..." }
]
}
}POST /sessions/:id/act
Common fields:
| Field | Type | Required | Used by |
|---|---|---|---|
action |
string | Yes | All |
ref |
string | No | click, type, select, hover, upload |
selector |
string | No | click, type, select, hover, upload, wait |
Action-specific fields:
| Action | Extra fields | Description |
|---|---|---|
click |
— | Click an element by ref or selector |
type |
value (required) |
Type text into input/textarea/contenteditable |
select |
value (required) |
Select dropdown option (native or custom) |
scroll |
direction (up/down/left/right) |
Scroll page by 500px or element into view |
wait |
selector, state, timeout |
Wait for element state or network idle |
keyboard |
key (required) |
Press a keyboard key or combo |
hover |
— | Hover over element (triggers menus/tooltips) |
upload |
filePaths (required) |
Upload files to a file input |
dialog |
dialogAction, promptText |
Configure how next dialog is handled |
Keyboard action — allowed keys:
- Named:
Enter,Escape,Tab,Backspace,Delete,Space - Arrows:
ArrowUp,ArrowDown,ArrowLeft,ArrowRight - Navigation:
Home,End,PageUp,PageDown - Function:
F1throughF12 - Modifiers:
Control+a,Shift+Tab,Alt+F4,Meta+c - Any single printable ASCII character
Wait action — states:
| State | Meaning |
|---|---|
visible |
Element exists and is visible (default) |
hidden |
Element is hidden or removed |
attached |
Element exists in DOM (may be hidden) |
detached |
Element removed from DOM |
Timeout: default 5000ms, max 30000ms. If no selector given, waits for network idle.
Dialog action:
| Field | Type | Default | Description |
|---|---|---|---|
dialogAction |
"accept" or "dismiss" |
"accept" |
How to handle alert/confirm/prompt |
promptText |
string | — | Text to enter in prompt dialogs |
Examples:
# Click
curl -X POST http://localhost:3010/sessions/$ID/act \
-H 'Content-Type: application/json' \
-d '{ "action": "click", "ref": "r5" }'
# Type
curl -X POST http://localhost:3010/sessions/$ID/act \
-H 'Content-Type: application/json' \
-d '{ "action": "type", "ref": "r3", "value": "hello world" }'
# Select dropdown
curl -X POST http://localhost:3010/sessions/$ID/act \
-H 'Content-Type: application/json' \
-d '{ "action": "select", "ref": "r7", "value": "option2" }'
# Scroll
curl -X POST http://localhost:3010/sessions/$ID/act \
-H 'Content-Type: application/json' \
-d '{ "action": "scroll", "direction": "down" }'
# Wait for element
curl -X POST http://localhost:3010/sessions/$ID/act \
-H 'Content-Type: application/json' \
-d '{ "action": "wait", "selector": "#results", "state": "visible", "timeout": 10000 }'
# Press keyboard key
curl -X POST http://localhost:3010/sessions/$ID/act \
-H 'Content-Type: application/json' \
-d '{ "action": "keyboard", "key": "Enter" }'
# Keyboard combo
curl -X POST http://localhost:3010/sessions/$ID/act \
-H 'Content-Type: application/json' \
-d '{ "action": "keyboard", "key": "Control+a" }'
# Hover (for dropdown menus)
curl -X POST http://localhost:3010/sessions/$ID/act \
-H 'Content-Type: application/json' \
-d '{ "action": "hover", "ref": "r4" }'
# Upload file
curl -X POST http://localhost:3010/sessions/$ID/act \
-H 'Content-Type: application/json' \
-d '{ "action": "upload", "ref": "r8", "filePaths": ["/path/to/file.pdf"] }'
# Configure dialog handling
curl -X POST http://localhost:3010/sessions/$ID/act \
-H 'Content-Type: application/json' \
-d '{ "action": "dialog", "dialogAction": "accept", "promptText": "yes" }'Response (all actions return the same shape):
{
"success": true,
"snapshot": { "url": "...", "title": "...", "refs": [...] },
"url": "https://example.com/page"
}POST /sessions/:id/extract
| Field | Type | Required | Description |
|---|---|---|---|
mode |
string | Yes | "text", "markdown", or "structured" |
selector |
string | No | CSS selector to scope extraction |
schema |
object | For structured | JSON schema for structured extraction |
maxLength |
number | No | Max characters returned (default: 4000) |
Modes:
| Mode | Engine | Best for |
|---|---|---|
text |
Mozilla Readability | Articles, blog posts, clean text |
markdown |
Turndown | Preserving structure (headings, links, lists) |
structured |
Custom heuristics | Extracting data (products, search results, tables) |
Structured extraction uses smart heuristics:
- Array schema → finds repeated elements, extracts properties per item
- Object schema → extracts single object from scoped element
- Property names like
url,href,image,srcauto-read from element attributes - Type coercion: string, number, integer, boolean
Examples:
# Plain text
curl -X POST http://localhost:3010/sessions/$ID/extract \
-H 'Content-Type: application/json' \
-d '{ "mode": "text" }'
# Markdown from specific section
curl -X POST http://localhost:3010/sessions/$ID/extract \
-H 'Content-Type: application/json' \
-d '{ "mode": "markdown", "selector": "main" }'
# Structured data
curl -X POST http://localhost:3010/sessions/$ID/extract \
-H 'Content-Type: application/json' \
-d '{
"mode": "structured",
"selector": ".product-list",
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"price": { "type": "number" },
"url": { "type": "string" }
}
}
}
}'Response:
{
"content": "Extracted text or markdown or structured data...",
"url": "https://example.com",
"title": "Example"
}GET /sessions/:id/observe
| Param | Type | Default | Description |
|---|---|---|---|
scope |
string | — | CSS selector to restrict snapshot ("#main", "form.login") |
verbosity |
string | "normal" |
"minimal", "normal", or "detailed" |
maxRefs |
number | — | Limit number of returned elements |
Verbosity levels:
| Level | Fields included |
|---|---|
minimal |
ref, role, name |
normal |
+ value, checked, disabled, expanded, options |
detailed |
+ descriptions |
Response:
{
"url": "https://example.com",
"title": "Example",
"refs": [
{ "ref": "r1", "role": "link", "name": "Home" },
{ "ref": "r2", "role": "textbox", "name": "Search", "value": "" },
{ "ref": "r3", "role": "button", "name": "Submit", "disabled": false }
]
}GET /sessions/:id/screenshot
| Param | Type | Default | Description |
|---|---|---|---|
fullPage |
boolean | false |
Capture full scrollable page |
quality |
number | 50 |
JPEG quality (1-100) |
Response: Binary JPEG image (Content-Type: image/jpeg)
curl http://localhost:3010/sessions/$ID/screenshot > page.jpg
curl "http://localhost:3010/sessions/$ID/screenshot?fullPage=true&quality=90" > full.jpgNote: The REST API returns JPEG (configurable quality). The MCP tool returns base64 PNG.
GET /health
Response:
{
"status": "ok",
"sessions": 2,
"config": {
"maxSessions": 10,
"sessionTimeoutMs": 300000,
"requestTimeoutMs": 30000
}
}Instead of dumping the full accessibility tree (thousands of nodes), steer:
- Walks the DOM and finds interactive elements (links, buttons, inputs, selects, contenteditable) plus structural elements (headings, landmarks, alerts)
- Filters out hidden elements (
display:none,visibility:hidden,opacity:0,aria-hidden) - Computes accessible names via ARIA labels,
<label>,alt,title,placeholder,textContent - Stamps each element with a stable
data-steer-refattribute (r1,r2, ...) - Returns a compact list of
{ ref, role, name, value?, checked?, disabled?, options? } - Agents use ref IDs to target elements in subsequent
actcalls
This typically reduces context by ~93% compared to raw accessibility trees, saving tokens and improving agent reasoning.
Interactive: a[href], button, input, textarea, select, [contenteditable], ARIA roles (button, link, checkbox, radio, tab, menuitem, switch, slider, combobox, option, spinbutton, searchbox, treeitem)
Structural: h1-h6, [role=heading], [role=alert], [role=alertdialog], [role=status], [role=dialog], img[alt], [aria-live], landmark roles
All configuration via environment variables:
| Variable | Type | Default | Description |
|---|---|---|---|
STEER_PORT |
int | 3010 |
HTTP server port |
STEER_HOST |
string | 0.0.0.0 |
HTTP server bind address |
STEER_MAX_SESSIONS |
int | 10 |
Maximum concurrent browser sessions |
STEER_SESSION_TIMEOUT_MS |
int | 300000 |
Session idle timeout in ms (5 min) |
STEER_REQUEST_TIMEOUT_MS |
int | 30000 |
HTTP request timeout in ms (30s) |
STEER_HEADLESS |
bool | true |
Run browser in headless mode |
STEER_BROWSER |
string | firefox |
Browser engine: chromium, firefox, webkit |
STEER_ALLOWED_DOMAINS |
string | (empty) | Comma-separated domain allowlist (empty = all) |
STEER_BLOCK_RESOURCES |
string | image,font,media |
Comma-separated resource types to block |
STEER_VIEWPORT_WIDTH |
int | 1280 |
Default viewport width |
STEER_VIEWPORT_HEIGHT |
int | 720 |
Default viewport height |
STEER_EXECUTABLE_PATH |
string | (auto) | Custom browser executable path |
STEER_MCP_PORT |
int | 3011 |
MCP HTTP server port |
STEER_LOG_LEVEL |
string | info |
Log level: silent, debug, info, warn, error |
docker build -t steer .
# REST API server
docker run -p 3010:3010 steer
# MCP server (HTTP transport)
docker run -d -p 3011:3011 steer node dist/cli.js --mcp-http# REST API server (default)
docker compose up steer # Start REST API on port 3010
docker compose up -d steer # Start detached
# MCP server (HTTP transport)
docker compose up steer-mcp # Start MCP on port 3011
docker compose up -d steer-mcp # Start detached
# Both services
docker compose up -d # Start everything
docker compose down # Stop allThe docker-compose.yml includes two services:
steer— REST API server on port 3010steer-mcp— MCP server on port 3011 (Streamable HTTP transport)
-
Start the MCP container:
docker compose up -d steer-mcp
-
Add to Claude Desktop config (
claude_desktop_config.json):{ "mcpServers": { "steer": { "url": "http://localhost:3011/mcp" } } } -
Restart Claude Desktop — steer tools will appear automatically.
steer supports two MCP transport modes:
| Mode | Flag | Use case |
|---|---|---|
| stdio | --mcp |
Local: Claude Desktop launches the process directly |
| HTTP | --mcp-http |
Remote/Docker: long-lived server, clients connect via HTTP |
The HTTP transport uses the MCP Streamable HTTP protocol. It supports multiple concurrent client sessions, each with their own browser sessions.
- Base:
mcr.microsoft.com/playwright:v1.58.2-noble - Browser: Firefox only (~400MB lighter than all browsers)
- Memory: 2GB limit, 512MB reservation
- Healthcheck:
GET /healthevery 30s - Multi-stage build: TypeScript compiled in builder stage, only dist/ copied to production
All errors follow a consistent JSON format:
{
"error": {
"code": "ACTION_FAILED",
"message": "Element ref \"r99\" not found in current snapshot",
"statusCode": 400
}
}| Error Code | HTTP Status | When |
|---|---|---|
SESSION_NOT_FOUND |
404 | Session ID doesn't exist |
SESSION_LIMIT_REACHED |
429 | Max concurrent sessions exceeded |
NAVIGATION_FAILED |
502 | Page failed to load |
ACTION_FAILED |
400 | Action execution failed |
VALIDATION_ERROR |
400 | Invalid request parameters |
DOMAIN_NOT_ALLOWED |
403 | URL blocked by domain allowlist |
- URL sanitization: Blocks
javascript:,data:,file:,vbscript:protocols; onlyhttp/httpsallowed - Domain allowlist:
STEER_ALLOWED_DOMAINSrestricts which domains can be navigated to - Selector sanitization: CSS selectors validated against injection patterns
- Keyboard validation: Only whitelisted keys and modifier combos allowed
- Rate limiting: 100 requests/minute per IP
- CORS: Configurable origin (default: permissive)
- Resource blocking: Images, fonts, media blocked by default
- Session isolation: Each session has its own BrowserContext (separate cookies, storage, cache)
- No downloads:
acceptDownloads: falseon all contexts - Request timeout: 30s default, returns 504 Gateway Timeout on expiry
npm test # All tests
npm test -- test/unit/ # Unit tests only (72 tests)
npm test -- test/integration/ # Integration tests (browser required)
SKIP_HEAVY_BROWSER_TESTS=1 npm test # Skip click/type tests (low-memory CI)| Fixture | Tests |
|---|---|
login-form.html |
click, type, select, checkbox interactions |
table-data.html |
Structured data extraction |
search-results.html |
Text/markdown extraction |
complex-spa.html |
Scroll, wait, dynamic content |
npm install # Install dependencies
npm run build # Compile to dist/ (ESM + DTS)
npm run typecheck # TypeScript type checking
npm run lint # Biome linter
npm run lint:fix # Auto-fix lint issuesBuild output:
dist/index.js— REST API serverdist/cli.js— CLI binarydist/mcp/server.js— MCP serverdist/*.d.ts— TypeScript type declarations
See ARCHITECTURE.md for technical deep-dive.
See ROADMAP.md for planned features.
MIT