By Chen Yu — building Operun, a local-first browser kernel. Written 2026-10-19.
TL;DR
Most browser-automation stacks treat “read the page” as a solved problem: dump the accessibility tree, hand it to the model, let the model pick a control. The failure mode that costs real days is not a wrong click. It is a snapshot that is quietly incomplete and looks complete. In Operun’s a11y snapshot we found three independent walls, each producing the same complaint from the operator (“the data is missing”) from a completely different root cause: a per-name hard clip at 50 characters, a token budget that truncated by break and said so only in a trailer, and a dispatch rule that never emitted nodes with no role at all. The fixes were not one constant: the name ceiling went 50 → 300 → 1,200 characters, the token budget went 4,000 → 40,000 tokens (roughly 120,000 characters of read surface), and the third wall could not be fixed by any constant — it needed a separate content-reading path. This post is the mechanism, the field evidence, and the design reasoning, including the reasons the numbers are ceilings rather than removals. Everything below comes from src/core/snapshot/scanner.ts and from instrumented runs against a live listing page; nothing here is a feature summary.
1. The read surface is a budget, not a dump
An a11y snapshot in Operun is produced by takeSnapshotInPage() and returned as { text, count } — one newline-terminated line per control, plus the number of controls reported, which is the unambiguous signal for “this page is genuinely empty” as opposed to “I found nothing I could name”. The function is injected into the page through chrome.scripting.executeScript, which imposes a discipline that shapes the whole file: no imports, no module scope, every helper nested inside the function. Anything closed over from module scope would simply not survive injection.
That constraint is also why the budget exists at all. Actionbook, the earlier kernel, could afford a 100,000-token ceiling because it wrote the full tree to a file and only the path traveled back. In Operun the text travels inline, in a STEP_RESULT frame over the cloud socket. So the token budget here bounds a message, not a file. That distinction is the entire design pressure: a message has to be one frame’s worth of payload, and the reader on the other end is a model deciding what to do next, not a human scrolling.
The walk itself is root.querySelectorAll('*') — every element, every time. The budget does not bound the walk; it bounds what leaves the page. That is worth stating plainly because it sets expectations: raising the ceiling tenfold did not make the scan cheaper, it made the answer complete. Cost moves to the message size, which is the cheaper of the two failures.
Three exclusions happen before a node can ever become a line, and they are worth knowing because they explain absences that look like bugs: <option> elements inside a native <select> (no action this kernel has can pick one, so a ref on an option would be a handle to nothing — the parent reports the currently chosen value instead); input[type=hidden] (excluded by what it is, not by how it is styled, since it computes to display: none anyway); and anything inside closest('[hidden], [aria-hidden="true"], [inert]'), which covers all three routes the platform offers for taking a subtree off the page.
2. Wall one: a name clipped at 50 characters
MAX_NAME_CHARS shipped at 50. Its only consumer was clip(), applied to the accessible name of each control. On a dense admin console that is invisible — button labels are short. On a recruiting or e-commerce listing it is catastrophic, because a listing card typically composes the whole card’s text into one accessible name: company, school, and self-description concatenated. The field evidence was blunt: 60 out of 60 cards on one BOSS listing had an accessible-name length of exactly 50, and the cut landed mid-word. Not “around 50” — precisely 50, minimum equal to maximum, because every card exceeded the cap and every card was clipped to it.
Why this is worse than a shorter answer: the name is how the agent decides which control a line is. A name that stops mid-sentence is a line the agent cannot match against what it is looking for, so it confidently reports “not present” about a card that is sitting on screen. A wrong answer that looks like a whole one is the most expensive failure class in this whole system.
The fix went in two steps, and the intermediate step is the interesting part. 50 → 300 was not enough: with the cap at 300 the observed distribution became {300: 32, 298: 1, 296: 1, …} — the mass of cards sitting exactly at the new ceiling, which is the signature of a clip that is still binding. Only at 1,200 did the distribution spread out naturally and the company name start surviving.
1,200 is a ceiling, not a removal, and that is deliberate. A page is free to put a 100 KB aria-label on a single element, and one element spending the budget that every other control draws from is exactly how a snapshot stops describing the page. The same trade is made explicitly nearby: MAX_VALUE_CHARS = 30, because a value is something you read once, not something you match on.
3. Wall two: the budget that truncated silently
MAX_TOKENS shipped at 4,000. The accounting loop walks the collected lines, estimates cost as Math.ceil(line.length / 3) — a character is roughly a third of a token, the same estimate the earlier kernel truncates by — and accumulates. When spent + cost > MAX_TOKENS it breaks, and appends exactly one trailer: … N more elements omitted.
The trailer is honest about how many and silent about which. That is the wall: the reader of a truncated a11y snapshot is an agent deciding what to do next, and it must know that what it holds is not the whole page — otherwise it concludes the page is short.
The original 4,000 was argued from “a dense admin page is a few hundred controls”. True of a console. False of a listing page that is thousands of cards and whose controls a workflow reaches by scrolling to the bottom. A ceiling that cuts that page is not a smaller answer, it is a blind one.
Diagnosing this wall has a trap worth documenting, because it produced a false accusation in our own notes. Two frames from the same drawer: frame A measured 12,175 characters ≈ 4,058 tokens > 4,000 → trailer present; frame B measured 7,713 characters ≈ 2,571 tokens < 4,000 → no trailer, and no reason to suspect the budget. If you see an absent field in frame B and blame token truncation, you are wrong by arithmetic. Always run the sum before blaming the wall.
The ceiling is now 40,000 tokens, which at ceil(len/3) is roughly 120,000 characters of read surface — about 120 KB of text, one WebSocket frame, comfortably inside what the transport carries without configuration, and still a bound. The arithmetic that matters when sizing a page for this budget is one line: cards × name ceiling ÷ 3 ≈ tokens, which must stay under the budget. Sixty cards at 1,200 characters is 72,000 characters ≈ 24,000 tokens — under 40,000, with room for the rest of the page.
4. Wall three: the nodes with no role at all
roleOf() returns one of three things, and the third is the wall. A declared role attribute is mapped through INTERACTIVE_ROLES; anything declared but not interactive (role="heading", role="presentation") returns null — a statement about interaction, not about content. Tags map directly: a[href] → link, button → button, select → combobox, and the input type fan-out covers checkbox, radio, the button-ish types, slider, spinbutton, searchbox, textbox. A <a> with no href returns the empty string. Everything else — a <div>, a <span>, a <section> — also returns the empty string, meaning no declared role.
That empty string is where plain-div UIs live. A page whose controls are <div>s with a delegated click listener has no roles anywhere, so the only way it becomes visible to an agent is pointerHint(): cursor: pointer, onclick, or tabindex. That is the inherited cursor-detection pass, and it is why some pages work beautifully and others return zero nodes despite a successful frame match.
The three-layer measurement from a BOSS detail drawer is the clearest illustration I have:
__TOP__ 28 → 36 nodes (the 8 new ones are the action bar)
iframe (frame A) 255 → 267 (growth is wall one's fix: longer names)
iframe[search] 160 → 160 (unchanged — checking only this layer
reports "the click did nothing")
iframe[src*=resume] → 0 nodes (the resume body lives here, plain text)
Three layers, three different diagnoses, and a fourth that simply has no roles to report. No constant fixes this. Raising MAX_NAME_CHARS to a million changes nothing about a paragraph of <div> text with no role, no cursor hint, and no tabindex. The dispatch is explicit about it: if (role === null) { pushContent(node, cache, lines); continue } — and since pushContent() opens with if (mode !== 'content') return, in interactive mode that branch is exactly if (role === null) continue: the node is visited, then dropped without a line. This is why the content-reading path is a separate mode rather than a bigger cap: mode === 'content' makes the walk call pushContent(), which emits @eN: [tag 'text'] for content tags with non-empty flattened text — guarded by if (text === '') return, because a block with no text is not a shorter answer, it is a line with nothing on it.
5. What changing a constant actually costs
Three engineering details separate “changed a number” from “shipped a fix”.
Build-time injection. The constants live in src/, and the snapshot is injected into the page at runtime from the built bundle. Editing src alone changes nothing until npm run build:operator runs and the unpacked extension is reloaded in chrome://extensions. There is no hot path: a stale service worker keeps serving the old background.js from memory, and a websocket reconnect does not reload code.
Never grep the built artifact for the number. Minification rewrites 40000 as 4e4 and 16000 as 16e3. Verifying a build by grepping for 40000 in background.js reports a false negative on a perfectly good build. Compare byte size and behavior, not literals.
The constant’s own name is redacted. MAX_TOKENS contains TOKEN, so reading that line through most tooling returns const MAX_TOKENS=***. I hit this on this very article: the line reads =*** and MAX_NAME_CHARS = 1200 right below it reads fine. The workaround is mechanical — index by line number and extract digits with a regex, never paste the whole line into a command and trust the echo.
One more design detail that shows the budget is not the only bound: the ref cache prunes itself. byRef is a Map, so it would pin every removed subtree for the life of the document; the walk deletes entries whose node is no longer isConnected. byNode is a WeakMap and releases on its own. A reparented row is re-registered from byNode on the next walk, so pruning costs nothing.
Conclusion
All three walls produce the same operator complaint — “the data is missing” — and each needs a different instrument. The checklist we now run before blaming the page:
- Count the controls first.
count === 0with a successful frame match is wall three, not a missing element. CheckpointerHintcoverage on that layer before touching any constant. - Measure name lengths, not names. If the distribution is
{cap: N, cap-2: 1, …}with the mass sitting exactly on the ceiling, the clip is still binding. A spread distribution means the cap is finally above the data. - Run the token arithmetic before blaming truncation.
sum(ceil(len/3))against the budget, per frame. An untruncated frame has no trailer — do not invent a cause for an absence it cannot explain. - Treat
… N more elements omittedas a page-level warning, not a footnote. It means the answer is incomplete and you do not know what is missing. - Check the layer you did not check.
iframe[search]stayed at 160 while the action bar grew and the resume frame went to zero nodes — one layer’s stability says nothing about the other three. - Reach for CSS only when perception genuinely cannot. Some things — a download control that is a bare
<div><svg><use>with no accessible name — are addressable only by structural selector. That is a fallback, not a strategy.
The general lesson is less about snapshots than about perception as an interface: a perception layer that fails by returning less is safe, and one that fails by returning a plausible subset is dangerous, because the agent downstream has no way to tell the difference. Every constant in that file is a decision about which of those two failures you are willing to ship.
About Operun
Operun is a local-first browser kernel: a cloud core on loopback plus an operator extension that drives your real, already-logged-in Chrome through CDP. Its a11y snapshot, token budget, and content mode are the perception layer an agent uses to decide what to click — which is why they are specified as constants with reasons attached rather than tuned per deployment. Local-first here is structural, not promotional: the browser session, the cookies, and the page text stay on your machine, and only the bounded snapshot crosses the socket.