Skip to content

Drive your own browser

Most of the web an agent would actually be useful on is behind a login. Not a paywall — a dashboard. Stripe, Cloudflare, your registrar, your bank’s business portal, the internal tool nobody has documented since 2019.

Crawlers cannot reach those pages, and the hosted “agentic browsers” that can are asking you to type your password into someone else’s browser, running in someone else’s data centre.

marrow-browse takes the other route: a local MCP server driving a real Chrome on your own machine, in a profile that stays yours. You sign in — through 2FA, through the CAPTCHA, through whatever your company’s SSO does. The agent takes it from there.

Terminal window
npm i -g @marrowdev/browse-mcp

0.1.4, published 2026-08-09. Then point your MCP client at it — for Claude Code, in ~/.claude.json:

"marrow-browse": {
"command": "marrow-browse",
"env": {
"MARROW_BROWSE_HEADLESS": "0",
"MARROW_BROWSE_IDLE_MS": "1800000"
}
}

Start with MARROW_BROWSE_HEADLESS=0 so the window is visible: the first thing you do is sign in by hand, once, in that window.

who does it
Sign in, 2FA, CAPTCHA, “confirm it’s you”the person
Everything past that — read, click, fill, paginate, extractthe agent
Deciding what to dothe agent’s model, wherever it lives
Holding credentialsnobody. Your browser’s own autofill does it

Marrow is the body, not the brain. It has eyes (a map of what is on the page), hands (click, fill, press), and a mouth for handing control back to you. It does not decide anything, which is why the model you point at it is entirely your choice.

Eight, over stdio MCP:

toolwhat it does
browse_openopen a URL, return a session and an affordance map
browse_actclick / fill / select / press / scroll / wait / snapshot, then re-perceive
browse_readthe visible text of the page
browse_paginatewalk “next” across a list and accumulate
browse_wait_for_humanhand the page to the person and wait for a named outcome
browse_listwhich sessions are alive
browse_closeclose one page — the browser stays up
browse_shutdownquit the shared browser (you will rarely want this)

Elements are addressed by ref — @e12 — read off the latest map. Refs are renumbered every time the page is perceived, so after anything moves, take a fresh snapshot rather than reusing old ones.

What the agent sees, and what it never sees

Section titled “What the agent sees, and what it never sees”

Every act returns the new map prefixed by changed: — naming which half moved: state (a control that is still on screen flipped — checked, expanded, disabled, or a field whose contents changed), membership (which affordances exist changed — navigation, a modal, or merely scrolling), both, or no.

That distinction is the point. “The click did nothing” and “the click worked and the page looks the same” are different facts, and an agent that cannot tell them apart will retry forever or declare victory wrongly.

Text fields report empty / len=N — the length, never the contents. A password or a one-time code reports only filled; a select reports option=N, not the label. So a fill is verifiable without the value ever entering the model’s context — including when the model is a hosted one you do not control.

A blocked click says what is on top of it (covered by div#overlay), and rows marked {offscreen} were never hit-tested — scroll to one before trusting it can be clicked.

The rules it will not break for convenience

Section titled “The rules it will not break for convenience”
  1. Every stop has a namerate-limited, blocked, covered by …. A wall is never reported as a completed job.
  2. It does not solve CAPTCHAs. It hands them to you (browse_wait_for_human). No stealth, no fingerprint games, no “undetectable” mode.
  3. It never holds a password. It holds sessions. Credential entry is your password manager’s job, and your Chrome profile’s autofill is already good at it.
  4. URLs and notifications carry origin, path and parameter names — never the values, never what you typed.

These are contract, not manners: the deciding model is remote and knows only what the body told it. Returning blocked and rate-limited under one name would not be impolite, it would be broken.

Your profile, and why it is a separate one

Section titled “Your profile, and why it is a separate one”

Sessions live in a dedicated Chrome profile (~/.local/share/marrow/profiles/default on Linux), not your everyday one. Two reasons, and the second is the one that matters in a regulated shop:

  • Chrome allows one process per profile directory, so pointing at your daily profile means fighting your own browser for a lock.
  • With a separate profile you can say, truthfully, that your personal browsing history, extensions and logins were never in scope.

What survives a restart is what the site allows: a “remember me” token, if the site issues one. Session cookies do not survive the browser quitting — which is why browse_close (close a page) and browse_shutdown (quit the browser) are two different tools. Keep the browser up and even sites without remember-me stay signed in.

In navigation work the frightening button is not the CAPTCHA, it is Delete. Shipped 2026-08-09.

Destructive controls are not hidden. Hiding one makes a remote model read “not here” and go hunting for another route — a menu, a keyboard shortcut, another page. Instead the control stays on the map, marked {human-only}, and acting on it comes back as a named refusal that points at browse_wait_for_human, so you take the page and press it yourself if it should be pressed.

There is no approval to grant. Not “permissions we decline to store” — the concept does not exist in the tool. Nothing to leak, nothing to go stale, and no dialog that gets clicked through on the fiftieth time.

What trips it is two things at once: the page’s own declaration that a control is dangerous (btn-danger, variant="danger", destructive — token names, so it works in any language), and a multilingual word list (delete / remove / revoke / cancel subscription / 削除 / 退会 / löschen / supprimer / удалить / 删除 / 삭제 …).

Cancel is deliberately not on that list, and neither is an enclosing red box. A guard that stops too much gets approved reflexively and is then worth nothing — so we measured the cost of the narrower rule instead of assuming it. Across ~1,500 controls on nine public pages, an enclosure rule would have marked “More info” on a red statistic tile: in the Bootstrap family danger is overwhelmingly a colour, not a claim about consequences. GitHub’s “Danger Zone” is the exception that made the idea look obvious. So the guard reads the control itself, and says so.