Every command speaks the same JSON envelope and the same exit-code contract, so a script or an agent parses one shape and branches on one number. This page is the lookup table for all of it; for the ideas behind the commands, start with the README and the scenario guides.
Add --json to any command and it emits exactly one envelope on stdout:
{ "ok": true, "command": "eval", "target": {"id":"…","title":"…","url":"…"},
"result": { "value": "…" }, "elapsed_ms": 12 }A failure uses the same shape with "ok": false and an error{code,message,details}, plus a nonzero exit code.
Branch on the exit code, not on message text.
| Exit | Code | Meaning |
|---|---|---|
| 0 | — | success |
| 1 | generic | unclassified failure |
| 2 | usage | bad flags or arguments |
| 3 | connection | attach / launch failed, or Chrome's consent prompt is unanswered |
| 4 | target/timeout | selector not found, timed out, or ambiguous/unknown target |
| 5 | cdp | CDP protocol error |
| 6 | daemon | daemon error |
| 7 | permission_denied | refused by policy — the origin, the verb, or the upload path is out of bounds |
Exit 3 covers three error.code values: connection_failed, not_debug_enabled, and consent_pending.
The last one means Chrome accepted the connection and then went silent because it is holding its browser-modal "Allow remote debugging?" dialog — nothing is broken, a human has not answered yet.
It is a distinct code on the same number because the remedy is "click the dialog", not "check your setup"; the numbers are contract and do not grow for a new failure mode.
Exit 7 is deliberately distinct from exit 4: an agent has to be able to tell "policy forbids this, stop and tell the user" from "element not found, retry differently".
Without --json the same information renders as a short human line (result to stdout, errors to stderr).
These apply to every command.
| Flag | Default | Purpose |
|---|---|---|
--json |
off | one JSON value to stdout |
--target <spec> |
sticky tab | tab to act on (see Targeting) |
--timeout <dur> |
30s |
max time to wait for the command |
--consent-timeout <dur> |
120s |
how long to wait for Chrome's "Allow remote debugging?" prompt (a refused endpoint still fails fast) |
--by <mode> |
css |
selector syntax (see Addressing) |
--wait <cond> |
visible |
element wait: visible | ready | enabled |
--no-wait |
off | act immediately; fail fast instead of waiting |
--role <role> |
— | with --by name: constrain to an ARIA role |
--nth <n> |
— | with --by name: pick the Nth (1-based) match |
--match <mode> |
exact |
with --by name: exact | contains | regex |
--in-row <text> |
— | with --by name: scope to the table row whose text contains this |
--on-dialog <policy> |
— | on click/type/fill: accept | dismiss a native dialog raised during the action (a dialog already up is dialog, not this) |
--pierce |
off | reach into shadow DOM / iframes (via DevTools search) |
--no-daemon |
off | connect directly instead of via the shared daemon |
--no-launch |
off | don't auto-launch a fallback Chrome |
--port <n> |
auto | explicit Chrome debug port |
--endpoint <url> |
— | explicit debug endpoint, ws://host:port/devtools/browser/<id> or http://host:port (wins over --port and the DevToolsActivePort file; see Explicit endpoint) |
--profile-dir <dir> |
default | managed-launch Chrome profile dir |
--session <name> |
— | namespace the sticky current tab, so several agents can share one Chrome without stealing each other's tab (see Several agents, one Chrome) |
--no-color |
off | plain output (also honors $NO_COLOR) |
-q, --quiet |
off | suppress non-essential output |
-v, --verbose |
off | verbose diagnostics on stderr |
--allow <pattern> |
— | policy: act only on these origins (repeatable) |
--policy-off |
off | policy: don't enforce the configured policy for this command |
Precedence, highest first: command-line flag > CHROME_CDP_* env var > config file > built-in default (see Configuration).
A command acts on one tab, chosen (highest precedence first) by --target, then the sticky tab set with use, then a target default in config.
| Spec | Matches |
|---|---|
<idprefix> |
a tab whose id starts with this |
url:<substr> |
first tab whose URL contains the substring |
title:<substr> |
first tab whose title contains the substring |
@N |
the Nth tab (1-based) in list order |
chrome-cdp use url:github # set the sticky tab once…
chrome-cdp snap # …then omit --target on later commands--session <name> (config key session, env CHROME_CDP_SESSION) namespaces the sticky current tab, so several agents driving the same Chrome each keep their own "current tab" instead of stealing each other's.
Set it once and every command in that shell inherits it:
export CHROME_CDP_SESSION=<task> # before the first command
chrome-cdp use url:github # sets THIS session's sticky tab, not any other'sA session name must match ^[A-Za-z0-9._-]{1,64}$; a malformed --session is usage/exit 2 before Chrome is touched, and a malformed CHROME_CDP_SESSION or config session is dropped the same way a malformed endpoint is.
list reports the active session name as current_session (empty when none), and use reports it as session, so an agent can tell which namespace a command ran under from the envelope alone.
The daemon socket is not per session: every session on the same endpoint shares one connection and its console/net event buffers by design, so console/net capture across sessions the same way they always have.
Only the sticky current tab is namespaced.
A sticky tab that was closed is not silently swapped for another tab — it resolves to target_not_found, the same tab_gone behaviour the agent-browser tool documents.
Run use again to point the session at a live tab.
--by chooses how a selector argument is interpreted.
On real apps, prefer name — it reads the accessibility tree, skips hidden/utility nodes, and crosses shadow DOM and same-origin iframes.
--by |
Selector is | Notes |
|---|---|---|
css |
a CSS selector | default; dynamic-id apps make it brittle |
id |
an element id | |
name |
an ARIA accessible name | prefer on real apps; pair with --role / --nth / --match |
ref |
a snap-issued e<id> ref |
act on the exact node snap reported, no re-resolve |
cell |
a [row|]column grid header |
resolves the editable input in that grid cell; a candidate in the header's own grid beats one elsewhere on the page, and an unoccluded one beats a covered one |
label |
a form control's visible label | for controls whose label isn't wired (no aria-label / <label for>) |
search |
DevTools text/XPath/CSS search | broad; first match wins |
jspath |
a JS path | |
css-all |
a CSS selector (all matches) |
Modifiers that refine a --by name match:
| Modifier | Effect |
|---|---|
--role button |
keep only nodes with that ARIA role |
--match contains |
match a case-insensitive substring (real apps have verbose names) |
--nth 2 |
pick the 2nd match among visible candidates |
--in-row "<text>" |
keep only the match inside the table row containing <text> |
Backgrounded tabs: --by css / id / search resolve via querySelector and work regardless.
--by name / ref / cell read the accessibility tree, which Chrome throttles on a tab it can't foreground — a timeout there returns tab_hidden: true so you know to foreground Chrome (--by name also falls back to a DOM name match, and --in-row / label resolve via the DOM, so those keep working).
| Command | Does |
|---|---|
list [--url <s>] [--title <s>] |
list open tabs (id, title, URL); filters narrow by substring |
open <url> |
new tab → navigate → make it current; returns its id |
use <target> |
set the sticky current tab |
nav <url> |
navigate the target tab, wait for load |
nav --back | --forward |
move through the tab's history (errors if there's no entry that way) |
nav --reload [--hard] |
reload; --hard bypasses the cache |
activate [<target>] |
bring the tab to the foreground — the fix for tab_hidden |
close [<target>] [--url <s>] [--title <s>] [--all] |
close a tab; --all closes every match |
chrome-cdp list --url outlook # just the Outlook tabs
chrome-cdp open https://example.com # returns the new tab's id
chrome-cdp nav --back # back one step in a wizard
chrome-cdp close --url staging --all # tidy up after a batch jobclose refuses to guess: when a filter matches more than one tab without --all, it closes nothing and exits ambiguous_target.
Closing the sticky tab clears it (sticky_cleared: true), so the next command reports no_current_target rather than failing against a dead id.
Recovering from tab_hidden.
Chrome throttles the accessibility tree on a tab it can't foreground, so --by name / ref / cell stall there and return tab_hidden: true.
activate is the remedy, and it makes the failure recoverable without a human switching tabs:
chrome-cdp snap --by name … || { chrome-cdp activate && chrome-cdp snap --by name …; }activate reports was_active so a retry loop can tell "I fixed it" from "it was already foreground, so the stall has another cause", and window_focused: false when the OS refused to raise the window.
| Command | Returns |
|---|---|
snap [--role <r>] [--grep <re>] [--region <name>] [--dedupe] |
accessibility snapshot: roles + names of actionable nodes, plus alerts, focused, per-node states/value/ref |
find <query> [--role <r>] [--limit <n>] [--region <name>] [--all] [--dedupe] [--min-score <s>] |
ranked element matches for a plain-language query, each with ref, exact name, states, score, and center |
grid [selector] |
a table/grid as {headers, rows, count} |
value <selector> [--all] |
a form field's value (--all: every match, as a list); password fields come back masked |
text <selector> |
visible text of a selector |
text --article [--markdown] [--min-chars <n>] |
the page's main readable content, boilerplate dropped |
html <selector> [--inner] |
outer (or inner) HTML |
eval <js> [--await] |
evaluate JS in the top frame |
The snap filters run server-side, so a read returns just the relevant nodes instead of a whole page.
chrome-cdp snap --role button --grep "[AP]M" # calendar-event buttons only
chrome-cdp grid # read a table without parsing snap
chrome-cdp value --all "input.hours" # every hour cell in one callvalue, value --all, snap, and find all report a password input's contents as bullets, and omit a hidden input's value entirely.
Chrome already masks passwords in the accessibility tree; the DOM-reading paths mask them to match, so no read verb quietly becomes the door that hands a typed password to a script or an agent transcript.
A masked single read says so with "masked": true, so a caller can tell bullets-because-masked from bullets-the-user-typed.
This is about not leaking by accident, not a security boundary: eval still reads whatever the page holds.
That is deliberate — when you genuinely need the characters, there is one explicit path for it rather than four incidental ones.
snap answers "what is on this page"; find answers "where is the thing I already know I want".
A short query — the element's purpose, a fragment of its text, or its hint text — is ranked against the accessibility tree, and the best matches come back with everything an acting verb needs:
chrome-cdp find "login button"
chrome-cdp find "delete" --region "Invoice 4102" --role button
chrome-cdp find "time type" --role textbox --limit 3{ "ok": true, "command": "find",
"result": { "query": "login button",
"matches": [
{ "ref": "e4821", "role": "button", "name": "Sign in to your account",
"score": 0.91, "center": {"x": 640, "y": 412},
"states": ["focusable"], "visible": true } ],
"count": 1, "truncated": false } }The ref feeds --by ref, the exact name feeds --by name, and center is a viewport CSS-pixel point.
This is the cure for verbose accessible names: find "review" returns the real name ("Review Approval: Awaiting Action by …") so you never have to guess it.
Matching is a deterministic heuristic — token overlap plus role words (button, link, field, box, bar, checkbox, tab, menu, heading, row, icon) that softly steer the ranking — not a model call.
It handles descriptive queries, not paraphrase; for "the thing that saves my work" you still read a snap.
Role words are a nudge; --role is the hard filter.
Finding nothing is an answer, not an error: count: 0, exit 0.
--min-score drops weak matches, --all includes hidden/ignored nodes (ranked lower), and --dedupe collapses identical role+name pairs in virtualized grids.
A --region that names no container on the page also yields zero matches rather than an error — the same as snap — but the envelope reports region_found: false, so a typo'd region is distinguishable from a region that exists and holds nothing.
On a backgrounded tab the a11y tree may be throttled; when it yields nothing there, find falls back to a DOM-computed pass (note: "dom_fallback") that uses the same accessible-name derivation --by name falls back to.
Fallback matches carry centres but no refs, and --region is not honoured (region scoping is an accessibility-subtree notion); --role, --dedupe, --limit, and --min-score all still apply.
A password field's value comes back masked on both paths, and a hidden input's is omitted — the fallback reads the DOM, where the value is the literal typed text, so it masks what Chrome's accessibility tree masks for you.
Other field values are returned as-is, exactly as snap and value return them.
center is the element's box centre in viewport CSS pixels, measured with the same primitive the pointer verbs use — so a centre find reports is a point a click lands on.
When that centre pixel resolves to something else (a cookie banner, a modal, a sticky header), the match carries occluded: true: the element is there, but a click at that coordinate would hit the overlay instead.
That is reported, never fatal — knowing the coordinate would miss is the useful part.
visible remains "has a box"; occluded is the separate question of whether the box is reachable.
find never scrolls the page to measure, because a read verb must not move the page under a running automation.
The pointer verbs do scroll their target into view, which is why an off-screen element can be clickable even when find reports its centre outside the viewport.
On a real page most of the visible text is chrome: navigation, footers, cookie notices, related-link rails.
--article scores the page's blocks the way Reader Mode does and returns only the winning subtree, so a caller spends its attention (or its context budget) on content.
chrome-cdp text --article # the body text, nothing else
chrome-cdp text --article --markdown > notes.md # keep the structure
chrome-cdp text --article --min-chars 500 # demand a longer articleExtraction runs in an isolated world and scores a clone of the DOM — it never writes to the page you are automating, and it leaves nothing on window.
Because extraction is a heuristic, the envelope reports what it kept, so a script can tell a good extraction from a bad one:
{ "ok": true, "command": "text",
"result": { "text": "…", "title": "Quarterly report", "byline": "A. Author",
"excerpt": "Revenue for the quarter reached…",
"chars": 4821, "total_chars": 24193, "ratio": 0.199,
"extracted": true, "format": "text" } }When it keeps fewer than --min-chars characters (default 250) it says so instead of handing back a plausible-looking fragment: extracted is false, reason explains it, article_chars reports how little it found, and text falls back to the full page text — at exit 0.
A read that did return usable text is not a failure, so it is not an error; the flag is there to be checked.
--markdown preserves headings, lists, links, code blocks, and blockquotes.
It is deliberately not a general HTML-to-markdown converter: tables, footnotes, and embedded media are out of scope — tables come through as plain text and images are dropped.
--markdown or --min-chars without --article, and --article together with a selector, are usage errors (exit 2), rejected before Chrome is contacted.
The selector combination is an error on purpose: "extract the main content, but only within this subtree" has no clear meaning yet, and it can be defined later without breaking anything.
Plain eval evaluates an expression, which is why the two most natural things to type both fail: a top-level await is a syntax error, and a statement list is not an expression.
--await switches on awaitPromise and replMode, which is exactly what DevTools' own console does:
chrome-cdp eval --await 'await fetch("/api/me").then(r => r.json())'
chrome-cdp eval --await 'const rows = [...document.querySelectorAll("tr")]; rows.length'The result records awaited: true, so a caller can tell which path ran.
A rejected promise is an error, never a value: exit 5 with error.code: cdp_error, the rejection's message in error.message, and its stack in error.stack.
A never-settling promise is bounded by --timeout (exit 4), and the connection stays usable afterwards.
--await is opt-in.
replMode changes how bare object literals and let/const re-declaration behave, so plain eval keeps its existing semantics rather than changing silently under scripts that already work.
| Command | Does |
|---|---|
click (<selector> | --at <x,y>) |
click at the element's occlusion-verified centre, or at a viewport coordinate |
hover (<selector> | --at <x,y>) |
move the pointer there without pressing — reveals hover-only menus and tooltips |
dblclick (<selector> | --at <x,y>) |
double-click (one dblclick event, detail: 2) — grid cells that edit on double-click |
tripleclick (<selector> | --at <x,y>) |
triple-click to select a whole text block (the prelude to copy or overwrite) |
rclick (<selector> | --at <x,y>) |
right-click, opening the context menu |
drag (<selector> | --at <x,y>) (--to <sel> | --to-at <x,y> | --dx <p> --dy <p>) |
press, move, release — reordering, kanban, sliders, canvas strokes |
key [selector] <keyspec> |
press keys that aren't literal text (Escape, Tab, cmd+a, ArrowDown) |
type <selector> <text> |
type via real keystrokes (appends; end with \n to press Enter) |
fill <selector> <value> |
set a field, replacing its content (clears, then types) |
select <field> <option> |
choose an option in a prompt / combobox / cascade / native <select> |
upload <selector> <path> [<path>...] |
attach local files to an <input type=file> |
upload (--drop <sel> | --drop-at <x,y>) <path>... |
deliver files by drag-and-drop to a drop zone with no file input |
scroll [selector] [--dx <p>] [--dy <p>] [--to] [--wheel] [--at <x,y>] |
scroll by a delta, --to a selector into view, or a real --wheel (anchored with --at) |
window size <w> <h> / window info |
resize or report the real Chrome window (not viewport emulation) |
attr get|list|set|rm <selector> [name] [value] |
read/write element attributes |
click, hover, dblclick, rclick and drag are one driver method behind five names: they resolve the identical occlusion-verified centre and all take --modifiers (ctrl/shift/alt/cmd, joined with +) — click --modifiers cmd is the multi-select in a table.
An element that resolves but never presents an unoccluded centre fails as target_timeout with occluded: true, so it's distinguishable from "not found"; the message names what sat on top (its centre is covered by DIV name="modalOverlay") or says the element measured 0x0, so the next step — dismiss an overlay, wait out a tooltip, re-check the selector — is read from the envelope rather than reproduced under instrumentation.
If the page replaces the element while a verb waits on it (a grid re-rendering its row after a commit), the verb re-resolves the selector and continues on the replacement; only when the replacement never settles either does it fail, as target_timeout with detached: true — the page is churning, so wait --stable before retrying.
key takes a named key, a printable character, a chord, or a space-separated sequence of those, and works with no selector at all — which is what makes it usable when nothing is addressable:
| Form | Example |
|---|---|
| named key | Escape, Tab, ArrowDown, F2, Space |
| printable character | a, / |
| chord | cmd+a, ctrl+shift+k |
| sequence | "End shift+Home Backspace" |
key flag |
Purpose |
|---|---|
--repeat <n> |
press the sequence n times (1–100) |
--delay <dur> |
pause between repeats, for apps that debounce |
cmd maps to Meta on every platform — the page decides which modifier it listens for, so the CLI never rewrites cmd to ctrl for you.
The result reports focused (role and accessible name of what has focus after the press) and, when that element has one, focused_id (its DOM id) — the disambiguator for grids whose inputs all read as textbox "", so a stroke that landed in the wrong cell shows in the envelope rather than only in a later value read-back.
shift+<character> presses the character that key actually produces, so shift+a is the same press as A (and shift+1 is !) rather than a lowercase a with a Shift bit set.
An unknown key name is a usage error rather than being typed as literal characters.
drag takes either a drop target or a pixel delta, never both:
drag flag |
Purpose |
|---|---|
--to <selector> |
drop target |
--to-by <mode> |
--by mode for the drop target (defaults to --by) |
--dx, --dy <px> |
pixel delta from the source's centre |
--steps <n> |
interpolated move events, default 10 |
--hold <dur> |
pause after pressing before moving, for long-press-to-drag UIs |
The intermediate moves aren't cosmetic: a press and release at two points is silently a click to most drag implementations.
The drop target inherits only how to read a selector — --by (overridable with --to-by), --wait, --pierce — and never the flags that narrow a match (--role, --nth, --match, --in-row), which describe which candidate the source is: a --in-row applied to the drop target would scope it to the source's row and make any target elsewhere unresolvable.
chrome-cdp key Escape # close the open dialog
chrome-cdp key --repeat 3 ArrowDown # walk a listbox
chrome-cdp key --by name "Description" cmd+a # select all, then retype
chrome-cdp hover --by name "Invoice 4102" # reveal the row's actions
chrome-cdp dblclick --by cell "Mon, 7/13" # edit a grid cell
chrome-cdp drag --by name "Task A" --to "Done" --to-by name
chrome-cdp click --by name "Row 2" --modifiers cmd # add to the selectionEvery acting verb — nav, click, type, fill, select, key, hover, dblclick, rclick, drag, upload — also takes --wait-text "<substr>": after the action, block until the page contains the text (a Saved toast), folding act-and-confirm into one call.
select addresses the field by accessible name by default; a cascade path is >-separated:
select flag |
Purpose |
|---|---|
--option-match <mode> |
how each option segment matches: contains (default) | exact | regex |
--filter <text> |
type this into the prompt to narrow options before selecting |
--sep <char> |
cascade path separator between levels (default >) |
chrome-cdp click --by name "Sign in" --role button
chrome-cdp fill "#hours" "8"
chrome-cdp fill --by cell "Mon, 7/13" "8" # grid input by column header
chrome-cdp select --by label "Category" "Direct Revenue" # native <select> by label
chrome-cdp select "Time Type" "Projects > Acme: Platform > Project > Time Entry" --role textbox
chrome-cdp click --by name "Delete" --in-row "row two" --role button
chrome-cdp click "#delete" --on-dialog accept # auto-accept a native confirm()upload sets the files on the input directly (DOM.setFileInputFiles, which also fires change).
It never clicks the input: a click opens the native OS file dialog, which lives outside the page, is invisible to CDP, blocks the browser's main thread, and — unlike a JavaScript dialog, which --on-dialog handles — has no CDP method that can dismiss it.
upload flag |
Purpose |
|---|---|
--append |
add to the files this session set on the input instead of replacing them |
--wait-text <substr> |
after the upload, block until the page contains the text |
--wait defaults to ready for this verb alone, because the real input behind a styled drop zone is usually display:none and waiting for visibility would fail on exactly the targets that need it.
Paths are ~-expanded, resolved to the absolute path CDP requires, and checked before Chrome is contacted, so a missing path, a directory, or an unreadable file is usage / exit 2 with no connection and no consent prompt.
Set upload_roots in the config file's [policy] table to bound what may be uploaded: a path outside those directories is permission_denied / exit 7, compared on the cleaned absolute path with symlinks resolved on both sides, so ../ traversal and symlink escapes are both refused.
Unset means unrestricted, and it is deliberately not a flag or an environment variable — an allow-list the calling agent could widen would not be one.
--policy-off does not lift it either, for the same reason: it is argv, and argv is what the threat model assumes the caller controls.
The result reports the files read back from the input after the call — not the arguments — plus multiple and accept, because an accept/multiple mismatch is the usual reason an upload appears to work and then silently does nothing.
A file outside accept adds accept_mismatch: true but is not refused: accept is advisory in HTML and plenty of apps set it loosely.
Two limitations are deliberate.
Passing several paths to an input without multiple is usage / exit 2 and leaves the input untouched, and an element that resolves but is not a file input is also usage / exit 2 (naming the tag and type found) rather than a timeout — the selector resolved, so retrying cannot help.
--append only works for files this CLI set earlier in the same session: setFileInputFiles replaces the list wholesale and the DOM does not expose existing files' paths, so appending onto anything else is refused instead of silently dropping what was there.
A drop zone with no underlying <input type=file> is out of scope — there is nothing to set.
chrome-cdp upload --by label "Receipt" ./receipt.pdf
chrome-cdp upload "#attachments" a.pdf b.png c.csv # a `multiple` input
chrome-cdp upload "input[type=file]" ~/docs/report.pdf --wait-text "Uploaded"upload sets a real <input type=file>, which is the correct and reliable path whenever one exists — including the hidden input behind most styled drop zones.
Some apps have no input at all: the only affordance is a div with a drop listener.
--drop targets those.
chrome-cdp upload --drop "[data-testid=dropzone]" ./report.pdf
chrome-cdp upload --drop-at 400,300 ./report.pdfThe files are real; only the drag is synthesized.
A file input is created but never attached to the document, the files are put on it with the same CDP call the ordinary path uses, and a function bound to the drop target moves those File objects into a DataTransfer before dispatching dragenter → dragover → drop.
A handler therefore sees genuine files whichever way it reads them — dataTransfer.files, dataTransfer.items, items[0].getAsFile(), or the dataTransfer.types "Files" guard most libraries gate on.
The page is never modified. Nothing is appended to the document and no attribute is written to your element, which matters beyond tidiness:
an attached input would fire change when the files are set, and change bubbles — so any script on the page (analytics, a compromised dependency, an XSS payload) would receive your real file data, which a native drag never permits.
A detached node's events reach no one.
Binding the dispatch to the resolved element also means it runs in that element's frame, so a drop zone inside a same-origin iframe works, and --drop composes with every addressing mode including --by name.
The result reports drop_handled:
{ "ok": true, "command": "upload",
"result": { "mode": "drop", "count": 1, "drop_handled": true,
"dropped_on": {"tag": "div", "name": "Upload files"},
"files": [{"name": "report.pdf", "size": 48213, "type": "application/pdf"}] } }A drop handler that means to accept files calls preventDefault, so an uncancelled drop means nothing consumed it — the files went nowhere.
That is reported as drop_handled: false with an explanatory note, at exit 0, because the dispatch itself succeeded; it is the difference between "delivered" and "dispatched into the void", and the usual cause is addressing the wrong element.
--drop takes no selector argument — every positional is a path.
It does not combine with --append (a drop delivers a fresh set each time), and the upload_roots allow-list bounds it exactly as it bounds a file input: a drop is still a file leaving the machine.
Every addressing mode answers "where is the thing I named". --at takes the answer as given:
chrome-cdp screenshot --scale 1 -o look.png # look
chrome-cdp click --at 512,340 # act on what you saw
chrome-cdp drag --at 120,400 --to-at 620,400 --steps 30
chrome-cdp scroll --wheel --at 512,340 --dy -240This is the only way to reach a canvas or WebGL surface — a drawing tool, a map, a chart, a PDF viewer, a game. The accessibility tree sees one node there, so no selector can address anything inside it. It is also the shape a screenshot-reading agent already thinks in: look at pixels, act at pixels.
The coordinate contract.
Coordinates are CSS pixels, origin at the top-left of the layout viewport, x right, y down — the same space snap geometry, find's center, and the pointer verbs' own centre resolution use.
A screenshot --scale 1 capture maps onto that space 1:1, which is what makes the look-then-act loop work; capture at --scale 1 for coordinate work, because the default capture is device pixels and a HiDPI display makes those differ.
A coordinate outside the viewport is an error (coordinate_out_of_bounds, exit 4) carrying the measured viewport, not a silent clamp.
A wrong-sized window is the usual cause, and clamping would turn a detectable mistake into a click on whatever sits at the edge.
scroll first, then act, when the target is off-screen.
--at bypasses element addressing entirely, so it cannot combine with a selector or with --by/--role/--nth/--match/--in-row — that pairing is a usage error (exit 2), rejected before Chrome is contacted.
Mixed forms are fine where they make sense: drag "#card" --to-at 900,300 and drag --at 100,100 --to "Trash" --to-by name both work.
Unlike an element click, a coordinate click is not occlusion-checked: the coordinate is the intent, and second-guessing it would break every canvas app, where elementFromPoint always answers "the canvas".
The result reports a hit — the tag, id, role, and accessible name of whatever sat under the point — so you can verify where it landed instead:
{ "ok": true, "command": "click",
"result": { "clicked": "512,340", "x": 512, "y": 340,
"hit": {"tag": "CANVAS", "id": "board", "role": "img", "name": "Floor plan"} } }emulate viewport tells the page it is a different size; the window on screen never moves, so a screenshot still shows the old one.
window size moves the window you can actually see:
chrome-cdp window size 1280 800 # make coordinates reproducible
chrome-cdp window info # {left, top, width, height, state}Use window size before a coordinate workflow so the pixels you read stay valid on the next run, and emulate viewport when you are testing responsive breakpoints.
window size is checked at the browser level under a policy: it resizes the window every tab shares, so an allow-list naming one origin does not authorize it (the same rule raw --browser follows), and --policy-off is the explicit way through.
window info reads only the window's own geometry and stays per-target.
The reported bounds are read back after the change, because the window manager may clamp a request to the screen — a clamped resize should not look like a successful one.
A maximized or fullscreen window is returned to normal first, since Chrome refuses a size change otherwise.
wait blocks until one condition holds (or --timeout).
Prefer a condition over a fixed --for sleep.
| Condition | Waits until |
|---|---|
--url <substr> |
the tab's URL contains the substring |
--visible <selector> |
the selector is visible |
--gone <selector> |
the selector is gone |
--text <substr> |
the accessibility tree (incl. alerts) contains the text |
--stable |
the accessibility tree stops changing (page settled) |
--idle |
network activity settles (no in-flight requests) — for SPA loads |
--request <substr> |
a matching HTTP request completes (see Network) |
--for <dur> |
a fixed duration (fallback) |
chrome-cdp wait --idle # after nav/open on an SPA
chrome-cdp wait --text "Success" # confirm a write landed
chrome-cdp wait --request "/api/save" --status 2xx # confirm the write actually POSTed--idle and --request answer different questions.
--idle is "the page settled"; --request is "this specific call finished with this outcome", which is the sharper tool on a page whose polling or long-lived stream never lets the network go quiet.
A native alert/confirm/prompt/beforeunload blocks the tab's renderer for as long as it is up: every eval, DOM read and screenshot hangs until it closes.
dialog inspects and closes one that is already on screen — the recovery for an unguarded click that opened a confirm() and timed out — which --on-dialog cannot help with, because it only handles a dialog the action it decorates itself opens.
chrome-cdp dialog status # {"open":true,"type":"confirm","message":"Delete 3 items?",…}
chrome-cdp dialog accept # confirm() -> true; the blocked click completes
chrome-cdp dialog accept "Quarterly report" # prompt() -> "Quarterly report"
chrome-cdp dialog dismiss # confirm() -> false / prompt() -> null / beforeunload stays| Subcommand | Args | Purpose |
|---|---|---|
dialog status |
none | report the dialog open on the target tab, or open: false |
dialog accept [text] |
at most one | close it as OK would; text answers a prompt() ("" when omitted — not the prompt's default) and is ignored for the other types |
dialog dismiss |
none | close it as Cancel would |
status answers from an event the connection retained when the dialog opened, without sending the browser any CDP traffic; accept/dismiss issue the one command that still works while the renderer is blocked, Page.handleJavaScriptDialog.
Both work while the page is blocked — status because it never talks to the renderer, and accept/dismiss because that one command is exactly the unblocker, so a dialog accept in another terminal completes even while a wedged click is still waiting out its own deadline in the first.
The retention is the design, and it bounds what dialog can see: a dialog is only visible to CDP if a connection with the Page domain enabled was attached to the tab when it opened.
With the daemon (the default), a tab is watched from the first command that touches it, so the normal flow — navigate, click, timeout, dialog accept — is covered throughout.
The first dialog status on a tab the daemon has never touched cannot know about an earlier dialog and says so once, with a note; every call after that is fully watched.
--no-daemon has only partial coverage, the same shape as console: every invocation is a fresh attach, so dialog status always answers open: false with the note and dialog accept/dismiss always refuse with target_not_found — unless the commands run inside one session, where the connection lives across lines and a dialog opened by an earlier line is retained for a later one.
dialog status with nothing open is not an error: exit 0, open: false — so "check, then act" needs no error handling for the normal case.
dialog accept/dismiss with nothing retained is target_not_found (exit 4) with error.dialog: "none" — well-formed, but the thing to act on is not there, so re-read the state rather than fixing the command.
dialog status result: open, and when open is true also type (alert|confirm|prompt|beforeunload), message, default_prompt, frame_url (the frame that opened it — an iframe's dialog is not the tab's own URL), opened_at.
dialog accept/dismiss result: handled: true, action, type, message, and — for a prompt accepted with text — text, or text_ignored: true when text was given to a dialog with no input.
console reads what the page said: console.* output and uncaught exceptions, with their stack.
Capture starts when the connection attaches to a tab, not when console first runs.
That is what makes it useful after the fact: the exception behind a failed click is already buffered by the time you go looking for it.
| Flag | Default | Purpose |
|---|---|---|
--grep <re> |
— | only messages whose text matches this regex |
--level <l> |
all | debug|log|info|warn|error; repeatable |
--only-errors |
off | shorthand for --level error (uncaught exceptions are reported at error level) |
--limit <n> |
100 |
most recent n matching messages |
--since <dur> |
— | only messages newer than this (e.g. 30s) |
--clear |
off | drop the buffered messages after reading |
--follow |
off | stream new messages as NDJSON until --timeout or interrupt |
--fail-on-match |
off | exit 1 if at least one message is returned |
chrome-cdp console --only-errors # what broke
chrome-cdp console --grep "\[Checkout\]" --limit 20 # one subsystem
chrome-cdp console --clear # reset before an action
chrome-cdp console --follow --level error # watch while you workEvery filter is applied where the buffer lives, before the result is built, so a chatty app cannot flood a caller's context.
The result carries messages, count (after filtering), buffered (held for this tab), dropped, and truncated (--limit cut the list).
Each message carries level, source (console | exception | log), text, ts, and — for an exception — its stack.
dropped > 0 means you read too late: the ring buffer evicted older messages before this read.
Raise console_buffer, or read closer to the action.
--fail-on-match exits 1 and still reports the messages (error.code is assertion_failed), so a CI log shows what failed, not just that something did.
--follow writes one JSON envelope per line, the same shape session streams.
It cannot combine with --fail-on-match, and it is a usage error inside session or a recipe step, where it would break the one-envelope-per-line contract a batch promises.
A page that says nothing produces no output at all and exits 0 — there is no closing summary, because a terminating envelope would be a second shape for a caller to parse.
Treat empty stdout as "nothing was logged in the window", not as a failure.
A follow does not block other commands: you can drive the page from another terminal while one is running, and a follow longer than the daemon's idle window keeps it alive rather than being cut off mid-stream.
--no-daemon has only partial history.
Without the daemon there was no process alive to receive the tab's earlier events, so what appears is whatever Chrome replays when capture is enabled (recent console output and uncaught exceptions it still holds) plus what arrives during the command.
The read carries a note saying so, rather than passing a short list off as a full session record.
The buffer is bounded by console_buffer (messages per tab, default 1000) and console_max_entry (per-message text cap, default 8192 bytes); see Configuration.
net reads the HTTP requests the tab made: method, URL, status, timing, sizes, and — on request — headers and bodies.
Like console, capture starts when the connection attaches to a tab, not when net first runs, so the 401 behind an empty screen is already buffered by the time you go looking for it.
| Flag | Default | Purpose |
|---|---|---|
--url <s> |
— | substring match; a re: prefix switches to regex |
--method <m> |
all | GET, POST, …; repeatable |
--status <spec> |
all | 200, 2xx, >=400, !2xx |
--type <t> |
all | document|xhr|fetch|script|stylesheet|image|font|websocket|other; repeatable |
--xhr |
off | shorthand for --type xhr --type fetch |
--failed |
off | non-2xx or network-level failure |
--limit <n> |
100 |
most recent n matching |
--since <dur> |
— | only requests newer than this |
--headers |
off | include request and response headers |
--body |
off | include request and response bodies (size-capped) |
--no-redact |
off | do not redact credential-shaped values |
--clear |
off | drop the buffered requests after reading |
--follow |
off | stream completed requests as NDJSON |
--fail-on-match |
off | exit 1 if any request matched |
--har <path> |
— | write the matching requests to <path> as HAR 1.2 and print a summary instead of the listing |
chrome-cdp net --xhr --limit 20 # recent API calls
chrome-cdp net --failed # what broke
chrome-cdp net --url "/api/save" --method POST --body # inspect the payload
chrome-cdp net --clear && chrome-cdp click "#save" && chrome-cdp net --xhrEvery filter is applied where the buffer lives, before the result is built, so a chatty page cannot flood a caller's context.
The result carries requests, count (after filtering), buffered, dropped, truncated, and pending.
pending counts requests that started but have not finished, so you can tell "nothing matched" from "not finished yet" — an empty listing during a slow save is otherwise indistinguishable from a save that never fired.
It is scoped to the same --url / --method / --type / --since filter as the listing, so a permanently open SSE stream or long poll does not make every read look unfinished forever.
--status and --failed are deliberately not applied to it: a request still in flight has no status, so applying them would make pending a constant zero for exactly the reads that ask about an outcome.
Each request carries id, method, url, type, status, status_text, started_ms (milliseconds since capture began on this tab), started_at (the same instant as an RFC 3339 UTC timestamp with millisecond precision, null when the record has no start), duration_ms, request_size, response_size, from_cache, failed, and error.
status, duration_ms, and error are null when they do not exist yet; failed means a non-2xx status or a network-level failure, so a delivered 500 and a DNS failure both show up under --failed.
Redaction is on by default.
This CLI drives your real, logged-in Chrome, so its buffers hold live session credentials by construction.
The values of authorization, cookie, set-cookie, x-api-key, proxy-authorization, and any header whose name contains token, secret, or password are replaced with <redacted> — the name stays, so a 401 is still diagnosable.
Credential-shaped URL query and fragment parameters (access_token, api_key, sig, code, key, …) are redacted the same way, including the query string of a hash-router fragment (#/callback?access_token=…).
Headers whose value is itself a URL (location, content-location, referer) go through the same URL redaction rather than being withheld wholesale, so the 302 that ends an OAuth flow stays readable without carrying the code.
--no-redact is the explicit, deliberate opt-out.
Headers and bodies are absent, not null, unless you ask.
Without --headers / --body those keys do not appear at all, so a routine listing stays small and does not spill tokens or PII into a log.
Response bodies are fetched lazily, never buffered.
They are pulled with Network.getResponseBody at read time, only when --body is passed — buffering every body would multiply the daemon's memory and retain payloads you never asked to see.
The consequence: a body may already be gone if the page navigated away, or if it is not UTF-8 text.
That is reported as "response_body": null with "body_unavailable": true, and the read still succeeds — a partial answer beats no answer.
Whether a body is text is judged on the payload Chrome delivered, not on what survives the cap, so the same image reports body_unavailable at any size.
Bodies over net_max_body (default 65536 bytes) are cut, with "body_truncated": true.
Request bodies arrive inline with the request, so they are retained and available retroactively; a request body that is not text is withheld the same way, as "request_body": null with "request_body_unavailable": true.
Bodies are redacted too.
Credential-shaped fields in form-encoded and JSON bodies (password, access_token, client_secret, api_key, …) are replaced with <redacted>, on requests and responses alike — a password is no less a secret for having travelled in a POST body than in the query string, which is already withheld.
The rest of the payload is reported exactly as sent, including a body the cap already cut.
A body in any other encoding (multipart/form-data, protobuf, a bare token) has no field structure to key on and is passed through unchanged, so treat --body output from those as sensitive.
net wait / wait --request blocks until one specific request completes.
chrome-cdp wait --request "/api/save" --status 2xx # the primary form
chrome-cdp net wait --url "/api/save" --status 2xx # the aliasIt matches already-buffered requests first, so a request that completed between the action and the wait is not missed.
It needs a URL substring or --failed to identify the request; --method, --status, and --type only narrow the match.
The matched record rides in result.request, in the same shape a listing uses.
No match before --timeout is target_timeout / exit 4.
--fail-on-match exits 1 and still reports the requests (error.code is assertion_failed), so chrome-cdp net --failed --fail-on-match is a usable CI assertion that shows what failed.
--follow writes one JSON envelope per completed request, the same shape session streams.
It cannot combine with --fail-on-match, and it is a usage error inside session or a recipe step.
A window in which nothing completed produces no output at all and exits 0, exactly as with console --follow, and it does not block other commands against the same daemon.
--no-daemon has only partial history, exactly as with console: enabling the domain surfaces the handful of resources Chrome still holds for the page, never the session, so the read carries a note rather than passing a short list off as the whole story.
--har <path> writes the same filtered read to <path> as an HTTP Archive 1.2 file, for handing evidence to a backend team, a vendor, or DevTools/Charles/Fiddler, instead of pasting JSON nobody's tool reads.
It runs exactly the read net would otherwise print — same --url / --method / --status / --type / --xhr / --failed / --since / --limit / --clear grammar — and forces headers on regardless of --headers; add --body for request/response payloads ("Save all as HAR with content", in DevTools' own terms).
Redaction is unchanged: the HAR can contain nothing the listing would not have printed under the same flags, so --no-redact is the only way a live credential reaches the file.
The file is written 0600, not the 0644 screenshot/pdf use, because a HAR records the user's logged-in session and, with --no-redact, may hold live tokens verbatim; an existing file at the path is overwritten.
Instead of the listing, the result is a summary: {path, bytes, entries, redacted, with_content, truncated_bodies, pending, buffered, dropped, truncated, note?}.
entries equals what the listing's count would have been; truncated_bodies counts entries whose request or response body was cut at net_max_body, and is 0 without --body; pending/buffered/dropped/truncated/note carry the same meaning the listing gives them.
--har '' and --har combined with --follow are usage / exit 2; a path naming an existing directory, or whose parent directory does not exist or is not a directory, is generic / exit 1 — both checked before Chrome is contacted.
With --clear the buffer is dropped inside the read itself before the file is written, so this pre-connect check also probes the destination is actually writable (not just that it exists), the same way record stop -o does, rather than risk losing the buffer to a late write failure.
--har composes with --fail-on-match: the file is written first, then the assertion is judged on the same count, so a tripped assertion never costs you the export.
It works inside session and a recipe step exactly like an ordinary net line — one envelope, the summary — and is not exposed to the MCP network tool, because a path on the CLI's disk is not something an MCP client on the other side of the protocol can use.
Bad --status / --type / --url regex / --since values are usage / exit 2, validated before anything connects to Chrome.
The buffer is bounded by net_buffer (records per tab, default 500) and net_max_body (per-body cap, default 65536 bytes); see Configuration.
session reads NDJSON argv lines on stdin and runs each over one held connection, emitting one NDJSON envelope per line — no per-command process spawn, and snap refs stay valid across the batch.
printf '%s\n' \
'["fill","--by","cell","Mon, 7/13","8"]' \
'["fill","--by","cell","Tue, 7/14","8"]' \
'["value","--all","input[data-automation-id=numericInput]"]' \
'["click","--by","name","Save and Close","--role","button","--wait-text","saved"]' \
| chrome-cdp session| Flag | Default | Purpose |
|---|---|---|
--record <path> |
— | record the whole batch and write it here |
--record-fps <n> |
4 |
with --record: frames per second to retain |
--record-annotate |
off | with --record: mark action positions on the exported frames |
--record starts as soon as a line resolves a tab (usually the first use) and stops after the last line, so it needs no manual bracketing.
The file is written even when a step failed — which is when a recording is worth the most — and the batch emits one extra NDJSON line describing it.
A recipe is a saved session script with a small header: a YAML file whose steps are argv arrays, with declared inputs substituted into argv elements.
It is the unit in which a working automation becomes something you can name, re-run, commit, and hand to a colleague.
| Command | Does |
|---|---|
recipe list [--dir <path>] |
list recipes with their description, inputs, and source |
recipe show <name> |
print the recipe's source (read it before you run it) |
recipe new <name> |
write a commented template and print its path |
recipe run <name> [--set k=v]… [--dry-run] [--from-step <n>] |
run it |
# .chrome-cdp/recipes/submit-timesheet.yaml
name: submit-timesheet
description: Fill and submit the weekly timesheet.
inputs:
week: { required: true, description: "Monday of the week, YYYY-MM-DD" }
hours: { default: "8", description: "Hours per weekday" }
target: url:workday
steps:
- label: open the timesheet
run: ["nav", "https://workday.internal/time/{{week}}"]
- run: ["wait", "--idle"]
- label: save
run: ["click", "--by", "name", "Save and Close", "--role", "button", "--wait-text", "saved"]
on_error: abortchrome-cdp recipe run submit-timesheet --set week=2026-07-20
chrome-cdp recipe run submit-timesheet --set week=2026-07-20 --dry-run # print, run nothingThe format, in full.
name (which must match the filename), description, inputs, target, steps; each step has run, an optional label, and an optional on_error.
That is everything — there is no other key, and an unrecognised one is an error rather than a silently ignored typo.
| Field | Means |
|---|---|
run |
an argv array, identical to a session stdin line — anything valid in session is valid here |
label |
a name for the step, echoed in its envelope and in the failure summary |
on_error |
abort (default) or continue; there are no retries, conditionals, or loops |
inputs |
required, default, description — no types and no validation expressions |
target |
a default --target for every step, overridden by a step's own --target or by --target on the run; takes {{placeholders}} like any argv element |
{{name}} substitutes an input into one argv element.
There is no shell anywhere in this design and there is no shell: step type, so a value is passed through byte for byte and nothing in it is interpreted.
Per-step flags a verb already accepts — --timeout 60s, --by name, --wait-text — go in that step's own run array, where a reader of the recipe can see them.
--timeout on the run applies to each step, not to the run.
Each step is one command and gets the whole budget, exactly as each line of a session does — so a 200-step recipe with --timeout 60s can take 200 minutes in the worst case, not one.
There is no whole-run budget on purpose: it would make recipe run and recipe run --dry-run | chrome-cdp session behave differently, and their equivalence is what keeps a recipe a session script with a header.
Give a slow step its own --timeout in its run array and leave the run's default low.
A step must name its command in the first element of run.
A leading flag (run: ["--json", "snap"]) is a load-time error: it would hide the command from validation, since the command tree resolves the verb only after stripping flags.
Which elements of a step are flags is decided by the recipe as written, never by an input value.
When a step is resolved, its data elements are emitted after a -- terminator — so run: ["text", "{{sel}}"] with --set sel=--target=@2 runs text --target <the recipe's target> -- --target=@2, and the value arrives as a selector rather than as a second --target pointing the step at another tab.
This is visible in --dry-run output, which is still exactly what runs.
Where recipes live. Resolution order for a name, first match wins:
./.chrome-cdp/recipes/— project-local; commit this directory and your team gets your internal-app automations from the repo$XDG_CONFIG_HOME/chrome-cdp/recipes/— your own--dir <path>
recipe list marks each entry's source, so you can see which copy is about to run.
recipe new writes into the project-local directory unless --dir says otherwise.
Output.
recipe run emits one NDJSON envelope per step — the same stream session produces, plus step and label fields so a caller can correlate without counting lines — then a summary:
{"ok":true,"command":"recipe","result":{"recipe":"submit-timesheet","steps":4,"completed":4,
"failed":null,"inputs":{"week":"2026-07-20","hours":"8"},"from_step":1,"elapsed_ms":4120}}A failing step stops the run (unless it says on_error: continue), and the summary carries failed: {"index":3,"label":"save","code":"target_timeout"}.
The process exit code is the failing step's, so a shell caller branches on the same contract as for a single command — and it is always the exit that failed.code maps to, so the number and the envelope cannot disagree.
Under --quiet only the summary is printed.
Because the run's output is NDJSON, a step may not write raw bytes to stdout.
screenshot -o - and pdf -o - write the file itself to stdout and emit no envelope, so such a step fails with usage instead of corrupting the stream — give it a path (-o shot.png) and read the path back out of its envelope.
Streaming steps (console --follow, net --follow) are a usage error for the same reason, exactly as inside session.
Reviewing a recipe someone sent you. A recipe drives the browser you are already signed into, so read one before running it, exactly as you would a shell script:
chrome-cdp recipe show their-recipe # the source, comments and all
chrome-cdp recipe run their-recipe --dry-run # the resolved argv, one array per line
chrome-cdp recipe run their-recipe --dry-run | chrome-cdp session # the same thing, executedThe dry run prints the exact bytes session consumes.
That is both a debugging tool and the proof that recipes add no hidden magic: the two paths run the identical commands.
Never put a credential in a recipe.
The whole premise of chrome-cdp is reusing an already-authenticated browser, so a recipe never needs one.
Errors. Everything a recipe can get wrong statically is exit 2, with Chrome never contacted:
| Situation | error.code |
Exit |
|---|---|---|
| Recipe not found in any search dir | usage |
2 |
Malformed YAML, unknown key, run not an array of strings |
usage |
2 |
Missing required input, unknown --set key, undeclared {{placeholder}} |
usage |
2 |
--from-step out of range |
usage |
2 |
| A step fails | that step's code | that step's exit |
An unknown --set key is rejected rather than ignored: silently dropping --set hurs=9 would run the recipe with the default you were trying to override.
--from-step <n> is a sharp tool.
It starts at step n (1-based) and assumes every earlier step's effect is already in place.
That is what you want when a ten-step automation failed at step 8 and re-running from the top would submit a form twice — and it is exactly wrong when the page is not where step n expects it.
Validation still covers the whole file: an undeclared placeholder in a skipped step is still an error.
What recipes deliberately do not have.
Conditionals, loops, retries, branching, and reading one step's output into a later step.
Recipes cannot invoke recipes, and a recipe is capped at 200 steps.
If an automation needs control flow, write a program that calls session — that is the supported answer, not a bigger recipe format.
mcp runs the CLI as a Model Context Protocol server over stdio, exposing the verbs as MCP tools.
It is a front end, not a fork: a tool call becomes the same argv you would type, runs through the same command tree against the same connection, and comes back as the same envelope — so a flow you debug at the shell behaves identically when an assistant runs it.
| Flag | Default | Purpose |
|---|---|---|
--read-only |
off | expose only tools that cannot modify page state |
--tools <set> |
default |
default, full, or a comma-separated list of tool names |
--allow-eval |
off | expose eval and raw, which are denied in this mode by default |
--target <spec> |
— | pin the server to one tab; otherwise each tool takes a target |
Global flags (--timeout, --no-daemon, --port, --profile-dir, --allow, config file) apply unchanged, and the daemon still holds the connection — a long-lived server over one shared connection is exactly what it is for.
Transport is stdio only; there is no HTTP or SSE mode, because a network-reachable server driving your authenticated browser is a different security posture.
A policy allow-list is required.
chrome-cdp mcp refuses to start unless a [policy] table with a non-empty allow is configured (or --allow is passed): it exits 2 and prints the block it needs.
The CLI's unrestricted default is right for a person who typed a command; handing an assistant a browser that is signed in to everything is a different question, and it should be answered on purpose.
Run chrome-cdp policy init on the tab you want it to drive.
--policy-off is refused in this mode, and an injected one cannot reach the parser: the server freezes its own policy flags, and every tool argument is passed after a -- terminator so a value that looks like a flag is data.
eval and raw are denied here unless you pass --allow-eval.
They can navigate the tab themselves, so an origin allow-list only means something while they are off — and the one-liner form of the gate (chrome-cdp mcp --allow '*.example.com') writes no config file and so set no verbs_denied at all, which left the recommended setup with a decorative boundary.
The mode now supplies that default itself.
A denied verb's tool is not listed either: it could only answer permission_denied, and an agent pays for the description.
--allow-eval opts back in to the mode's default only — a verbs_denied you configured yourself still stands.
The tool surface is bounded — an agent pays for every tool description in its context window — so related verbs are grouped behind an action or kind argument.
Names are prefixed chrome_cdp_ so they stay unambiguous in clients that flatten every server into one namespace.
| Tool | Wraps | Notes |
|---|---|---|
chrome_cdp_tabs |
list, open, use, close, activate, window info/size, dialog status/accept/dismiss |
one action argument; --read-only keeps dialog_status and drops dialog_accept/dialog_dismiss |
chrome_cdp_navigate |
nav, including back/forward/reload |
|
chrome_cdp_snapshot |
snap |
the primary read |
chrome_cdp_read |
text, html, value, grid |
one kind argument |
chrome_cdp_click |
click |
|
chrome_cdp_type_text |
type, fill |
replace: true picks fill |
chrome_cdp_key |
key |
|
chrome_cdp_pointer |
hover, dblclick, rclick, drag |
one action argument |
chrome_cdp_select_option |
select |
cascade paths included |
chrome_cdp_scroll |
scroll |
|
chrome_cdp_upload |
upload |
upload_roots still applies |
chrome_cdp_wait_for |
wait |
every condition, --request included |
chrome_cdp_screenshot |
screenshot |
returns an image content block |
chrome_cdp_console |
console |
|
chrome_cdp_network |
net |
|
chrome_cdp_evaluate |
eval |
powerful and unconstrained; needs --allow-eval |
chrome_cdp_batch |
session |
several tools over one round trip |
chrome_cdp_raw_cdp |
raw |
--tools full (or named in --tools), plus --allow-eval |
chrome_cdp_storage |
storage local|session list|get|set|rm|clear |
one action + scope argument; --tools full (or named in --tools); --read-only keeps list/get and drops set/rm/clear |
Each tool's arguments mirror the CLI flags they wrap, in snake_case (in_row for --in-row), and the element-addressing arguments (by, role, nth, match, in_row, wait, pierce) are documented in every schema that takes them — the accessible-name addressing is this tool's advantage on real applications, and an agent only gets it if the schema says so.
The streaming forms (console --follow, net --follow) are not exposed: they would break the one-result-per-call contract.
recipe is not either — a recipe is authored and reviewed at the shell.
--read-only reuses the policy layer's verb classification rather than a second table, so it can never disagree with a read_only origin.
It exposes tabs (without open or close), snapshot, read, wait_for, screenshot, console, network and batch; invoking anything else by name returns a typed usage error rather than a protocol error.
close is withheld even though the classification table calls it exempt — it touches no page content, but it does change the browser, and a server that says it cannot modify anything should not close your tabs.
close is bounded by the allow-list here, unlike at a shell: an MCP client may close a tab only on an origin the policy permits, per tab, and a bulk close closes the permitted ones and reports the rest under refused.
Results keep the contract.
A success carries the envelope's result object as structuredContent, plus a one-line text summary.
A failure is isError: true with structuredContent carrying code and exit — and the recoverable details (tab_hidden, occluded, zero_area) — so an agent branches on the same values a shell script does rather than on prose.
stdout is the protocol; diagnostics go to stderr. Nothing else may write to stdout while the server runs, and the process enforces that rather than trusting it.
| Command | Writes |
|---|---|
screenshot [-o <path>] |
an image of the viewport, an element, a region, or the full page |
pdf [-o <path>] |
a PDF of the page |
Both write to ./<command>-<timestamp>.<ext> by default — with a -1, -2, … counter rather than overwriting — or to -o <path>, or to stdout with -o -.
screenshot
| Flag | Default | Purpose |
|---|---|---|
--selector <sel> |
— | capture this element's box; honours every addressing flag (--by, --role, --nth, --match, --in-row, --pierce) |
--full-page |
off | capture the whole scrollable page, beyond the fold |
--region x,y,w,h |
— | capture an explicit page-coordinate rectangle |
--format |
png |
png | jpeg | webp |
--quality <n> |
80 |
0–100; jpeg/webp only — passing it with png is a usage error, not a silent no-op |
--scale <f> |
1 |
output scale factor, 0.1–3, applied in the renderer so text stays crisp |
--padding <px> |
0 |
expand an element capture, clamped to the page so an element at an edge keeps a non-negative origin |
--annotate |
off | number every actionable element in the captured area and list them in result.annotations; on a backgrounded tab the labels are skipped (annotated: false) — run activate first |
--selector, --full-page and --region select different modes and are mutually exclusive.
chrome-cdp screenshot --selector "#invoice-table" --padding 8 -o invoice.png
chrome-cdp screenshot --full-page -o report.png
chrome-cdp screenshot --format jpeg --quality 60 --scale 0.5 -o small.jpg
chrome-cdp screenshot --selector "Summary card" --by name --role region
chrome-cdp screenshot --annotate -o labelled.pngThe result reports path, bytes, width, height, format, scale, mode (viewport | element | full_page | region) and the resolved clip in page coordinates.
mode and clip are what make a capture that came out wrong debuggable without opening the image.
Full-page capture does not force lazy-loaded content to load: images below the fold that appear on scroll may come out blank.
Scroll through the page first (scroll --dy …, then wait --idle) when that matters.
--annotate (RFC-0016).
It draws a numbered label [N] at the centre of every actionable element in the captured area — the things a person clicks, types into, or toggles, not every node snap would print — and appends a legend to the result: annotations: [{n, ref, role, name, center, states?, occluded?}], in label order, n matching the number drawn on the image.
center is the element's centre in viewport CSS pixels at capture time, the same contract find reports and --at accepts; for a --full-page capture a below-the-fold centre is outside the viewport, so use --by ref there instead.
ref is an e<id> that --by ref accepts, so a legend entry is a live address, not just a picture.
The result also carries annotated (true when at least one label put pixels on the image) and truncated (true when more than 200 actionable nodes existed — the label cap).
On a backgrounded tab, or a capture with nothing actionable in it, the plain image still comes back: annotated is false and reason names why (tab_hidden | tree_unavailable | no_actionable_nodes); the command still exits 0.
--annotate composes with --selector, --region and --full-page — only elements inside the resolved clip are labelled and listed.
--annotate --format webp and --annotate -o - are both usage errors raised before Chrome is contacted: the labels are drawn in Go (no standard-library webp encoder), and the legend lives in the envelope, which -o - does not emit.
--annotate --format jpeg is fine — the capture is taken losslessly and encoded to jpeg once, after drawing.
| Flag | Default | Purpose |
|---|---|---|
--landscape |
off | orientation |
--paper <name> |
letter |
letter | legal | tabloid | ledger | a0–a6 (case-insensitive), or WxH in inches |
--margin <spec> |
0.4in |
one value, or top,right,bottom,left; units in (default), cm, mm, px, pt |
--scale <f> |
1 |
render scale, 0.1–2 |
--background |
off | print background graphics |
--pages <ranges> |
all | e.g. 1-3,5 |
--header <tpl> / --footer <tpl> |
— | HTML templates (classes date, title, url, pageNumber, totalPages) |
chrome-cdp pdf --landscape --paper a4 --background -o report.pdf
chrome-cdp pdf --pages 1-3,7 --margin 0.5in,1in,0.5in,1inThe result reports path, bytes, and pages.
Every value above is parsed before anything connects to Chrome, so a malformed rectangle, paper name, margin spec or page range — or an out-of-range quality or scale — is usage / exit 2 with the browser untouched.
An element that resolves but has a zero-area box (display:none, collapsed) is exit 4 with zero_area: true: the selector was right, so it is reported differently from "not found".
record captures the tab while other commands drive it, and exports the frames as an animated GIF (or an MP4/WebM, or a directory of numbered PNGs).
chrome-cdp record start --annotate
chrome-cdp click --by name "Save"
chrome-cdp record stop -o demo.gifThis records your real, logged-in browser. A recording attached to a public issue may contain your own data — your tabs, your name, whatever the page was showing. Nothing in the CLI can know which pixels are sensitive, so look at the file before you share it.
record stopprints the frame count and the path in human mode, so it is never ambiguous that a file was written.
| Command | Does |
|---|---|
record start |
begin capturing the target tab |
record stop [-o <path>] |
stop and write the animation (default ./record-<timestamp>.gif) |
record status |
report whether the tab is being recorded, and how much is held |
record cancel |
discard the recording without writing anything |
record start
| Flag | Default | Purpose |
|---|---|---|
--fps <n> |
4 |
frames per second to retain; a ceiling, not a fixed interval |
--scale <f> |
0.5 |
capture scale relative to the viewport, 0.1–1 |
--annotate |
off | mark action positions when exporting (overridable at record stop) |
--max-duration <dur> |
2m |
stop capturing after this long; the frames stay exportable |
--max-frames <n> |
600 |
ring-buffer size (config key record_buffer) |
record stop
| Flag | Default | Purpose |
|---|---|---|
-o, --output <path> |
./record-<timestamp>.<ext> |
output path, or - for stdout (no envelope, as with screenshot/pdf) |
--format |
from the extension, else gif |
gif | mp4 | webm | frames |
--max-size <size> |
— | best-effort ceiling, e.g. 2MB or 1500000 |
--loop <n> |
0 (forever) |
how many times the GIF plays; --loop 3 plays three times |
--annotate |
from record start |
draw the action markers |
The result reports path, format, frames, fps, duration_ms, width, height, bytes, annotated, decode_failures, and the capture's own dropped_frames / truncated / reason.
A failed export does not cost you the recording.
Everything that can be checked before the frames are handed over is: the encoder's availability, and whether the output path can actually be written — a missing directory, a path that is itself a directory, or a directory you have no permission to write to are all errors with the recording untouched, so record stop can be retried at a different path.
If the write fails anyway (a full disk, an ffmpeg that dies) the frames are handed back to the daemon and the error says so, so the retry still works.
A frame the encoder cannot read is skipped and counted in decode_failures rather than failing the export; only a recording with no decodable frame at all is an error.
Frames live in the daemon, not in the command that started the recording.
That is what makes a run which crashed half way still have a recording of the failure: record stop afterwards writes it, and it writes it even when the tab itself has since closed (the result then says tab_closed: true).
A recording whose tab closed is held for ten minutes and then released, so an abandoned one does not stay a hole in a long-lived daemon.
--format frames replaces the previous export's PNGs.
A shorter recording written over a longer one removes the frame-NNNNN.png files the last export left, so the directory holds exactly the frames the result reports; anything else you keep in that directory is untouched.
Capture is a screencast, not a screenshot loop.
Chrome pushes a frame only when the page actually changes, so a static page costs almost nothing and --fps throttles a busy one rather than polling a quiet one.
A frame skipped by that throttle is not a dropped frame — you asked for 4fps.
A tab that renders nothing at all (fully backgrounded, on some platforms) produces no frames, and record stop says so rather than writing an empty file.
Truncation is always reported.
The ring keeps the most recent --max-frames, a byte ceiling (record_max_bytes) keeps the retained frames bounded no matter how large the viewport is, and --max-duration stops the capture.
Whichever fires sets truncated: true with a reason and counts the loss in dropped_frames, so a partial recording is never presented as a complete one.
Annotation is composited at export, never at capture.
The daemon records (timestamp, command, coordinates) alongside the frames — the coordinates come from the pointer verbs, which already resolve and report a centre point — and the exporter draws position markers.
Without --annotate the exported frames are pixel-identical to what Chrome captured, which is what makes a recording usable as a README asset.
One recording can therefore be exported both ways.
annotated in the result is a claim about the pixels: it is true only when a marker was actually drawn, so a recording with no actions in it — or one whose only mark fell outside the frame — reports false rather than implying markers you will not find.
--max-size dropping frames does not drop their markers: a dropped frame's marks move to the nearest kept one.
Formats.
gif and frames need nothing installed, and gif is the default because the dependency-free path should be the one that works out of the box.
mp4 and webm need ffmpeg on PATH, and its absence is a usage error naming the requirement — checked before the recording is drained, so the frames survive to be exported as a GIF instead.
--format conflicting with the output extension is an error rather than a guess, since a WebM in a file called demo.gif plays nowhere.
For mp4 and webm the reported width/height/fps/duration_ms are what ffmpeg actually wrote: the canvas is rounded down to even dimensions (yuv420p requires them) and the frame rate is floored at 1, which any recording containing a pause reaches.
A window resized mid-recording is letterboxed, not stretched. The canvas comes from the first frame; a later frame with a different shape is scaled to fit and padded, so the export never shows a page at an aspect ratio it never had.
--max-size is best-effort.
It re-encodes at a smaller scale, and then at a lower frame count, until the file fits; the result reports reduced and the export_scale used when a reduction happened.
within_max_size is reported whenever --max-size was given at all — including when no reduction step was possible, since a small canvas with few frames is refused at the first step and misses the ceiling just the same.
The ladder is bounded by --timeout like everything else: a long recording can run out of time before it works through every step, and the result then carries max_size_timed_out: true alongside the best attempt that did finish, so a larger --timeout is a visible next move rather than a guess.
Recording is per-tab. A batch that opens new tabs records the one it started on; a multi-tab recording is out of scope.
session --record <path> brackets a whole batch (see Batch mode).
Agents: record is deliberately outside the default MCP tool set — an agent silently recording the user's browser is a surprising capability — and is available only under the full tool set.
| Command | Does |
|---|---|
cookie list|set|rm|clear |
read and write cookies for the tab |
storage local|session list|get|set|rm|clear |
read and write the tab's localStorage / sessionStorage |
headers set <k=v> … |
set extra request headers |
emulate viewport|geo|reset |
override viewport size / geolocation, or clear overrides |
frame list |
list the tab's frame tree |
storage reads and writes the DOM Storage area of the tab's top frame — localStorage under local, sessionStorage under session — over the DevTools DOMStorage domain.
Nothing runs in the page's JavaScript context, so it works on a hidden tab and is unaffected by a page that has shadowed window.localStorage.
It is shaped like cookie on purpose: subcommands rather than flags, positional key and value, storage as the envelope's command.
chrome-cdp storage local list # keys + redacted values
chrome-cdp storage local get theme # {"value":"dark","present":true}
chrome-cdp storage local set onboarding_done 1
chrome-cdp storage session rm draft
chrome-cdp storage local clear
chrome-cdp storage local list --no-redact --max-value 0 # everything, verbatim| Subcommand | Args | Purpose |
|---|---|---|
storage local|session list |
none | every key in the area, values redacted and size-capped |
storage local|session get <key> |
exactly one | one value, raw and uncut; present: false when the key is absent |
storage local|session set <key> <value> |
exactly two | create or overwrite one key |
storage local|session rm <key> |
exactly one | remove one key; removing an absent key succeeds silently |
storage local|session clear |
none | remove every key in the area |
Flag (on list only) |
Default | Meaning |
|---|---|---|
--no-redact |
off | do not redact credential-shaped keys and values |
--max-value <bytes> |
4096 |
cut each listed value at this many bytes and mark it truncated: true; 0 means no cap; a negative value is usage |
list redacts by default.
This CLI drives your real, logged-in Chrome, so localStorage/sessionStorage hold live session tokens as a matter of course — Supabase, Firebase, MSAL and redux-persist all keep them there.
list withholds a value wholesale when the key is credential-shaped, and otherwise redacts credential-shaped fields inside the value, using the same predicates and the same <redacted> placeholder net applies to headers, URL parameters and bodies.
Redaction runs before the size cap, so a token that would straddle --max-value is fully withheld rather than partially visible.
get <key> has no --no-redact and no cap: naming the key is the explicit ask, so it always returns the raw, uncut value.
--no-redact is list's explicit opt-out.
The area that does not exist is target_not_found, not a crash.
A data: page, a fresh about:blank tab, or a sandboxed document has no identifiable security origin, so it has no web storage at all.
storage checks this before issuing any DOMStorage command and fails with error.code: "target_not_found" (exit 4) and error.opaque_origin: true, naming the page's URL in the message.
get of an absent key and rm of an absent key are both not errors: get reports present: false with value: "", rm reports removed: true, both exit 0.
set, rm, and clear are always set: true / removed: true / cleared: true on success — the failure case is an error envelope, never a false.
clear removes every key in the area and takes no confirmation flag; bound it with read_only or verbs_denied = ["storage local clear"] the same way as cookie clear.
chrome_cdp_storage is exposed over MCP only under --tools full, or named explicitly in --tools: reading the area a session token lives in is a capability a user opts into, not a default.
raw calls any CDP method by name — full protocol coverage, no per-method wrapper.
chrome-cdp raw --list # list CDP domains
chrome-cdp raw Network.setCacheDisabled '{"cacheDisabled":true}'
chrome-cdp raw Browser.getVersion --browser # browser-level method| Command | Does |
|---|---|
doctor |
probe the connection, report ready | consent_pending | no_endpoint | unverified, and print the exact fix (--no-probe to connect to nothing) |
daemon start|stop|status |
manage the background connection |
policy init |
write a starter [policy] table allow-listing the current tab's origin (--wildcard, --print, -o) |
exit-codes |
print the exit-code table |
version |
print the version |
skill [--full]|list|get <name> |
print an embedded agent skill: drive-chrome-cdp plus the scenario skills built on it |
completion bash|zsh|fish|powershell |
shell completion script |
skill serves the agent skills baked into this binary, so the doc an agent reads always matches the CLI version it drives — a vendored copy can drift, this can't.
With no subcommand it prints the drive-chrome-cdp core loop; --full adds every drive-chrome-cdp reference.
skill list names every embedded skill (drive-chrome-cdp, check-logged-in, fill-grid-and-confirm, …) and the drive-chrome-cdp references.
skill get <name> resolves <name> in this order: a drive-chrome-cdp reference (core, widgets, …), else a skill name (check-logged-in), else the explicit <skill>/<reference> form (drive-chrome-cdp/core); anything else is a usage error (exit 2).
It never connects to Chrome.
In human mode each form writes the raw markdown to stdout, no envelope; skill list prints the skill names (drive-chrome-cdp first), a blank line, then the reference names.
With --json: bare skill/skill --full wrap content as {"name":"drive-chrome-cdp","references":[…],"content":"…"}; skill get <name> wraps it as {"name":"<skill>","reference":"<ref, or empty for a whole skill>","content":"…"}; skill list returns {"name":"drive-chrome-cdp","skills":[…],"references":[…]} (no content).
chrome-cdp attaches to your real Chrome.
There are two ways to let it, and they are not equivalent.
Recommended — launch Chrome with the flag. It never prompts:
open -a "Google Chrome" --args --remote-debugging-port=9222 # macOS
google-chrome --remote-debugging-port=9222 # LinuxAlternative — the chrome://inspect/#remote-debugging toggle.
It works on the default profile where the classic flag does not (Chrome M136+ dropped --remote-debugging-port for the default profile, which is why the toggle exists), but it raises a consent prompt on every fresh attach.
That prompt is browser-modal: until it is answered, Chrome accepts no other input, so an unanswered one looks exactly like a crashed browser.
Read The consent prompt before choosing it.
Either way, chrome-cdp reads Chrome's DevToolsActivePort file and connects the WebSocket directly.
If no debug-enabled Chrome is found, it launches a managed Chrome on a dedicated profile alongside your real one.
A background daemon holds the connection, so the consent prompt appears once per session rather than once per command.
It starts lazily on first use and idles out after 30 minutes; manage it with daemon start|stop|status, or bypass it with --no-daemon.
chrome-cdp is not Chrome-specific: it looks for a DevToolsActivePort file from any Chromium-family browser, in this order (first match wins), and attaches to whichever one wrote it.
| Browser | macOS | Linux | Windows (under %LOCALAPPDATA%) |
|---|---|---|---|
| Chrome | ~/Library/Application Support/Google/Chrome/DevToolsActivePort |
~/.config/google-chrome/DevToolsActivePort |
Google\Chrome\User Data\DevToolsActivePort |
| Chromium | ~/Library/Application Support/Chromium/DevToolsActivePort |
~/.config/chromium/DevToolsActivePort |
Chromium\User Data\DevToolsActivePort |
| Brave | ~/Library/Application Support/BraveSoftware/Brave-Browser/DevToolsActivePort |
~/.config/BraveSoftware/Brave-Browser/DevToolsActivePort |
BraveSoftware\Brave-Browser\User Data\DevToolsActivePort |
| Edge | ~/Library/Application Support/Microsoft Edge/DevToolsActivePort |
~/.config/microsoft-edge/DevToolsActivePort |
Microsoft\Edge\User Data\DevToolsActivePort |
| Vivaldi | ~/Library/Application Support/Vivaldi/DevToolsActivePort |
~/.config/vivaldi/DevToolsActivePort |
Vivaldi\User Data\DevToolsActivePort |
| Arc | ~/Library/Application Support/Arc/User Data/DevToolsActivePort |
— | — |
Arc has no Linux build and does not ship this profile layout on Windows, so it is macOS-only here.
Every one of them skips the consent prompt entirely when launched with --remote-debugging-port=9222.
On macOS: open -a "Google Chrome" --args --remote-debugging-port=9222, open -a Chromium --args --remote-debugging-port=9222, open -a "Brave Browser" --args --remote-debugging-port=9222, open -a "Microsoft Edge" --args --remote-debugging-port=9222, open -a Vivaldi --args --remote-debugging-port=9222, or open -a Arc --args --remote-debugging-port=9222.
On Linux: google-chrome --remote-debugging-port=9222, chromium --remote-debugging-port=9222, brave-browser --remote-debugging-port=9222, microsoft-edge --remote-debugging-port=9222, or vivaldi --remote-debugging-port=9222.
If none of these is already debug-enabled, the managed-launch fallback execs Chrome by default; set CHROME_CDP_BROWSER_BIN (config key browser_bin) to a different binary's path to have it launch that browser instead.
--endpoint <url> (config key endpoint, env CHROME_CDP_ENDPOINT) names the debug endpoint directly, bypassing both --port and the DevToolsActivePort file.
It takes a ws:// or http:// URL; anything else is a usage error before any connection is attempted.
wss:// and https:// are refused too, not just undocumented: the upgrade probe dials plaintext TCP, so a TLS endpoint can never complete a handshake through it — forward the port locally (e.g. ssh -L) and pass the resulting ws:// or http:// URL instead.
This is how you reach a Chrome that chrome-cdp cannot discover on its own — one running in a container, over an SSH tunnel, or on another machine with its debug port forwarded.
The two schemes matter for different reasons:
- Pass the
ws://URL from theDevToolsActivePortfile (ws://host:port/devtools/browser/<id>) for a Chrome reached viachrome://inspect/#remote-debugging. That path serves a 404 on/json/*, so there is no HTTP endpoint to hand it instead — only the literal WebSocket URL works. - Pass
http://host:portfor a Chrome launched with--remote-debugging-port. That flag serves/json/version, sochrome-cdp --endpoint http://127.0.0.1:9222 doctorresolves the browser-level WebSocket the same way--portdoes. For a remotehttp://endpoint, use an IP address orlocalhostas the host: Chrome rejects/json/*requests whoseHostheader names anything else.
An explicit --endpoint also keys the daemon socket and sticky-target state (by its own host:port), so two Chromes reached through different endpoints never share either.
An unreachable --endpoint never falls back to launching, or instructing you to enable, some other local Chrome: it fails with connection_failed naming the endpoint, since falling back would silently act on a different browser than the one you named.
On the chrome://inspect path, a fresh attach makes Chrome ask "Allow remote debugging?".
Three things about it are worth knowing before it happens:
- It is modal to the whole browser, not to a tab. Nothing else in Chrome responds until it is answered.
- It can sit behind the Chrome window, so the usual experience is a browser that appears frozen with no visible dialog.
- Answering it late is fine.
chrome-cdpholds the connection open for--consent-timeout(default 120s) and connects the moment you click Allow.
If the wait runs out you get exit 3 with error.code: consent_pending and a message naming the dialog.
A refused endpoint is unaffected by any of this and still fails in milliseconds — only an open port whose upgrade is hanging earns the long wait.
While the prompt is up, chrome-cdp says so on stderr rather than waiting silently, on the daemon path and with --no-daemon alike.
A second command started during the wait says that it is queueing behind the first rather than opening a second connection, and if the first gives up with consent_pending the ones behind it inherit that answer instead of each raising a fresh prompt.
--consent-timeout (config key consent_timeout) is clamped to between 1s and 10m.
0s or a negative value means the 120s default rather than "do not wait", which would abandon the prompt the moment it was raised; the ceiling exists because the value is also how long a queued command can be held up.
chrome-cdp doctor answers "can I connect?" by connecting, and reports one of four states:
state |
Means | Envelope |
|---|---|---|
ready |
the WebSocket upgrade completed, or a running daemon answered a live CDP round trip | ok: true |
consent_pending |
the port accepted and went silent — Chrome is holding the prompt | exit 3, consent_pending |
no_endpoint |
nothing usable answered (no port file, a stale one, or another process on the port) | exit 3, connection_failed |
unverified |
--no-probe: an endpoint exists and nothing was checked |
ok: true |
When the daemon is running AND it has just proved its connection to Chrome, doctor answers through it (via: "daemon") and opens no new connection — probing is itself a connection request, and on the toggle path that is what raises the prompt.
A daemon that is merely running is not an answer: it holds its socket for its whole idle window, so quitting Chrome leaves a reachable daemon with a dead connection behind it, and doctor falls through to the probe rather than reporting ready.
Otherwise it says on stderr that it is about to connect, then probes (via: "probe").
--no-probe reports only what the port file says, clearly marked state: "unverified".
doctor honours --port and --endpoint like every other verb: doctor --port 9333 diagnoses the Chrome on that port, and doctor --endpoint ws://host:port/devtools/browser/<id> diagnoses that endpoint specifically, not whichever one the DevToolsActivePort file names.
A ready verdict reached by probing says so: the probe's own connection is closed once it has its answer, so on the toggle path the next command is a fresh attach and can prompt again.
Run chrome-cdp daemon start to be asked once per session instead.
The result carries state, via, probed, and the endpoint it looked at (endpoint, plus port_file and ws where they apply), plus endpoint_source (flag | port | port-file, when known) and browser_bin (when CHROME_CDP_BROWSER_BIN/browser_bin is set).
The daemon-backed answer adds running, connected, socket, and target_count — a count, not the tab list: doctor --json is the first thing many callers run, and open tab titles and URLs are not an answer to "can I connect?".
chrome-cdp drives your real, already-authenticated Chrome, which means anything holding a connection to it can act as you on every site you are logged into — not just the one you meant.
The optional policy layer bounds that: which origins the CLI may act on, which verbs are permitted there, and which local paths may be uploaded.
It is off unless you configure it, and it changes nothing until you do.
This bounds a cooperative caller.
It is not a sandbox.
Anything that can run chrome-cdp can also edit this config, or connect to Chrome directly and skip the CLI entirely.
It is a guardrail against a confused or misdirected caller — an agent that read "now go to the admin console" off a web page, a shared recipe you did not read line by line — and it is worth having for exactly that.
Overstating it would be worse than not shipping it.
chrome-cdp use url:myapp # be on the tab you want to bound
chrome-cdp policy init # writes [policy] allow = ["app.example.com"]
chrome-cdp policy init --wildcard --print # see the *.example.com version without writing[policy]
enabled = true
allow = ["*.workday.com", "intranet.corp.local", "localhost:*"]
deny = ["*.bank.example", "admin.corp.local"]
read_only = ["*.wikipedia.org"]
verbs_denied = ["raw"]
upload_roots = ["~/Documents/receipts"]
audit_log = "~/.local/state/chrome-cdp/audit.log"
audit_all = false
on_violation = "error" # error | prompt| Key | Purpose |
|---|---|
enabled |
master switch; a present table is on unless you set this to false |
allow |
origins that may be acted on; empty means "everything except deny" |
deny |
always refused, and it beats allow |
read_only |
origins where reading verbs work and acting verbs are refused |
verbs_denied |
verbs refused on every origin (e.g. raw, eval) |
upload_roots |
directories files may be uploaded from |
audit_log |
append-only NDJSON of refusals (and of every action with audit_all) |
on_violation |
error (default), or prompt to confirm interactively |
Patterns are [scheme://]host[:port], matched against the parsed URL's host — never against a substring of the raw URL.
There is no regex: a policy language that is hard to read is a policy that is wrong without anyone noticing.
| Pattern | Matches | Does not match |
|---|---|---|
example.com |
example.com, on any scheme or port |
a.example.com |
*.example.com |
a.example.com, a.b.example.com |
example.com (needs its own entry), notexample.com, example.com.evil.io |
localhost:3000 |
localhost on port 3000 |
localhost:8080 |
localhost:* |
localhost on any port |
— |
https://x.test |
x.test over https |
http://x.test |
Host matching is case-insensitive, and ports are compared numerically, so host:443 and host:0443 are the same port.
*.host in deny covers host itself, which is the one place the wildcard reads differently from the table above.
In allow and read_only, excluding the apex is the strict reading a boundary needs — *.example.com must not quietly widen to example.com.
In deny it would be a hole: deny = ["*.bank.example"] means "not my bank", and reading it as "every subdomain of my bank, but the bank itself is fine" would protect you everywhere except the host you were thinking of.
Over-blocking is the safe direction in a list of what may never be touched.
Matching is on the origin Chrome resolves, not on the string as typed.
view-source:, blob: and filesystem: URLs are unwrapped to the origin whose content they actually serve, and a \ in the authority is normalised to / the way Chrome normalises it.
So https://bank.example\@evil.io/ is bank.example, and view-source:https://bank.example/statement is checked — and refused — as bank.example.
Unwrapping runs in both directions: view-source: of an allowed origin stays allowed.
A URL with no identifiable origin at all — about:blank, data:, file://, javascript: — is refused whenever a policy is active, whatever shape the rules take.
A policy cannot decide about an origin it cannot identify, and "matches nothing" would be the safe answer under an allow list and a free bypass under a deny list.
(chrome://settings does parse, to the host settings, and is decided about like any other origin: an allow list refuses it because nothing named it.)
A pattern the CLI cannot parse is a fatal error: unlike the rest of the config, which warns and carries on, a policy that could not be read refuses to run, because a policy that fails open is worse than no policy.
A config file that exists but cannot be read — wrong permissions, a bad mount — is treated exactly the same way, rather than as a policy that was never there.
Use --policy-off to run while you fix it.
| Verb class | Checked against |
|---|---|
Acting (click, type, fill, select, scroll, key, pointer verbs, upload, attr set/rm, cookie set/rm/clear, headers, emulate, eval, raw, dialog accept/dismiss, storage local/session set/rm/clear) |
allow/deny, read_only, verbs_denied |
Reading (snap, text, html, value, grid, screenshot, pdf, frame, wait, attr get/list, cookie list, dialog status, storage local/session list/get) |
allow/deny, verbs_denied |
Navigating (nav <url>, open) |
the destination origin, before navigating |
Tabs and meta (list, use, close, activate, version, session, recipe run, …) |
verbs_denied only; no origin check, and every envelope's target, and every tab list or an ambiguous close enumerates, is reduced to a bare origin with no full URL and no title when the policy does not cover it |
A verb that is not classified is treated as acting, so a new verb over-restricts rather than slipping through.
verbs_denied is checked first, ahead of the class, so it reaches every verb including the tab and meta ones.
verbs_denied = ["recipe run"] therefore refuses running a saved recipe — a file someone else wrote, driving your authenticated browser — while leaving recipe show and recipe run --dry-run available for reading one.
close is the one exception to the last row, and only under MCP mode: there it is checked against allow/deny per tab.
At a shell you decided to close your own tab, and refusing it would produce an error a long way from its cause.
An assistant driving the browser under a boundary you wrote is a different caller, and a server that enforced the allow-list for reads but not for destruction would be enforcing half a boundary.
A bulk close under MCP closes the tabs the policy permits and reports the rest under refused.
Redirects are the honest limitation: a nav to an allowed origin that redirects elsewhere cannot be stopped, so the policy is re-evaluated on the settled URL and the next command is refused.
eval and raw can navigate the tab themselves.
eval "location='https://bank.example/'" on an allowed tab issues an authenticated GET to an origin the allow-list would have refused, and no check in front of nav and open can see it coming.
What the policy still gives you is that the tab is then off-limits: the next command is refused on the settled origin, so nothing is read back.
But the request happened.
So an origin allow-list is only meaningful alongside verbs_denied = ["eval", "raw"], and that is what chrome-cdp policy init writes — and what MCP mode applies by default, whether or not a config file says so.
If you need eval, understand that you have kept a verb that can walk out of the boundary and come back — the boundary still bounds what you can read, not what you can reach.
{ "ok": false, "command": "click",
"error": { "code": "permission_denied",
"message": "origin admin.corp.local is not permitted by policy",
"origin": "admin.corp.local", "verb": "click",
"rule": "deny: admin.corp.local",
"config": "~/.config/chrome-cdp/config.toml" },
"elapsed_ms": 2 }rule names the entry that decided it, so a refusal points at the line to edit.
The browser is never asked to act on a refused command.
chrome-cdp --allow "*.example.com" click "#save" # one-off allow-list, replacing the configured one
chrome-cdp --policy-off click "#save" # run without the policy — explicit, and logged--allow narrows; it never unblocks something deny or verbs_denied refused.
--policy-off exists because a bad policy that cannot be bypassed is worse than none, but it is never implicit: it warns on stderr and lands in the audit log.
--policy-off covers the origin policy only.
upload_roots stays in force regardless, because it is a filesystem boundary rather than an origin rule: its threat model is a caller that writes the argv, and --policy-off is argv.
Widen the roots or move the file.
audit_log is append-only NDJSON, one record per decision:
{"ts":"2026-07-26T09:12:03Z","origin":"other.test","verb":"click","decision":"refused","rule":"allow: no match"}Refusals are always recorded; set audit_all = true to record permitted actions too.
It records the origin, never the URL, and never any value — no typed text, no cookie values, no selectors — because a log that captured those would be the most sensitive file this tool produces.
A URL with no origin to record is written as its scheme plus a placeholder (file:(unparseable), javascript:(unparseable)), never as the string itself: a refused URL's query is exactly where a session token lives.
Persist flags you'd otherwise retype in $XDG_CONFIG_HOME/chrome-cdp/config.toml (usually ~/.config/chrome-cdp/config.toml); see config.example.toml for the full key set.
json = true # default to machine-readable output
timeout = "10s"
consent_timeout = "2m" # how long to wait for Chrome's consent prompt (1s-10m; 0 means the 120s default)
by = "search" # default selector syntax
target = "url:github" # default tab when neither --target nor `use` is set
endpoint = "ws://127.0.0.1:9222/devtools/browser/<id>" # explicit debug endpoint; see Explicit endpoint
session = "task-a" # namespace the sticky current tab; see Several agents, one Chrome
browser_bin = "/usr/bin/chromium" # binary the managed-launch fallback execs instead of Chrome; env CHROME_CDP_BROWSER_BIN; see Which browsersA malformed config is a warning on stderr, not a fatal error — the CLI still runs on the built-ins.
The one exception is the [policy] table: a policy the CLI cannot read makes it refuse to act rather than act unbounded, and that covers a file that cannot be read (wrong permissions, a bad mount) as well as one that does not parse.
XDG_CONFIG_HOME chooses which file is read, so an environment that points it at a directory without one leaves you with no [policy] table at all.
No CHROME_CDP_* variable can set a policy key, but that is a statement about the table's contents, not about which file supplies them.
When XDG_CONFIG_HOME is set and there is no config file there, the CLI says so on stderr rather than letting a boundary disappear quietly.