Skip to content
Cavemandocs
01Proxy/BSL-1.1

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:

json
{"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}
terminal
curl -O https://docs.caveman.so/examples/compressors/accessibility-trees/axtree.json
caveman-engine compress --type a11y < axtree.json 2> report.json

Stdout is the whole compressed view, six lines. This is all of it.

text
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"
json
{"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:

terminal
caveman-engine retrieve "$(python3 -c 'import json;print(json.load(open("report.json"))["recovery_handle"])')" \
| cmp - axtree.json

The 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.

  1. A node marked ignored is skipped and its children are walked at the same depth, so the wrapper disappears and its contents move up.

  2. 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, and editable.

  3. A uid is minted when the node has a positive backendDOMNodeId and 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 of button, checkbox, combobox, link, menuitem, menuitemcheckbox, menuitemradio, option, radio, searchbox, slider, spinbutton, switch, tab, textbox, treeitem; or when the node carries a true focusable or clickable property; 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. RootWebArea and WebArea never get one.

  4. A node is dropped from the output when its role is InlineTextBox, when it is an empty LabelText, or when it is a generic, presentational, or none with no name, no value, no state other than editable, and no true focusable or clickable property. Every other node is emitted and its children indent one level deeper.

  5. A final pass removes any StaticText node 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.

terminal
caveman-engine detect < axtree.json
text
json

The 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:

terminal
caveman-browse snapshot <url> [query]
caveman-browse act <uid> click
caveman-browse eval <expression>
caveman-browse recover <handle>
caveman-browse close

snapshot 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:

terminal
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
json
{"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.