ZeroDOM
MCP server · deterministic · no LLM in the loop

Your agent doesn't need the DOM.It needs to know what it can click.

ZeroDOM is an MCP server that turns a web page into a token-optimized interaction graph — Graphify for the browser. Your agent gets a flat list of the buttons, inputs and links it can actually act on, at 71% fewer tokens than the ARIA snapshot your agent gets today, and a predictable ~10 tokens per actionable node across Airbnb, GitHub and Hacker News alike.

$ pip install zerodom
news.ycombinator.com
input · 438 bytes
<tr class="athing" id="49151933">
  <td align="right" valign="top" class="title">
    <span class="rank">1.</span></td>
  <td valign="top" class="title"><span class="titleline">
    <a href="https://example.com/story">Show HN: ZeroDOM
    — agents only need to know what they can click</a>
  </span></td>
</tr>
lxml · deterministic · no LLM in the loop14ms
interaction graph
output · 3 lines
PAGE: Hacker News
[01] a 'Show HN: ZeroDOM — agents only need to know…'
[02] a 'dev'
[03] a '214 comments'
231/231 selectors resolve11,882 tok 2,326#row > span > a never leaves your process
71%
fewer tokens than the ARIA snapshot
~10
tokens per actionable node, flat
10–30ms
deterministic parse, no model call
231/231
selectors verified to resolve
The problem

An agent driving a browser gets two action spaces today. Both are bad.

Pixels are slow, expensive, and produce coordinates that go stale the moment the page scrolls. The accessibility tree is cheaper but enormous, and it has no stable handles. ZeroDOM is a third option.

Pixels

vision model + screenshot

  • Slow and expensive per step
  • Coordinates go stale on scroll
  • No stable handle for a repeat element

Accessibility tree

aria_snapshot, Playwright MCP

  • Cheaper than pixels, still enormous
  • Carries headings, prose and containers to keep its shape
  • 102 of Hacker News' 220 nodes share a (role, name) pair

ZeroDOM

flat interaction graph

  • Only what the page can do, one line each
  • Every id unique by construction
  • Selectors resolved on your side, never in context

No LLM in the loop

lxml in, graph out, 10–30ms, identical output every run. Nothing about your page reaches a model until you send the graph to one.

Selectors never enter context

The model sees [03]; the CSS path #row > span > a stays in selector_map() on your side. On real pages those paths cost more tokens than the labels do.

Nothing leaves your machine

The browser is yours, the parse is local, the graph is a dict you own. No telemetry, no API keys, no accounts, no storage.

Live benchmarks

Measured on real pages, on 2026-08-03. Rerun them yourself.

Both suites ship in the repo — benchmarks/benchmark_tokens.py and benchmarks/benchmark_vs_a11y.py. Rerun them and the counts will have drifted by a few percent.

The fair comparison isn't raw HTML — nobody sends a model raw HTML. It's Playwright's page.aria_snapshot(), specifically mode="ai", which is what Playwright MCP puts in a model's context.

PageARIAARIA mode="ai"ZeroDOMTargetable by (role, name)Saved vs ai
airbnb.comZeroDOM found 170 nodes · the ARIA tree exposed 721,6773,3461,69272 / 72
49.4%
github.com/…/issues8,28712,3752,97683 / 118
76.0%
en.wikipedia.org article7,58512,9583,060132 / 185
76.4%
news.ycombinator.com30 identical link 'upvote', 30 identical link 'hide'10,34512,6842,350118 / 220
81.5%
developer.mozilla.org4,0685,9341,67759 / 87
71.7%

Mean 71.0% fewer tokens than the snapshot a model actually gets. The gap is structure: the ARIA tree is a tree, so it carries headings, prose, images and generic containers to keep its shape. ZeroDOM emits a flat list — an agent choosing what to click doesn't need the ancestry of the thing it clicks.

The tool suite

Five tools. One in-memory selector map. No page re-reads.

The server keeps one Chromium session and holds node_id → selector server-side. Every action re-reads the page in place, so a click's result comes back in the same turn and node ids never go stale.

zerodom_parse_url(url, verbose=False)

Navigate, then return the compact graph. A navigation renumbers every id, so this is the only call that returns the page whole.

229 tok25 lines — the entire demo page
zerodom_read_page(verbose=False)

Re-read the live DOM without navigating. Re-navigating wipes typed state; this preserves it — measured on Hacker News.

in placetyped input survives · goto wipes it
zerodom_find(query)

Answer “where is the dispatch button?” with one line instead of the whole graph. The agent loop stops re-reading what it already has.

7 tok[15] button 'Dispatch'
zerodom_click_node(node_id)

Click, then return what changed as a structural diff. Accepts whatever the model saw — 03, [03] and node_03 all resolve.

diff only+ appeared · − gone · ~ value changed
zerodom_fill_node(node_id, text)

Type into a node and return the same diff. Filling a field that changes nothing structurally costs almost nothing to report.

14 tokno structural change

The saving compounds

It is the difference between an agent spending the full graph on every one of twenty actions and spending it once. A diff is only ever a reduction, never a loss.

MCP setup
structural diff, after an action
no re-read
+ [18] button 'Confirm dispatch'
− [17] button! 'Recall (in transit)'
~ [07] input* 'Assigned driver' '' → 'A. Reyes'
Actions return + appeared, gone, ~ value changed — rather than re-listing every node on the page.
the duplicate-target problem
news.ycombinator.com
ARIA
link "upvote" × 30 — get_by_role throws
link "hide" × 30 — which story?

ZeroDOM
[42] a 'upvote' → unique by construction
102 of 220 actionable nodes on Hacker News are not uniquely addressable by (role, name). Every ZeroDOM id maps to a selector verified to resolve to exactly one element.
Engineering boundaries

