Skip to main content
Glama

agent-browser

A general-purpose browser body for an AI agent. It drives a real, persistent Chrome on your machine over MCP — the agent reads pages as accessibility trees and acts on them by name. Any site, any flow, no site-specific glue.

snap = agent_browser_visit(url="https://news.ycombinator.com")   # READ
agent_browser_act({ name: "Sign in", action: "click" })          # DO

That's the whole API. No refs to track, no snapshot generations, no selectors.

Why this and not Playwright

  • Stealth-first. No CDP, no chrome.debugger, no Playwright/Puppeteer, no automation flags. The browser is an ordinary Chrome — automation fingerprints are the primary bot-detection signal, and this avoids all of them.

  • Persistent identity. One profile at ~/.agent-chrome/profile. Cookies and logins survive reboots, so the agent stays signed in to whatever you signed it in to, once.

  • Accessibility-tree perception, not pixels or raw DOM — compact enough to hand an LLM directly, stable enough to act on.

Related MCP server: ecobrowser MCP server

Architecture

┌──────────────────── Host (macOS) ─────────────────────┐
│                                                        │
│  Chrome (persistent profile, no automation flags)      │
│   └─ Agent Bridge MV3 extension ──┐                    │
│                                   │ ws 127.0.0.1:8787  │
│  mcp-server (daemon) ◄────────────┘                    │
│   └─ HTTP MCP :8788   → any HTTP-capable MCP client    │
│        ▲                                               │
│  launcher (stdio, one per client session)              │
│   └─ lists tools always; starts the daemon on demand   │
│        ▲  Claude Code et al.                           │
│                                                        │
│  host-helper :8789 (launchd, optional)                 │
│   └─ one job: spawn GUI Chrome on request              │
└────────────────────────────────────────────────────────┘

First call on a fresh machine

Most of it an agent can do itself; one step only a person can.

Who

Step

agent

agent_browser_status — is the server up, which build, is the extension current?

agent

agent_browser_ensure_running — starts the server and Chrome as needed

person, once

chrome://extensions → Developer mode → Load unpacked → packages/extension. Chrome allows no other way to install it. Afterwards the extension reloads itself when its files change; if agent_browser_status ever says extension.stale, press Reload on its card once.

When it is down

  1. agent_browser_status. If staleBuild is true: agent_browser_ensure_running({restartIfStale: true}).

  2. agent_browser_ensure_running.

  3. Still failing: the error's code and humanAction say what is wrong. Tell the person in one sentence, and record the step as not done (carry the result's degraded entries into the final report) — never report a page that was not reached as empty.

Install

Two commands, and one click that Chrome insists a person makes.

npm install
npm run setup

npm run setup checks the machine, builds, writes the bridge secret, installs the server as a login item, launches Chrome on the agent profile, and verifies the whole chain. It is safe to run again at any time; it only changes what is missing or out of date.

The one manual step is loading the extension into Chrome — no Chrome release lets a script do it. Setup opens the right page and waits; in that window:

  1. chrome://extensions

  2. Developer mode (top right)

  3. Load unpacked → the path setup prints

Then npm run setup again, or npm run doctor, until it says Ready.

Command

npm run setup

install and verify everything

npm run doctor

check only, change nothing (non-zero exit when not ready)

npm run setup -- --with-input

also set up real OS input (macOS: one Accessibility grant)

npm run setup -- --no-daemon

do not install the login item

npm run setup -- --fresh

reinstall dependencies first

Requirements: Node 20 or newer, and Google Chrome or Chromium. macOS gets the full set; on Linux the server and browser work, the login item and real input are macOS-only.

One install per machine: the server owns fixed localhost ports, so a second clone would fight the first. Setup notices and stops with the path of the one already running.

