name: chrome-cdp description: Inspect, measure, and screenshot a running Chrome tab programmatically via the Chrome DevTools Protocol. Use when debugging frontend bugs that differ between environments (local dev vs staging/prod), comparing two pages side-by-side, reading computed CSS / DOM geometry / loaded stylesheet rules without asking the user to do DevTools ceremony, capturing visual proof during iteration, or inspecting content inside same-origin iframes (Storybook preview, embedded apps). Also use whenever the user has a debug-mode Chrome running on port 9222 or names CDP/DevTools directly. Not for driving user flows (clicks, forms, e2e runs) — use Playwright for that.
Chrome DevTools Protocol
Inspect and control a running Chrome instance from the command line — read computed styles, run arbitrary JS, capture screenshots, walk into iframes — without asking the user to open DevTools and click around.
When to use this
- Debugging "works locally but breaks in prod" frontend issues — measure geometry / computed CSS in both tabs and diff them.
- Verifying a fix landed (computed styles changed as expected, asset hashes updated).
- Capturing a screenshot of the current page state for inclusion in your reasoning.
- Inspecting Storybook stories (the preview lives in an iframe —
iframe.contentDocumentworks because it's same-origin). - Comparing two pages programmatically — open both as tabs in a debug Chrome and run the same eval against each.
Skip this skill when the user just needs a quick eyeball check; ask for a screenshot instead.
For driving a browser through user flows — clicking, filling forms, multi-step e2e verification, test generation — use Playwright instead (playwright-cli is on PATH); it owns the browser lifecycle and provides trusted input events and auto-waiting. This skill is for attaching to and interrogating a tab the user already has open (their session, their cookies, their state).
playwright-cli writes .playwright-cli/ snapshots and console logs under its working directory. Run it from an explicit owned scratch/evidence directory that is already ignored, rather than a shared source checkout: untracked browser output can block clean-source build gates. If relocating an active session, detach it first and preserve only your own artifacts before resuming from the new directory.
Prerequisites — launch Chrome with the debug port
Chrome only exposes CDP when launched with --remote-debugging-port. The user must do this themselves — you cannot enable it on a Chrome they already have open. Tell them to run:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--remote-debugging-port=9222 \
--user-data-dir="$HOME/.chrome-debug-cdp"
The separate --user-data-dir is important so they keep their main Chrome session intact. They then navigate to the page(s) they want inspected in this new Chrome instance.
If python3 -c "import websocket" fails, install it once:
pip3 install --user --break-system-packages websocket-client
Xcode's bundled Python uses an older pip that rejects
--break-system-packages; with that interpreter, rerun the install without
that flag:
python3 -m pip install --user websocket-client
Workflow
1. Find the tab you want
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py # list all
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py localhost # filter by url substring
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py --ws uat # print only ws URLs
Each tab has a webSocketDebuggerUrl like ws://localhost:9222/devtools/page/<HEX>. That's the handle the eval/screenshot scripts take.
2. Run JS in the page
For one-line evals, pass the expression inline:
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_eval.py "$WS_URL" "document.title"
For anything non-trivial, write the JS to a file and use -f. This avoids quoting nightmares with backticks and arrow functions:
cat > /tmp/probe.js <<'JS'
(() => {
const el = document.querySelector('.my-component');
if (!el) return { error: 'not found' };
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
return {
width: Math.round(r.width),
height: Math.round(r.height),
overflow: cs.overflow,
borderRadius: cs.borderRadius,
};
})();
JS
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_eval.py "$WS_URL" -f /tmp/probe.js
Wrap your code in a (() => { ... })() IIFE that returns a plain object — the eval runs with returnByValue: true and awaitPromise: true, so async IIFEs work too.
3. Capture a screenshot
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_screenshot.py "$WS_URL" /tmp/page.png
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_screenshot.py "$WS_URL" /tmp/full.png --full
Then Read the PNG to view it inline.
4. Capture network activity
# 5-second capture of whatever loads / requests during the window
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_net.py "$WS_URL"
# Reload the page and capture the full page-load lifecycle
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_net.py "$WS_URL" --reload --seconds 8
# Watch only API calls to /api, and pull response bodies for debugging
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_net.py "$WS_URL" --filter /api --bodies --seconds 10
Output is a JSON list of {url, method, status, type, mimeType, encodedDataLength, errorText, fromCache, body?} — one entry per request that completed (or failed) inside the window. Useful for:
- Verifying which CSS/JS chunks the page actually loads (
type: "Stylesheet"/"Script"). - Diffing the asset hash list across local vs prod.
- Capturing the JSON body of a failing API call without DevTools.
- Confirming a request was served from cache (
fromCache: true).
--bodies skips responses larger than 1 MiB and tags binary bodies as <base64:N> so the output stays readable. Inline bodies are capped at 4000 chars.
5. Reach into an iframe (Storybook, embedded previews)
Storybook renders stories inside iframe#storybook-preview-iframe. From the outer page, you can walk into the iframe's contentDocument because it's same-origin:
const iframe = document.querySelector('#storybook-preview-iframe, iframe');
const doc = iframe?.contentDocument;
if (!doc) return { error: 'iframe doc not accessible' };
// then query doc.querySelector(...) instead of document.querySelector(...)
Cross-origin iframes will throw on contentDocument access — for those, target the iframe's own page tab if it has one, or use Page.navigate to load the iframe URL directly in a new tab.
Recipes
Compare element geometry across two tabs
LOCAL_WS=$(python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py --ws localhost:5173)
PROD_WS=$(python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py --ws app.example.com)
cat > /tmp/measure.js <<'JS'
(() => {
const el = document.querySelector('.target');
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return { width: Math.round(r.width), overflow: cs.overflow };
})();
JS
echo "LOCAL:"; python3 ~/.claude/skills/chrome-cdp/scripts/cdp_eval.py "$LOCAL_WS" -f /tmp/measure.js
echo "PROD:"; python3 ~/.claude/skills/chrome-cdp/scripts/cdp_eval.py "$PROD_WS" -f /tmp/measure.js
Check whether a CSS rule is loaded on the page
(() => {
const matches = [];
const walk = (rules) => {
for (const rule of rules) {
if ((rule.selectorText || '').includes('.target-class')) {
matches.push({ selector: rule.selectorText, cssText: rule.cssText.slice(0, 200) });
}
if (rule.cssRules?.length) walk(rule.cssRules); // descend into grouping and nested rules
}
};
for (const sheet of [...document.styleSheets]) {
let rules; try { rules = sheet.cssRules || []; } catch (e) { continue; }
walk(rules);
}
return { matchCount: matches.length, matches };
})();
Cross-origin stylesheets throw on cssRules access — the try/catch skips them silently. The recursive walk matters: rules inside @media/@supports/@layer blocks are nested and a flat iteration misses them. Check the current rule before descending: modern Chrome exposes an empty cssRules collection on ordinary style rules, so treating the property's presence as proof of a grouping rule silently skips normal selectors.
Find every rule that actually applies to an element
Selector-substring matching tells you what rules exist. To debug an unexpected computed style, you usually want what rules target this element — use element.matches(selector):
(() => {
const el = document.querySelector('.target');
if (!el) return { error: 'not found' };
const out = [];
const walk = (rules) => {
for (const r of rules) {
if (r.selectorText) {
let m = false; try { m = el.matches(r.selectorText); } catch (e) {}
if (m) out.push({ selector: r.selectorText, css: r.cssText.slice(0, 220) });
}
if (r.cssRules?.length) walk(r.cssRules);
}
};
for (const sheet of [...document.styleSheets]) {
let rules; try { rules = sheet.cssRules || []; } catch (e) { continue; }
walk(rules);
}
return { count: out.length, rules: out };
})();
Order in the result roughly mirrors cascade order (later-added stylesheets last). Combine with getComputedStyle(el) to see which rule actually won.
Diff loaded rules between two environments
The single most useful pattern when "same code renders differently in env A vs env B": run the rule-finder above against the same selector in both tabs. If one tab is missing a rule the other has, you've found the bug — usually a bundler dropping a CSS side-effect import. (Vite pre-bundling linked CJS packages is a frequent offender; the JS gets bundled, the require("pkg/css") side effect doesn't.)
LOCAL_WS=$(python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py --ws localhost:5173)
PROD_WS=$(python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py --ws localhost:6006)
# same probe.js for both — diff the rule lists
echo "LOCAL:"; python3 ~/.claude/skills/chrome-cdp/scripts/cdp_eval.py "$LOCAL_WS" -f /tmp/probe.js
echo "PROD:"; python3 ~/.claude/skills/chrome-cdp/scripts/cdp_eval.py "$PROD_WS" -f /tmp/probe.js
List asset URLs the page loaded
({
scripts: [...document.scripts].map(s => s.src).filter(Boolean),
styles: [...document.styleSheets].map(s => s.href).filter(Boolean),
})
Get the active element / scroll position / viewport state
({
active: document.activeElement?.tagName + '#' + document.activeElement?.id,
scroll: { x: window.scrollX, y: window.scrollY },
viewport: { w: window.innerWidth, h: window.innerHeight },
})
Anti-patterns
- Don't try to attach to the user's main Chrome. Recent Chrome rejects CDP on profiles that weren't launched with
--remote-debugging-port. They need to launch a separate debug Chrome (with--user-data-dir). - Don't skip
suppress_origin=Trueor localhost proxy bypass. The Pythonwebsocket-clientlibrary sendsOrigin: http://localhost:9222by default, which Chrome's CDP server rejects with403 Forbidden. It can also honor proxy env vars for127.0.0.1and fail or hit the wrong socket. The bundled scripts passsuppress_origin=True,http_proxy_host=None, andhttp_proxy_port=None; use the same settings in custom clients. - Don't shell-quote complex JS. Use the
-f <file>form for anything beyond a one-liner. Quoting a multi-line arrow function with backticks through bash will eat your day. - Don't poll on a sleep loop after an action. If you triggered a navigation or HMR, use
Page.frameStoppedLoadingor just check the result of a fresh eval. Sleeping rarely catches the right moment. - Don't expect
cssRuleson cross-origin stylesheets. The browser throwsSecurityError— wrap intry/catchand continue. - Don't forget Vite's dev cache. When linking a local package and HMR doesn't pick it up,
rm node_modules/.vite/deps/<pkg>.js(or restart the dev server with--force). CDP can't fix a stale dep pre-bundle. getBoundingClientRect()is post-transform;getComputedStyle().heightis pre-transform. When the page (or any ancestor) appliestransform: scale(...)— React Flow's zoom, embla, canvas viewports, CSS animations — bounding rects return the painted size, not the layout size. A 389px element underscale(0.62)measures as 242px. For "what did the layout engine decide?" use computed style; for "what's on screen right now?" use bounding rect. Probing both side-by-side surfaces the discrepancy fast.- Don't click a canvas by
dispatchEventfromRuntime.evaluate. Page-constructedPointerEvent/MouseEvents look fine in the console and still miss Three.js/WebGL picking (raycasters, OrbitControls). Send CDPInput.dispatchMouseEventwith viewport CSS pixels (mousePressedthenmouseReleased, samex/y) when you must click a canvas from this skill; prefer playwright for a real flow. DOM.click()on buttons still works.
