# Public Browser
[](https://github.com/Silbercue/public-browser/releases)
[](https://www.npmjs.com/package/public-browser)
[](#why-an-mcp-server-and-not-a-cli)
[](LICENSE)
[](https://nodejs.org)
Lets Claude Code, Cursor and any MCP client drive Chrome. In a blind benchmark on a 30-test page, five runs each, Public Browser 3.0 passed 30/30 in every run and used a median of **3.0M session tokens where agent-browser 0.38.1 used 4.5M — a third fewer tokens, a third less cost, a quarter fewer tool calls and 48% faster** (261 s against 386 s) ([Benchmarks](#benchmarks), including where it loses). Measured 2026-09-23/24 with Claude Code 2.1.281, driver Claude Opus 5 and Chrome 153; agent-browser ran through its CLI with its official skill file, and the test page is our own. Direct CDP, a11y-tree refs, several steps per call with `run_plan` — 2,700+ TypeScript tests, 280+ Python tests.
Built for [Claude Code](https://claude.ai/claude-code), [Cursor](https://cursor.sh), and any MCP-compatible client — and, without an LLM in the loop, for decision models like [Jev](#perfect-for-jev--a-decision-model-needs-a-menu-public-browser-hands-it-one).
> **Looking for an alternative to agent-browser, Playwright MCP, Chrome DevTools MCP or Browser MCP?** Public Browser is an MCP server that talks to Chrome directly over the DevTools Protocol — no Playwright dependency, no extension bridge, no shell command per step. One command to install, zero config. [See the benchmark comparison below](#benchmarks).
## Why Public Browser?
- **Fewer tokens per task.** Every tool call makes the model re-read the conversation so far, so the session total is what you pay for. On the benchmark page Public Browser 3.0 needed 3.0M tokens (median of five runs) where agent-browser needed 4.5M. In the field run a day earlier, with Public Browser still at 2.10.6, Playwright MCP needed 7.8M and Chrome DevTools MCP 10.3M. The lead comes from fewer, denser steps: `run_plan` executes several actions with variables and conditions in one call, and since 3.0 the responses carry less repetition — diffs show only what changed, text the parent line already shows is not repeated, a tip appears once per session.
- **Loud failures instead of silent ones.** A CSS selector that matches several elements does nothing and returns the candidates with their refs. Refs are kept per tab and never reused, so a ref from a page you left reports `stale ref` instead of clicking whatever node now has that number. `drag` answers `Drag not confirmed` when nothing reacted, and a click that opens a tab names the new tab.
- **Nested cross-origin iframes and shadow DOM.** Clicks reach elements in a cross-origin iframe that sits inside another cross-origin iframe — agent-browser 0.38.1 reads one level ([#1784](https://github.com/vercel-labs/agent-browser/issues/1784)). Open and closed shadow roots are read as well.
- **Two ways in without an LLM.** A Node library (`createSession()`) and a Python client (`pip install publicbrowser`) run the same tool handlers as the MCP server.
**What agent-browser does better:** it can copy your Chrome profile so its logins come along (Public Browser's profile mode does not carry site logins on macOS, see [Chrome Profiles](#chrome-profiles)), records HAR files and intercepts requests, saves PDFs and video, and drives iOS Safari. If your agent works from the shell rather than through an MCP client, it is a strong choice.
| Blind benchmark, median of 5 runs each | **Public Browser 3.0** | agent-browser 0.38.1 (CLI) |
|---|---|---|
| Passed (30 scored tests) | **30/30 in 5 of 5 runs** | 29/30 in 5 of 5 runs — misses T5.2, a `navigator.webdriver` check |
| Session tokens, whole run | **3.02M** (2.47–3.15M) | 4.53M (4.40–5.33M) |
| Cost per run, Opus 5 list price | **$2.40** | $3.56 |
| Tool calls | **79** | 104 |
| Time to finish, wall clock | **261 s** | 386 s |
| Tool-response volume | **78.9k chars** | 84.4k chars |
| Average tool response | 1,040 chars | **754 chars** |
2026-09-23/24, Claude Code 2.1.281, driver `claude-opus-5`, Chrome 153.0.8010.53. Method, per-run table and the rest of the field: [Benchmarks](#benchmarks).
## Quick Start
### Install in Claude Code
One command — installs globally for all projects:
```bash
claude mcp add --scope user public-browser -- npx -y public-browser@latest
```
**Important:** after `claude mcp add` you must **fully quit and reopen Claude Code**. `/mcp reconnect` is not enough — Claude Code reads the `mcpServers` config only at session start and caches it. After the restart, the first tool call auto-launches Chrome **visible** (no headless, no port setup). Done.
> To enable parallel Python [Script API](#script-api-python--perfect-for-jev-loops) access, add `--script` to the args:
> `claude mcp add --scope user public-browser -- npx -y public-browser@latest -- --script`
### Install in Cursor
Add to `~/.cursor/mcp.json`:
```json
{
"mcpServers": {
"public-browser": {
"command": "npx",
"args": ["-y", "public-browser@latest"]
}
}
}
```
> For parallel Python [Script API](#script-api-python--perfect-for-jev-loops) access, use `"args": ["-y", "public-browser@latest", "--", "--script"]`
### Install in Cline
Add to your `cline_mcp_settings.json`:
```json
{
"mcpServers": {
"public-browser": {
"command": "npx",
"args": ["-y", "public-browser@latest"]
}
}
}
```
### Install in other MCP clients
Any client that supports stdio MCP servers: `npx -y public-browser@latest` with no arguments.
### Try it — your first prompt
After installing, paste this into your AI coding assistant:
> Open mcp-test.second-truth.com, read the page, and fill the contact form with Name "Test User" and Email "test@example.com".
This exercises three core tools in sequence: `navigate` loads the page, `view_page` reads the accessibility tree with stable element refs, and `fill_form` fills multiple fields in one call. You should see Chrome open, the page load, and the form filled — all without writing a single line of code.
### Uninstall
```bash
claude mcp remove --scope user public-browser
```
## Chrome Profiles
By default, Public Browser starts Chrome with a fresh temp profile — no cookies, no logins, no extensions. You can also start Chrome with one of your own Chrome profiles.
### List available profiles
```bash
npx public-browser profiles
```
### Launch with a profile
Three ways — pick whichever fits your setup:
```bash
# CLI flag
npx public-browser --profile "Work"
# Environment variable
PUBLIC_BROWSER_PROFILE="Work" npx public-browser
# MCP tool (call BEFORE any browser interaction)
configure_session({ profile: "Work" })
```
Chrome refuses remote control on its default data directory, so Public Browser creates a lightweight wrapper directory with a symlink to your profile folder and starts Chrome on that. The wrapper is removed when Public Browser closes Chrome, and wrappers left behind by a crash are removed on the next start; your profile folder itself is never deleted.
**What carries over, and what does not.** Bookmarks, history, extensions and Chrome's own Google sign-in come along, so Google sites are signed in. **Other sites are not, at least on macOS:** current Chrome (tested with 153) does not load the profile's cookies through the symlink — the sandbox of Chrome's network service only allows paths below the wrapper — so sites start logged out, and logins made during the session are not saved to your profile. Linux and Windows are untested. Copying the profile at start, as agent-browser does, is planned.
**No open debugging port.** A real profile is driven over `--remote-debugging-pipe`: CDP runs through a pipe that only Public Browser holds, and nothing listens on a TCP port — other programs on your machine cannot take over your browser. The flip side: `--attach` and the Script API escape hatch (`page.cdp`) do not work with a real profile. Should Chrome ever refuse the pipe, Public Browser restarts it with a random debugging port (never 9222) and says so on stderr and once in the next tool response: while that Chrome runs, the profile is reachable for local programs.
### If Chrome is already open
Public Browser detects this via lock-file inspection. If Chrome is running with remote debugging enabled, it attaches via CDP. If not, it shows a clear error asking you to close Chrome first. A profile can be open in only one Public Browser at a time: a second instance stops with an error naming the PID of the Chrome that holds it.
## Perfect for Jev — a decision model needs a menu, Public Browser hands it one
[Jev](https://typesafe.ai/blog/introducing-system-one-models-and-jev) (TypeSafe AI, announced 15 September 2026, early access) is not a chat model. It takes program state plus a bounded set of options and returns **one typed choice with calibrated probabilities** in 70–500 ms, at $0.042 per million input tokens with free output — it cannot produce free text, so it cannot invent a selector that does not exist. TypeSafe calls this a "System One model". Two browser agents already run on it: [browser-use/jev-ultrafast](https://github.com/browser-use/jev-ultrafast) (Google Flights search in 7.1 s, $0.0039, 91% fewer browser-protocol calls) and [jev-browser](https://github.com/MahmoudAdelbghany/jev-browser) (1.5× faster and 1.6× cheaper than Playwright MCP on a 12-task suite, 97% autonomous success at ~$0.0005 per task).
Every one of those loops needs the same three things from the browser side, and they are exactly what Public Browser is built around:
| Jev needs | Public Browser delivers |
|---|---|
| A **bounded menu** of actions, not a screenshot or a raw DOM | `view_page` (`filter: "interactive"`) — the a11y-tree elements an agent can act on, each with a stable `e`-ref. Ø 1.2–1.3k chars per view in the September benchmark, well inside Jev's ~32k-token page budget and 255-option choice cap. |
| **Refs that survive the action** so the chosen option can be executed and verified | `e`-refs are cached across calls and survive scrolls and DOM re-renders; `click`/`type`/`fill_form` return a DOM diff (NEW/REMOVED/CHANGED) that serves as the deterministic verification signal Jev-style loops use instead of a second model call. |
| A **programmatic driver without an LLM in the loop** | The [Script API (Python)](#script-api-python--perfect-for-jev-loops) over HTTP and the [Node Library API](#node-library-api-multiple-instances-in-one-process--perfect-for-jev) in-process — same tool handlers as the MCP server, one Chrome per Jev worker, headless or with one of your Chrome profiles. |
**Measured, not claimed.** [`examples/jev-loop.mjs`](examples/jev-loop.mjs) is that loop in ~150 lines on the Node Library: `view_page` on the test card → one Jev `choice` over the card's refs (plus a `boolean` "already done?") → `click` / `type` / `fill_form` → repeat. Jev cannot write text, so when it picks a "type" action, `gpt-4.1-nano` writes the literal value for that one field — the same split browser-use/jev-ultrafast uses. Run on the six Level-1 cards of the [public benchmark page](https://mcp-test.second-truth.com), two runs, 2026-09-18, Jev via Vercel AI Gateway, headless Chrome:
| | Run 1 | Run 2 |
|---|---|---|
| Cards passed | **6/6** | **6/6** |
| Steps = Jev calls (one decision per step) | 23 | 21 |
| Text-model calls (form fields, secret code, table sum) | 8 | 7 |
| Wall-clock, all six cards | 20.2 s | 16.4 s |
| Cost, all six cards (Jev $0.042/M in, nano $0.10/M in, $0.40/M out) | $0.0012 | $0.0011 |
Per card that is ~3 s and ~$0.0002. The same six cards inside the LLM-driven September runs above (Opus 5 over MCP, 30 cards in 281–296 s for $3.35–3.41) come to roughly 9–10 s and $0.11 per card — a different setup (a frontier model reads the whole page and plans; Jev only picks from a menu), so read it as "what the cheap path costs", not as a benchmark of equals. Level 1 is the easy tier; whether a Jev-only loop survives Level 2–4 (observe, shadow DOM, canvas, races) is the open question, and the harness for asking it is in the repo. Raw data: [`test-hardest/results/jev-loop-run1.json`](test-hardest/results/jev-loop-run1.json), [`run2`](test-hardest/results/jev-loop-run2.json). Setup: `npm i ai @ai-sdk/openai public-browser`, `AI_GATEWAY_API_KEY` + `OPENAI_API_KEY`, `node examples/jev-loop.mjs`.
## Script API (Python) — perfect for Jev loops
A second way to use Public Browser — deterministic browser automation from Python, without an LLM in the loop. Scripts use the same tool implementations as the MCP server (Shared Core) — every improvement to `click`, `navigate`, `fill_form` etc. automatically benefits your scripts too. The MCP server handles AI-driven workflows; the Script API is for repeatable scripts you write yourself.
How fast that is without an LLM: a scripted run of the 24-test version of the benchmark suite finished the whole suite in **21 seconds** (`type: mcp-scripted`, 2026-04-04). That number says what deterministic scripting costs, not how Public Browser compares to other MCP servers — every cross-server comparison in [Benchmarks](#benchmarks) is LLM-driven on both sides.
### Installation
```bash
pip install publicbrowser
```
Or, from a source checkout, install the local package:
```bash
python -m pip install ./python
```
`Chrome.connect()` auto-starts the Public Browser server as a subprocess via a local `public-browser` binary or the `npx` fallback — no manual Chrome launch or port setup needed.
> **Legacy single-file alternative:** For quick prototyping you can copy [`python/publicbrowser_standalone.py`](python/publicbrowser_standalone.py) into your project. This uses v1 direct CDP and does not benefit from server-side improvements — use the local `publicbrowser` package for the full Shared Core experience.
### How it works
```
Python Script Escape Hatch (Power User)
| |
v v
HTTP POST /tool/{name} WebSocket (CDP)
Port 9223 Port 9222
| |
v |
Public Browser Server |
| |
v |
registry.executeTool() |
| |
v |
Tool Handler |
(click.ts, navigate.ts, ...) |
| |
v v
Chrome <------------ CDP --------------->
```
Your script sends HTTP requests to the Public Browser server on port 9223. The server executes the exact same tool handlers that the MCP server uses — one codebase, one test suite (2,700+ tests), two access paths.
### Auto-Start
`Chrome.connect()` finds and starts the server automatically:
1. **Running server** — asks `GET /health` on port 9223 and connects only if a Public Browser server answers and accepts the key; any other program on that port is reported, never used
2. **PATH binary** — finds `public-browser` in PATH, starts it with `--script`
3. **npx fallback** — runs `npx -y public-browser@latest -- --script`
4. **Explicit path** — `Chrome.connect(server_path="/path/to/public-browser")` for custom setups
### Access key
The Script API only answers requests that carry its key (`Authorization: Bearer `), so web pages and programs running under another user account cannot drive your browser through it. Programs running under your own user account can read the key file, just as they can read your browser profile — the key does not protect against them. You rarely see the key:
- When `Chrome.connect()` starts the server itself, it generates a key and hands it over in the `PUBLIC_BROWSER_SCRIPT_TOKEN` environment variable.
- A server started with `--script` (for example from your MCP config) generates its own key and writes it to `~/.public-browser/script-api-.token`, readable only by your user. `Chrome.connect()` reads it from there.
- To use a key of your own, set `PUBLIC_BROWSER_SCRIPT_TOKEN` for both sides or pass `Chrome.connect(token=...)`.
Two scripts that call `Chrome.connect()` at the same moment while no server runs each start a server with their own key. One of them gets the port, the other gets a `PermissionError`. Connect once and open one page per task from that connection (`chrome.new_page()` can be called from several threads), or start the server beforehand with `public-browser --script`, so that every script reads the same key file.
Requests without the key get `401`. Requests from a browser (with an `Origin` header) or with a `Host` other than `127.0.0.1:` / `localhost:` get `403` — that blocks web pages and DNS rebinding even if they guess the port.
**Upgrading:** the server and the `publicbrowser` Python client go together: `publicbrowser` 2.0.0 needs Public Browser 3.0.0 or newer, and `publicbrowser` 1.0.0 does not work with 3.0.0 — it does not send the key, so it reports `ConnectionError: Public Browser server not reachable` although the server runs. An MCP config with `npx -y public-browser@latest -- --script` picks up the new server on its next start — update the client at the same time (`pip install -U publicbrowser`).
### Example: Login + Data Extraction
```python
from publicbrowser import Chrome
chrome = Chrome.connect()
with chrome.new_page() as page:
page.navigate("https://shop.example.com/login")
page.fill({"#email": "me@example.com", "#password": "***"})
page.click("button[type=submit]")
page.wait_for("text=Dashboard")
for cat in ["electronics", "furniture", "toys"]:
page.navigate(f"https://shop.example.com/orders/{cat}")
rows = page.evaluate(
"[...document.querySelectorAll('tr')].map(r => r.textContent)"
)
save_csv(cat, rows)
chrome.close()
```
### Methods
| Method | Description |
|---|---|
| `Chrome.connect()` | Connect to or auto-start the Public Browser server |
| `chrome.new_page()` | Context manager — opens a new tab, auto-closes on exit |
| `page.navigate(url)` | Navigate and wait for load |
| `page.click(selector)` | Click by CSS selector (must match exactly one element), visible text (`"text=Sign in"`) or ref (`"e12"`) |
| `page.type(selector, text)` | Type text into an input |
| `page.fill({"sel": "val"})` | Fill multiple form fields at once |
| `page.wait_for(condition)` | Wait for page text (`"text=..."`), a ref, a CSS selector (`#`, `.`, `[`), `"network_idle"` or a JS condition |
| `page.evaluate(expression)` | Run JavaScript, return result |
| `page.download()` | Wait for pending downloads, return the download report (JSON or a notice) |
| `page.close()` | Close the tab (auto-called by context manager) |
| `page.cdp.send(method, params)` | Escape Hatch — direct CDP access via WebSocket (see below) |
### Escape Hatch: Direct CDP Access
For use cases the high-level API doesn't cover — network interception, console log subscriptions, performance tracing, cookie management — you can drop down to raw CDP commands:
```python
with chrome.new_page() as page:
page.navigate("https://example.com")
# Enable network tracking
page.cdp.send("Network.enable")
# Get all cookies
cookies = page.cdp.send("Network.getAllCookies")
# Performance tracing
page.cdp.send("Tracing.start", {"categories": "-*,devtools.timeline"})
```
The Escape Hatch communicates directly with Chrome via WebSocket (port 9222), bypassing the server. It connects lazily on the first `send()` call and reuses the connection for subsequent calls. Each page gets its own WebSocket routed to the correct tab. It needs Chrome's debugging port, so it is not available when the server drives a real profile (`--profile`), which runs without one: `/session/create` then returns `cdp_ws_url: null` plus a `cdp_ws_note`, and `page.cdp` raises `RuntimeError`.
### MCP Coexistence
When the MCP server and Python scripts need to run at the same time, add `--script` to the MCP config. `Chrome.connect()` handles the rest automatically — each script works in its own tab, MCP tabs are never touched.
### Enabling `--script` in MCP Config
**Claude Code:**
```bash
claude mcp add --scope user public-browser -- npx -y public-browser@latest -- --script
```
**Cursor / Cline (`mcp.json`):**
```json
{
"mcpServers": {
"public-browser": {
"command": "npx",
"args": ["-y", "public-browser@latest", "--", "--script"]
}
}
}
```
See [`python/README.md`](python/README.md) for the full API reference and advanced examples.
## Node Library API (multiple instances in one process) — perfect for Jev
The MCP server and the Python Script API both drive exactly **one** Chrome per
process. When you need several browsers at once — say a read-only research
browser and a separate action browser per agent — spawning one
`npx public-browser` per instance costs 4–6 s of start-up each. `createSession()`
runs the same session inside your own Node process instead:
```ts
import { createSession } from "public-browser";
const research = await createSession({
cdpUrl: "http://127.0.0.1:9333", // or cdpPort: 9333
userDataDir: "/var/agents/a1/research", // created if missing
headless: true,
stealth: false, // stay identifiable — see below
downloadDir: "/var/agents/a1/quarantine", // never deleted by us
downloadHash: true, // adds sha256 to every download
downloadNaming: "suggested", // real filenames, not GUIDs
cortexDir: "/var/agents/a1/cortex", // per-instance pattern store
inheritEnv: ["HTTPS_PROXY"], // opt in — see Environment below
});
const action = await createSession({ cdpPort: 9334, userDataDir: "/var/agents/a1/action" });
await research.callTool("navigate", { url: "https://example.com" });
const page = await research.callTool("view_page", {});
await research.close();
await action.close();
```
`callTool(name, params)` takes the same tool names and parameters as the MCP
tools (`navigate`, `view_page`, `click`, `type`, `fill_form`, `run_plan`,
`download`, ...) and routes through the identical handlers (Shared Core).
**Isolation.** Each session runs in its own worker thread by default, so the
module-level caches (element refs, selector cache, viewport state, stealth flag,
cortex matcher) exist once *per session* rather than once per process — two
sessions can never hand each other stale element refs.
Measured on macOS with `isolation: "process"`, attaching to a Chrome started
outside Public Browser (a worker thread saves ~40 ms):
| | Median |
|---|---|
| `createSession()` launches its own headless Chrome | ~0.9 s |
| `attach` to a running Chrome, up to the first tool response | ~0.7 s |
| ...through to a real page navigated and read | ~1.8 s |
Most of the attach cost is Chrome starting a renderer for the tab Public
Browser opens for itself — an attached session never takes over tabs that
belong to someone else.
A thread is not a security boundary: same process memory, same file
descriptors. `isolation: "process"` forks one OS process per session instead —
separate heap, separate descriptors, separate crash domain — for integrators
whose trust model draws the line there. `isolation: "inline"` skips isolation
altogether and is only correct when the thread runs exactly one session.
| `isolation` | Boundary | Startup | Use when |
|---|---|---|---|
| `"worker"` (default) | thread — private module caches | ~1 s | several sessions in one trusted process |
| `"process"` | OS process — private memory + descriptors | ~1 s | the sessions must not share a process with the host |
| `"inline"` | none — the calling thread | fastest | exactly one session per thread |
**No listening CDP port (`transport: "pipe"`).** By default Chrome is launched
with `--remote-debugging-port`, which is what makes `--attach`, the Script API
and reconnect-after-crash possible — and which also means every other process
on the machine can drive that browser. For a session holding real logins that
is a way around any permission check you perform yourself.
```ts
const action = await createSession({
transport: "pipe", // no --remote-debugging-port at all
userDataDir: "/var/agents/a1/action",
headless: true,
});
```
CDP then travels over the child's stdio pipe, which only Public Browser holds:
`lsof` shows nothing listening and a second process finds no way in. The price
is everything the port paid for — no reconnect after a Chrome crash, no second
client and no `attach`; combining `"pipe"` with `attach` fails at
`createSession()` rather than at the first tool call. A named `profile` always
runs over the pipe, whatever `transport` says — `"pipe"` only forbids the
random-port fallback Public Browser would otherwise use if Chrome refused the
pipe. `session.transport` reports the actual connection, and `session.cdpPort`
is `undefined` when nothing listens — reporting the default would name
whatever Chrome the user has open on 9222.
**Environment.** A session does **not** start from the host environment. It
starts from a documented minimum and you widen it deliberately — an
orchestrator holding cloud credentials, API keys and tokens should not hand
them to a browser session just because the two share a process tree.
What a session always gets is `ESSENTIAL_ENV_VARS`: `PATH`, `HOME`, the temp
dir, `CHROME_PATH`, locale/timezone, the Linux display variables and the
Windows process basics. Everything else is opt-in:
```ts
// PATH/HOME/CHROME_PATH plus the proxy — and nothing else from the host.
await createSession({ inheritEnv: ["HTTPS_PROXY", "NO_PROXY"] });
// Full inheritance, the pre-2.8 behaviour.
await createSession({ inheritEnv: true });
```
Proxy variables are deliberately *not* essential: a proxy URL can carry
credentials, so it is allowlisted on purpose rather than inherited by accident.
On top of that, a session never inherits Public Browser's own `SILBERCUE_*` /
`PUBLIC_BROWSER_*` configuration variables — in any `inheritEnv` mode. Each of
them has an option here, and a host-level variable, usually set for the *host's*
own Chrome, silently redirecting a configured session is a bug, not a feature:
with `SILBERCUE_CHROME_HOST=10.9.9.9` in the orchestrator's environment, a
session created with `cdpPort: 9450` still talks to `127.0.0.1:9450`. Use `env`
to set one back deliberately.
**Shutdown.** `close()` resolves only once Chrome is actually gone — SIGTERM,
SIGKILL after 5 s — so the port and the user-data-dir are free for the next
launch instead of racing a process that was merely asked to exit.
**One session per Chrome.** Some CDP settings are browser-wide rather than
per-session, `Browser.setDownloadBehavior` among them: two sessions attached to
the *same* Chrome share one download directory, and whichever connected last
wins.
This fails silently and it corrupts the record: the losing session keeps
reporting paths under *its* `downloadDir`, but the file was written to the
other one. `path` then points at nothing, with no error to notice. Give each
session its own Chrome — its own port (or `transport: "pipe"`) and its own
user-data-dir — whenever `downloadDir` matters.
| Option | Default | Description |
|---|---|---|
| `cdpUrl` | — | `http://host:port`, `host:port` or a bare port. Wins over `cdpPort`/`cdpHost` |
| `cdpPort` / `cdpHost` | `9222` / `127.0.0.1` | CDP endpoint this session drives. `session.cdpPort` is `undefined` when nothing listens (`transport: "pipe"`, or a named `profile`) |
| `userDataDir` | — | Chrome `--user-data-dir` for auto-launch. One directory per instance |
| `profile` | — | Named Chrome profile instead of a raw directory. Runs over the pipe — no CDP port. On macOS, site logins do not carry over ([Chrome Profiles](#chrome-profiles)) |
| `headless` | `false` | Launch Chrome headless |
| `stealth` | `true` | `false` disables all `navigator.webdriver` masking |
| `attach` | `false` | Never auto-launch; attach to a running Chrome and fail fast if there is none |
| `downloadDir` | temp dir | Where downloads land. A directory you supply is never deleted |
| `downloadHash` | `false` | Report `sha256` for every completed download |
| `downloadNaming` | `"guid"` | `"suggested"` renames finished files to the server-supplied name |
| `cortexDir` | `~/.public-browser/cortex` | Per-instance cortex store |
| `transport` | `"port"` | `"pipe"` launches Chrome with no listening CDP port (no attach/reconnect) |
| `inheritEnv` | `false` | Essentials only. Array = essentials + allowlist, `true` = whole host env |
| `env` | — | Extra environment variables for the session, applied last |
| `isolation` | `"worker"` | `"process"` for an OS-process boundary, `"inline"` for none |
| `eager` | `false` | Launch/attach during `createSession()` instead of on the first call |
| `startupTimeoutMs` | `30000` | Budget for the session thread/process to report ready |
### Multiple instances via the CLI
The same thing without a Node host — one process per Chrome, each on its own port:
```bash
public-browser --port 9333 --profile research --download-dir /q/research
public-browser --port 9334 --profile action --download-dir /q/action
```
With `--profile` Chrome runs over the pipe and `--port` stays unused; only if Chrome refused the pipe would it get a random port, with a warning.
`--profile ` uses one of your real Chrome profiles. For a throwaway
per-agent Chrome, point at a raw directory instead — it is created if missing:
```bash
public-browser --port 9335 --user-data-dir /var/agents/a3/chrome
```
`--attach` connects to an already-running Chrome on the configured port instead
of launching one. `SILBERCUE_CHROME_PORT` and `SILBERCUE_SCRIPT_PORT` are the
environment equivalents of `--port` and `--script-port` and are part of the
stable public contract.
## Identifiable automation (`--no-stealth`)
By default Public Browser masks `navigator.webdriver` (it reports `undefined`)
and launches Chrome with `--disable-blink-features=AutomationControlled`. That
is the right default for consumer automation, but the wrong one when your
integration must be transparently identifiable as a bot — compliance-driven
crawling, internal agent fleets, or sites whose terms require honest signalling.
Turn the masking off completely:
```bash
public-browser --no-stealth
# or
SILBERCUE_STEALTH=0 npx public-browser
```
```ts
await createSession({ stealth: false });
```
With stealth off, `navigator.webdriver` stays `true` **and** keeps its native
getter (`Object.getOwnPropertyDescriptor(Navigator.prototype, "webdriver").get`
still reports `[native code]`) — permanently, across navigations and tab
switches, with no post-correction needed on your side. No masking script is
injected at any point and the launch flag is omitted.
## Downloads
Downloads land in a per-session temp directory that is removed on shutdown.
Point them at a directory of your own — a quarantine dir, a shared volume — with
`--download-dir` / `PUBLIC_BROWSER_DOWNLOAD_DIR` / `downloadDir`. A directory you
supply is created if missing and **never** deleted by Public Browser.
With `--download-hash` (or `downloadHash: true`) every completed download also
carries a `sha256`, so the `download` tool returns path, size and digest:
```json
{"filename":"report.pdf","path":"/q/research/A1B2...","size":48213,"sizeKb":48,
"url":"https://example.com/report.pdf","sha256":"9f86d081884c7d659a2f..."}
```
**Filenames.** Chrome writes downloads under their internal GUID, so the file on
disk is called `A1B2...` and only the `filename` field carries the real name.
That is fine when you read the JSON, and useless when something else has to walk
the directory. `--download-naming suggested` (or `downloadNaming: "suggested"`,
`PUBLIC_BROWSER_DOWNLOAD_NAMING=suggested`) renames each finished file to the
server-supplied name:
```json
{"filename":"report.pdf","path":"/q/research/report.pdf","size":48213,"sizeKb":48,
"url":"https://example.com/report.pdf","sha256":"9f86d081884c7d659a2f..."}
```
The name is sanitised before it touches the disk — basename only, no control
characters, never hidden, length-capped — and a collision gets a `-1`, `-2`, ...
suffix rather than overwriting an existing file. `filename` always reports the
name the file actually has, so `join(downloadDir, filename)` equals `path`. If
the rename fails, the GUID path and the raw server name are kept and reported;
a download is never lost to a naming problem.
**Timing.** `action: "status"` waits up to 250 ms for a download to *start*
before reporting that there is none, because Chrome fires `downloadWillBegin` a
few milliseconds after the click that triggers it — without the window, the
first call after a click misses a file that is already on its way. Adjust it per
call with `settle` (`{"action":"status","settle":0}` for an instant check,
`5000` for a slow server). Once a download has started, `status` waits for it to
finish, bounded by `timeout`.
**For polling loops use `action: "list"`** — it returns the full session history
immediately and never waits, for either a start or a completion.
## Tool Overview
| Tool | Description |
|---|---|
| **Reading & Observation** | |
| `view_page` | A11y-tree with stable `e`-refs — primary way to understand the page. `filter: "interactive"` (default) returns the elements an agent can act on; `filter: "all"` adds headings, paragraphs and other static text. |
| `capture_image` | WebP screenshot, max 800px, <100KB. For visual verification only — refs come from `view_page`. |
| `console_logs` | Browser console output with level/pattern filters |
| `network_monitor` | Start/stop/query network requests with filtering |
| `observe` | Watch DOM changes: `collect` (buffer over time) or `until` (wait for condition, then auto-click) |
| `wait_for` | Wait for element visible, page **text**, **URL**, network idle, or JS expression. `assert: true` checks once and fails with a typed `code` instead of waiting |
| `tab_status` | Active tab's cached URL/title/ready/errors (0ms) |
| `virtual_desk` | Lists all tabs with stable IDs. Call first in every session. |
| `dom_snapshot` | Bounding boxes, computed styles, paint order. For spatial questions `view_page` cannot answer. |
| **Interaction** | |
| `click` | Real CDP mouse events by ref, selector, text, or coordinates. The answer names the element it hit (`Clicked [e12] button "Save"`). The DOM diff (NEW/REMOVED/CHANGED) arrives with the next page action, or in this one with `wait_for_diff: true`. |
| `type` | Type into an input by ref/selector |
| `fill_form` | Fill a complete form in one call — text, `