Setup registers the launcher with Claude Code when the claude CLI is present. For any other client, register this (not the daemon's HTTP address):

claude mcp add agent-browser --scope user -- "$(which node)" "$PWD/packages/mcp-server/dist/launcher.js"

Register the launcher, not the daemon's HTTP address. A client only reads a server's tools when a session starts: registered by URL, a session that starts while the daemon is down gets no browser tools at all, and nothing inside the session can bring them back. The launcher always starts, lists the tools from the same code the daemon runs, and starts the daemon on the first call that needs it. agent_browser_status reports "off" without starting anything, and nothing starts while the kill switch is on.

Clients that only speak HTTP can still use http://127.0.0.1:8788/mcp directly.

When Chrome isn't running, the browser tools launch it themselves — the daemon runs in your GUI login session, so it can open Chrome directly. The host helper is only needed when the server has no GUI session (for example, run over ssh or in a container):

npm run host-helper:install

When it is installed and answering, launches go through it; otherwise the server spawns bin/launch-chrome.sh itself.

Why a daemon rather than stdio

The extension holds a persistent WebSocket to the bridge, so the bridge has to outlive any one client. A stdio MCP server lives only as long as the client that spawned it — between sessions the extension has nothing to connect to. And since the server owns fixed ports and exits if they're taken, two concurrent stdio clients fight and the second one dies. One long-lived server fixes both: any number of clients attach over HTTP.

The launcher is a thin stdio front for that one daemon, so many sessions share it. (npm run mcp runs the full server on stdio instead; don't run it beside the daemon.)

npm run mcp:status

is it up?

npm run mcp:restart

reload after npm run build (loads it if it was stopped)

npm run mcp:stop

stop it; it starts again at login or on the next browser tool call

npm run mcp:logs

follow stderr (says why it stopped)

npm run mcp:uninstall

remove the launchd agent

Troubleshooting

Symptom

Fix

No agent-browser tools in a session

The client is registered by URL, or the registration is missing. Register the launcher (above), then start a new session — or /mcp → reconnect.

Could not find service "com.agent-browser.mcp-server"

Old mcp:restart; update, or run npm run mcp:install.

Tools answer HALTED

The kill switch is on (~/.agent-chrome/HALT). That is the user's call: npm run resume.

Sessions: one agent, one window

Each MCP connection (one launcher — one agent conversation) gets its own browser window, opened in the background on its first visit. Its calls act on its own tab and nothing else: two agents testing the same page never see each other's typing or navigation, and an agent never lands in a tab the user has open. Every tool takes an optional session:

session

Meaning

omitted

your private window

"name"

another private window of yours (for a subagent sharing your connection)

"shared:<name>"

one tab shared by every caller passing the same name; each result says who else acted since your last call (lastActor, changedSinceYourLastCall), and lock: true leases it

"default"

the old behaviour — whatever tab is focused — with a warning

Cookies and logins are not separated: there is one browser profile, so a login in one session is a login in all. Use test accounts you are happy to leave in that profile; a page's attempt to save a credential (navigator.credentials.store) is refused and reported.

Results

Every result carries degraded: string[] — what could not be done — so a caller can carry it into its report. Every failure carries a code (NEEDS_HUMAN, OCCLUDED, NO_MATCH, STALE_SNAPSHOT, TAB_GONE, HOVER_NOT_EFFECTIVE, …), agentCanFix, fixSteps and humanAction, beside the error and recovery prose. A captcha or a notice in the way comes back as outcome: "needs_human" with the blocking element and a screenshot.

Tools

Primary

Tool

What it does

agent_browser_visit({url} | {history}, text?, snapshot?, details?)

Navigate your session's tab — or go back / forward / reload — wait for the page to settle, return an accessibility tree. text: true adds the visible text; snapshot: "none" drops the tree; details: true adds hrefs and srcs. Failed requests and console errors since the call began are summarised in diagnostics, the API calls the page made in network.

agent_browser_act({name, role?, action, value?, to?, snapshot?, onDialog?})

Find an element by accessible name, act on it, wait, return the new tree. A failed action is an error; one that changed nothing says noEffect. drag takes to (an element or a point) and at/toAt as percentages. waitForResponse: {urlPattern} also returns the response the action made the page fetch.

browser_observe({mode: "read", offset?})

The page's visible text, paged, including same-origin iframes.

browser_screenshot({fullPage? | name? | ref? | clip?})

Viewport, whole page (stitched), one element, or a region — after the page settles (settled, settleReason), with documentGeneration and layoutHash to tell two captures of a changed page apart. An element is centred and any fixed bar over it hidden; something else covering it is an OCCLUDED error. Saved to disk as evidence.

browser_inspect({name | ref})

One element's state: attributes (src, href, every aria-*), value, checked, media state, box, visibility, topmost-at-centre.

browser_eval({expression})

A JS expression in the page, its value as JSON — app state, video.readyState. Refused on eval.blockedDomains.

browser_network({urlPattern?, status?, type?, includeBodies?, action?})

What the page sent and received: fetch/XHR bodies (JSON parsed), WebSocket and EventSource messages, headers and timing. action: "export" writes a HAR or NDJSON file. See Network capture.

browser_diagnostics({since?})

Failed requests (every host); console errors, exceptions and rejections (development hosts).

browser_jobs({action})

Background jobs: keep a page open, record it, work it with steps, heal it. See Background jobs.

browser_pointer({sequence})

A down / move / up / wheel sequence, for gestures drag does not cover.

browser_tabs({action})

List, switch, open or close tabs.

browser_scroll({to | y})

Scroll; reports atBottom and how much an infinite feed grew.

browser_wait({text? | name? | role?, gone?} | {response})

Wait for text or an element to appear or go; for a response ({urlPattern}, up to 5 minutes, returned parsed); with none, for the page to go quiet.

agent_browser_status()

Is the browser ready? No side effects.

agent_browser_ensure_running()

Idempotent launch (auto-fires on first visit).

action is one of click | type | fill | clear | press | hover | focus | check | uncheck | submit | select | drag. Name matching is case-insensitive: an exact name wins over a prefix, a prefix over a substring, a control over a heading; pass role when a name is still ambiguous.

Events are synthetic (isTrusted: false) — the price of having no CDP. Hover applies the page's own :hover rules and fails with HOVER_NOT_EFFECTIVE when nothing changes; drag works with pointer capture and HTML5 drag-and-drop. Things that need a real user gesture — video.play() with sound, requestFullscreen — are refused by Chrome, and the refusal shows up in diagnostics. snapshot is full (default), viewport, or none — use the smaller ones for a run of fills.

Advanced primitives

browser_status · browser_ping · browser_locate · browser_navigate · browser_act · browser_robust_click · browser_wait_for_navigation

Ref-based, for surgical control. Most agents never need them.

Two things that make it work on real sites

Dead-click escalation. SPA buttons routinely ignore a synthetic click from an extension's isolated world — the handler is a React fiber prop, hydration hasn't attached the listener yet, or the effect is gated on the tab being focused. When a click provably changes nothing, agent_browser_act waits to confirm, then re-clicks inside the page's own JS world: hydration wait → focus and visibility spoof → authentic pointer/mouse sequence → native click → React fiber onClick → formAction POST, reporting which strategy landed. The confirm step exists so a slow-but-successful handler is never clicked twice; pass robust: "never" on non-idempotent buttons.

Redirect capture. Every top-frame navigation goes into a ring buffer, so browser_wait_for_navigation({urlPattern}) can answer "did we pass through a URL matching this, and what were its query params?" after the chain settles. That's what makes OAuth consent, SSO hand-off, payment returns, and magic links tractable — you never have to out-run a redirect.

Real input, viewports and mobile

Synthetic events from an extension carry isTrusted: false, and Chrome refuses some things without a real gesture: sound, fullscreen, some native dialogs. agent_browser_act({..., trusted: true}) posts the click or hover from the operating system instead, through a small macOS helper — the page sees a person's input. The agent's window comes forward for the instant of the gesture and focus goes back afterwards; the cursor is put back where it was.

npm run input-helper:install    # build + start it (asks for Accessibility once)
npm run input-helper:status     # running? allowed?
npm run input-helper:uninstall

macOS needs a one-time Accessibility grant: System Settings → Privacy & Security → Accessibility → Agent Browser Input. It is a separate app so the grant covers nothing else; after a rebuild macOS treats it as new and asks again.

Option

On

Gives

trusted: true

agent_browser_act (click, hover)

a real gesture: sound without a click first, fullscreen, native dialogs

viewport: {width, height, deviceScaleFactor}

agent_browser_visit (kept for the session)

a real layout at that size — 375px at 3×, media queries included

mobile: true

agent_browser_visit (kept for the session)

a phone's User-Agent and client hints, touch, no hover, a coarse pointer (an approximation, no CDP)

sound: true

agent_browser_visit

this session's page may be heard (agent windows open muted)

allowPopups: true | false

visit, act

let the site open a popup without a click (or stop it) — audit-logged

Network capture

chrome.webRequest sees every request's headers but never a body, so the extension also records from inside the page: a script in the page's own world, loaded before any of the page's scripts, records what fetch, XMLHttpRequest, WebSocket and EventSource send and receive. An agent reads the data a page loaded — a product list, search results, a dashboard's readings — instead of scraping it off the screen.

agent_browser_act({ name: "Search", waitForResponse: { urlPattern: "/api/search" } })
// → response.json: the results the page fetched
browser_network({ urlPattern: "/api/", includeBodies: true })
browser_network({ action: "export", format: "har" })   // → a file devtools opens
  • Where: every page except network.blockedDomains (identity, payment and banking hosts by default; edit in config/local.json). On those, only what webRequest sees: status and URL, no headers or bodies.

  • Secrets first: before anything is stored, the listed headers (Authorization, Cookie, Set-Cookie, API keys…) are removed, along with any JSON, form or query field whose name matches network.redactKeys, JWTs, and values the agent typed into password fields. Cookies never reach the extension at all.

  • The page must not notice: each patched function is a Proxy of the original and reports itself as native code; fetch hands back the browser's own promise; only a clone of a response is read. Cross-origin frames (mostly ads and fraud scripts) are left alone. A page that compares functions against a pristine frame's copy could still tell — that is the remaining risk.

  • Kept: in the browser (IndexedDB in the agent profile), per tab, up to maxEntriesPerTab and retainHours; cleared when the tab closes and when the browser restarts. It is written to disk only when exported, or by a job.

  • Limits: bodies up to maxBodyChars; images, fonts, media, scripts and streams are recorded without bodies; WebSocket messages are capped per socket. Named EventSource events (event: foo), requests from workers, and pages opened before capture was on are not recorded (reload the page).

Background jobs

A job keeps a page open in a window of its own and writes the responses that match to ~/.agent-chrome/jobs/<id>/<date>.ndjson, for as long as it runs: hours or days, beyond any conversation. Start one only for a page you want watched or recorded over time.

npm run ab -- jobs start --id readings --url https://dash.example/overview \
  --capture /api/readings --health-text '°C'
npm run ab -- jobs status readings
npm run ab -- jobs tail readings --follow
npm run ab -- jobs stop readings

When the page stops answering — its requests fail, or the health text is gone — the job climbs a ladder: reload, then navigate again, then reopen the window, then stop and say so (needs-attention, ab jobs status --check exits 3). Only a real recovery clears the ladder: good responses and a quiet spell. A cached answer right after its own reload does not count. Nothing is done while a page loads, and page loads, scheduled refreshes included, are capped per hour (jobs.defaults.maxLoadsPerHour), so a sick site is never hammered.

Steps: what a job does on its page

A job can work its page after each load. A page often needs a click before it shows anything: accept a cookie banner, pick "Today", press Refresh.

npm run ab -- jobs start --id dash --url https://dash.example/overview \
  --capture /api/readings \
  --step dismiss-consent --step 'click?:Accept all' \
  --step 'click:Today@tab' --step 'click:Refresh'

--step

Does

dismiss-consent

Clicks the accept button of a cookie or consent banner, when one shows

click:Name

Clicks the element with that accessible name (add @role to narrow it)

click?:Name

The same, skipped when it is not there (a banner that only sometimes shows)

fill:Field:value / type:Field:value

Types into a field

fill:Field:@secret

Types a secret you approved (below)

press:Field:Enter, select:Field:Option, check:Name

As they read

wait:Text

Waits until that text is on the page

At most 10 steps, run once per page load after jobs.defaults.pageGraceMs. A step that finds nothing (unless marked ?) counts as the page being unwell: the healing ladder takes over. No step ever double-fires: steps use a plain click, never the dead-click retry or real OS input.

Secrets: a password, code or card a job may type

A job never types into a password, one-time-code or card field on its own. It does it only from a secret you approve for a named host:

npm run ab -- secrets set shop-login --kind password --host login.shop.example
npm run ab -- secrets set shop-2fa   --kind totp     --host login.shop.example
npm run ab -- secrets list          # names, kinds and hosts — never values
npm run ab -- secrets remove shop-login

Then --step 'fill:Password:@shop-login'. Kinds: password, totp, card-number, card-expiry, card-cvc, text.

  • Where it is kept: the macOS login Keychain. secrets.json holds only the name, kind and hosts. (Off macOS: a 0600 file in the state dir, and the CLI says so.)

  • Where it is used: only on the hosts you named (or their subdomains). A redirect to a look-alike host gets nothing, and the job stops and says so.

  • Where it shows up: nowhere. Not in jobs.json, the job log, the audit log, or the recorded traffic: the value is redacted from captures before they are stored, whatever the field is called.

  • TOTP stores the authenticator app's seed (base32 or the otpauth:// link) and computes the six-digit code at the moment it is needed (RFC 6238), so a 2FA login can run unattended. An SMS code cannot: it is different every time, so that step still needs a person.

  • SMS and push approvals still need you. A job that meets one flags needs-attention and waits.

The server drives jobs, not the page: they survive the extension sleeping and the server restarting (it finds the tab again). The kill switch pauses them; what the page recorded meanwhile is written when it is lifted. If the browser stays disconnected — on macOS, a Chrome with no window open has unloaded the extension — the server opens a window, a few times a day at most. Records are removed after evidence.retainDays.

From a terminal

Everything an agent's tools can read or start, npm run ab does too, with --json for scripts:

npm run ab -- status
npm run ab -- tabs
npm run ab -- network --tab <id> --url /api/ --bodies
npm run ab -- network --tab <id> --export har
npm run ab -- jobs list
npm run ab -- secrets list

Safety

Navigation and ordinary interaction are unrestricted — the agent can go anywhere and click anything. The only gate is action class: a caller can label an action payment, delete_destructive, credential_entry, or file_upload, and those are refused off policy.trustedDomains.

Letting an agent ask, and you click approve. Your own domains live in config/local.json, which is never committed:

{ "policy": { "trustedDomains": ["shop.internal"] } }

When a block names a domain, the agent can propose exactly that edit; Claude Code shows you the diff and you approve it in chat. The server re-reads the file when it changes, so the next action is allowed — no restart. The agent never writes the file itself: your approval is what makes the change happen.

  • Kill switch: touch ~/.agent-chrome/HALT blocks every action. npm run resume to clear.

  • Dry-run: AB_DRY_RUN=1 reports intent without executing.

  • Activity feed: npm run activity (~/.agent-chrome/audit.log).

  • Redaction: password / OTP / card fields are stripped from snapshots (policy.redactSelectors), and secrets from recorded traffic (see Network capture).

  • Web pages cannot drive it: the control port listens on 127.0.0.1, which any web page in any browser on the machine can reach. Requests carrying what only a browser adds (Origin, a cross-site Sec-Fetch-Site) or naming another host (DNS rebinding) are refused and audit-logged.

Layout

  • bin/ — Chrome launcher, profile reset

  • packages/mcp-server/ — MCP server + bridge + control plane (TypeScript)

  • packages/extension/ — MV3 Agent Bridge: service worker, perception/action engine (src/content), the page-world network hook (src/main), and its store, redaction and joining (src/lib)

  • host-helper/ — optional launchd daemon that spawns GUI Chrome

  • scripts/ — ab.mjs (the terminal client), setup, bridge-secret sync, smoke + action tests

  • skills/ — the persistent-web-engine agent skill

  • config/default.json — policy, bridge, profile paths

Troubleshooting

Symptom

Fix

bridge not connected

Is Chrome open on the agent profile? Is the extension loaded and enabled?

Extension loaded but never connects

Re-run node scripts/sync-ext-secret.mjs, then reload the extension.

Edited extension code, nothing changed

The extension compares its files with what it runs and reloads itself on start and on every reconnect. If agent_browser_status still says extension.stale (or a call fails EXTENSION_STALE), hit reload ↻ on chrome://extensions once.

staleBuild: true in a result

The server runs older code than dist/: npm run mcp:restart, or agent_browser_ensure_running({restartIfStale: true}). A launcherWarning means the client's tool list is old: reconnect it (/mcp).

Daemon crash-looping in the logs

Something else holds 8787/8788. The ports are single-owner; stop the other instance.

Service-worker lifetime. Chrome tears down an extension service worker after ~30s idle, which would drop the bridge between tool calls and leave it down until the extension's alarm backstop fired up to a minute later. The server sends a heartbeat every 20s; receiving a WebSocket message resets that idle timer, so the worker stays resident while the browser is open. Calls that do arrive during a genuine reconnect (browser just started, extension reloaded) wait up to 8s rather than failing immediately.

Tests

npm test                 # unit tests, no browser (also in CI)
npm run test:e2e:setup   # once: a person loads the extension into a test profile
npm run test:e2e         # end to end, in real Google Chrome

The end-to-end tests run a second, isolated instance — their own server on free ports, real Google Chrome on ~/.agent-chrome-test/profile, local fixture pages in test/fixtures — and never touch your agent browser, its profile or its server. Chrome no longer loads extensions from the command line, so the test profile is set up like the agent profile: the extension is loaded once by hand.

Uninstall

npm run host-helper:uninstall
claude mcp remove agent-browser --scope user

Related MCP Connectors

Related MCP Servers