Claude Code can drive Chrome natively through the Claude in Chrome extension, and it will not do it from WSL2. The reason is structural rather than a missing flag. The extension runs inside Windows Chrome, which discovers native messaging hosts through the Windows registry, while Claude Code running in WSL installs its host manifest into the Linux filesystem at ~/.config/google-chrome/NativeMessagingHosts/
, pointing at a Linux binary. Neither half can see the other. On my machine the Linux manifest is sitting there and the Windows registry key simply doesn't exist, so /chrome
reports "Extension not detected" and no amount of reinstalling changes that. The docs say WSL is unsupported. There are open issues that suggest this gets fixed eventually, and there's an unofficial Windows-side bridge if you enjoy that kind of thing, but I wanted something that worked now.
So you fall back to the Chrome DevTools Protocol, which works, and which is where every guide I found stops: you can now take a screenshot. Screenshots are the expensive part. A single page costs a few thousand tokens as an image, and an agent working through a ten step form takes one at every step. The thing it actually needs, which elements exist and which ones it can click, comes to a few hundred tokens if you ask for the accessibility tree instead.
The other half of the problem is login. A fresh Chrome for Testing has never seen your app's sign-in screen, so if your dev app puts everything behind auth, an agent driving that browser spends its life staring at a login form. You want the browser you already logged into, and you want to drive it with something cheaper than pictures. That's the setup below: one Chrome on 9222, two drivers attached to it, and a rule for which one to use when.
Windows
chrome.exe
--remote-debugging-port=9222 CDP server, HTTP + WebSocket
--user-data-dir=...\ChromeDebugProfiles\myapp isolated, stays logged in
bound to 127.0.0.1 only
localhost:9222 (shared loopback via networkingMode=Mirrored)
WSL2
agent-browser attaches by CDP cheap a11y snapshots, default driver
chrome-devtools-mcp attaches by CDP network, console, Lighthouse
Claude Code drives both
One browser, two clients on it. CDP is a plain HTTP and WebSocket server and it accepts multiple simultaneous clients, so this isn't a trick anyone should feel clever about. The one rule is that each client gets its own tab, since two clients driving the same active page will conflict.
You need networkingMode=Mirrored
in your Windows .wslconfig
, which is the thing that makes localhost:9222
mean the same address on both sides of the WSL boundary, and you need a Chrome listening on 9222 with an isolated profile. If you don't have that yet, Nebrass Lamouchi's post covers the setup properly and I'm not going to re-teach it here. One warning from that side: mirrored mode can fight with Docker Desktop, and running Docker Engine natively in WSL2 was easier than trying to make them coexist.
The minimum launcher, from Windows or from WSL through /mnt/c
:
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" \
--remote-debugging-port=9222 \
--user-data-dir="C:\Users\youruser\AppData\Local\ChromeDebugProfiles\myapp" \
--no-first-run \
--no-default-browser-check \
--remote-allow-origins='*' &
The --user-data-dir
is doing more work than it looks like. It gives you a profile separate from your everyday Chrome, so the debug browser never touches your real cookies or extensions, and it persists across runs, so you sign into your app once inside it and the session is still there tomorrow. That persistence is most of why this is worth setting up at all.
Confirm it's alive before connecting anything:
curl -s http://127.0.0.1:9222/json/version | python3 -m json.tool
A healthy response has "Browser": "Chrome/..."
and a webSocketDebuggerUrl
in it. If you get nothing back, skip ahead to the gotchas, because one of them is probably why.
vercel-labs/agent-browser
is a browser automation CLI aimed at AI agents, Apache-2.0, out of Vercel Labs. I looked at provenance before installing because the package ships a native binary: the npm package does map to the GitHub repo, it's published through GitHub Actions with OIDC and SLSA provenance, and it has zero runtime dependencies with a clean audit. The postinstall
pulls a prebuilt Rust binary from GitHub releases over HTTPS without verifying a checksum, which is the part I'm least comfortable with. It's standard practice for native-binary npm packages and I installed it anyway, so take that for whatever it's worth.
npm install -g agent-browser
The docs will point you at agent-browser install
, which downloads Chrome for Testing. Skip it. That's the logged-out browser this whole post is trying to avoid.
First run under WSL2 fails like this:
✗ Failed to create socket directory: Permission denied
agent-browser runs a session daemon over a Unix socket under $XDG_RUNTIME_DIR
, and on WSL2 /run/user/1000
usually doesn't exist. Point it at somewhere writable:
export XDG_RUNTIME_DIR=/tmp/abr-runtime
mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR"
The export doesn't survive between separate shell invocations, which matters when an agent is calling the CLI one command at a time and each call gets a fresh shell. Putting it in ~/.bashrc
with the mkdir clears that up.
agent-browser connect 9222
connect 9222
attaches over CDP to the Chrome you already have running, with your login already in it. If you let agent-browser start its own browser instead, you get a fresh Linux Chrome for Testing that's logged out and wants a display or headless mode, which throws away the only property here worth having. There's also --auto-connect
, which discovers a running Chrome for you, though I'd rather name the port than trust discovery.
Ask what's on the page, get back a tree of elements with refs, act on the refs.
agent-browser snapshot
agent-browser fill @e7 "ABC-123"
agent-browser type @e17 "hello, can you help with this?"
agent-browser click @e18
agent-browser get text "main"
get text
reads the result back as plain text for almost nothing. Screenshot when you genuinely need pixels, like a visual regression or a layout bug, rather than to check whether a click landed.
Two things caught me out. Refs go stale as soon as the DOM changes, so take a fresh snapshot after anything that re-renders instead of reusing e17 from thirty seconds ago. And for single page apps, pushstate <url>
is the navigation command you want, not open
. I lost a chunk of an evening to that one: open
waits on a load event that an SPA route change never fires, so it reports a timeout on a page that has in fact loaded and is sitting right there in front of you. pushstate
detects the router, Next.js included, and goes through it.
The rest of the surface is roughly what you'd guess. find role|text|label|placeholder|testid <value> <action>
for locating things, get url|title|text|html|value
and is visible|enabled|checked
for state, console
/ errors
/ network requests
/ vitals
for a quick look, tab new|list|close
for tabs. One wrinkle worth knowing before it confuses you: type
isn't a find action, so it stays type <selector> <text>
at the top level.
A screenshot of a normal app page runs into the low thousands of tokens, as an image, every time you take one. The accessibility snapshot of that same page is a few hundred tokens of text. I haven't benchmarked this properly, so treat those as the order of magnitude I watched my context fill up at rather than measured figures, but the gap is not subtle and over a ten step flow it shows up on the bill.
The second order effect interests me more. Because the snapshot is small and structured, Sonnet handles this work fine. Browser driving is repetitive tool calling rather than reasoning, and paying Opus rates to click buttons is silly. You can flip the whole session with /model sonnet
, or keep Opus as the driver and hand browser tasks to a Sonnet subagent, which reaches the same session-connected MCP tools without any extra wiring.
None of that means throwing away chrome-devtools-mcp. It sees things agent-browser doesn't: full network request bodies, structured console output, Lighthouse audits, performance traces, heap snapshots. When I'm working out why a request 500s or why a page feels slow, that's the tool I want. So the split I've landed on is agent-browser to drive and chrome-devtools-mcp to investigate, on the same browser, so switching mid-task costs nothing. I'll admit I'm not certain this is the right factoring rather than just where I stopped tuning it. It has held up for a few months of daily use.
Pointing the MCP server at the same Chrome is one line of config:
{
"mcpServers": {
"chrome-devtools": {
"type": "stdio",
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest", "-u", "http://127.0.0.1:9222"]
}
}
}
That's Claude Code's ~/.claude.json
. Gemini CLI takes the same shape in ~/.gemini/settings.json
and Copilot CLI in ~/.copilot/mcp-config.json
.
For Claude Code, add it user-scoped so every session in every directory picks it up, and add it through the CLI rather than hand-editing the file. Claude Code rewrites ~/.claude.json
while it's running and will happily clobber a manual edit:
claude mcp add --scope user chrome-devtools -- \
npx -y chrome-devtools-mcp@latest -u http://127.0.0.1:9222
This is my favourite of the three, partly because I couldn't find it documented anywhere and partly because it survives every reset you'd instinctively reach for.
The symptom: Chrome won't start on 9222, nothing you know about is listening, curl
on the CDP endpoint returns nothing at all, and netstat
insists the port is taken. wsl --shutdown
doesn't clear it and neither does a reboot, which is what makes it so disorienting.
The cause is leftover netsh interface portproxy
rules from the old pre-Mirrored era of WSL port bridging:
0.0.0.0:9222 -> 127.0.0.1:9222
172.24.160.1:9222 -> 127.0.0.1:9222
Those are hosted by the Windows IP Helper service, iphlpsvc
, running inside svchost.exe
, which is why netstat blames svchost. They're persistent Windows configuration rather than a WSL artifact, so rebooting was never going to help. Worth noticing the 0.0.0.0
listen address too, since that rule had been quietly exposing 9222 to the whole LAN for who knows how long.
Find it:
/mnt/c/Windows/System32/netstat.exe -ano | grep ':9222' | grep -i LISTENING
/mnt/c/Windows/System32/tasklist.exe /svc /fi "pid eq <PID>" # shows iphlpsvc
powershell.exe -Command "netsh interface portproxy show all"
Delete it from an elevated PowerShell, since portproxy is protected:
netsh interface portproxy delete v4tov4 listenport=9222 listenaddress=0.0.0.0
netsh interface portproxy delete v4tov4 listenport=9222 listenaddress=172.24.160.1
netsh interface portproxy show all
The socket frees immediately, with no reboot and no wsl --shutdown
needed.
If your setup wraps the MCP server in something that waits for CDP and then degrades to a noop server instead of crashing, a wrong port looks identical to a broken install. I spent a while on this with one session driving the browser perfectly while another insisted nothing was running, and the answer was that their configs pointed at different ports and only one matched the Chrome I'd actually launched.
When the tools vanish, suspect the port before you suspect anything else:
grep -n "browser-url\|--browser" ~/.claude.json
curl -s http://127.0.0.1:9222/json/version
The same trap catches you with a per-repo launcher that auto-picks a free port. Repo A takes 9222, repo B takes 9223, and now there are two Chromes and only one of them has your login. Pin the port explicitly if you want a single shared browser.
MCP servers connect at startup. Start one before Chrome exists and it holds a dead session, after which every call fails with Protocol error (Target.setDiscoverTargets): Target closed
while curl
on 9222 looks perfectly healthy. Chrome first, then /mcp
and reconnect. It's a small thing but the error message points nowhere near the actual cause, so it's worth having in your head before you meet it at 1am.
Any local process that can reach localhost:9222
can fully drive that browser, which is simply how CDP works, and two details keep that manageable. Chrome has bound --remote-debugging-port
to 127.0.0.1 only since version 111, so it isn't reachable from other machines unless you deliberately add --remote-debugging-address=0.0.0.0
, which I'd think hard about before doing. And the isolated --user-data-dir
means the blast radius is whatever you signed into inside the debug profile rather than your entire browsing life, which is a good reason to keep that profile boring: your dev app and nothing else.
When something breaks, paste this before you start theorising:
curl -s http://127.0.0.1:9222/json/version | python3 -m json.tool
curl -s http://127.0.0.1:9222/json/list
grep -n "browser-url\|--browser" ~/.claude.json
claude mcp list | grep chrome-devtools
powershell.exe -Command "netsh interface portproxy show all"
ls -ld "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
Nearly every problem I've had with this setup was one of those lines returning something I didn't expect. The portproxy rule was the exception and it cost me most of an afternoon, so it's in the list now.