| 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.
|
| user_invocable | true |
| context | fork |
| model | sonnet |
| argument-hint | <operation> [args...] |
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 --remote-debugging-port=9222
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
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 select 0
$V1 select AB71CD183BCE05DD...
$V1 screenshot
$V1 screenshot --full-page --format jpeg -o /tmp/page.jpg
$V1 snapshot --depth 3
$V1 evaluate "document.title"
$V1 evaluate "fetch('/api').then(r=>r.json())" --await
$V1 evaluate --stdin <<< 'var x = document.title; x'
$V1 evaluate --no-rewrite "const x = 1"
$V1 navigate "https://example.com"
$V1 navigate "https://example.com" --wait-for none
$V1 reload
$V1 reload --hard
$V1 revive
$V1 revive --url "https://example.com"
$V1 back
$V1 forward
$V1 back --wait-for load
$V1 evaluate "document.title" --frame "iframe.embedded"
$V1 evaluate --frame main "location.href"
$V1 evaluate --frame-url "youtube" "location.href"
$V1 wait --selector "div.loaded" --timeout-ms 10000
$V1 wait --text "Order complete"
$V1 wait --url-contains "/dashboard"
$V1 wait --load-state networkidle
$V1 wait --function "window.__APP_READY__ === true"
$V1 doctor
$V1 cdp_call Page.getLayoutMetrics
$V1 cdp_call Storage.getCookies --params-json '{"urls":["https://example.com"]}'
$V1 cdp_call DOM.getDocument --stdin <<< '{"depth":1}'
$V1 error_list
$V1 error_list --filter "Network" --limit 20
$V1 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.
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
$V3 add_init_script "window.__TEST_HOOK__ = true"
$V3 add_init_script --stdin <<'JS'
Object.defineProperty(navigator, 'webdriver', { get: () => false });
JS
$V3 remove_init_script "1"
$V3 download_wait --timeout-ms 60000
$V3 download_wait --download-path /tmp/my-dl --timeout-ms 30000
$V3 state_save ~/.cache/my-session.json
$V3 state_load ~/.cache/my-session.json
$V3 drag 100 100 300 300 --steps 20
$V3 dialog accept
$V3 dialog accept "prompt input"
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.
- 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)
A fetch issued via v1 evaluate --await with credentials: 'include' may return 403 Forbidden on mutating endpoints (PUT/POST/DELETE) even though the cookie session is valid — many sites additionally require a CSRF token header on state-changing requests. The token lives in the page DOM (common locations: <meta name="csrf-token">, hidden inputs — verified example: Datadog stores it in the _current_user_json hidden input as csrf_token).
1. v1 evaluate "..." → Locate + extract the CSRF token from page DOM
(synchronous read; no --await needed)
2. v1 evaluate --await --stdin → Retry the fetch with the token attached as the
appropriate header (e.g. X-CSRF-Token)
3. Verify the response status / side effect
$V1 evaluate "JSON.parse(document.querySelector('input[name=_current_user_json]').value).csrf_token"
$V1 evaluate --await --stdin <<'JS'
var r = await fetch('/api/v1/resource', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '<token>' },
body: JSON.stringify({ /* payload */ })
});
return r.status;
JS
Note: the token's DOM location and header name are site-specific — capture a successful mutating request with v3 network_start, locate it via network_list --filter <api-path>, then read its request headers from ~/.cache/cdp-attach/network-events.jsonl (the Network.requestWillBeSent entries carry the JS-set request headers — X-CSRF-Token and the like appear there, though browser-added headers arrive only via Network.requestWillBeSentExtraInfo, which the collector does not record; network_list itself prints only method/status/type/URL). If the tab holding the session is lifecycle-frozen (fetch hangs instead of returning 403), combine this with helper-tab routing below: read the token from the frozen tab synchronously, then issue the fetch from a fresh same-origin tab.
Frozen (Hidden) Tab — Network Calls Suspended
Chromium freezes hidden/backgrounded tabs (observed in Dia browser, Chrome 149): fetch/XHR/timers are suspended, so evaluate --await network calls hang silently — the promise never settles — while synchronous main-thread JS still executes (DOM reads work). Verified non-recoveries: Page.bringToFront does not unfreeze the renderer, and Page.setWebLifecycleState "active" does not stick — Chromium implements it as a one-shot transition (the same SetPageFrozen call the browser's own freezing policy uses), not a persistent override: the tab remains hidden, so the freezing policy simply re-freezes it at its next decision point (verified in Chromium source: page_handler.cc SetWebLifecycleState, freezing_policy.cc).
Recognition test: synchronous v1 evaluate "1" returns instantly, but a short --await fetch hangs. (Contrast with a wedged renderer, where even the synchronous evaluate times out — see Error Handling.)
Workaround — helper-tab routing (reducible to existing primitives; no dedicated command, same rationale as the virtualized-tables fallback):
1. v1 evaluate "..." → (if needed) read same-origin DOM state from the
frozen tab synchronously first (e.g. a CSRF token)
2. v2 new_page <same-origin-url> → Fresh tabs start unfrozen; cookies are shared
3. v1 evaluate --await "..." → Run the fetch/XHR from the helper tab
4. v2 close_page → Close the helper tab when done
Note: v1 revive also works (the reopened tab starts unfrozen) but discards renderer state unnecessarily — a frozen tab is not wedged; prefer helper-tab routing.
Virtualized Tables / Data Grids (rows missing from DOM)
Modern grid libraries (MUI X DataGrid, AG Grid, TanStack Table, react-virtualized) unmount offscreen rows. DOM queries return only the visible window — querySelectorAll('[role=row]') may yield 10 rows for a 100-row dataset, and snapshot / scan_interactive / find_element all hit the same limit because they walk the live DOM/AX tree.
The authoritative data lives in the XHR/Fetch response that populated the grid. network_start captures these bodies; retrieve them directly instead of fighting virtualization:
1. v1 select <tab>
2. v3 network_start → Start collector BEFORE the request fires
3. v1 navigate "..." or v2 click "<search button>" → Trigger data fetch
4. v3 network_list --filter <api-path> --bodies → Find requestId (✓ = body saved)
5. v3 network_body <requestId> → Print JSON; pipe to jq / Python for extraction
Body capture is constrained to XHR/Fetch/EventSource responses under 5MB. Bodies fetched before network_start are unrecoverable — CDP's Network.getResponseBody only works inside the same session that received the response, and v1 cdp_call Network.getResponseBody opens a fresh session whose body buffer is empty for past requests.
For server-paginated grids, each pagination click triggers a new request that the collector captures automatically — iterate the UI (or increase page size) and call network_body per request.
When the grid was already loaded before capture began (no fresh request available), fall back to scroll-and-harvest with v2 scroll --selector "<scroller>" + v1 evaluate between scrolls. This is reducible to existing primitives; no dedicated command.
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.
Argument Dispatch
When user provides arguments to /cdp-attach:
- Single word matching a subcommand → run directly (e.g.,
/cdp-attach list)
- Free-form request → map to appropriate v1/v2/v3 command sequence