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.
Install
Section titled “Install”npm i -g @marrowdev/browse-mcp0.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.
The split it is built around
Section titled “The split it is built around”| who does it | |
|---|---|
| Sign in, 2FA, CAPTCHA, “confirm it’s you” | the person |
| Everything past that — read, click, fill, paginate, extract | the agent |
| Deciding what to do | the agent’s model, wherever it lives |
| Holding credentials | nobody. 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.
The tools
Section titled “The tools”Eight, over stdio MCP:
| tool | what it does |
|---|---|
browse_open | open a URL, return a session and an affordance map |
browse_act | click / fill / select / press / scroll / wait / snapshot, then re-perceive |
browse_read | the visible text of the page |
browse_paginate | walk “next” across a list and accumulate |
browse_wait_for_human | hand the page to the person and wait for a named outcome |
browse_list | which sessions are alive |
browse_close | close one page — the browser stays up |
browse_shutdown | quit 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”- Every stop has a name —
rate-limited,blocked,covered by …. A wall is never reported as a completed job. - It does not solve CAPTCHAs. It hands them to you (
browse_wait_for_human). No stealth, no fingerprint games, no “undetectable” mode. - 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.
- 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.
It will not press Delete
Section titled “It will not press Delete”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.