Guide computer-use remote through Linux AT-SPI
Add a Linux-specific host computer-use skill, route Wayland/AT-SPI backends to it instead of macOS AX guidance, and include compact structural tree outlines in AX/UIA snapshot responses so agents can pick paths and semantic targets from the tool result.
Alessandro committed
May 23, 2026 at 19:25 UTC
97953db46ba0ff49bef1d0ea59e48a8a06c91bff
3 files changed
+167
plugins/_a0_connector/skills/host-computer-use-linux/SKILL.md
new
+86
@@ -0,0 +1,86 @@
1
+---
2
+name: host-computer-use-linux
3
+description: Backend-specific Linux guidance for `computer_use_remote`. Load after `status` or `start_session` reports backend_family `linux`, backend_id `wayland`, or AT-SPI features. Covers AT-SPI structural targeting, Wayland portal caveats, and screenshot verification.
4
+---
5
+
6
+# Host Computer Use - Linux
7
+
8
+Use this after `host-computer-use` when the connected A0 CLI reports the Linux/Wayland computer-use backend.
9
+
10
+Do not use this skill for macOS, Windows, Xpra, Docker, browser-only tasks, or the internal Agent Zero Desktop. If the backend is not Linux or does not advertise AT-SPI support, skip Linux structural actions and follow the generic host computer-use rules.
11
+
12
+## Linux AT-SPI Targeting
13
+
14
+Linux backends can advertise structural AT-SPI features:
15
+
16
+- `atspi-tree-snapshot`
17
+- `atspi-structural-targeting`
18
+- `atspi-element-action`
19
+- `atspi-set-value`
20
+
21
+When these features are present, prefer structural targeting over pixel clicks for named buttons, menu items, text fields, dialogs, toolbar items, tab strips, and application windows.
22
+
23
+Use `ax_snapshot` to inspect the Linux AT-SPI tree:
24
+
25
+```json
26
+{
27
+ "tool_name": "computer_use_remote",
28
+ "tool_args": {
29
+ "action": "ax_snapshot",
30
+ "max_depth": 4,
31
+ "max_nodes": 200
32
+ }
33
+}
34
+```
35
+
36
+The snapshot returns paths, roles, names/titles, descriptions, frames, states, actions, text previews, values, and child nodes. Use it to choose a target, not as final visual proof.
37
+
38
+Use `ax_action` for structural actions:
39
+
40
+```json
41
+{
42
+ "tool_name": "computer_use_remote",
43
+ "tool_args": {
44
+ "action": "ax_action",
45
+ "target": {
46
+ "role": "push button",
47
+ "title": "OK"
48
+ },
49
+ "operation": "press"
50
+ }
51
+}
52
+```
53
+
54
+Supported operations are:
55
+
56
+- `press`: activate a button, menu item, tab, checkbox, or similar action-bearing node
57
+- `focus`: focus a focusable node before typing or keyboard input
58
+- `set_value`: set text/value on editable nodes; pass `value` or `text`
59
+
60
+Targeting options:
61
+
62
+- Prefer a semantic `target` when a node has a stable role plus title/name/description/text/state/action.
63
+- Use a `path` returned by the latest `ax_snapshot` only while the UI is unchanged.
64
+- If an action reports ambiguity, take a fresh snapshot and narrow the target with role plus title/name/description.
65
+- If an action reports a missing target, take a fresh snapshot before trying coordinates.
66
+
67
+## Wayland Notes
68
+
69
+Use screenshots for proof after every state-changing action. AT-SPI actions and keyboard events are attempts, not proof, and Wayland focus can reject or redirect input when the active window changes.
70
+
71
+On GNOME/Wayland, useful shortcuts include:
72
+
73
+- `Super+H`: hide the active window
74
+- `Alt+Tab`: switch applications
75
+- `Ctrl+L`: focus a browser address bar when the browser is already focused
76
+- `Ctrl+T`: open a new browser tab when the browser is already focused
77
+
78
+Treat every shortcut as an attempt. Inspect the fresh screenshot before saying it worked. If text lands in the wrong app, stop and reassess from `capture` or `ax_snapshot`; do not continue typing from assumed focus.
79
+
80
+Some apps expose shallow AT-SPI trees unless their own accessibility support is enabled. If the AT-SPI tree is too shallow for a task, fall back in this order: app-native/browser tooling, reliable keyboard paths, then normalized coordinate clicks from a fresh screenshot.
81
+
82
+## Permissions
83
+
84
+If `computer_use_remote` returns `COMPUTER_USE_AX_UNAVAILABLE`, `COMPUTER_USE_REARM_REQUIRED`, `COMPUTER_USE_APPROVAL_REQUIRED`, or `status=rearm required`, stop immediately and ask the user to re-arm or fix the Linux desktop accessibility/session state.
85
+
86
+Do not bypass a permission or host-visibility failure with server screenshots, Docker commands, the built-in Linux Desktop/Xpra skill, or `code_execution_tool`.
plugins/_a0_connector/skills/host-computer-use/SKILL.md
+2
@@ -79,6 +79,7 @@ If any tool result contains `COMPUTER_USE_REARM_REQUIRED` or `status=rearm requi
79
80
## Backend Skills
81
82
+- If the backend is Linux/Wayland or features include `atspi-tree-snapshot` / `atspi-structural-targeting`, load `host-computer-use-linux` before using Linux AT-SPI structural actions.
83
- If the backend is macOS or features include `accessibility-tree-snapshot` / `accessibility-structural-targeting`, load `host-computer-use-macos` before using macOS structural Accessibility actions.
84
- If the backend is Windows or features include `uia-tree-snapshot` / `uia-structural-targeting`, load `host-computer-use-windows` before using Windows UI Automation structural actions.
85
- Do not use backend-specific actions just because their argument names exist in the generic contract. Treat them as unavailable unless the connected CLI advertises the matching feature.
@@ -88,6 +89,7 @@ If any tool result contains `COMPUTER_USE_REARM_REQUIRED` or `status=rearm requi
89
- Only the latest screenshot or a definitive tool result counts as evidence.
90
- If a tool result says a screenshot was attached but you cannot actually see the image, stop and report that visual verification is unavailable. Do not continue with another action from an assumed host state.
91
- Outside advertised structural accessibility support, use normalized global screen coordinates; do not assume window ids, element indexes, background-safe input, or semantic click targets unless the runtime explicitly advertises them.
92
+- On Linux, AT-SPI structural targeting uses backend-specific actions documented in `host-computer-use-linux`; do not apply macOS AX-specific assumptions unless the backend is macOS.
93
- Prefer accessibility and semantic UI paths first: shortcuts, command palettes, menu accelerators, address/search bars, focus traversal, and other keyboard-accessible controls.
94
- Prefer `key` and `type` over pointer actions whenever a reliable keyboard path exists.
95
- When a menu or popup is open, treat it as the active UI and prefer keyboard navigation over clicking small transient rows by coordinate.
plugins/_a0_connector/tools/computer_use_remote.py
+79
@@ -480,6 +480,20 @@ class ComputerUseRemote(Tool):
480
backend_id = str(data.get("backend_id", "") or "").strip().lower()
481
backend_family = str(data.get("backend_family", "") or "").strip().lower()
482
features = {feature.lower() for feature in self._backend_features(data)}
483
+ has_linux_atspi = bool(
484
+ features
485
+ & {
486
+ "atspi-tree-snapshot",
487
+ "atspi-structural-targeting",
488
+ "atspi-element-action",
489
+ "atspi-set-value",
490
+ }
491
+ )
492
+ if backend_id in {"wayland", "x11", "linux"} or backend_family == "linux" or has_linux_atspi:
493
+ return (
494
+ " Load skill `host-computer-use-linux` before using Linux AT-SPI "
495
+ "structural actions."
496
+ )
497
has_macos_ax = bool(
498
features
499
& {
@@ -548,6 +562,7 @@ class ComputerUseRemote(Tool):
562
return (
563
f"AX snapshot for {app_name}: {node_count} node(s){truncated}. "
564
f"Root {root_label}. Use path or semantic target fields with ax_action."
565
+ f"{self._structural_tree_outline(tree)}"
566
)
567
568
def _format_uia_snapshot(self, data: dict[str, Any]) -> str:
@@ -562,8 +577,72 @@ class ComputerUseRemote(Tool):
577
f"Root {root_label}. Prefer node actions with uia_action; use "
578
f"focus_window/minimize/restore/maximize for windows, and reserve click "
579
f"for a last resort."
580
+ f"{self._structural_tree_outline(tree)}"
581
)
582
583
+ def _structural_tree_outline(self, tree: dict[str, Any], *, max_lines: int = 80) -> str:
584
+ if not tree:
585
+ return ""
586
+ lines: list[str] = ["", "", "Nodes:"]
587
+ truncated = False
588
+
589
+ def visit(node: dict[str, Any], depth: int) -> None:
590
+ nonlocal truncated
591
+ if len(lines) - 3 >= max_lines:
592
+ truncated = True
593
+ return
594
+ lines.append(self._structural_node_line(node, depth=depth))
595
+ children = node.get("children")
596
+ if not isinstance(children, list):
597
+ return
598
+ for child in children:
599
+ if len(lines) - 3 >= max_lines:
600
+ truncated = True
601
+ break
602
+ if isinstance(child, dict):
603
+ visit(child, depth + 1)
604
+
605
+ visit(tree, 0)
606
+ if truncated:
607
+ lines.append("... outline truncated; request a narrower max_depth/max_nodes snapshot if needed.")
608
+ return "\n".join(lines)
609
+
610
+ def _structural_node_line(self, node: dict[str, Any], *, depth: int) -> str:
611
+ indent = " " * max(0, depth)
612
+ role = str(node.get("role") or "element")
613
+ path = node.get("path", [])
614
+ parts = [f"{indent}- path={path} role={role}"]
615
+ for key in ("title", "name", "description", "automation_id", "class_name", "selector"):
616
+ value = node.get(key)
617
+ if isinstance(value, str) and value.strip():
618
+ parts.append(f"{key}={value.strip()[:120]!r}")
619
+ break
620
+ frame = node.get("frame")
621
+ if isinstance(frame, dict):
622
+ x = frame.get("x", "?")
623
+ y = frame.get("y", "?")
624
+ width = frame.get("width", "?")
625
+ height = frame.get("height", "?")
626
+ parts.append(f"frame=({x},{y} {width}x{height})")
627
+ actions = node.get("actions")
628
+ if isinstance(actions, list) and actions:
629
+ names = [
630
+ str(item.get("name") or "").strip()
631
+ for item in actions
632
+ if isinstance(item, dict) and str(item.get("name") or "").strip()
633
+ ]
634
+ if names:
635
+ parts.append(f"actions={','.join(names[:6])}")
636
+ states = node.get("states")
637
+ if isinstance(states, list) and states:
638
+ values = [str(item).strip() for item in states if str(item).strip()]
639
+ if values:
640
+ parts.append(f"states={','.join(values[:8])}")
641
+ text = node.get("text")
642
+ if isinstance(text, str) and text.strip():
643
+ parts.append(f"text={text.strip()[:120]!r}")
644
+ return " ".join(parts)
645
+
646
def _ax_target_label(self, target: dict[str, Any]) -> str:
647
role = str(target.get("role") or "element")
648
title = str(target.get("title") or target.get("description") or target.get("identifier") or "").strip()