LLM USAGE GUIDE:
Workflow: inspect -> read uids -> act (click/fill/select/check) -> assert.
Headless by default. --stealth for bot-protected sites. --json for structured output.
ok:true means the command ran, NOT that the page complied. Read `verdict` and `next`.
An action reports what it changed, so a second inspect is usually unnecessary. Re-inspect after
a navigation, or when the report says document_changed.
Reading an action's response — branch on "next", never on the verdict word.
"next" is one token from a closed set of six: proceed inspect retry confirm dismiss stop.
verdict reason next what it means
changed tree_delta proceed the page moved; `delta` says how
changed nodes_moved proceed same nodes, reordered
changed focus_only proceed only focus moved, onto a real element -- on a path
with no hit test that is the sole sign it arrived.
focus.to may be a focusable ANCESTOR (a span inside
a link focuses the link); read `uid` for your target.
Focus onto the DOCUMENT answers identical_tree.
changed value_kept proceed read back on the element (value.verbatim); no delta
could show it. fill, select and check all reach it.
If the PAGE read also failed the same verdict
answers next=inspect -- the field is confirmed, the
page around it was never seen.
changed values_lost confirm moved AND emptied a filled field; values_lost names
them. Submitted-and-cleared and discarded-without-
sending look identical: confirm before re-filling.
navigated document_replaced inspect new document; every stored uid is dead
intercepted hit_test_receiver dismiss intercepted_by got the event; target unknown
intercepted modal_dialog dismiss a <dialog> opened with showModal() owns the top
layer; press Escape first
not_kept value_reverted stop element held NOTHING; read value.actual, do not re-fill
not_kept value_rewritten stop element holds something else (mask/normaliser)
no_effect delivered_no_change confirm delivery PROVEN and tree still within observed_after_ms
unchanged identical_tree confirm identical tree, delivery not proven
unknown no_baseline inspect nothing to compare against yet
unknown read_failed inspect the action ran, the read after it failed
unknown identity_unreadable inspect the two trees may be different documents
unknown aim_point_off_target inspect nothing dispatched, and the miss is STABLE
unknown scroll_not_settled retry nothing dispatched, and the miss is TRANSIENT
not_checked reporting_disabled proceed you passed --verdict off
# unknown = could not compare, never "nothing happened". scroll_not_settled is the ONE
# unknown that licenses a repeat: two readings of the aim point DISAGREED, so the next
# attempt aims at a settled box. aim_point_off_target means they AGREED -- the point is
# outside the element's own boxes (wrapped inline box, clipped container) or outside the
# viewport and pinned there; read `aim`. An identical retry misses identically.
# verdict_reason is the machine token; verdict_hint names the next command in words.
"delivery" on a pointer-targeted action (click, dblclick, the check/uncheck click) is a hit test
at the coordinate about to be dispatched, and the only thing that licenses the two strong words:
target_hit the target, a descendant, its label's control or its shadow host -- licenses no_effect
intercepted an element outside the target's subtree -> intercepted_by names it
off_target no aimable point, two readings AGREED: NOTHING DISPATCHED, miss stable -> inspect
not_settled two readings DISAGREED, still moving: NOTHING DISPATCHED, miss transient -> retry
js a JS click()/MouseEvent: no hit test, licenses nothing
not_probed no hit test, or it misses the document that matters (iframe): licenses nothing
# --on-intercept: dispatch (default) sends it anyway and names the receiver; refuse always
# errors; guard reads intercepted_by.actionable (interactive tag, ARIA role, tabIndex>=0, or
# cursor:pointer), dispatches through an inert receiver and refuses one that looks like a
# control -- an <iframe> receiver always refuses under guard. A refusal is ok:false (exit 1)
# with the SAME fields as a dispatch plus dispatched:false; nothing reached the page, so
# re-aiming duplicates nothing.
# "waited_ms" appears only when the action waited for a load after it -- that is why a
# command took ten seconds. Per action, never carried to the next pipe command.
# A pointer event Chrome does not answer within 8s FAILS (ok:false) instead of waiting out
# --timeout; it may already have landed, so run inspect, do not repeat it. click/dblclick/
# hover/drag bring their page to the foreground, making it the active tab (a background tab
# answers pointer events on a 5s timer).
# Two blind spots no retry fixes: the read-back window is a fixed 60ms (a validator at 400ms
# is outside it -- use wait + assert value), and canvas/WebGL/CSS-only effects are invisible
# to the a11y tree (use screenshot or eval, never a second click).
Flag position: global flags (--json, --browser, --verdict, --budget, --stealth, --on-intercept,
--page, --dialog ...) parse on either side of the verb. --timeout and --max-depth are the two
exceptions: a command that declares its own takes it after the verb (`wait selector ".x"
--timeout 5`), everywhere else they come BEFORE it. Passing one where it is not declared is an
exit-1 usage error naming the invocation that works.
Targeting (3 modes: uid from inspect, CSS selector, coordinates):
chrome-agent click n40
chrome-agent click --selector "button.submit"
chrome-agent click --xy 100,200
chrome-agent fill --uid n20 "value"
chrome-agent fill --uid n21 "hunter2" --secret
chrome-agent fill-form n20="a@b.com" n30="pass"
chrome-agent hover n30
chrome-agent dblclick n42
chrome-agent drag n10 n20
# Every targeted action returns the `uid` it actually resolved -- check it against the delta.
# drag uses mouse events: works with Sortable.js and React DnD, NOT with HTML5 drag-and-drop.
# Exactly one targeting mode per invocation: two is a usage error before a browser is opened.
# --secret on fill/type reports lengths instead of the value; it only ever ADDS redaction to
# what the element already declares, and there is no flag that turns redaction off.
Form controls:
chrome-agent select --uid n15 "Option text"
chrome-agent check n20
chrome-agent uncheck n20
chrome-agent upload --uid n30 /path/to/file.pdf
# select matches option value first, then visible text, and refuses if the page reverts it.
# Custom dropdowns (React, MUI) are not <select>: click to open, then click the option.
# check/uncheck are idempotent and read the state back -- prefer them over click. File
# inputs are usually hidden from the a11y tree: target them with --selector.
Keyboard:
chrome-agent type "search term"
chrome-agent type "hunter2" --secret
chrome-agent press Enter
# A key NAME that is not mapped is refused rather than dispatched with virtual key code 0.
Navigation:
chrome-agent goto https://example.com
chrome-agent goto https://spa.com --wait-for "main"
chrome-agent goto https://api.co --header "Authorization: Bearer XYZ"
chrome-agent back
chrome-agent forward
# back/forward are one step with a sign: both answer {url, title}, and at either end of the
# stack {"ok":true,"title":"","message":"Already at first|last history entry"} with no url.
# Both drop the uid map, as goto does -- re-inspect after either.
chrome-agent scroll down
chrome-agent wait text "Success"
chrome-agent wait network-idle --idle-ms 800 --timeout 20
chrome-agent history --filter "github"
# goto returns "landed": {requested, final, redirected, http_status, serving}. Check
# "redirected" -- an expired session bouncing to a login wall reads as a successful load.
# Fragment-only and trailing-slash differences are not redirects; http_status is the final
# hop's and is absent (never 0) when the browser reports none. A redirect onto /login,
# /signin, /auth or /sso adds a "hint" guessed from the URL: inspect to confirm. --header is
# repeatable; wait network-idle beats a fixed sleep for SPA/XHR settle.
"serving" on goto is what ANSWERED, and it never changes ok or the exit code:
page nothing measured contradicts a served page -- NOT a certificate: a
paywall, a cookie wall and an unknown captcha all read as page
challenge an anti-bot vendor's frame/script and no form of the site's own;
challenge_from names the vendor host. --stealth does not defeat these
error the server answered 4xx/5xx. Read http_status
nothing_actionable no link, no form control, no script, almost no text. An edge-served
refusal and a page that had not rendered look identical: inspect first
unreadable the shape probe did not run; nothing is known about the document
# challenge_from can also appear under serving:"page" (a captcha widget on a usable form):
# the word is the branch, the field is the evidence.
Inspection:
chrome-agent inspect
chrome-agent inspect --max-depth 2
chrome-agent inspect --filter "button,link"
chrome-agent inspect --uid n50 --urls
chrome-agent inspect --max-chars 4000 --offset 4000
chrome-agent diff
# --filter gives a flat list; --scroll --limit N loads infinite-scroll feeds; --urls
# resolves hrefs before you choose what to click;
# --max-chars caps the output and tells you the next --offset. Narrowing flags change only
# what is PRINTED: the stored snapshot is always the full tree, so `inspect --filter button`
# then `diff` still reports what really moved.
Content extraction (in order of preference):
chrome-agent extract
chrome-agent extract --a11y --scroll --limit 20
chrome-agent read
chrome-agent text --selector "main" --truncate 500
chrome-agent eval --selector "h1" "el.textContent"
# extract: repeating records (grids, feeds, tables). ~1,570 tokens for all 30 HN stories,
# against ~5,650 for a full inspect and ~8,730 for the raw HTML. Reach for it first.
# read: articles, via Readability. text --selector: scoped. eval: structured data. --a11y
# reads the a11y tree instead of the DOM (React SPAs like X.com); --scroll scrolls and waits
# on a MutationObserver for lazy content.
Network and console:
chrome-agent network
chrome-agent network --filter "graphql" --body
chrome-agent network --abort "*tracking*" --live 30
chrome-agent console --level error
# network with no --live lists resources already loaded (stealth-safe). --body without
# --filter fetches textual types only; WITH --filter every match is fetched. Binary bodies
# are never printed: body_omitted names the size and the download command.
# --abort is blocking for --live N seconds -- start it before navigating.
Assertions (exit code IS the answer: 0 held, 2 did not hold, 1 could not be checked):
chrome-agent assert value --selector "#email" --equals "ada@example.com"
chrome-agent assert text --contains "Order shipped"
chrome-agent assert text --selector "#status" --matches "Shipped on \d{4}"
chrome-agent assert url --matches "/orders/\d+$"
chrome-agent assert state --selector "#terms" --checked
chrome-agent assert state --uid n15 --selected "California"
chrome-agent assert exists --selector ".result" --count 10
chrome-agent assert exists --selector ".error" --min 1
# 2 = the page is not in that state (report or repair). 1 = nothing was compared: no browser,
# a selector matching nothing, an unparseable regex, a CDP timeout (retry). Only assert exits
# 2. assert is a read: no change report, no verdict, and it never clicks. --checked reads the
# same classification check/uncheck apply and --selected the same reading select uses, so an
# assertion cannot disagree with the action; --enabled/--disabled and --visible read state
# too. Secrets are compared, never printed. --matches is
# a Rust regex: \d \w \s ASCII-only, no \p or lookaround, "(?i)" for case-insensitive;
# --contains is a plain case-sensitive substring. Inside pipe/batch an assertion has no exit
# code of its own: the same assertion object arrives with ok:false, and a batch that stopped
# on it exits 1, not 2.
Files (written 0600 under ~/.chrome-agent/tmp; path on stdout, never base64):
chrome-agent screenshot --format jpeg --quality 60 --max-width 1024
chrome-agent screenshot --uid n42
chrome-agent pdf --filename page.pdf
chrome-agent download https://site/report.pdf
chrome-agent download https://site/data.zip --out ./data.zip --max-bytes 10485760
chrome-agent download --uid n47 --timeout 60
# download takes EXACTLY one target: a URL, --uid or --selector. Two is refused, not ranked.
# A URL is fetched in-page (the login is kept), capped at 64 MiB. --uid/--selector CLICKS the
# element and captures the browser-native download -- the only route to a Blob-built file or
# a POST no anchor names; same hit test, same --on-intercept. READ `downloaded`, NOT `ok`: a
# click that landed and produced no file answers ok:true with downloaded:false, so never
# repeat it blind. --timeout bounds the whole window (begin AND finish), --max-bytes cancels
# and removes the partial file. No verdict, no change report.
Macros (a path that already worked, kept and replayable):
chrome-agent macro list
chrome-agent macro record checkout --from-recording session.jsonl
chrome-agent macro run checkout --var email=ada@example.com
chrome-agent replay session.jsonl
# In pipe: {"cmd":"macro","action":"record","name":"x"} distils THIS session; "from"
# overrides where the task starts (default: the last successful navigation).
# A step keeps only what survives tomorrow: delivery target_hit, the verdict WORD,
# value.verbatim, a url_matches built from the PATH. Never counters, uids or durations.
# A step aimed by uid is recorded by role+name or REFUSED; --xy is never recordable. A secret
# field becomes a declared parameter and is never stored: run refuses without --var.
# A guard that does not hold STOPS the run, naming the step index, the guard, what was
# observed and the action's own next -- nothing is repaired, retried or skipped. Steps that
# promise nothing are marked unguarded. replay re-runs a `pipe --record` file raw: no guards,
# no parameters.
# Exit 2 when a guard was CHECKED and did not hold (stopped_by "guard") -- the same code as a
# failed assert, because it is the same kind of claim. Exit 1 when the run stopped for any
# other reason (stopped_by "error"): the step itself failed, the page could not be read, the
# macro file is missing or malformed. Branch on stopped_by, or on the exit code.
Pipe and batch (one connection, uids stay valid across commands; ~12 ms less overhead per
command -- measured 1.5x on a read stream, 1.1x on fills and clicks, so use it for the uids):
echo '{"cmd":"goto","url":"https://example.com","inspect":true}' | chrome-agent pipe
echo '[{"cmd":"goto","url":"https://example.com"},{"cmd":"inspect"}]' | chrome-agent --json batch
chrome-agent --json batch --stop-on-error
# pipe answers one JSON line per command; batch answers ONE object holding every result,
# and prints it as JSON only under --json (text mode gets one line per entry). With
# --stop-on-error the response adds stopped_at and skipped AND the process exits 1 -- 1,
# never 2, which is only ever an assertion that did not hold. Without --stop-on-error it
# ran everything and exits 0 even when an entry failed: read ok. In pipe that flag is
# {"cmd":"batch","stop_on_error":true,"commands":[...]}. Each CLI batch invocation is a
# fresh connection, so uids change between them.
Iframes (the binding lives on the connection: use pipe or batch, not separate CLI calls):
chrome-agent frame "#payment-iframe"
chrome-agent frame main
# frame scopes eval and inspect; it does NOT scope --selector targeting. Re-inspect after
# switching, then act by uid (backendNodeId is page-global and works cross-frame). A nested
# selector resolves inside the bound frame, so `frame main` first to reach a sibling. The
# isolated world sees the frame's DOM but not its main-world JS variables. <iframe> only,
# not legacy <frame>/<frameset>.
Device emulation (explicit CDP metrics, no preset catalog):
chrome-agent --page mobile emulate device --label "checkout phone" --width 412 --height 915 --dpr 2.625 --mobile --touch
chrome-agent --page mobile emulate status
chrome-agent --page mobile emulate reset
# Persisted per named page and reapplied on each connection (Chrome reverts overrides when
# the session that set them detaches); sibling pages keep their own metrics. Acting on an
# emulated page activates its tab, which backgrounds siblings. Under --touch, click/check
# dispatch touch taps; dblclick/hover/drag stay mouse events. A Chrome restart discards it.
WebMCP tools (document.modelContext.getTools()/.executeTool()):
chrome-agent webmcp list
chrome-agent inspect
chrome-agent webmcp call add_to_cart --args '{"item":"X"}'
# The protocol defines no outputSchema, so a tool's declared_result is a freeform string with
# nothing to check it against. webmcp call reports it beside verdict/delta/next from the same
# machinery every other action gets: a tool that declares success and moves nothing reads
# unchanged/identical_tree, never a stronger claim. Under `frame` it hits the isolated-world
# blindness eval has -- the response carries frame_scoped:true, so an empty list there is
# unproven, not "none". Most Chrome installs have no native WebMCP: use --chrome-arg.
Sessions, tabs and cleanup:
chrome-agent status
chrome-agent tabs
chrome-agent --page tab2 goto https://other.com
chrome-agent --browser agent1 goto https://example.com
chrome-agent close --purge
chrome-agent close --orphans
# Parallel agents MUST use distinct --browser names; sharing "default" corrupts sessions.
# status lists browsers no session entry claims as orphan=<name>; --orphans closes them and
# --purge also deletes cookies and the profile.
Bot protection:
chrome-agent --stealth goto https://protected-site.com --inspect
chrome-agent --stealth --copy-cookies goto https://x.com/home --inspect
chrome-agent --connect http://127.0.0.1:9222 goto https://protected-site.com --inspect
chrome-agent --chrome-arg --enable-features=WebMCP,WebMCPTesting goto https://example.com
# --stealth is 7 CDP-level patches (navigator.webdriver, chrome.runtime, UA, WebGL, input
# leak, and it skips Runtime.enable). Measured: it clears a Cloudflare JS challenge
# ("Just a moment...", shop.app: 403 without, real page with), and does NOT clear a managed
# Turnstile (nowsecure.nl: identical either way) or DataDome (leboncoin.fr: 403 either way).
# For those, --connect to a real Chrome started with --remote-debugging-port=9222.
# --copy-cookies reads your real Chrome profile's cookies -- no manual login needed.
# --chrome-arg is repeatable, applies only to the Chrome chrome-agent launches (refused, not
# ignored, under --connect) and is fixed for the life of a named browser. Refused outright:
# --user-data-dir, --remote-debugging-port, --remote-debugging-pipe, --proxy-server (use the
# global one), --headless (use --headed).
Dialogs (alert/confirm/prompt/beforeunload) are auto-accepted so the page never hangs:
chrome-agent --dialog dismiss click n20
chrome-agent --dialog accept --dialog-text "hi" click n20
# --dialog manual opts out. The handler logs to stderr, so --json output stays clean.
JSON mode and exit codes:
chrome-agent --json goto https://example.com
# -> {"ok":true,"url":"...","title":"..."}; errors are {"ok":false,"error":"...","hint":"..."}
# on stdout with exit 1. Exit codes: 0 success · 1 error (also a bad flag) · 2 a claim this
# tool made did not hold · 130 Ctrl+C. Exactly two things return 2: a failed assertion, and
# a `macro run` guard that was checked and did not hold. A failed assertion prints
# {"ok":false,"assertion":{...}} on stdout with exit 2; without --json that line goes to
# stderr and stdout stays empty.
# A batch --stop-on-error that stopped exits 1, with its response still on stdout.
Cost and correctness notes:
- UIDs (n47, n123) are stable across inspects of the same page; an SPA route change (back,
forward, a click that re-renders) reassigns them. Re-inspect.
- For SPA product/detail pages prefer `goto <direct-url>` over clicking a link.
- `added`/`removed`/`changed` of 0 does NOT mean the action had no effect: an overlay that
swallowed the click, an effect the a11y tree cannot see, and a late handler look the same.
- Read value.verbatim after every fill, select and check before reporting success.
- --budget N caps the change report (default 1200 chars); --verdict off removes it entirely,
and every guarantee above with it. A full inspect is ~5,650 tokens on the HN front page,
--filter button,link ~1,735.