Automation MCP — Drive a Teksilo App from an Agent
A Teksilo app exposes a rich semantic surface — the AccessKit accessibility
tree — plus an AT-action dispatch path, both of which are queryable and
drivable in-process, without the OS accessibility layer. The
teksilo-automation-mcp server turns that latent capability into a
Model Context Protocol (MCP) server so an
AI agent (or any MCP client) can observe (semantic tree + screenshots) and
drive (AT actions + synthetic input) a Teksilo app.
This is the capability an agent can't get otherwise: the TreeUpdate lives
inside private platform state with no external channel except the OS AT layer;
the AT-action channel is OS-AT-only; headless operation needs no display
server; and a WidgetId-derived node id (stable across in-place changes —
see the tool surface) is steadier to cache than a fragile OS handle. It
complements (does not replace) a real screen-reader OS smoke test — the
live --connect mode (debug builds) drives your actual app, while the headless
mode is the toolkit's CI harness / a build-your-own-harness kit (see below).
Two modes
| Mode | Command | What it drives |
|---|---|---|
| Headless (default) | teksilo-automation-mcp --headless | A built-in demo app owned entirely in-process on a dedicated thread. No display, GPU daemon, or AT layer needed. The right mode for CI and agent test-authoring. |
| Live (connect) | teksilo-automation-mcp --connect <sock> --token <uuid> | A running app that opted into the debug-only in-app bridge. The agent drives the real window the user sees. |
Both speak MCP over stdio.
Headless
teksilo-automation-mcp --headless
A dedicated std::thread owns a HeadlessApp and the async rmcp handlers
marshal Send DTOs to it; the !Send WidgetTree never leaves that thread.
Screenshots render offscreen on the tree thread via pollster::block_on
(reusing teksilo_render::test_support::create_test_renderer — the same
offscreen path the widget previewer's PNG export uses).
What the stock binary drives. teksilo-automation-mcp --headless builds a
small built-in demo (a heading, two buttons, a text field, a checkbox) — it
is the toolkit's own conformance harness and a worked reference, not your
app. To headlessly automate your app there are two paths:
- Build a tiny harness with the GUI-free
teksilo-automationcrate: own your app'sWidgetTreeon one thread (or reuse a headless test tree) and callteksilo_automation::execute(&mut tree, &mut ops, &op, &settle)per request.executeworks against anyWidgetTree, so this is ~a screenful of glue — but it is a kit, not a turnkey "point it at my app" binary. - Use the live
--connectmode below — the turnkey "drive my real app" path today (needs a display and a running debug build).
Live (connect)
A debug build opts in with one line:
#![allow(unused)] fn main() { use teksilo::prelude::*; TeksiloAppBuilder::new() .theme(intui::light()) .install_automation_bridge_in_debug() // debug-only; a no-op in release .initial_window(/* ... */) .run(); }
Once the socket is bound and listening it prints its path and the token to stderr — the announcement follows the bind, so the path is connectable the instant it appears and a client needs no wait-for-socket loop:
teksilo-automation: bridge socket = /run/user/1000/teksilo-automation-12345/sock
TEKSILO_AUTOMATION_TOKEN=8f3c…
teksilo-automation: connect with `teksilo-automation-mcp --connect /run/user/1000/teksilo-automation-12345/sock --token 8f3c…`
Then point the server at it:
teksilo-automation-mcp --connect /run/user/1000/teksilo-automation-12345/sock --token 8f3c…
Each rmcp tool handler writes the op to the Unix socket and reads one reply;
the in-app bridge thread reads the socket and posts an AutomationPayload
(carrying a Send reply channel) through the existing AppEvent::External
path, and the winit main thread runs the op against the real window — the
settle runs synchronously on the main thread, never across a frame boundary.
Install
Add the automation feature to the umbrella crate (debug-only by design):
[dependencies]
teksilo = { version = "0.6", features = ["automation"] }
install_automation_bridge_in_debug() is gated on debug_assertions: a
release build with the feature on still contains no socket, token, or
bridge — the method is the identity. The GUI-free DTO toolkit is available as
teksilo::automation for writing harnesses against the same protocol.
The server binary builds from cargo build -p teksilo-automation-mcp.
Tool surface (27 tools)
Mutating tools accept an optional settle argument (see the settle model
below); query tools don't. Node ids come from snapshot_tree / find_node
and are the raw AccessKit NodeId values — derived deterministically from the
widget id. An id is stable for the lifetime of the widget instance (across
relayout, repaint, theme, and locale changes, which mutate widgets in place),
but a structural rebuild that destroys and recreates the widget — a
data-model change, a Switcher swap, a Rebuild-level binding — allocates a
new id. So caching an id is the payoff over an OS handle for in-place
changes; after a structural change, re-find_node (by role/label, which
carries the usual label fragility) rather than reuse a possibly-stale id.
Query — snapshot_tree, read_node, find_node, assert_node,
list_windows
Layout / geometry — layout_tree, inspect_node
Drive (AT actions) — invoke_action, focus_node, set_value,
expand, collapse, scroll
scroll takes ctrl / shift / alt / meta alongside dx / dy, all
defaulting to false. A modifier-held wheel is a different gesture from a
plain one — WidgetEvent::Scroll carries modifiers precisely so an app can
implement Ctrl-wheel-to-zoom — so a probe for such a feature must be able to
send one, not merely a bare wheel.
Synthetic input — inject_pointer, right_click, inject_key,
type_text, type_ime, drag_node
Introspection — get_overlays, get_shortcuts, list_live_regions,
pull_announcements
Time / settle — advance_clock, settle, wait_for_condition
Visual — screenshot (returns an MCP image content block)
snapshot_tree returns { root, focus, nodes: [SemanticNode…] }, where each
SemanticNode carries id, role, label, value, toggled, expanded,
selected, disabled, focused, live, numeric_value, bounds,
actions, and children.
layout_tree and inspect_node expose the full widget/layout (arena)
tree — the same data the debug inspector's Tree + Properties tabs show, and
strictly richer than the accessibility snapshot: it includes widgets the AT
tree prunes (layout primitives like HStack/Padding/Spacer, dormant
Switcher branches, presentational / access_exclude widgets), so an agent
can debug layout (overlap, clipping, off-screen, wrong size) — not just
semantics. Each LayoutNode carries id, type (the concrete Rust type
name), bounds, active, clips_children, parent, children, and — when
requested (include_debug, or always for inspect_node) — debug, the
widget's Debug repr (its constructor parameters). Layout nodes are keyed by
the same node-id space as the AT tools, so when a widget appears in both,
the two records share an id and can be correlated. (Coordinates are logical
window-relative pixels, identical to the AT bounds.)
Right-click & context menus
Teksilo context menus are attached with the .context_menu(factory) builder
and open on a Secondary PointerDown — a real right-click. To open one from
automation, use the node-based right_click tool:
// 1. Find the row / cell / item you want the menu for.
find_node { "role": "Row", "label": "report.pdf" } // → { "node": 123 }
// 2. Right-click it — injects a secondary press+release at its centre.
right_click { "node": 123 }
// 3. Read the menu that opened, then pick an item.
get_overlays // → { "count": 1, … }
find_node { "role": "MenuItem", "label": "Rename" } // → { "node": 456 }
invoke_action { "node": 456, "action": "click" }
Equivalent alternatives, in order of preference:
right_click(node)— the clearest verb; no coordinate math. Preferred.invoke_action(node, "show_context_menu")— theShowContextMenuAT action. It routes to the same.context_menu(..)factory (unless the widget wires its ownShowContextMenuhandler, which then wins). This is exactly what a screen reader's "show context menu" does.inject_pointer(x, y, button="secondary")— the low-level path. Only reach for it when you need a menu at a specific point rather than a node's centre; you must supply coordinates yourself (from a node'sbounds).
After any of these, settle runs automatically, so the very next
snapshot_tree / get_overlays / find_node sees the mounted menu. Dismiss it
with inject_key { "key": "escape" } or by clicking elsewhere.
Multi-window routing
Every tool accepts an optional window_id (the TeksiloWindowId raw value
from list_windows). window_id: None resolves to the focused window, else
the primary. Headless is single-tree, so routing is always unambiguous there.
The settle model
After a mutating op, the executor settles the tree so the next snapshot
reflects the change, then re-syncs the AT tree. The settle argument (all
fields optional) is:
| Field | Default | Meaning |
|---|---|---|
clock_millis | 0 | Advance the simulation clock first (drives tooltip / overlay timers). |
max_anim_frames | 60 | Cap on 16 ms animation ticks (~1 s). A perpetually-looping animation hits the cap — expected. |
layout_after | true | Run a layout pass after ticking, so height-for-width / reflow settles before the AT re-walk. |
settle_timeout_ms | 500 | Hard wall-clock budget; exceeding it ends the settle (the live bridge reports SETTLE_TIMEOUT). |
The settle loop is simulation-clock-driven (tick_animations doesn't wait
on VSync or OS events), so it can't deadlock — it progresses to quiescence or
the cap. wait_for_condition polls snapshot → predicate on the same clock
until a NodeExists / NodeValue / NodeGone / AtVersionAtLeast condition
holds or settle_timeout_ms elapses (WAIT_TIMEOUT).
Live regions & announcements
Teksilo has no OS AT layer in headless mode, and no in-process way to observe
what the platform spoke. So the WidgetTree diffs the live (Live::Polite /
Live::Assertive) nodes of each freshly-built TreeUpdate and records the
changes into a ring buffer. pull_announcements { since_seq } drains it — a
faithful, in-process model of the live-region stream. list_live_regions
reports the live nodes themselves.
Screenshots
screenshot renders the window (or, with node, that node's bounds) to a PNG
and returns it as an MCP image content block.
- Headless: an offscreen
RENDER_ATTACHMENT | COPY_SRCtexture, rendered via the test renderer, read back, PNG-encoded. If no GPU backend is present (CI without a GPU), the tool returnsGPU_UNAVAILABLE(non-fatal). - Live:
PlatformWindow::capture_offscreenrenders the live frame into an offscreen texture in the window's own surface format (the swapchain texture lacksCOPY_SRC), swizzling BGRA→RGBA as needed; the bridge base64-encodes the PNG over the socket and the--connectclient rehydrates it to an image block.
WebView blind spot: a native WebView subview composites on top of the
wgpu surface and is invisible to the readback (a transparent hole). When the
AT tree contains a WebView node the screenshot reply adds
warnings: ["webview_hole_possible"]. There is no platform-capture workaround
in scope.
Security model
The live bridge is defence-in-depth:
- Feature- and debug-gated. Every item that binds the socket, generates the
token, or spawns the bridge thread is
#[cfg(debug_assertions)]; a release build'sinstall_automation_bridge_in_debug()is the identity. A shipped release binary contains no socket, token, or bridge. - Unix-domain socket only (never TCP), in a
0700per-process directory under$XDG_RUNTIME_DIRwith a0600socket (so it isn't world-connectable even during the bind→chmod window), removed on startup and on bridge-thread exit. - Single connection at a time, single in-flight request, with a 10 s read-timeout on the token handshake and a 16 MiB cap on a request frame.
- Per-process UUID token: the client must send the token (printed to
stderr, or pinned via
TEKSILO_AUTOMATION_TOKEN) as the first line, or the connection is rejected. - Bounded main-thread settle. Because the live settle runs on the winit
main thread, the bridge clamps
max_anim_frames ≤ 120andsettle_timeout_ms ≤ 2000so no op (including a longwait_for_condition) can freeze the UI for more than ~2 s. Headless keeps the caller's values (no UI to freeze).
The threat model is a trusted local user with a non-shared $XDG_RUNTIME_DIR
— the same-uid socket plus the per-process token. The token is printed to
stderr, so anyone who can read the app's stderr (or /proc/<pid>/environ when
it's pinned) can drive the UI; this is a dev-tool stance, kept out of
production by the debug gate above. Regression guard: the debug-only banner
string only exists in the gated spawn_bridge_thread, so a release binary with
the feature on must not contain it — a CI canary:
cargo build --release -p widget-catalog # has the bridge wired + feature
! grep -qa "teksilo-automation: bridge socket" target/release/widget-catalog \
|| { echo "BRIDGE LEAKED INTO RELEASE"; exit 1; }
Headless mode has no socket at all.
Documented limitations
- WebView pixels in screenshots (compositor hole — warned, not captured).
- Windows live bridge: the socket is Unix-only;
install_automation_bridge_in_debug()is a no-op on Windows (headless mode works everywhere — it has no socket). - Release-build automation: debug-gated by design.
- Software-GPU fallback for CI screenshots: a clean
GPU_UNAVAILABLEerror, not a fallback. - This complements, not replaces, a real screen-reader OS round-trip.
Architecture
The wire protocol is serde DTOs, never closures or !Send handles. One
core function does the work, in the GUI-free teksilo-automation crate:
#![allow(unused)] fn main() { teksilo_automation::execute( tree: &mut WidgetTree, ops: &mut dyn WindowOps, op: &AutomationOp, settle: &SettleSpec, ) -> AutomationReply }
WidgetTree is Rc/RefCell-based and therefore !Send, so it lives on
exactly one thread; the async / socket layers marshal Send DTOs to it.
list_windows and screenshot are the only ops execute can't serve (they
need the window manager / a GPU); both return HOST_REQUIRED, and the
headless tree thread and the live bridge serve them with the context they
alone hold.
| Crate | Role |
|---|---|
teksilo-automation | GUI-free toolkit: DTOs, execute, RecordingWindowOps, the tool catalog. Mirrors teksilo-data's core-only-peer design. |
teksilo-automation-mcp | The rmcp server binary (--headless / --connect) + offscreen screenshots. tokio / rmcp are confined here. |
teksilo-app (automation feature) | The debug-only in-app bridge — std::os::unix::net + the existing send_external path; no async runtime in the framework. |
teksilo-platform | PlatformWindow::capture_offscreen — the live-window readback. |
RecordingWindowOps is why an AT action that opens a window (a menu item, a
"New window" button) never crashes the headless server: instead of panicking
on open_window, it records the request and returns a synthetic id.
Run snippets
# Headless MCP server (CI / agent test-authoring):
teksilo-automation-mcp --headless
# Drive a live running app (after `install_automation_bridge_in_debug()`):
teksilo-automation-mcp --connect <sock> --token <uuid>
# Live-bridge smoke test (needs a display):
cargo run -p automation_bridge_smoke
# — or keep it alive for an external client:
cargo run -p automation_bridge_smoke -- --serve
Testing
- Toolkit (
teksilo-automation): unit tests driven throughexecute(), each validating the producedTreeUpdatewith the realaccesskit_consumer. - MCP conformance (
teksilo-automation-mcp): the 24-tool router, the async-handler ⇄ tree-thread marshaling, and a screenshot that decodes to PNG magic bytes (skipped when no GPU). - Golden screenshots behind the
golden-testsfeature (cargo test -p teksilo-automation-mcp --features golden-tests), inline per-channel pixel compare with tolerance ≤ 2,UPDATE_GOLDENS=1to refresh. - Bridge round-trip (
examples/automation_bridge_smoke): a real app +install_automation_bridge_in_debug(), an in-process client runningsnapshot → invoke → re-snapshotand asserting the0600socket.
See also: Accessibility overrides, Scene accessibility.