•
6 min read
how a tool call reaches a tab in the chrome you already use
autopilot browser-automation mcp chrome

Every browser automation tool for AI agents picks one of two families. Launch a clean browser the agent owns, with no logins and a clean process boundary, which is what Playwright and the DevTools MCP do. Or drive the browser the person is already signed into, which is what Anthropic’s Claude in Chrome does. Autopilot is in the second family, with one extra rule: the agent works in the background. Nothing on screen moves while it runs.

Picking the second family means giving up the clean process boundary that the first gets for free. Everything hard about the project follows from that choice. This post walks the path a tool call takes from an MCP client to a tab, says why each hop exists, and ends on the rule.

why an extension

A signed-in profile is the point. An agent that can read the page I am looking at, in the account I am logged into, without a second browser or a second set of credentials, is useful in a way a clean headless browser is not. Chrome offers exactly one supported way in: an extension. Since Chrome 137, the --load-extension flag no longer works for arbitrary builds, so the extension is loaded unpacked through a manual click-through. That is the first known limit and it is listed in the README as one.

An extension cannot listen on a socket and cannot be spawned by an outside process. It can open a native messaging port to a host process that Chrome itself spawns. So the chain has to bridge from something an MCP client can run to something Chrome will talk to.

the four hops

MCP client --stdio--> mcp-server --named pipe--> native-host --native messaging--> extension --CDP--> page

stdio. Every MCP client can run a command. Claude Code, Claude Desktop, Cursor, VS Code, Codex, Gemini CLI, and OpenCode all configure a server the same way: a command line, nothing else. So the server is one Node process per client session, speaking JSON-RPC over stdin and stdout, exposing 26 tools. No port, no URL, no config beyond the path to the script.

Named pipe. The native host is one process per connected browser, and Chrome spawns it rather than the user. The MCP servers cannot be its children. So the host listens on a local pipe and each MCP server connects to it. This direction was forced by a bug. The first version had the servers listen and the host connect, and the day a second browser profile was connected, both hosts fought over the same server. With the host listening, any number of servers can attach to any number of browsers, and a select_browser call picks which.

Native messaging. Chrome’s protocol between an extension and its host is length-prefixed JSON frames with a 1 MB cap per message. A screenshot or a large page read exceeds that, so anything over 384 KB is chunked on one side and reassembled on the other, with the chunk threshold set well under the cap to leave room for the envelope.

CDP. Inside the browser, reads go through a content script and input goes through the Chrome DevTools Protocol via the extension’s debugger permission. The split is decided by one property: isTrusted. A synthetic event dispatched from a content script carries isTrusted: false, and real sites check it. A click dispatched through the debugger is indistinguishable from a real one. Reads do not need that, so they stay on the cheaper path.

what keeps the chain alive

A Manifest V3 service worker is killed after thirty seconds idle. The host pings the extension every twenty seconds over the port, and port traffic resets the idle timer, which keeps the worker alive for the life of the connection. A request has a 120-second ceiling.

Restarts are the interesting case. A tool that finishes after the MCP server that requested it has died belongs to a session that no longer exists. Both sides carry a generation counter. The extension bumps it on every port disconnect, the host learns it from the hello frame, every envelope carries it, and a late result from a previous generation is dropped and recorded in the action journal, because a silently missing reply is exactly the thing this guards against. A response whose requesting socket died is parked under the session id and replayed to the next connection presenting it, which turns an MCP server restart mid-call into a late result instead of a lost one.

One more two-line fix from the same area: after switching browsers, the first call failed because the old socket’s late close handler cleared state belonging to its replacement. The close and message handlers now check if (link !== socket) return. Every long-lived connection needs that guard and most first versions do not have it.

background mode is a rule

Nothing in the codebase calls chrome.tabs.update({active: true}) or chrome.windows.update({focused: true}). Tabs open unselected in the window the person is already looking at, grouped, with the group title carrying the call status as a mark: an hourglass while running, a check when finished, a cross when failed. Revealing a session’s tab from the popup is the only path that brings one forward.

That rule creates most of the work. Chrome throttles a hidden tab: it holds input acknowledgements, stops animation frames, and reports itself hidden. On attach, the extension sends two commands, Emulation.setFocusEmulationEnabled and Page.setWebLifecycleState with active. Measured on Chrome 152, the tab then answers a mouse event in 1 ms, runs animation frames, keeps timers accurate, and reports itself visible and focused, including when the window is minimized. Screenshots of a hidden tab are the exception and got their own path, which is a post of its own.

the doctor

A four-process chain has four places to break. npm run doctor runs nine checks in order and names the first broken link: the manifest carrying the pinned key, the native host manifest written and registered with Chrome, the wrapper pointing at a real node binary, a browser connected, the extension attached, the running extension version matching the manifest on disk, and so on. That last one exists because Chrome keeps a compiled module cache for extension workers across restarts and can run stale code while serving the edited file. Every change under the extension directory bumps the manifest version, and the doctor printing the running version is the only proof a reload took.

what it cost

The honest list. Installation is a manual click-through. The debugger banner stays visible, because hiding it would be the wrong kind of clever. Cross-origin iframes are leaves in the tree. The named pipe has no authentication, so any local process that can open it can drive the browser, which is the same trust boundary as the user’s own session but worth stating. Chrome for Testing 152 disables an unpacked extension outright on chrome.runtime.reload(), deterministically, which removes the normal developer reload path and is why the launcher script deletes the module cache instead.

Twenty thousand lines of source, fourteen thousand of tests, one runtime dependency, and a browser I never have to look at while it works.