agentleFS
alexjmiller5alexjmiller5/agent-config-public/skills/chrome-control/SKILL.md

chrome-control

Skill0 starsChanged 42 days agoMITView on GitHub

Invoke FIRST for blocked web access (Reddit, Instagram, Wirecutter/NYT, HTTP 403, bot checks, login/paywall stubs) and ANY browser task - Chrome, pages, tabs, localhost, HTML, JS, clicks/forms, E2E tests, network, cookies, console, screenshots. Must load before any mcp__claude-in-chrome__* call; web-recon builds on it.

1 other project uses a version of this.

Adopted by

Most recently taken up by

The file

Show its rules and text

We split this skill into the rules it gives an agent

24 rules. About 0 are common, 18 are distinctive, and 6 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈18

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 6

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    If web search/open, fetch, curl, or another page reader returns a bot block, HTTP 403, login/paywall stub, or empty JavaScript shell, open …

    line 10
  2. Distinctive:

    Use the user's existing signed-in session and subscription access, with your own session group and explicit target as described below.

    line 17
  3. Distinctive:

    If Chrome itself still needs login, MFA, a subscription, or a CAPTCHA, follow the password-manager and human-handoff guidance below.

    line 21
  4. Very specific:

    When $CHROME_CONTROL_HOST is set (your machine config exports it), EVERY browser task drives that host's shared agent Chrome - Tier 4 …

    line 27
  5. Very specific:

    When $CHROME_CONTROL_HOST is the literal local, the agent Chrome is on this machine at 127.0.0.1:${CHROME_CONTROL_PORT:-9222}: skip the ssh …

    line 47
  6. Distinctive:

    If the configured host cannot be resolved or the SSH tunnel fails, diagnose connectivity before declaring the host unavailable:

    line 60
  7. Very specific:

    If the local Tailscale client is stopped or logged out, reconnect its existing enrollment through its native UI or CLI, confirm …

    line 70
  8. Distinctive:

    If re-enrollment or other human authentication is required, stop and follow the machine's documented recovery procedure.

    line 74
  9. Distinctive:

    Every tab you open lives in a window created for this session, inside a tab group named after the session.

    line 88
  10. Very specific:

    Group cleanup is part of finishing the task: Keep every group name you create and run --close for each before your final response, …

    line 108
  11. Distinctive:

    Only in the local exception may they fill a gap a shell tier can't (native mouse clicks on a canvas), and any tab they open is closed by …

    line 143
  12. Distinctive:

    Don't reach for CDP for that.

    line 179
  13. Distinctive:

    Every new WebSocket connection pops a modal: *"Allow remote debugging?

    line 210
  14. Distinctive:

    If loading inserts rows above the target and pushes it outside the viewport, the helper scrolls it into view again within the bounded wait.

    line 265
  15. Distinctive:

    Use stdin for expressions containing sensitive values or large payloads, so they do not appear in process arguments.

    line 294
  16. Distinctive:

    No prompts, fully scriptable, but logged out.

    line 337
  17. Distinctive:

    Human handoff = Screen Sharing: When a site needs the person (an SMS-only login, a CAPTCHA, "confirm on your phone", a "Continue with …

    line 414
  18. Very specific:

    Response bodies are evicted on navigation: Call Network.getResponseBody on loadingFinished, not later.

    line 489
  19. Distinctive:

    Every group, tab and window you create is yours to clean up.

    line 521
  20. Distinctive:

    Check the command's exit status.

    line 531
  21. Very specific:

    If ungrouping succeeds but window closure fails, close the tracked window by its id.

    line 532
  22. Distinctive:

    Keep secret values out of tool output, files, process arguments and chat.

    line 572
  23. Distinctive:

    Ask the user only when a concrete step needs them: inaccessible credentials, provider reauthentication the supported tools cannot complete, …

    line 574
  24. Distinctive:

    Do not bypass security challenges or grant unrelated account permissions.

    line 577

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Chrome Control

When normal web access is blocked

If web search/open, fetch, curl, or another page reader returns a bot block, HTTP 403, login/paywall stub, or empty JavaScript shell, open the exact URL in the configured agent Chrome and read the rendered page before declaring the source inaccessible or asking the user to paste it. For sites known to block ordinary readers, such as Reddit, Instagram, and Wirecutter/NYT, go directly to agent Chrome for page content; search tools can still find links.

Use the user's existing signed-in session and subscription access, with your own session group and explicit target as described below. The persistent browser can load content that anonymous fetches cannot. Verify the requested article or post actually loaded; a challenge page is not source content. If Chrome itself still needs login, MFA, a subscription, or a CAPTCHA, follow the password-manager and human-handoff guidance below. Browser access does not guarantee that every page will be available.

The browser runs on the agent-Chrome host, not on this machine

When $CHROME_CONTROL_HOST is set (your machine config exports it), EVERY browser task drives that host's shared agent Chrome - Tier 4 below: a headed Chrome on its own data dir, fixed remote-debugging port, kept alive as a login item, holding every login ever done in it. The Chrome on the machine you are running on is off limits: driving it slows the user's own laptop down. The ONE exception is a task literally about a tab the user has open in front of them ("pull the HTML of this tab", "what does my current tab say") - then Tier 1/2, locally, and nothing more.

H="$CHROME_CONTROL_HOST"; P="${CHROME_CONTROL_PORT:-9222}"
ssh -N -L 9223:127.0.0.1:$P "$H" &                 # once per session; 9223 locally so this machine's own Chrome (9222) is untouched
curl -s 127.0.0.1:9223/json/version | head -2      # alive?
S=~/.claude/skills/chrome-control/scripts
node $S/cdp-group.mjs "claude: <task>" https://example.com --port 9223         # your tabs (next section)
node $S/cdp-eval.mjs 9223 'document.title' --target <targetId>                  # = chrome-cli source/execute
node $S/cdp-eval.mjs 9223 --shot /path.png --target <targetId>                  # = screenshot
curl -s 127.0.0.1:9223/json/list | jq -r '.[]|select(.type=="page")|.id+" "+.url'   # = list tabs

When $CHROME_CONTROL_HOST is the literal local, the agent Chrome is on this machine at 127.0.0.1:${CHROME_CONTROL_PORT:-9222}: skip the ssh forward, pass that port to every script, and expect no Allow sheet (that Chrome runs on its own data dir). Everything else in Tier 4 applies unchanged: own window and tab group per session, cleanup, no driving of any other Chrome.

P="${CHROME_CONTROL_PORT:-9222}"
curl -s 127.0.0.1:$P/json/version | head -2
node $S/cdp-group.mjs "claude: <task>" https://example.com --port $P

If the configured host cannot be resolved or the SSH tunnel fails, diagnose connectivity before declaring the host unavailable:

H="$CHROME_CONTROL_HOST"
tailscale status --json
ssh -G "$H" | rg '^hostname '
tailscale ping --c 1 --timeout 5s <resolved-hostname>

If the local Tailscale client is stopped or logged out, reconnect its existing enrollment through its native UI or CLI, confirm BackendState is Running, and retry the SSH tunnel once. Do not mint or reuse auth keys, change ACLs, install or reconfigure Tailscale, or mutate the remote host from this skill. If re-enrollment or other human authentication is required, stop and follow the machine's documented recovery procedure. Do not fall back to the laptop's local Chrome while the configured host is still unreachable.

There is no chrome-cli on the host - cdp-eval.mjs --port covers source/execute/shot/trusted-click, /json/list covers tab listing. A dev server on THIS machine is reachable from the host through a reverse forward in the same ssh (-R 5173:127.0.0.1:5173, then open http://127.0.0.1:5173 there). A job that must outlive this session, or the user stepping away, runs its driver on the host (Tier 4 rules). Human steps (logins, CAPTCHAs) happen over Screen Sharing on the host, and stick.

Every session gets its own window + tab group (MANDATORY)

Every tab you open lives in a window created for this session, inside a tab group named after the session. Popups, OAuth redirects and target=_blank pages spawned from those tabs land in the same window and group (Chrome keeps a tab's children with it), so the user sees one labelled cluster per agent session and never finds strays mixed into their own windows. scripts/cdp-group.mjs does all of it:

G=~/.claude/skills/chrome-control/scripts/cdp-group.mjs   # add --port 9223 on the agent host; without it: local real profile (Tier 2)
node $G "claude: <task>" https://a.com https://b.com   # first call creates window + group, later calls add tabs to it
# -> window=<id> group=<id> tabs=<id,...> targets=<targetId,...>   tabs = Chrome/chrome-cli tab ids, targets = CDP ids, same order
node $G "claude: <task>"                                # no urls: sweep strays in the session window into the group
node $G "claude: <task>" --close                        # end of task: deletes the group, then closes its window and tabs

The name is claude: <short task>, the same string on every call. One window per session is the rule; parallel drivers that genuinely need a window each (several Maps windows at once) use one name per driver - that is the only reason to have more than one.

Group cleanup is part of finishing the task. Keep every group name you create and run --close for each before your final response, including when a browser attempt fails or is abandoned. Follow "Close what you open" below.

How it works: CDP has no tab-group API, so the script opens a hidden target on an installed extension's origin and calls chrome.tabs / chrome.tabGroups from there. The profile needs an extension holding the tabGroups permission - Claude in Chrome (the default --ext) does. A dedicated agent profile gets it through Chrome policy (ExtensionSettings in a root-owned plist under /Library/Managed Preferences on macOS, which is Mandatory; the same key in the user's defaults domain is only Recommended and ignored). Signing into the extension is a one-time human step in that browser's window. Without --port the call goes through the real profile (Tier 2, one auto-approved Allow sheet, ~2 s); with --port it is prompt-free.

Local tiers (the exception above, and the mechanics Tier 4 reuses)

Cheapest first; climb only when the tier you're on genuinely can't answer. Tier 1 needs no setup and no clicks; Tier 2 costs an auto-approved sheet; Tier 3 costs the logged-in session.

NeedTier
HTML of the user's open tab, tab list, run some JS there1 - chrome-cli
Network traffic, response bodies, console, cookies of the user's session2 - CDP on the real profile
Unattended/scripted browsing with no agent host configured3 - throwaway profile
Everything else - the default whenever $CHROME_CONTROL_HOST is set (local = this machine, no forward)4 - the agent-Chrome host

The claude-in-chrome MCP is a LAST RESORT

The mcp__claude-in-chrome__* extension tools drive the LOCAL Chrome, one slow round-trip per action, with JS in an isolated world (gotcha 0(c)). With an agent host configured they are not an option at all: screenshots are cdp-eval.mjs --shot, coordinate clicks are cdp-eval.mjs --click. Only in the local exception may they fill a gap a shell tier can't (native mouse clicks on a canvas), and any tab they open is closed by you, not the user - see "Close what you open".


Tier 1 - chrome-cli (no setup, no prompts)

Install chrome-cli with your package manager. It talks to Chrome over AppleScript, so it reads the tabs the user actually has open with zero configuration.

chrome-cli list tabs                  # [windowId:tabId] Title
chrome-cli source                     # full HTML of the frontmost tab
chrome-cli source -t 736899764        # ...of a specific tab
chrome-cli execute 'document.title' -t 736899764   # run JS in a tab
chrome-cli list links -t 736899764
chrome-cli open https://example.com   # new tab (always new; can't retarget)
chrome-cli info -t 736899764          # title + URL

-t goes AFTER the JS/subcommand argument, never before. chrome-cli execute -t <id> '<js>' fails with the useless error No matching handler found.

To navigate an existing tab (rather than opening a new one), set window.location.href via execute - open always creates a new tab. Clicking a real element is better still when the site's links are JS-driven or carry one-time tokens:

chrome-cli execute 'var a=[...document.querySelectorAll("a")]
  .find(x=>/Account Name/i.test(x.textContent)); a.click(), "ok"' -t <id>

This is the right answer for "pull the HTML from the tab I have open." Don't reach for CDP for that.

Requires View > Developer > "Allow JavaScript from Apple Events" (once per profile; source and execute are built on it, list tabs is not). Also needs an Automation TCC grant for the calling process (macOS asks once). The machine context says whether both are already in place.

Limits: no network traffic, no response bodies, no console history, no cookies. Those are Tier 2.


Tier 2 - CDP against the real, logged-in profile

This is the only way to capture authenticated API traffic. It attaches to the user's everyday Chrome - their real cookies, their open tabs.

One-time setup

the user ticks "Allow remote debugging for this browser instance" at chrome://inspect/#remote-debugging. This is a browser-wide preference that persists across restarts forever. Declare it in your machine configuration if you manage Chrome preferences declaratively, so a fresh machine gets it.

Chrome then listens on 127.0.0.1:9222 and writes the endpoint to ~/Library/Application Support/Google/Chrome/DevToolsActivePort (line 1 = port, line 2 = ws path). Always read that file - the port is usually 9222 but falls back to an ephemeral one if taken.

The per-connection dialog - auto-approved

Every new WebSocket connection pops a modal: "Allow remote debugging? An external app wants full control over this Chrome session." Google closed the "remember my choice" request as not planned - there is no allowlist and no token. The sheet is titled Allow remote debugging? and its Allow button is an AXButton whose description (not name) is Allow, nested in groups.

scripts/cdp-allow [secs] [grace] answers it for you: it polls for that sheet via macOS UI scripting (System Events) for up to secs (default 20), clicks Allow on every matching sheet it sees (Chrome queues one sheet per pending connection, so a stale one from an earlier attach must not steal the click), keeps watching grace more seconds (default 5) after a click, then exits 0. cdp-act.mjs and cdp-sniff.mjs spawn it automatically right after opening their WebSocket, so a Tier 2 attach needs no human. Any other CDP client does the same: open the socket, then run cdp-allow (or spawn it just before connecting). It only approves while it runs - it is not a standing allowlist, and other processes' connections still prompt.

One-time requirement: Accessibility permission for the process that runs osascript (System Settings > Privacy & Security > Accessibility - the terminal or agent app). Without it the click silently no-ops and the human must click within the timeout. A GUI session must exist (the sheet is rendered in a window).

While attached, Chrome shows a persistent "Chrome is being controlled by automated test software" infobar. That's expected, not a problem.

Trusted input + capture - scripts/cdp-act.mjs

The actor. Same one-click approval, but drives the page with trusted input (Input.dispatchMouseEvent / dispatchKeyEvent), which is the only way to click things that ignore synthetic events (Google Maps list links, icon pickers). One-shot mode reads a single JSON array of steps on stdin; --follow reads one step object per line.

# one-shot (add --port 9223 on the agent host: no Allow sheet there)
node ~/.claude/skills/chrome-control/scripts/cdp-act.mjs --url <tab-substr> < steps.json
# persistent: ONE Allow click, then append steps to the file as you go
node ~/.claude/skills/chrome-control/scripts/cdp-act.mjs --url <substr> --follow steps.ndjson &
echo '{"click":"document.querySelector(\"h1\")"}' >> steps.ndjson

Steps: {"eval":"<expr>"} {"click":"<expr returning Element>"} {"type":"text"} (insertText) {"keys":"text"} (per-character key events) {"key":"Enter|Escape|Tab"} {"sleep":ms} {"shot":"/path.png"} {"capture":"<url substr>"} (prints matching request/response bodies) {"quit":true}. Output is NDJSON on stdout.

Click the settled element. Both actors scroll the clickable element into view, wait for its rectangle to stop moving, and hit-test its center before sending input. A layout can move after scrollIntoView; an old coordinate can activate a neighboring control. Select the clickable ancestor when a label has pointer-events disabled. Obscured targets fail without sending a click. Check with node scripts/test-click-target.mjs. If loading inserts rows above the target and pushes it outside the viewport, the helper scrolls it into view again within the bounded wait.

One-shot eval / trusted click / screenshot - scripts/cdp-eval.mjs

For scripts that make many small calls (a Tier 3/4 driver): one process per call, exits immediately (~70 ms).

node cdp-eval.mjs <port> '<expr>' [--url <substr>]            # prints the value
node cdp-eval.mjs <port> --click '<expr returning Element>'   # trusted click at its centre (brings page to front first)
node cdp-eval.mjs <port> --shot /path.png                     # Page.captureScreenshot
node cdp-eval.mjs <port> - --target <id> < expression.js       # JS via stdin

Gotchas learned the hard way: a setTimeout safety timer must be .unref()ed or every call idles until it fires; --click takes an EXPRESSION - wrap statement blocks in (function(){ …; return el })(); a hidden page (document.visibilityState) drops all input, so keep the remote display awake and launch Chrome with backgrounding disabled (--disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-features=CalculateNativeWinOcclusion).

Explicit targets are strict. --target fails if that page no longer exists; it never falls back to a different page. Callers must check the exit status and stop on bridge failures. A missing target otherwise lets parallel drivers navigate or click each other's windows. Check with node scripts/test-cdp-target.mjs.

Use stdin for expressions containing sensitive values or large payloads, so they do not appear in process arguments. The bridge drains stdout before exiting; an immediate process.exit can truncate a large result at a pipe buffer boundary. Live smoke check: node scripts/test-cdp-output.mjs <port> <targetId>.

Capturing traffic - scripts/cdp-sniff.mjs

The passive sniffer. Attaches to open tabs and streams XHR/fetch as NDJSON while the user browses by hand. Zero dependencies (Node 22+ global WebSocket).

node ~/.claude/skills/chrome-control/scripts/cdp-sniff.mjs \
  --secs 120 > capture.ndjson

# options
--url <substr>   only watch tabs whose URL contains this
--port N         a dedicated-profile Chrome (the agent host over its forward) instead of the real one
--secs N         capture window (default 300)
--all            include non-XHR (images, scripts, documents)
--raw            do NOT redact credentials (default is redacted)

Each NDJSON line: ts, type, method, url, status, mimeType, requestHeaders, requestBody, responseHeaders, responseBody.

Credentials are redacted by default - any header whose name matches auth|cookie|token|session|api-key|secret|password|csrf|bearer is replaced with <redacted:Nb>. Only pass --raw when the user explicitly needs the live token, and never write a --raw capture anywhere but the scratchpad.

Reading a capture:

jq -r '.url' capture.ndjson | sort -u                    # what endpoints exist
jq 'select(.mimeType|test("json"))' capture.ndjson       # the API calls
jq -r 'select(.url|test("api")) | .responseBody' capture.ndjson | jq .

Tier 3 - throwaway profile (unattended)

No prompts, fully scriptable, but logged out. For automation and cron.

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --remote-debugging-port=9333 --user-data-dir=/tmp/chrome-dbg --no-first-run &
curl -s localhost:9333/json/list | jq -r '.[] | select(.type=="page") | .url'

Or let chrome-devtools-mcp's CLI launch and manage the browser - it only ever drives its own logged-out one, never the user's profile:

export CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS=1   # ALWAYS - opts out of Google telemetry
bunx -p chrome-devtools-mcp@latest chrome-devtools start

To get logins without the dialog, copy the profile to a non-default path - a different path passes Chrome's check, and on macOS the keychain key is app-scoped so cookies still decrypt. Copy only Profile 1 + Local State (the full dir is ~8GB). Caveat: it's a point-in-time fork, and device-bound session credentials may log some sites out anyway.

CDP fingerprinted? mitmdump --set hardump=capture.har + mitmproxy2swagger -i capture.har -o api.yml -p https://target.com/api -f har; both in nixpkgs, needs a CA install.


Tier 4 - remote Chrome on another machine

$CHROME_CONTROL_HOST (ssh host) and $CHROME_CONTROL_PORT (default 9222) name this machine's agent Chrome; the top of this skill is the day-to-day recipe, this section is why it is shaped that way. cdp-eval.mjs, cdp-group.mjs, cdp-act.mjs and cdp-sniff.mjs all take --port for it.

Browsers are the heavy part of any automation; the agent session is a terminal. So the browser runs on a second machine (a home server, a spare Mac) with the session staying where the user is, driving it over CDP through an ssh port-forward: only websocket traffic crosses the wire and the user's own machine stays responsive.

One shared agent Chrome per remote machine. Declare it as a login item (a launchd user agent on macOS): a headed Chrome on its own data dir with a fixed --remote-debugging-port, kept alive. Every job attaches to that one endpoint and opens its own tab (Target.createTarget), and every login done in its window - by a job, or by the user over Screen Sharing - is there for every later run of every tool. A browser that holds all the user's logins also looks like a person's browser; a fresh single-site profile per tool looks like automation. Give a site its own profile and port only when isolation between sites is actually wanted.

# the remote host runs one Chrome at login, e.g. (macOS launchd agent):
#   "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
#     --user-data-dir=$HOME/.local/share/agent-chrome --remote-debugging-port=9222 --no-first-run
# locally: forward the port, then drive 127.0.0.1:9222 exactly like Tier 3
ssh -N -L 9222:127.0.0.1:9222 <host> &

Rules that make this work:

  • The profile lives on the remote host permanently. Device trust, "remember this browser", and cookies accumulate there; never copy them back and forth.
  • Your tab is yours, the browser is not. Other jobs may be attached at the same time: create your own target, never act on "the active tab", and close what you opened.
  • Secrets travel over stdin, never the command line. printf '%s' "$T" | ssh <host> 'read -r T; TOKEN="$T" <script>' keeps the value out of both machines' process lists and out of the transcript. A remote ssh session cannot read the host's Keychain (per-session locked), so pipe what it needs.
  • The remote host needs a console session (a user logged in at the screen) for Chrome to get a window; check stat -f %Su /dev/console.
  • Anything that reads local state runs where the state is: SMS codes from Messages, files, a local database. Either the remote host has that state (synced Messages) or the script fetches it over ssh.
  • Human handoff = Screen Sharing. When a site needs the person (an SMS-only login, a CAPTCHA, "confirm on your phone", a "Continue with Google" that needs the account signed in), they open the remote machine's screen (macOS Screen Sharing over the tailnet, one click), do the step in the shared Chrome, and close it - it persists for everything after. Before asking, screenshot the page over the forwarded CDP (Page.captureScreenshot) and show it in chat - often that alone resolves it.
  • Chrome 136+ still ignores --remote-debugging-port on the default profile (gotcha 1); the dedicated --user-data-dir is what makes the port listen, remote or not, and what keeps the per-connection Allow sheet away.
  • If the user might close or leave the laptop, the driver moves too. An ssh port-forward dies with the laptop's session, so a job that must survive the user stepping away runs its driver ON the remote host, talking to 127.0.0.1:<port> there, with its log on that host. The session then only checks in over ssh (tail the log, pgrep the driver). Do this BEFORE the user leaves; keep any local run going until the remote one is confirmed writing, then stop the local one.
  • Plain ssh host 'nohup cmd &' is NOT enough on macOS - the process dies with the ssh session (so did a screen -dmS session, observed 2026-09-06). What survives: a declared launchd agent for anything permanent (the shared Chrome itself), and for a one-off driver a transient launchd job: `launchctl submit -l <label> -- /bin/bash<script>`. launchd jobs start with a bare PATH - `export PATH=…` at the top of the script or every nix/homebrew binary (node!) silently fails as "" output. Check the job with `launchctl list | grep <label>`, remove with `launchctl remove <label>`. (`open -na "Google Chrome" --args …` also survives and gets a window, but a browser started that way is not declared - prefer the launchd agent.)

Screenshots

On the agent host: cdp-eval.mjs <port> --shot /path.png --target <id> (Page.captureScreenshot), no prompts. With no host configured, a URL that needs no login (a localhost dev server, a public page) renders straight to a PNG with headless Chrome - a short-lived process, not the user's browser:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless --screenshot=/path/out.png --window-size=1400,1000 \
  --hide-scrollbars --virtual-time-budget=6000 \
  --user-data-dir="$(mktemp -d)" http://localhost:5173/

The PNG is written even when the process then fails to exit, and the launcher can exit before its child has written it (observed Chrome 154) - background it, wait for a complete file rather than for the process, then pkill -f the temp profile path (write the pattern without leading dashes; pkill -f --user-data-dir=... parses as an option). The viewport is shorter than --window-size (about 40 px on Chrome 154) and the uncovered strip repeats the top of the page, so for an exact output size render a taller window and crop. --virtual-time-budget gives client-side JS (charts, fetches) time to settle before capture. A page behind a login on the local real profile is cdp-act.mjs's {"shot":...} step (Tier 2).

  1. chrome-cli execute quirks (chrome-cli 1.9 / Chrome 139, observed 2026-08-25): (a) a non-string result crashes it (NSInvalidArgumentException) - end every script with a string expression; (b) scripts beyond ~1000 chars silently no-op (empty output, nothing runs) - keep each call short or inject a <script> element; (c) its JS has an isolated ES module map: import() there returns fresh module instances, never the page's live ones. To reach the app's real modules (Vite dev included), append an inline <script type="module"> (textContent, not src - a src-based module pointing at a Vite-served file did not execute) and pass results back through a DOM attribute like document.body.dataset.x.
  2. Chrome 136+ ignores --remote-debugging-port on the default profile. Passing the default path explicitly doesn't help - Chrome compares resolved paths. Symptom: Chrome starts fine, nothing listens on the port. The chrome://inspect approval flow (Tier 2) is the only way onto the real profile, and it deliberately bypasses this check.
  3. /json/list and /json/version return 404 in approval mode. Anything that probes HTTP discovery first will fail confusingly. Read DevToolsActivePort and go straight to the WebSocket.
  4. Response bodies are evicted on navigation. Call Network.getResponseBody on loadingFinished, not later.
  5. Network.enable on the browser session captures nothing. You must Target.attachToTarget {flatten:true} per page and enable on that session. The new session's id arrives in params.sessionId of Target.attachedToTarget, not at the message's top level.
  6. Kill leaked browsers. The CLI daemon and headless one-shots leak Chrome processes. pgrep -fl "remote-debugging-port|chrome-devtools-mcp" and clean up when done. --headless --dump-dom likewise prints the DOM and may never exit (Chrome 154): read stdout up to </html>, then kill it.
  7. Killed renderers hang CDP and can take the borrowed extension down (Chrome 154). A crashed tab ("Can't open this page", error code 15) still lists in /json/list but every Runtime/Page call on it times out: close it and open a fresh tab. If cdp-group.mjs then exits 3 with Cannot read properties of undefined (reading 'query'), the extension is TERMINATED (chrome://extensions-internals, registry_status); from a chrome://extensions/ tab of your own, evaluate chrome.developerPrivate.reload("<ext id>", {failQuietly: true}, cb).

When reality contradicts this skill, FIX THIS SKILL

Everything here is empirical and drifts as Chrome ships. A command not behaving as documented is expected maintenance, not a dead end - but before editing: reproduce it twice (a down site, a network blip, or an unclicked Allow is not a skill bug), name the actual cause rather than the symptom, and verify the fix. Then edit surgically: correct the specific claim, record the Chrome / tool version you observed it on, add to Gotchas only if it will bite again, and state current behaviour only - no changelogs, no dated notes. Tell the user what changed, in chat. Same bar applies to web-recon.

Remove your groups and close what you open (MANDATORY)

Every group, tab and window you create is yours to clean up. Before ending the task (and before answering any "done" message), remove the session's groups completely, including their saved entries, and close everything you opened. This also applies to failed or abandoned browser work.

  • Each session group: cdp-group.mjs "<name>" --close (use the same --port and --ext as creation). It ungroups the group's tabs first, deleting the saved group, then closes the session window, including stray tabs. Closing the window alone leaves a saved group behind in Chrome 152. Collapsing, hiding or unpinning a group does not remove it either. Check the command's exit status; a cleanup error is unfinished work. If ungrouping succeeds but window closure fails, close the tracked window by its id; a retry can no longer find it by group name. closed=false only means no open group matched, not that a saved entry was deleted. If the window was already closed and its saved group remains, use Chrome's saved-groups menu to Delete group for that exact session: the ⋮ app menu > Tab groups > <name> group - Closed > Delete group, then the "Delete Tab Group?" dialog's Delete Group button. On a headless host mac-control's System Events tier presses these by AX name from a window of your own; re-list the menu to confirm. Never bulk-delete other sessions' groups by prefix or apparent age.
  • chrome-cli open prints Id: <tab> / Window id: <win> - keep them and run chrome-cli close -t <id> (or close -w <win> for an open -n window) when finished with it. A tab you navigated but did not create stays open.
  • MCP tabs_create_mcp → tabs_close_mcp on that same tab id.
  • Tier 3 throwaway Chromes → kill the process (gotcha 5), which takes its windows with it. The agent host's Chrome is never killed - it is shared.
  • CDP scripts (cdp-act.mjs / cdp-sniff.mjs) → {"quit":true} / let the capture window end, then close any tab you opened for them.

Track names and ids as you go; do not rely on a tab listing at the end to guess which were yours. In scripted drivers, put cleanup in finally or an exit trap. Verify your session windows and groups are gone; absence from /json/list alone does not prove a saved group was deleted. Live regression check: node scripts/test-cdp-group.mjs <port>.

Leaving a group or tab open is only acceptable when the user explicitly asked to keep it, and then identify what remains in chat.

Human handoff

A login wall is a reason to try the supported sign-in flow, not an automatic handoff. For an authorized account task, load the password-manager skill and resolve the saved account and sign-in method first. Use the existing session, saved-credential/autofill flow, or password-manager-supported login. For "Sign in with Google/Apple", click the provider button, select the account matching the saved login, and complete ordinary returning-user OAuth screens. An already authenticated provider session may sign the user in without a password. Verify that the requested account page actually loads afterward.

One matching saved account resolves the choice. If several accounts genuinely match, ask which to use; never guess from the browser's signed-in accounts. Keep secret values out of tool output, files, process arguments and chat.

Ask the user only when a concrete step needs them: inaccessible credentials, provider reauthentication the supported tools cannot complete, MFA approval, a passkey requiring their presence, a CAPTCHA, or new/broader permissions. Do not bypass security challenges or grant unrelated account permissions. State the exact blocking step and what was already attempted; a generic "please log in" before trying the saved sign-in method is unfinished work.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.