---
title: Accessibility trees
summary: Browser accessibility trees down to the nodes an agent can act on.
canonical: https://docs.caveman.so/docs/proxy/compressors/accessibility-trees
layer: proxy
license: BSL-1.1
capability: engine
updated: 2026-09-16T21:32:40-07:00
basis: inferred
---

# 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}
```

```bash
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](/docs/proxy/tokens). 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:

```bash
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.

A node marked `ignored` is 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`, and `editable`.

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.

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.

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.

```bash
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:

```bash
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:

```bash
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.

**A snapshot that does not compress returns an error, not the raw tree**
When the engine has no recovery store, or the view is not smaller, `browser_snapshot` answers
`cave_browser_snapshot_uncompressed` and keeps the previous page's uid map. Dumping a raw AX tree into the agent's
context would be worse than not calling the tool, and wiping the uid map would make acting unpredictable.

## Recovery

The original AX payload is stored before the view is emitted, and the handle retrieves it byte for byte: see
[Recovery](/docs/proxy/recoverable).
