| name | cdp-attach |
| description | This skill should be used when the user asks to "take browser screenshot",
"list browser tabs", "click page element", "navigate browser",
"automate browser", "inspect accessibility tree", "monitor network",
"run JavaScript in browser", "fill form in browser", "debug web page",
or mentions CDP. Attaches to a running CDP instance for browser automation.
Pass the user's request verbatim — this skill reads a free-form request, not a subcommand.
|
| user_invocable | true |
| context | fork |
| model | sonnet |
CDP Attach
Attach to a running Chrome DevTools Protocol instance. Command-level timeouts are bounded, and frozen (hidden) tabs have a documented helper-tab routing workaround.
Prerequisites
A visible (headed) CDP-enabled browser must be running. Headless instances are blocked — silent execution without user visibility is a security risk.
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--user-data-dir="$HOME/.cache/cdp-attach/browser-profile" --remote-debugging-port=9222
--user-data-dir is required, not decorative. Since Chrome 136 the port flag is
ignored when the profile directory is the platform default
(announcement), so the
command without it opens no port at all — it fails silently, with no error to read.
Any non-default path works. A Chromium fork may still honour the flag on its own
default profile; that is fork behaviour and can disappear on any update, so do not
build on it.
The dedicated profile starts empty — no logins, no extensions, no existing tabs. To
drive the browser you already work in, leave that browser running and attach to it
rather than launching a new instance.
Note: claude --chrome may launch a headless instance. If v1 version shows HeadlessChrome, use the manual launch method above instead.
Scope Guard
cdp-attach is for acting on the attached browser, not for researching through it.
WILL use cdp-attach for:
- Capturing current state (screenshot, snapshot, network/console logs)
- Interacting with attached page (click, fill, JS evaluate, dialog handling)
- Navigating as a workflow step (e.g., form submit → result page, SPA state transitions)
- Monitoring (network_start/stop, console, performance tracing)
- Error diagnosis (screenshot + Read of error pages during automation)
- API response debugging (navigate to endpoint as part of debugging workflow)
WILL NOT use cdp-attach for:
- Browsing documentation or API references
- Searching for information via web pages
- Opening new tabs to research topics
- Reading web content for knowledge gathering
Litmus test: "Am I acting on the page, or learning from it?"
If learning → switch to Tavily MCP (tavily_search, tavily_extract) or Prothesis for multi-perspective investigation.
Redirect rule: When a research need arises mid-workflow, pause the cdp-attach context, resolve via Tavily, then resume cdp-attach with the findings.
Execution
Each Bash call is a separate shell. Always combine the variable assignment with the command:
V1="${CLAUDE_PLUGIN_ROOT}/scripts/v1_core.py" && $V1 list
V2="${CLAUDE_PLUGIN_ROOT}/scripts/v2_interact.py" && $V2 click --selector "a"
V3="${CLAUDE_PLUGIN_ROOT}/scripts/v3_advanced.py" && $V3 network_start
Selecting a target — profile-aware
Before selecting a target for the first time in a session, check for multiple browser profiles: run $V1 list --contexts. A list --search that matches no tab counts as the same situation — the target is undetermined, so run this check instead of returning empty-handed. If it reports 2 or more contexts and the user has not already named which profile or target to act on:
- Do not pick one yourself — the choice is the user's. Build a numbered list of the contexts, each option labeled by its 2–3 most recognizable open tab titles so the user recognizes the profile at a glance — never lead with the raw
browserContextId. Keep the context id prefix alongside the label as the machine handle.
- This skill runs forked (
context: fork), so it cannot pause for user input mid-run: return that numbered list as the final result, taking no target action in this run — the main conversation presents the choice and re-invokes with the user's pick. (If running inline instead, ask directly and wait for the pick.)
- On an invocation that carries the user's pick, narrow to the chosen profile with
--context <id-prefix> (e.g. $V1 select 0 --context 3f2a).
A single context, or a target the user has already disambiguated, proceeds without asking. Why this matters: which browser profile to drive is a user-private choice, and Target.getTargets exposes no human-readable name for a browser context (only id, title, url, type) — so a context's open tab titles are the only reliable recognition signal. Tabs from different profiles otherwise intermingle in the flat list, hiding the multiplicity entirely.
Quick Reference
v1 — Core (v1_core.py)
V1="${CLAUDE_PLUGIN_ROOT}/scripts/v1_core.py"
$V1 version
$V1 list
$V1 list --search "github" --limit 20
$V1 list --type all
$V1 list --contexts
$V1 list --context 3f2a
$V1 select 0
$V1 select AB71CD183BCE05DD...
$V1 select 0 --context 3f2a
$V1 screenshot
$V1 screenshot --full-page --format jpeg -o /tmp/page.jpg
$V1 snapshot --depth 3
$V1 snapshot --diff
$V1 evaluate "document.title"
$V1 evaluate "fetch('/api').then(r=>r.json())" --await
$V1 evaluate --stdin <<< 'var x = document.title; x'
evaluate --no-rewrite
navigate
navigate --wait-for none
reload
reload --hard
revive
revive --url
back
forward
back --wait-for load
evaluate --frame
evaluate --frame main
evaluate --frame-url
--selector --timeout-ms 10000
--text
--url-contains
--load-state networkidle
--
doctor
cdp_call Page.getLayoutMetrics
cdp_call Storage.getCookies --params-json
cdp_call DOM.getDocument --stdin <<<
error_list
error_list --filter -- 20
error_list --since-seconds 300
Note on --frame: accepts a CSS selector matching a frame owner (e.g. iframe, frame, object, embed) or the literal main for the top-level document. Cross-origin frames resolve the same way because CDP exposes per-frame execution contexts regardless of origin.
Note on doctor, cdp_call, error_list: bypass the headless guard so they run on any reachable CDP endpoint. doctor reports headless state itself; cdp_call is the escape hatch for CDP methods not wrapped by v1/v2/v3; error_list reads ~/.cache/cdp-attach/errors.jsonl, which cdp_client.send() populates automatically on every CDP failure (CDP error response, timeout, or WebSocket error). Disable error logging with CDP_ATTACH_NO_ERROR_LOG=1. The file rotates to errors.jsonl.1 at 1MB.
Note on list --contexts / --context: a Chromium profile is a browserContextId (stable, non-experimental TargetInfo field), but the HTTP /json/list endpoint does not expose it — resolving it opens a short-lived WebSocket to the browser-level endpoint (Target.getTargets). Display + filter only, not an access boundary — select --context refuses cross-context selection but nothing prevents a bare select <id> bypassing it. A prefix matching more than one context is an error (ambiguous), not a silent first-match.
Note on snapshot --diff: caches the accessibility tree per target under ~/.cache/cdp-attach/snapshots/{targetId}.json, keyed by backendDOMNodeId (a node keeps its identity across DOM reordering, unlike nodeId, so a moved node is not miscounted as removed+added). The diff compares membership, role, name, nesting depth, and immediate parent — see Known Limitations for what it does not detect. Without --diff the full tree still prints and the cache still updates, so the first --diff call after a plain snapshot has a fresh baseline. No baseline yet → falls back to the full tree with a note.
Common Mistakes
Commands that do NOT exist:
read_screenshot — use v1 screenshot then Read /tmp/cdp-screenshot-*.png
Arguments that do NOT exist:
click --coordinates x y — use positional: click 100 200
click --text "..." — use click --selector with a CSS selector instead
screenshot --selector — not supported; screenshot captures the full viewport (or --full-page)
find_element --id — use --node-id or --backend-node-id in get_bounds
scan_interactive --selector — not supported; use find_element --selector instead
get_bounds --text — use find_element --text first, then get_bounds --node-id
JavaScript in evaluate:
const/let are auto-rewritten to var by default (prevents re-declaration errors on repeated calls). Use --no-rewrite to disable.
- For complex JS with quotes/backticks, use
--stdin with heredoc:
$V1 evaluate --stdin <<'JS'
document.querySelectorAll('a').forEach(a => console.log(a.href))
JS
- Use
--await flag for promises (auto-wraps in async IIFE if needed):
$V1 evaluate --await "await fetch('/api').then(r => r.json())"
- The single-line auto-wrap inserts a
return. Multi-line bodies (anything containing ; or a newline) are wrapped without return — write the return yourself:
$V1 evaluate --await --stdin <<'JS'
var r = await fetch('/api');
var t = await r.text();
return t;
JS
v2 — Interaction (v2_interact.py)
V2="${CLAUDE_PLUGIN_ROOT}/scripts/v2_interact.py"
$V2 click 100 200
$V2 click --selector "button.submit"
$V2 click 100 200 --button right
$V2 click 100 200 --clicks 2
$V2 click --selector "a" --modifiers ctrl
$V2 scroll
$V2 scroll --delta-y -500
$V2 scroll 100 400 --delta-y 200
$V2 scroll --selector "div.scrollable" --delta-y 100
$V2 fill --selector "input[name=q]" "search query"
$V2 upload_file --selector "input[type=file]" /path/to/file.pdf
$V2 upload_file --selector "input[type=file]" /tmp/a.txt /tmp/b.txt
$V2 press_key Enter
$V2 press_key a --modifiers ctrl
$V2 hover --selector "a.nav-link"
$V2 new_page "https://example.com"
$V2 close_page
Note on click variants: --button right triggers contextmenu events. --clicks 2 follows CDP convention (clickCount increments within a click sequence — single/double/triple). --modifiers accepts comma-separated names: ctrl,shift,alt,meta (or cmd as an alias for meta).
Note on upload_file: targets <input type="file"> only — pre-validated via DOM.describeNode to fail fast on wrong selectors. Paths expand ~ and resolve to absolute. Multiple files via positional args (e.g., for <input multiple>).
Note on --frame-url: walks the frame tree with Page.getFrameTree, matches the first frame whose URL contains the substring. Useful when CSS selectors are unstable (hashed classes, dynamic IDs) but URL patterns are stable. Mutually exclusive with --frame.
Note on cross-origin iframes (OOPIF): cross-origin iframes (different origin from the parent page) run in separate processes and are not visible in the parent target's Page.getFrameTree. Both --frame (CSS selector) and --frame-url reach same-origin iframes only. Cross-origin iframe debugging requires attaching to the iframe's own target via Target.attachToTarget (not currently exposed by v1/v2/v3).
v2 — Element Discovery (v2_interact.py)
When CSS selectors fail (collapsed SPA panels, shadow DOM, hashed classes), use CDP DOM/Accessibility API.
V2="${CLAUDE_PLUGIN_ROOT}/scripts/v2_interact.py"
$V2 find_element --name "Search" --role button
$V2 find_element --text "Property Info"
$V2 find_element --xpath "//button[contains(.,'Search')]"
$V2 find_element --selector "div.panel" --pierce
$V2 get_bounds --node-id 42
$V2 get_bounds --backend-node-id 87
$V2 get_bounds --selector "button.submit"
$V2 get_bounds --selector "button.submit" --no-scroll
$V2 scan_interactive
$V2 scan_interactive --viewport
$V2 scan_interactive --role button,link
$V2 scan_interactive --limit 30
v3 — Advanced (v3_advanced.py)
V3="${CLAUDE_PLUGIN_ROOT}/scripts/v3_advanced.py"
$V3 network_start
$V3 network_list --filter "api"
$V3 network_list --filter "api" --bodies
$V3 network_body <requestId>
$V3 network_body <requestId> -o /tmp/r.json
$V3 network_stop
$V3 console_start
$V3 console_list --level error
$V3 console_stop
$V3 perf_start --categories "devtools.timeline"
$V3 perf_stop -o /tmp/trace.json
$V3 emulate --width 375 --height 812 --scale 3 --mobile
$V3 emulate --geolocation "37.5665,126.9780"
$V3 emulate --offline true
$V3 emulate_reset
add_init_script
add_init_script --stdin <<
Object.defineProperty(navigator, , { get: () => });
JS
remove_init_script
download_wait --timeout-ms 60000
download_wait --download-path /tmp/my-dl --timeout-ms 30000
state_save ~/.cache/my-session.json
state_load ~/.cache/my-session.json
drag 100 100 300 300 --steps 20
dialog accept
dialog accept
Note on state_save / state_load: batch session packaging only. Individual cookie or storage reads/writes go through v1 evaluate (browser is the authoritative state holder). state_save captures current-tab origin cookies (via Network.getCookies URL filter) plus that tab's localStorage / sessionStorage; pass --all-cookies to capture the entire browser cookie jar instead. IndexedDB is not included in v1 of the state snapshot.
Note: dialog is reactive — it handles an already-open dialog. It will fail if no dialog is currently visible. Trigger the dialog first (e.g., via evaluate or navigation), then call dialog to respond.
Typical Workflows
Screenshot Analysis
1. v1 list --search "target" → Find the tab
2. v1 select <index> → Select it
3. v1 screenshot → Capture PNG
4. Read /tmp/cdp-screenshot-*.png → View in Claude
Form Interaction
1. v1 select <tab>
2. v1 snapshot --depth 3 → Understand page structure
3. v2 fill --selector "input" "text"
4. v2 click --selector "button[type=submit]"
5. v1 screenshot → Verify result
SPA / Dynamic Page Interaction
When CSS selector fails (zero-dimension, hashed class, shadow DOM):
1. v2 scan_interactive → List interactive elements + coordinates
2. v2 click <x> <y> → Click by coordinates
Or:
1. v2 find_element --name "Search" → Discover backendNodeId
2. v2 get_bounds --backend-node-id <id> → Resolve coordinates
3. v2 click <x> <y> → Click
Vision Fallback (last resort)
When all programmatic approaches fail:
1. v1 screenshot -o /tmp/page.png → Capture page
2. Read /tmp/page.png → AI visually estimates coordinates
3. v2 click <x> <y> → Click estimated coordinates
4. v1 screenshot → Verify result
Known Limitations:
- During React hydration,
find_element --name/--role may return empty results. Wait for hydration via v1 evaluate, then retry.
scan_interactive makes a CDP call per element; use --limit to constrain (default 50).
- nodeId is invalidated on DOM changes. For stable references, use backendNodeId.
snapshot --diff compares node membership (by backendDOMNodeId), role, name, nesting depth, and immediate parent. It does not detect a pure sibling reorder that preserves all of those (same parent, same depth, same role and name — only the order among siblings changed): such a change reports "no changes". A --depth change between calls IS detected and forces a full-tree re-baseline instead of a false diff. For order-sensitive verification, read the full tree (without --diff).
list --contexts / list --context and select --context filter and guard only browser contexts that carry a browserContextId. Targets with no browserContextId are shown grouped as (default) but cannot be selected or guarded by --context (which requires a real context-id prefix). The default context is display-only by design — the profile filter is a display/filter aid, not a completeness guarantee over every target.
- CDP setter state is session-scoped, not target-scoped. Each v1/v2/v3 invocation opens a fresh CDP session and closes it on exit, so anything you "install" via
cdp_call or v3 add_init_script is discarded when the command returns. Affected: Security.setIgnoreCertificateErrors, Network.setExtraHTTPHeaders, Page.addScriptToEvaluateOnNewDocument (so v3 remove_init_script <id> from a separate call sees "Script not found"), Emulation.setUserAgentOverride when not paired with the same-session navigation. Workarounds: relaunch the browser with the equivalent command-line flag (e.g. --ignore-certificate-errors, --user-agent=...), or do the install + use inside a single cdp_call chain (limited because cdp_call is one method per invocation).
Network Debugging
1. v1 select <tab>
2. v3 network_start → Begin capture
3. v1 navigate "https://..." → Trigger requests
4. v3 network_list --filter "api" → Inspect
5. v3 network_stop → Cleanup
Mutating API Calls via Page Session (CSRF)
CSRF-token retry pattern for 403s on mutating fetch calls: details in references/network.md.
Frozen (Hidden) Tab — Network Calls Suspended
Wedged-renderer vs lifecycle-frozen-tab discrimination + helper-tab routing: details in references/recovery.md.
Virtualized Tables / Data Grids (rows missing from DOM)
Virtualized-grid / network-body fallback guidance: details in references/network.md.
Configuration
| Variable | Default | Description |
|---|
CDP_HOST | 127.0.0.1 | Chrome DevTools host |
CDP_PORT | 9222 | Chrome DevTools port |
Or use --host / --port flags on any command.
State
- Selected tab:
~/.cache/cdp-attach/state.json
- Network events:
~/.cache/cdp-attach/network-events.jsonl
- Network bodies:
~/.cache/cdp-attach/network-bodies/{requestId}.json
- Console events:
~/.cache/cdp-attach/console-events.jsonl
- Error log (diagnostic):
~/.cache/cdp-attach/errors.jsonl (rotates at 1 MB; surfaced via v1 error_list)
Error Handling
| Error | Cause | Resolution |
|---|
| CDP HTTP endpoint unreachable | Browser not running with --remote-debugging-port | Start browser with CDP enabled |
| Timeout waiting for response | Wedged renderer, lifecycle-frozen hidden tab, or just slow | Mutating action? Verify the side effect first (outcome unknown). Then discriminate with v1 evaluate "1": if even that times out → wedged renderer → v1 revive (discards renderer state); if it returns instantly but --await network calls hang → lifecycle-frozen tab → helper-tab routing (see "Frozen (Hidden) Tab" workflow), not revive |
| 403 Forbidden on mutating fetch via evaluate | Site requires a CSRF token header in addition to session cookies | Extract the token from page DOM and retry with the header — see "Mutating API Calls via Page Session (CSRF)" workflow |
| Tab unresponsive after navigate/reload | Renderer wedged (e.g., reload with pending blocked fetches) | v1 revive — WebSocket re-attach won't help; the HTTP endpoints still work |
| WebSocket send failed (command NOT sent) | Connection already dead before the call | Safe to re-select (list + select) and retry — nothing was executed |
| WebSocket connection failed | Tab closed or navigated away | Re-select tab with list + select |
| Element not found | Invalid CSS selector | Check selector with evaluate + querySelector |
navigate, reload, and back/forward (with --wait-for load) run a bounded renderer probe after the load wait — two attempts (5s, then 10s), since a renderer busy with a long main-thread task is not wedged — and exit with the revive hint only when both attempts stay silent, instead of letting every subsequent call burn its full 30s timeout.
Uncertain outcomes on mutating actions: a timeout or WebSocket error after a command was sent does not prove the action failed — the browser (or the server behind the page) may have committed it before the response channel was lost. The same applies to harness-level errors (e.g., API Error: Unable to connect to API), which are not CDP failures at all. Before reporting failure on a save/submit/delete, verify the side effect (re-read the resource); if verification is impossible, report "outcome unknown — verify" rather than "failed".
Bug Triage
When a CDP method or skill behaviour breaks (Chrome version drift, new edge case, undocumented contract), the in-session task and the permanent fix run on two tracks. Work around the bug in this session with existing primitives; the plugin's PreToolUse hook (hooks/hooks.json → hooks/pretooluse_context.sh) injects a one-line additionalContext reminder whenever the cdp-attach skill or one of its scripts is about to run, so the agent files a GitHub issue with detailed context if an error actually occurs during use.
The injected guidance says, in effect: file at https://github.com/jongwony/cc-plugin/issues with the failing command, verbatim error output, environment (Chrome version, OS), and a one-line first-interpretation. Labels: cdp-attach,triage-needed. The agent is the issue filer (via gh), not the harness — this keeps the mechanism free of background daemons or per-session Python.
Triage decisions (wontfix, redirect, patch via PR) belong to the triage session, not the session that hit the bug. See ~/.claude/rules/triage-gated-vendor-harness.md for the principle.
Fallback Strategy
Tab attachment and screenshot capture are inherently flaky. When a CDP operation fails after 2 attempts, escalate through the fallback chain before abandoning CDP entirely.
| Symptom | 1st fallback (stay in CDP) | 2nd fallback (leave CDP) |
|---|
| Tab attachment fails | v1 list → re-select a different tab index | Ask user to manually navigate, then retry |
| Tab wedged (every call times out) | Confirm with v1 evaluate "1", then v1 revive — close + reopen the tab via HTTP API (renderer-immune; discards sessionStorage/in-memory JS) | Ask user to close/reopen the tab manually |
| Screenshot capture times out | v1 evaluate "document.title" to extract DOM text directly | Ask user to capture screenshot manually |
| DOM navigation unreliable (react-select, dynamic SPAs) | v2 scan_interactive or v2 find_element --name/--role to discover elements via CDP DOM/Accessibility API | Vision fallback: screenshot → Read → click coordinates |
| HTTP/TLS inspection needed | v1 evaluate with fetch() to inspect responses | curl / openssl s_client as CLI alternative |
Protocol Reference
For CDP domain methods, key codes, and device presets, see references/cdp-protocol.md.