Accessibility trees
Browser accessibility trees down to the nodes an agent can act on.
The accessibility-tree compressor turns a Chrome Accessibility.getFullAXTree payload into indented
[uid] role "name" lines. It keeps every node an agent can act on, gives each one a short handle, and drops the
wrappers, the layout text fragments, and the per-node source metadata that make up most of a raw AX tree. The
handle-to-node map travels with the recovery record, so an action taken against a uid still lands on the right
DOM node.
It is never picked by detection. A caller names the type, and caveman-browse is the caller that does.
Before and after#
The fixture is a 22 node AX tree captured from headless Chrome, 18,200 bytes of JSON. One node looks like this:
{"nodeId": "3", "ignored": true, "ignoredReasons": [{"name": "uninteresting", "value": {"type": "boolean", "value": true}}], "role": {"type": "role", "value": "none"}, "chromeRole": {"type": "internalRole", "value": 0}, "parentId": "2", "childIds": ["7"], "backendDOMNodeId": 3}curl -O https://docs.caveman.so/examples/compressors/accessibility-trees/axtree.json
caveman-engine compress --type a11y < axtree.json 2> report.jsonStdout is the whole compressed view, six lines. This is all of it.
page "Caveman AX Fixture" {focused}
main
[ua] button "Save settings"
[u1] textbox "Email address" = "ops@example.com" {editable}
heading "Operations Overview"
[uf] link "View trace detail"{"content_type":"a11y","tokens_before":5351,"tokens_after":58,"ratio":0.9891609…,
"basis":"inferred","recovery_handle":"ccr_ea17…","method":"a11y","lossless_to_model":false}Captured from a caveman-engine build dated 2026-08-24; caveman setup --install today pins bin-v1.1.7.
5,351 tokens to 58, counted by the offline o200k_base counter described on
Token counting. The handle and the trailing digits of the ratio are trimmed with … here,
since both vary per run.
The button, the textbox, and the link carry uids because they can be acted on. page, main, and heading are
containers, so they render without one. Passing the report's handle to retrieve gives back all
18,200 bytes:
caveman-engine retrieve "$(python3 -c 'import json;print(json.load(open("report.json"))["recovery_handle"])')" \
| cmp - axtree.jsonThe command prints nothing, which is cmp saying the files are identical.
How it works#
The payload parses as either a bare array of nodes or a {"nodes": [...]} envelope. A duplicate nodeId, a
missing nodeId, a non-ignored node with an empty role, or zero non-ignored nodes all make it decline. A
childId pointing at a node that is not in this payload is fine: getFullAXTree returns one frame at a time, so
an iframe legitimately points into another frame's response, and that child is treated as a leaf.
The tree is then walked depth first from every node that nothing else lists as a child, then from any node the walk did not reach.
A node marked
ignoredis skipped and its children are walked at the same depth, so the wrapper disappears and its contents move up.Each surviving node is normalised to depth, role, name, value, and state. State is read from the node's properties and is limited to
disabled,checked,expanded,selected,focused, andeditable.A uid is minted when the node has a positive
backendDOMNodeIdand its role is actionable. The uid is"u" + base36(backendDOMNodeId), which is why the fixture's node 10 renders as[ua]. The role is actionable when it is one ofbutton,checkbox,combobox,link,menuitem,menuitemcheckbox,menuitemradio,option,radio,searchbox,slider,spinbutton,switch,tab,textbox,treeitem; or when the node carries a truefocusableorclickableproperty; or when its role is not on the known structural list at all, because an unknown role is more likely a custom control than a container.RootWebAreaandWebAreanever get one.A node is dropped from the output when its role is
InlineTextBox, when it is an emptyLabelText, or when it is ageneric,presentational, ornonewith no name, no value, no state other thaneditable, and no truefocusableorclickableproperty. Every other node is emitted and its children indent one level deeper.A final pass removes any
StaticTextnode whose name, lowercased and trimmed, already appears as the name or value of a node with a real role. That is what stops a button's label from being printed twice.
Rendering is one line per record: two spaces per depth level, then [uid] when there is one, then the role,
then the name in Go-quoted form, then = and the value when the value differs from the name, then the state
labels in {a,b}. Three roles are renamed for length: RootWebArea and WebArea become page, StaticText
becomes text, and LabelText becomes label. Every other role is lowercased, so Chrome's ListItem prints as
listitem and a custom MyWidget prints as mywidget.
Two of the six state flags print when they are false as well as when they are true: a false checked renders
unchecked and a false expanded renders collapsed, so a closed disclosure reads as closed instead of reading
as a node with no state at all. disabled, selected, focused and editable print only when true.
With a query#
When the caller passes a query, the records are filtered before rendering. Each record is scored by how many query
terms appear in its role, name, value, and state text. Up to 12 records are kept, highest score first, and when
one record covers every term the lower partial matches are left out. Every kept record pulls in its ancestor chain
so the indentation still reads as a tree, and a match inside a row or listitem pulls in that whole item so the
sibling cells come with it. A query that matches nothing returns the root plus one line,
note "no accessible match".
What is always kept#
Every actionable node and its uid. The role, name, and value of every node that survives the drop rules. The six
state flags when the node carries them. The nesting order, expressed as indentation. And, in the recovery record's
metadata, the map from every visible uid to its backendDOMNodeId, frameId, and nodeId, so acting on a uid
resolves to the same DOM node the snapshot saw.
When it is chosen#
Detection returns json for an AX tree. The engine's own test suite asserts that, so the compressor stays
reachable only by naming the type and ordinary JSON keeps going to the JSON compressor.
caveman-engine detect < axtree.jsonjsonThe type is named by caveman-engine compress --type a11y and by caveman-browse, which calls
Compress(raw, Options{Type: "a11y", Query: …}) on every snapshot.
It declines, and the bytes go through unchanged, on invalid JSON, on a tree that fails the node checks, when curation left no records, when the view is no smaller than the input, and when no recovery store is available.
Options#
The only option is the query, and the engine CLI has no flag for it. caveman-browse is the caller that passes
one.
How caveman-browse feeds it#
caveman-browse is a local MCP server that attaches to Chrome, reads Accessibility.getFullAXTree, and sends the
raw payload straight to this compressor. It exposes browser_snapshot, browser_act, browser_eval, and
browser_recover. There is also a direct CLI:
caveman-browse snapshot <url> [query]
caveman-browse act <uid> click
caveman-browse eval <expression>
caveman-browse recover <handle>
caveman-browse closesnapshot prints one JSON object, the same object the browser_snapshot tool hands an agent. Run against a page
held in a data URL, with the recovery store kept in memory:
CAVEMAN_BROWSE_EPHEMERAL=1 caveman-browse snapshot 'data:text/html,<title>Caveman AX Fixture</title><main><h1>Operations Overview</h1><button>Save settings</button><label for=e>Email address</label><input id=e value=ops@example.com><a href=/trace>View trace detail</a></main>'
caveman-browse close{"uids":"page \"Caveman AX Fixture\"\n main\n heading \"Operations Overview\"\n [u9] button \"Save settings\"\n [u2] textbox \"Email address\" = \"ops@example.com\" {editable}\n [uc] link \"View trace detail\"","recovery_handle":"ccr_98ad…","tokens_before":2637,"view_tokens":56,"tokens_after":123,"ratio":0.9533560864618885,"basis":"inferred"}Captured from caveman-browse built from the current browse/ source. uids is the compressed view as one
string, with \n between its lines. tokens_before counts the raw AX payload. view_tokens counts the tree
lines alone, and tokens_after counts the whole JSON object above, which is what the agent actually reads and
what ratio is computed from. A uid is base36 of Chrome's backend DOM node
id, so the digits differ from one navigation to the next; the handle is trimmed with … here for the same reason.
The uid map is what makes browser_act work: caveman-browse reads it back from the snapshot's recovery
metadata and uses it to resolve [u9] to a backend DOM node. browser_recover returns the byte-exact original AX
payload for a snapshot handle.
Recovery#
The original AX payload is stored before the view is emitted, and the handle retrieves it byte for byte: see Recovery.