Ever handed an LLM a full web page and watched the amount of tokens being used?
A single product listing is 20-30K tokens of
soup before the model finds what it needs: wrapper divs, css class, SVG, JSON blobs etc. The agent needs maybe 300 tokens of that (the items and their prices). When you feed an LLM with the raw DOM, you pay for markup cleanup.This is why chrome-agent exists. It's one 3 MB binary that drives Chrome over the DevTools Protocol (CDP) and hands your agent the page in a shape a language model can cheaply read and act on. No MCP server, no Node runtime.
For instance, ask an agent to: find standing desks under $400 on a store, return the top five with prices. A boring job. Use a default agentic workflow with basic "WebSearch" and you're cooked.
Let me show you why chrome-agent is the best to do the job here.
Land on the page and look
chrome-agent goto "https://store.example.com/search?q=standing+desk" --inspect
goto navigates. --inspect grabs the useful content (no div soup), so the agent gets useful minimum content in one round trip. What comes back is not HTML. It's the accessibility tree (the same roles-and-labels view a screen reader gets) flattened to compact text:
uid=n1 heading "Standing Desks (48 results)"
uid=n14 searchbox "Search products"
uid=n22 button "Apply filters"
uid=n41 link "Uplift V2 Standing Desk"
uid=n47 link "Flexispot E7"
uid=n52 link "Vari Electric"
...
Notice what's gone: no divs, no class names, no base64 <img>. useless HTML nodes are stripped, roughly a 70% cut against the raw accessibility tree (which is a 99% over raw HTML).
chrome-agent has also magic command such as inspect to extract deterministically what makes on the page, based on Reader modes from browser. It can returns like 50 tokens, against the 20-30K of raw HTML the same page would otherwise cost the model. That's insane!
A page becomes something the model can read in tokens it can afford, and act on by pointing at a uid.
Why UID and not CSS selector? Because CSS selectors suck and cost way more tokens.
Why uids instead of CSS selectors?
Because the agent needs a stable handle it can point at without inventing CSS. Those n41, n47 values are Chrome's own internal IDs. They're stable across repeated inspects on the same page, so the agent can inspect, reason, then act on n47 a few commands later and still hit the same element.
chrome-agent has multiple targeting modes that the agent picks per situation:
-
uid(from inspect) => the default, cheap, specific -
--selector "css"=> when you already know the DOM shape -
--xy 100,200=> canvas, maps, anything with no real DOM node
In our example, the agent doesn't need every link, it needs the price control. So it narrows:
chrome-agent inspect --filter "textbox,button" --urls
--filter keeps only the roles you name (with aliases, so textbox also pulls in searchbox and combobox). Now the tree is a handful of lines:
uid=n14 searchbox "Search products"
uid=n62 textbox "Max price"
uid=n22 button "Apply filters"
There's n62. The agent saw it, so it can point at it.
Act, then wait for the grid to settle
chrome-agent fill "400" --uid n62
chrome-agent click n22
fill types into the max-price field, click applies the filter. The full action set is there when the task needs it: click, fill, dblclick, select (matches by option value then visible text), check/uncheck (idempotent, so re-running a step never toggles you into the wrong state), upload, drag, and press for raw keys.
You have a SPA so there's no real navigation and the DOM keeps being replaced? That's also fine! Clicking the filter fires an XHR and re-renders the grid client-side. chrome-agent waits on the network, no need of pseudo-sleep:
chrome-agent wait network-idle --idle-ms 500
This tracks in-flight requests and returns once the network has been quiet for 500ms (plenty enough to ensure all the Javascript calls are done).
Getting the data out is a decision, not a command
The filtered grid is on screen and the agent needs the top five as structured data. There's a hierarchy, and the skill is matching the rung to the shape of the data, not reaching for the same command every time:
-
read=> one article or long body of prose. Injects Mozilla's Readability, turns ~15K of HTML into ~500 clean tokens. Wrong tool here (this is a grid, not an essay). -
extract=> repeating records: product grids, search results, feeds, tables. MDR/DEPTA-style heuristics (sibling structural similarity, text-to-link ratio, hidden-element exclusion) find the repeating region on their own. This is our rung. -
text --selector "main"=> scoped visible copy when you just want words from one region. -
eval "..."=> a single computed value the heuristics would miss. -
network=> when the grid was painted from a JSON API, skip the DOM and read the response that fed it.
For this task, extract:
chrome-agent extract --limit 5 --json
{
"ok": true,
"records": [
{"title": "Branch Duo", "price": "$349", "url": "/p/branch-duo"},
{"title": "Flexispot E7", "price": "$389", "url": "/p/flexispot-e7"},
{"title": "Vari Electric", "price": "$395", "url": "/p/vari-electric"}
]
}
If the store lazy-loads rows on scroll, extract --scroll drives the page down and watches a MutationObserver until new content lands.
Sometimes the cheapest rung isn't the page at all. That grid was populated by a JSON call. The agent can grab it directly:
chrome-agent network --filter "/api/search" --body --limit 1
Same prices, no scraping.
When it breaks, the error tells the agent what to try
Agents fail constantly. What decides whether they recover is what a failure hands back. In --json mode an error exits 1 but still prints a parseable object on stdout, and when there's an obvious next step it comes back with a hint:
{"ok": false, "error": "No element with uid=n47", "hint": "Page may have changed. Re-run inspect to get current uids."}
The agent reads ok:false, reads the hint, re-inspects, retries. Self-healing without you hand-writing recovery logic for every command.
One connection instead of a hundred spawns
Running the binary once per command pays process startup and a fresh Chrome connection every time. For a multi-step task that adds up. So there's pipe mode: one persistent connection, JSON commands in on stdin, JSON results out on stdout.
chrome-agent pipe <<'EOF'
{"cmd":"goto","url":"https://store.example.com/search?q=standing+desk","inspect":true}
{"cmd":"fill","selector":"input[name=maxPrice]","value":"400"}
{"cmd":"click","selector":"button[type=submit]"}
{"cmd":"wait","what":"network-idle","idle_ms":500}
{"cmd":"extract","limit":5}
EOF
Playwright already exists. When do you reach for this?
Different use-cases:
- Playwright / Puppeteer => deterministic end-to-end tests: assertions, fixtures, waiting on specific selectors, visual diffing. A full, mature automation API for humans writing test suites.
- chrome-agent => an LLM agent that has to read and act on pages it's never seen, in tokens it can afford, and recover from failure on its own.
Different jobs. If you're writing an assertion-heavy CI suite, use Playwright. If you do web researches and your agents need to browser the web, they are burning their context window (parsing HTML soup) and you are paying for it. Don't.
Even more magic!
chrome-agent supports even more things an LLM agent needs:
- it can
dragelements - it can
downloadelements - it can handle
iframe - it can
--stealthto apply patches to bypass Cloudflare and Turnstile. It is not guaranteed against DataDome or Kasada though; they fingerprint the bundled Chromium and ship detection updates constantly. - it can access your logged-in site by using your real user cookies using
--copy-cookies.
Try it
Go with the skill to install and forget:
npx skills add sderosiaux/chrome-agent
# or
cargo install chrome-agent
Top comments (0)