chrome-agent 0.16.0

Web tasks that compile. Browser automation that reads the page back after every action and reports what actually happened, in JSON. Single binary, CDP direct to Chrome.
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.