An agent drives a real browser by writing TypeScript programs. The MCP server
exposes browser_api and browser_run; the web app adds chat and a browser preview.
await browse.open("https://example.com");
const { tree } = await browse.snapshot();
const { title } = await browse.get("title");
return { title, tree };The production path runs entirely inside Cloudflare Workers or a Vercel Node Function: Hono → MCP → QuickJS WebAssembly → direct Chrome DevTools Protocol. There is no Stagehand, browser extension, Run SDK, Node worker thread, or separate execution service. TypeScript is stripped with Sucrase before QuickJS evaluates it.
The browser itself runs locally or with a hosted provider. Tabs, cookies, and page state live there. Every program opens and closes its own CDP connection. Snapshot refs are local to a program and are cleared on navigation/tab changes. Take the snapshot in the program that uses its refs. Drive a given browser sequentially across programs; there is no distributed execution lock.
There are no server-side browser/session registries or preview connection caches. The UI retains its serializable browser handle in tab-local sessionStorage. Refreshes and requests routed to different server instances can reuse that handle.
QuickJS exposes only browser commands and logging. Programs have wall-clock, instruction, memory, command-count, and output budgets. The instruction budget also stops tight loops in Workers, where the clock does not advance during synchronous JavaScript. Programs that fail are never automatically replayed.
Requires Node 22.13+ and Chrome/Chromium.
pnpm install
# Put ANTHROPIC_API_KEY in the workspace .env for chat.
pnpm devpnpm dev starts local Chrome and TanStack Start, which mounts the Hono app at
/api/browse/* (MCP: /api/browse/mcp). There is no separate API listener.
Chat invokes the same Hono app in-process. Click Start browser
to attach the UI. Ctrl-C/SIGTERM stops the processes it started and removes its
temporary Chrome profile. An existing CDP browser is borrowed and left running.
pnpm cli dev # local Chrome + MCP, no web app
pnpm dev:server # same server-only dev lifecycle
pnpm dev:web # web app alone
pnpm chrome # Chrome alone
pnpm cli serve # production-style Node listener, no browser launchThe built CLI also supports browse-code-mode dev and browse-code-mode serve.
Overrides: CHROME_PATH, CHROME_PORT, CHROME_HEADLESS=0,
CHROME_USER_DATA_DIR, PORT, HOST, WEB_PORT, and NO_OPEN=1.
User-supplied profile directories are never deleted. Dev commands always use
local Chrome regardless of the configured production provider.
Only the Node dev lifecycle imports child_process and filesystem APIs.
packages/tools hands the same browser_run and browser_api to agent
frameworks without an MCP hop. Descriptions, sandbox, and result text come from
packages/mcp-server/src/core.ts, which the MCP server registers too.
Pass a Browser and the model also manages its own sessions. Each method backs
one tool:
interface Browser {
start(options?: { signal?: AbortSignal }): Promise<BrowserSession>; // browser_start
stop(sessionId: string): Promise<void>; // browser_stop
listSessions(): Promise<BrowserSession[]>; // browser_list_sessions
liveView(sessionId: string): Promise<LiveView>; // browser_live_view
}browser_run then takes a sessionId instead of a cdpUrl, so CDP credentials
never reach the model. browser_live_view returns a screenshot of the current
page, plus the provider's live view URL when it has one (Kernel does).
Implement Browser yourself, or adapt any BrowserProvider with
providerBrowser:
import { browserTools, instructions } from "@browse-code-mode/tools/ai-sdk"; // or /mastra
import { createBrowserToolkit, providerBrowser } from "@browse-code-mode/tools"; // framework-free
import { localProvider } from "@browse-code-mode/mcp-server/local-browser";
const browser = providerBrowser(localProvider()); // or providerFromEnv(process.env) for hosted
const tools = browserTools({ browser }); // or browserTools({ cdpUrl }) for one fixed browser
// ...
await browser.close(); // stops every session still openlocalProvider launches Chrome (or borrows one on CHROME_PORT). It is
Node-only, so it lives beside the dev lifecycle in
packages/mcp-server/src/node/local-browser.ts.
examples/ shows them in use:
pnpm example # 01-hello-world: AI SDK generateText answers by browsing
pnpm example 02-mcp-server # the tools on your own Streamable HTTP MCP server
pnpm example 01-hello-world "What's on the front page of Hacker News?"A provider implements two methods in packages/mcp-server/src/browse/provider.ts:
interface BrowserHandle {
provider: string;
cdpUrl: string;
sessionId?: string;
liveViewUrl?: string;
}
interface BrowserProvider {
readonly name: string;
create(options?: { signal?: AbortSignal }): Promise<BrowserHandle>;
shutdown(browser: BrowserHandle): Promise<void>;
}The handle contains data, not a cleanup closure. Shutdown can run on a fresh instance and must be idempotent. Treat handles/CDP URLs as credentials.
| Request | Behavior |
|---|---|
POST /browser |
Create a browser; return its handle |
DELETE /browser with handle JSON |
Shut down the provider session |
GET /browser?cdpUrl=... |
Inspect tabs via CDP |
POST /screen with { cdpUrl } |
Capture one JPEG, then disconnect |
POST /mcp |
Stateless MCP transport |
GET /api.d.ts, GET /api.md |
Browser program reference |
Pass the handle's cdpUrl to every browser_run to reuse a browser. Alternatively
set the MCP request header x-browse-cdp-url; the web app does this so the model
does not select a browser. Explicitly created browsers remain alive until shutdown
or provider expiry. The UI's Stop browser button calls shutdown; closing the tab
does not guarantee cleanup, so provider-side expiry is essential.
If browser_run has no endpoint, it creates a temporary browser using the
configured provider and shuts it down in finally, including on connection or
program failure. The cdp provider borrows BROWSE_CDP_URL, so shutdown is a no-op.
Set BROWSE_PROVIDER to:
cdp(default): requiresBROWSE_CDP_URL.browserbase: requiresBROWSERBASE_API_KEY; optionalBROWSERBASE_PROJECT_ID.kernel: requiresKERNEL_API_KEY.
BROWSER_TIMEOUT_SECONDS defaults to 600. Browserbase uses an absolute session
timeout and keepAlive so connections can detach between programs; the provider
plan must support keepAlive. Kernel uses an inactivity timeout.
Adapters use fetch against the documented Browserbase session API and Kernel browser API. They are covered by mocked HTTP contract tests; live provider accounts were not used during validation.
To add another provider, pass it to createApp({ config, provider }) in the
deployment entry point, or add it to providerFromEnv. The browserProvider
helper wraps create/shutdown callbacks. No command or sandbox changes are needed.
The MCP Worker imports the precompiled QuickJS WASM module and uses Workers' fetch/WebSocket upgrade API. It does not connect to a Node execution backend. TanStack Start mounts this app in the same Worker; no second deployment is needed.
pnpm --filter @browse-code-mode/mcp-server types:worker
pnpm --filter @browse-code-mode/mcp-server check:worker
pnpm --filter @browse-code-mode/web build:cloudflareConfigure BROWSE_PROVIDER, provider credentials, MCP_AUTH_TOKEN, and
ANTHROPIC_API_KEY on the web Worker, and deploy from apps/web after its
Cloudflare build. The standalone MCP Worker remains available for CLI-only clients.
Use a hosted/publicly reachable CDP browser in production; localhost is only for dev.
Create one project with root directory apps/web and workspace files available.
The web package uses TanStack Start + Nitro's Vercel output, with the Node
QuickJS WASM bytes embedded in its server bundle:
pnpm --filter @browse-code-mode/web build:vercelSet provider/auth variables and ANTHROPIC_API_KEY on that project.
MCP clients connect to /api/browse/mcp. Function duration must accommodate browser work.
Neither deployment starts Chrome. No production deployment is performed by builds.
| Variable | Default | Meaning |
|---|---|---|
PORT / HOST |
8787 / 127.0.0.1 | Node listener only |
MCP_ENDPOINT |
/mcp | MCP path |
BROWSE_PROVIDER |
cdp | Browser lifecycle adapter |
BROWSE_CDP_URL |
none | Externally managed CDP endpoint |
SANDBOX_TIMEOUT_MS |
120000 | Program wall-clock budget |
SANDBOX_MAX_COMMANDS |
256 | Host command budget |
SANDBOX_MAX_RESULT_BYTES |
4194304 | Output cap |
BROWSE_ALLOW_COMMANDS / BROWSE_DENY_COMMANDS |
all / none | Command access |
MCP_AUTH_TOKEN |
none | Bearer authentication for all MCP app routes |
MCP_ALLOWED_ORIGINS |
none | Optional Origin allowlist |
ANTHROPIC_API_KEY |
none | Web chat credential |
ANTHROPIC_MODEL |
claude-opus-5 | Chat model |
The command table remains the source of truth; call browser_api for signatures.
Snapshots use Chrome's accessibility tree, with refs mapped to backend DOM nodes
(the legacy xpathMap field contains these selectors). CSS and XPath also work.
Snapshots cover every frame. Same-process iframes and cross-site (out-of-process)
iframes, nested at any depth, are attached over CDP and spliced in under their
Iframe line. A ref is <frame>-<node>: 0 is the top page, and iframes are
numbered in document order. Shadow DOM content, including closed roots, appears
in the tree, and refs act on it directly. CSS selectors pierce open shadow roots,
and >> enters an iframe by selector (iframe#checkout >> input[name=card]).
Clicks translate frame coordinates to the top page and wait for the element to
hold still across a drawn frame, because Chrome routes input between frames using
the last drawn layout. get box returns top-page coordinates, so mouse.click
works on elements inside frames. eval(expression, { frame }) runs in a frame's
own main world (its page globals included), with the frame named by snapshot
index ("2"), the iframe's ref, or an iframe selector.
Screenshots return base64; writing screenshot paths on the application server is not supported. Upload paths refer to files on the browser host, never the Worker/function filesystem. Hosted files must be staged with the provider first.
The preview uses a provider live-view URL when available, otherwise request-scoped
screenshots at roughly one per second. It no longer requires a persistent
screencast process. A failed command's real error is in failedCall; log.info
collects intermediate output.
The sandbox isolates the application host, not websites. Browser commands have the browser's network access and cookies. Protect the web UI with your platform's access controls before exposing its server-side MCP credentials to other users. MCP bearer authentication protects the backend; it is not web-user authentication.
pnpm lint # Biome: format, recommended rules, and biome-plugin-no-type-assertion (no `as` casts)
pnpm check
pnpm test
pnpm build
pnpm --filter @browse-code-mode/mcp-server test:runtime
pnpm --filter @browse-code-mode/mcp-server test:worker
pnpm test:e2e # existing browser on BROWSE_CDP_URL or port 9222pnpm check runs Biome (lint, formatting, import order)
before type-checking every package. pnpm lint:fix applies Biome's safe fixes
and formatting; pnpm format only formats. Biome warnings fail the check.
pnpm install adds a pre-push hook that runs pnpm lint, and CI runs the same
check on every pull request. Set SKIP_SIMPLE_GIT_HOOKS=1 to skip the hook.
The runtime suites start an isolated Chrome and exercise real MCP execution, TypeScript, snapshot-ref clicks, loop interruption, screenshots, and lifecycle cleanup against Node and local workerd. They shut down their test processes.