Desktop Agents Without Pixels
- Understand what an accessibility-tree agent reads, and how it differs from a screenshot agent at the protocol level
- Learn the retry rule that most computer-use harnesses get wrong — and why 'did it fail?' is the wrong question
- See why refs are scoped to a snapshot, and what a harness should do instead of guessing when one goes stale
- Know the three-state property model that stops an agent acting on a failed read
- Spot the two failure modes this approach does NOT fix: truncated trees and apps with no accessibility surface
- Understand the security surface: everything on screen becomes agent input
On 28 October 2026 Vercel Labs tagged v1.0.0 of agent-desktop, and by 31 October it was at the top of GitHub's trending list. It is a Rust CLI and C library, Apache-2.0, that gives an agent computer use on macOS, Windows and Linux by reading the operating system's accessibility tree — the same structure a screen reader consumes: AX API on macOS, UI Automation on Windows, AT-SPI on Linux. Vercel's own framing is "agent-browser, for your whole computer."
Every write-up so far stops at the headline: trees instead of pixels, so clicks land where they should. That is true and it is the least interesting half.
The interesting half is that the repository ships a CONCEPTS.md which is, in effect, a specification for how a computer-use harness should handle uncertainty — and it answers questions that the mainstream computer-use APIs leave entirely to you. If you build agent loops, read this page for the model even if you never install the tool. Our computer-use agents compared page covers the screenshot-based APIs from Anthropic, OpenAI and Google; this is the other architecture.
What changes when you read the tree instead of the screen
A screenshot agent gets an image and returns coordinates. The contract is: here are some pixels, tell me where to click. Everything the agent knows about the UI it has to infer visually, and every action is a bet on a coordinate.
A tree agent gets structure. A snapshot returns JSON with a ref for every element the agent can act on, and actions name a ref rather than a point.
- Snapshot the accessibility tree of a window, app or surface. You get back JSON — roles, names, states — with a ref per actionable node, and a snapshot ID that the refs belong to.
- Before the action fires, the ref is re-matched against the CURRENT live tree using identity evidence stored at snapshot time. This step is the whole ballgame and is covered below.
- Dispatch under an interaction lease — machine-wide exclusivity over desktop input, held by one process at a time, so two callers cannot interleave synthetic input into each other's actions.
- Re-read the element to confirm the change actually reached it. A mutation is only 'delivered' once a re-read says so.
The practical differences that follow from this are larger than they look:
- Refs survive layout changes that break coordinates. A button that moves 200 px down the window is the same node in the tree. For a screenshot agent it is a new bet.
- It is headless by default. Input goes through the accessibility API without moving the system pointer or stealing focus, so the agent can drive apps while you keep using the machine. A
--headedmode exists for physical input synthesis when an app ignores programmatic input. This is a genuinely different operating model from screenshot agents, which fight you for the cursor. - Snapshots are text, not images. That matters for your bill and your context window — see why agents burn tokens. But it is not a free win, because dense trees get large; the repo's own answer is to map coarsely first (
--skeleton --compact) and then drill into a region with--root. - You still need screenshots sometimes. The tool ships
screenshotalongsidesnapshot. Canvas-rendered apps, custom-drawn UIs and games have little or no accessibility surface, and no amount of tree-reading invents one.
The minimal observe / act / verify loop
npm install -g agent-desktop agent-desktop version # macOS only: accessibility permission must be granted first agent-desktop permissions --guide # 1. observe — -i limits output to interactive elements agent-desktop snapshot --app Finder -i # 2. act — the ref MUST carry its snapshot ID; a bare @e8 is rejected agent-desktop click @s8f3k2p9:e8 # 3. verify — re-snapshot and confirm the change landed agent-desktop snapshot --app Finder -i # dense UI? map coarsely, then drill into one region agent-desktop snapshot --app Slack --skeleton --compact agent-desktop snapshot --root @s8f3k2p9:e42
Output is JSON with an ok boolean and either data or an error carrying a code, a disposition and a suggestion. The commands group into observe (snapshot, find, get, screenshot, list-surfaces), act-on-ref (click, type, set-value, scroll, toggle, expand), raw keyboard and mouse (press, hover, drag, key-down), window and app management (launch, focus-window, resize-window, list-apps), and system (permissions, status, batch, skills).
The idea worth stealing: delivered, not successful
Here is the rule, and it is the single most useful thing in the repository:
The distinction that matters is not success versus failure. It is delivered versus not delivered.
Think about what an agent does when a type command returns an error. The instinctive harness behaviour — retry on error — is actively dangerous, because "error" conflates two completely different situations:
- The keystrokes never reached the field. Retrying is free.
- The keystrokes may have landed and the confirmation read failed. Retrying types everything twice.
A harness that cannot tell these apart will silently double-submit forms, double-click Send, and duplicate rows. So agent-desktop classifies every mutation into three delivery states instead of a boolean:
| Delivery state | Meaning | Retry? |
|---|---|---|
| Not delivered | Argument error, access denial, or a read-only command — the action demonstrably never reached the target | Safe. Retry freely |
| Delivered unverified | Input was posted but the effect is unconfirmed — the typical outcome of physical input synthesis | Unsafe. Re-reading state is the only correct move |
| Delivered | Effect confirmed by re-reading the element | Done; nothing to retry |
Reads get a parallel four-way classification, and the first one is the subtle one:
- Settled absence — the provider was asked and the target genuinely lacks that property or pattern. A final answer, never retried.
- Retryable failure — a transport fault such as a timeout or dropped connection. Worth repeating within a budget.
- Unavailable element — it disappeared. Settles as a stale ref if the element had been resolved; retryable if hit mid-walk.
- Terminal failure — a denial or an unclassified code. Final, surfaced as-is.
The tool exposes this directly: you retry only when error.disposition.retry says safe.
- If you are writing your own computer-use loop today, port this one idea even if you port nothing else. Classify every action by whether it could have landed, and make 'may have landed' a distinct branch that re-reads state instead of retrying. Idempotency is not a property of your retry code — it is a property of each individual action, and only the tool layer knows which is which.
Why refs are scoped to a snapshot
In agent-desktop a ref looks like @e8, but you may never use it bare — a bare ref fails with INVALID_ARGS. It must be qualified by its snapshot ID: @s8f3k2p9:e8.
That looks like friction. It is a deliberate type system. The stated reason: refs are deterministic inside one snapshot but are not stable across UI changes. The snapshot ID is a namespace, and every lookup is confined to the namespace it was created in — a snapshot taken under a session needs that same session scope later.
So the awkward syntax makes a real category error unrepresentable. You cannot accidentally carry an identifier from an observation three actions ago and have it silently resolve to whatever now occupies that slot. Compare the screenshot world, where a coordinate from a stale screenshot is syntactically indistinguishable from a fresh one and resolves to something every time.
Fail-closed resolution: refusing to guess
When the action fires, the ref is resolved against the live tree through a graded sequence, and the ordering encodes a judgement about which evidence is trustworthy:
- Follow the path recorded at snapshot time down the current tree.
- Check the candidate against what was recorded: native ID first, then role-conditional stable text. Position gets you to a candidate; identity decides whether it is the right one.
- If the path no longer exists, search more widely — but with a bound, not indefinitely.
- Resolve by unique geometry as a last resort, and only when the ref has NO meaningful identity to match on. On Windows and Linux the stored bounds must be a positive-area rectangle — a zero-extent stored hash never promotes to a geometric match.
The design commitment is in what happens when none of that produces a confident match: the resolution fails closed. Missing, stale and ambiguous matches are rejected — STALE_REF, AMBIGUOUS_TARGET — rather than resolved to a best guess. On macOS the implementation retains the native accessibility objects specifically so an element cannot be silently misidentified when tree ordering shifts underneath it.
This is the opposite of how a pixel agent degrades. A stale coordinate always hits something; a stale ref returns an error. Trading a silent wrong action for a loud failure is the entire reliability claim, and it is the right trade for anything an agent does unsupervised.
- The same reasoning shows up in the repo's two-sided treatment of partial results: if a snapshot runs out of its time budget mid-walk, it returns what it observed rather than discarding the walk — but it reports completeness per node. A harness that ignores that field will confidently reason over a truncated tree and conclude a button does not exist. Check completeness before you conclude absence.
Tri-state properties: Absent, Unknown, Known
A smaller mechanism with outsized consequences. Properties are never collapsed to a boolean. Three states stay distinct:
- Known — a value was read.
- Absent — the provider was asked and genuinely has no value.
- Unknown — the read itself failed.
Most harnesses flatten Unknown into false or null, and an agent then acts on a failed read as if it were a negative result: the checkbox "isn't checked" (we could not read it), the field "is empty" (the query errored). The repo's phrasing is that uncertainty must travel — when a higher-priority evidence source fails, that failure is carried forward rather than smoothed away. Accessible names are computed in the core from ranked slots rather than in each platform adapter, so shared logic never sees a platform-specific token and the same uncertainty rules apply everywhere.
If you take two things from this page, take delivery semantics and this. They are both cases of the same discipline: do not let a failure to know masquerade as knowledge.
Two limits worth naming
The interaction lease covers dispatch only. Input exclusivity is machine-wide and held by one process at a time, but the lease deliberately does not cover the waiting that precedes an action, because waiting for an element to become actionable can run long. The practical consequence: an element can become actionable and then change again between your wait and your dispatch. That is exactly why the verify step is not optional.
Platform support is not uniform, and Linux is the ragged edge. Accessibility tree, clicking and typing work across all three OSes, but on Linux clicking and typing run through AT-SPI with X11/GNOME caveats, screenshots need X11 or a Wayland portal, the clipboard needs wl-clipboard under Wayland, app management is launch-and-list only, and notifications are GNOME Shell. macOS needs accessibility permission granted explicitly, plus a TMPDIR that is user-owned with mode 0700. Windows needs no special permission at matching terminal integrity level. Of roughly 60 commands, not all are operational on every platform — check agent-desktop status on the machine you actually plan to run on.
The security surface is the whole screen
This needs stating plainly, because the convenience is seductive.
An accessibility-tree agent reads the text content of applications, which is a strictly richer capture than a screenshot of the focused window — including, potentially, content that is on screen but not what you were looking at. The repo is explicit that snapshots, screenshots and clipboard contents can contain sensitive data and should be kept out of public logs. It does build in one real mitigation: secure fields. Once a platform marks an element secret, the read path withholds both its value and related text evidence, and the action path may write into it but never echoes the attempted or observed value.
That protects passwords. It does not protect the open email, the Slack DM, or the customer record in the next window. Two consequences:
- Transcripts become sensitive artifacts. Agent logs from a desktop session are closer to a screen recording than to a terminal history. Treat them accordingly — see what your agent uploads.
- Every readable surface is an injection surface. Any text an app displays becomes agent input, so a crafted calendar invite, filename or chat message is a prompt-injection vector with no network request involved. The same reasoning as the web your agent reads applies to the desktop, and prompt injection is the primer. An agent with machine-wide input exclusivity and no pointer movement is also, by construction, one whose actions you will not see happening.
- Headless-by-default is a usability feature and a supervision hazard at the same time. Because the agent does not move your pointer or steal focus, there is no ambient signal that it is acting. If you run this unattended, the compensating control has to be the log and a scoped-down target app — not your attention. Our hardening-autonomous-runs page covers the general pattern.
When this architecture is the right choice
| Situation | Reach for |
|---|---|
| Native desktop app with real accessibility support (Finder, Office, most Cocoa/WinUI apps) | Tree agent. Stable refs, cheap text snapshots, no coordinate math |
| Web app in a browser | A browser-specific harness first — the DOM is a better tree than the browser's AX projection. See computer-use agents compared and shared-login browsers |
| Canvas/WebGL app, game, custom-drawn UI, remote desktop | Screenshot agent. There is no accessibility surface to read |
| Agent must run while you keep working | Tree agent, headless mode — no focus stealing, no pointer fight |
| Unsupervised, repeated, mutating actions | Tree agent, and honour the delivery semantics. This is the case the whole design exists for |
| Visual verification ("does this look right?") | Screenshot, regardless of harness. A tree cannot tell you the layout is broken |
Check yourself
0/5- Accessibility trees replace a coordinate bet with a named reference, so layout changes that break a screenshot agent are a non-event — but canvas, games and custom-drawn UIs have no tree to read.
- Steal the delivery model: classify actions by whether they COULD have landed, not by whether they returned an error. 'May have landed' deserves a re-read, never a retry.
- Scoping refs to a snapshot is a type system, not friction — it makes stale-identifier bugs impossible to express rather than silently wrong.
- Fail-closed resolution is the reliability claim: a stale coordinate always hits something, a stale ref returns STALE_REF. Prefer the loud failure for anything unsupervised.
- Keep Unknown distinct from Absent. An agent that reads a failed query as a negative result will act confidently on nothing.
- Headless operation removes the ambient signal that an agent is working. If you run it unattended, logs and a scoped target app are the control — not your attention.
Sources & further reading
- vercel-labs/agent-desktop — the project: README, install and platform-support matrix, command groups, JSON output shape, the
--skeleton/--compact/--rootflags, licence (Apache-2.0) and the v1.0.0–v1.0.2 release history - agent-desktop — CONCEPTS.md — the source for everything in the second half of this page: ref scoping, graded fail-closed resolution, the four read outcomes, delivery semantics, tri-state evidence, the interaction lease, secure fields and partial observation
- agent-desktop — docs/faq.md and docs/json-output.md — error codes and the machine-readable output contract
- Apple — Accessibility API (NSAccessibility) · Microsoft — UI Automation · GNOME — AT-SPI2 — the three platform trees underneath
- Related on AILmanac: Computer-use agents compared — the screenshot-based APIs from Anthropic, OpenAI and Google, and the coordinate bugs this architecture sidesteps
- Related on AILmanac: Shared-login browsers for AI agents — the browser variant, where the agent inherits your session by design
- Related on AILmanac: Prompt injection — the primer that now applies to every window on your desktop
- Related on AILmanac: What your agent uploads — why desktop agent transcripts should be treated like screen recordings
Next
- Computer-use agents compared — the other architecture, and how the three big providers' action spaces diverge
- Shared-login browsers for AI agents — the browser case, where the security posture inverts
- Hardening autonomous runs — the controls to put around an agent that acts without an ambient signal
- Agent runtimes: isolates vs containers — where this loop should actually execute