What ZeroDOM deliberately does not do.

Known and worth knowing before you build on it. Every one of these is a design decision with a reason, not a roadmap item we're hiding.

Closed shadow roots are unreachable

Open roots are parsed and light-DOM selectors are scoped against them. A root attached with {mode: 'closed'} is hidden from every API, including Playwright's, so nothing can enumerate it.

Iframes aren't traversed

Each frame is a separate document; ZeroDOM parses the top one. Payment fields, embedded editors and consent gates typically live in an iframe and won't appear.

Visibility is read from markup, not layout

Hiding is detected on the element itself — [hidden], aria-hidden, type="hidden", inline display:none. A stylesheet rule can't be seen, so <div class="hidden"> is emitted as if visible. No reflow, no layout pass, no latency cost.

Pure middleware — 0% bot-detection footprint

ZeroDOM operates on a Page you already control and never fetches anything, so it has no anti-bot surface to defeat and makes no claim to bypass one. If your agent drives an authenticated Chrome session, Cloudflare and Akamai were satisfied before ZeroDOM ran.

Canvas and WebGL have nothing to parse

Figma-style surfaces draw their controls as pixels — there is no element to emit. Screenshot-based computer use is the right tool there, not this.

Nothing waits for the page to finish thinking

from_page and zerodom_read_page snapshot the DOM at call time. An SPA still fetching its content returns a graph of the shell. Wait for your own condition first, then parse.

Security posture

Labels come from the page, and the page is written by someone else. A hostile site can name a button so that it reads as an instruction to whatever model you send the graph to. Every tool that shows a model a web page has this problem — ARIA snapshots, raw HTML, screenshots fed to a vision model. ZeroDOM doesn't add to it, and helps a little by never handing the model a selector or URL it can act on directly. Treat every label as untrusted input, and keep the decision to click on your side.

Questions

Frequently asked questions

The short answers, with the number that backs each one. Everything here is reproducible from the repo.

How do I reduce token usage in a browser agent?

Stop sending the agent the page structure and send it only the actions. ZeroDOM replaces Playwright's ARIA snapshot with a flat interaction graph — one line per clickable or fillable element — which measured 71% fewer tokens across five live pages, and holds a flat ~10 tokens per actionable node instead of the accessibility tree's 23–70.

What is ZeroDOM?

ZeroDOM is an open-source MCP server and Python library that converts a web page into a token-optimized interaction graph for AI browser agents. It parses the DOM deterministically with lxml in 10–30ms, emits one line per actionable node, and keeps the CSS selector for each node on your side so it never enters the model's context window.

How is ZeroDOM different from Playwright MCP?

Playwright MCP sends the model an ARIA accessibility snapshot, which is a tree and therefore carries headings, prose, images and generic containers to keep its shape. ZeroDOM sends a flat list of actions only, which measured 71.0% fewer tokens on average, and gives every node a unique id — on Hacker News, 102 of 220 actionable nodes are not uniquely addressable by (role, name), so a snapshot-driven agent cannot say which story to upvote.

How many tokens does ZeroDOM actually save?

Against Playwright's aria_snapshot(mode="ai"), the snapshot a model actually receives, ZeroDOM saved 49.4% on Airbnb, 76.0% on GitHub issues, 76.4% on Wikipedia, 81.5% on Hacker News and 71.7% on MDN — a mean of 71.0%. Against raw HTML the mean saving is 93.1%, with the worst page at 80.4%. All figures were measured on live pages on 2026-08-03 with tiktoken cl100k_base and are reproducible from the repo.

Does ZeroDOM send my page data to an API?

No. The parse is local and deterministic with no LLM in the loop — lxml in, graph out, identical output every run. There is no telemetry, no API key, no account and no storage; the only network traffic is the page you pointed it at, and nothing about your page reaches a model until you send the graph to one.

Does ZeroDOM work with Claude Desktop and Cursor?

Yes. ZeroDOM ships an MCP server that runs with uvx --from zerodom zerodom-mcp, configured in claude_desktop_config.json for Claude Desktop or .cursor/mcp.json for Cursor. It exposes five tools: zerodom_parse_url, zerodom_read_page, zerodom_find, zerodom_click_node and zerodom_fill_node.

Does ZeroDOM bypass bot detection or CAPTCHAs?

No, and it makes no claim to. ZeroDOM is pure middleware over a Playwright Page you already control and never fetches anything itself, so it has a 0% bot-detection footprint — if your agent drives an authenticated Chrome session, Cloudflare and Akamai were satisfied before ZeroDOM ran.

Can ZeroDOM read iframes, shadow DOM and canvas apps?

Open shadow roots are parsed and their selectors are correctly scoped; closed shadow roots, iframes and canvas-rendered controls are not. A closed root is hidden from every API including Playwright's, each iframe is a separate document that ZeroDOM does not traverse, and a canvas app draws its controls as pixels so there is no element to emit.

Is ZeroDOM free and open source?

Yes. ZeroDOM is Apache 2.0 licensed, published on PyPI as zerodom, and installable with pip install zerodom on Python 3.10 or newer. The browser-backed features additionally need playwright install chromium.

How does ZeroDOM stop an agent from re-reading the same page?

Two of its five tools exist for exactly that. zerodom_find returns only the nodes matching a phrase — 7 tokens instead of the full 229-token graph on the demo page — and actions return a structural diff (+ appeared, − gone, ~ value changed) rather than re-listing every node, so the full graph is spent once instead of on every one of twenty actions.

Private B2B beta

Stop paying for the DOM.

We're onboarding a small number of teams running browser agents in production. Bring your hardest page — the one where the ARIA snapshot eats your context window.

Work emails only. We review each request — funded teams shipping browser agents get in first.

pip install zerodom · Apache 2.0 · no telemetry