Claude Code can drive Chrome natively through the Claude in Chrome extension. Run /chrome inside WSL2 and you get this instead:
Claude in Chrome is not supported in WSL at this time.
That's on 2.1.211. It isn't a detection failure or a misconfiguration you can flag your way out of. Claude Code checks whether it's running under WSL and declines before it tries anything, so there's no connection to debug and no extension to reinstall.
It's worth understanding why the gate is there, because it explains why the workarounds floating around don't work either. Chrome finds native messaging hosts through the Windows registry. Go looking, and there is a key:
HKCU\Software\Google\Chrome\NativeMessagingHosts\com.anthropic.claude_browser_extension
-> C:\Users\youruser\AppData\Roaming\Claude\ChromeNativeHost\...
Which looks like success until you read it twice. That host belongs to the Claude desktop app on Windows. The one Claude Code installs is called com.anthropic.claude_code_browser_extension, note the _code_ in the middle, and on WSL2 it goes here instead:
~/.config/google-chrome/NativeMessagingHosts/com.anthropic.claude_code_browser_extension.json
-> /home/youruser/.claude/chrome/chrome-native-host (a Linux binary)
Two different hosts, two different filesystems. Even if the WSL check disappeared tomorrow, Windows Chrome would enumerate the registry and never find Claude Code's manifest, because the manifest is sitting in the Linux filesystem pointing at a Linux binary. It's in the right place for the wrong operating system, and reinstalling puts it right back there.
Which brings me to the muddle that cost me an hour. The docs say WSL isn't supported and the CLI agrees. But the changelog for 2.1.0 says "Fixed Claude in Chrome support for WSL environments," and both statements have been live for months. There's an open docs issue about exactly that contradiction (anthropics/claude-code#18061), still unresolved, where people keep arriving with the same confusion.
That thread also carries a suggested workaround, CLAUDE_CODE_PROXY_RESOLVES_HOSTS=true plus mirrored networking, which one commenter reports working and another reports doing nothing. I went and read what that flag actually does in the bundle. It's declared alongside HTTPS_PROXY, NO_PROXY, and the cert settings, and it controls whether your HTTP proxy resolves DNS names instead of your client resolving them locally, the same distinction as socks5 versus socks5h. Useful if a corporate proxy is between you and the API. It has nothing to do with native messaging, which isn't even a network protocol, and it certainly can't talk its way past a WSL check. My guess is the thread has two different bugs in it and the flag fixed the other one.
The fallback is the Chrome DevTools Protocol, which works fine, 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.
The shape of it
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.
What this assumes
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.
The cheap driver: agent-browser
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.
The socket fix
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.
Attach, never launch
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.
Snapshot, then act on refs
Ask what's on the page, get back a tree of elements with refs, act on the refs.
agent-browser snapshot
# textbox "code" [ref=e7]
# textbox "message" [ref=e17]
# button "Send" [ref=e18]
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.
The token math, and when to pay more
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
Gotchas that cost me real hours
A portproxy ghost squatting on 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.
Empty tool list, or "Chrome DevTools is not running"
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 started before Chrome
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.
Security, briefly
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.
Health check
When something breaks, paste this before you start theorising:
# Is the logged-in Chrome actually up on 9222?
curl -s http://127.0.0.1:9222/json/version | python3 -m json.tool
# What pages does it have open?
curl -s http://127.0.0.1:9222/json/list
# Which port is each MCP config pointing at? All should say 9222.
grep -n "browser-url\|--browser" ~/.claude.json
# Are the MCP servers connected?
claude mcp list | grep chrome-devtools
# Is a stale portproxy rule squatting on 9222? Should be empty.
powershell.exe -Command "netsh interface portproxy show all"
# agent-browser socket dir present?
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.
Top comments (0)