Control protocol
Every ashlar app can be driven by an agent in three ways, all speaking one method vocabulary:
| Surface | Transport | Crate |
|---|---|---|
| Control port | JSON lines over TCP 127.0.0.1:<port>, keyed | ashlar-control (Server, Client) |
| Child process | JSON lines over stdin/stdout, no key (private pipe) | ashlar-control::serve_stream, <tool> serve |
| MCP | JSON-RPC 2.0 over stdio; tools map onto the methods below | ashlar-mcp |
| CLI | one-shot verbs, one-line replies | ashlar-cli |
Protocol version: 1 (reported by app.version).
Framing
One JSON object per line (\n, an optional \r is stripped), each way, one reply per request, in
order.
Request:
{"id": 7, "key": "<64 hex digits>", "method": "engine.execute", "params": {"command": "edit.undo"}}
id(optional, any JSON): echoed in the reply; omitted from the reply when absent.key: the app’s control key (TCP only; see Keys). Not needed afterauthon the same connection.method(required string).params(optional;nullor absent means{}).
Reply:
{"id": 7, "ok": true, "result": …}
{"id": 7, "ok": false, "error": "human-readable message", "detail": …}
detail is optional machine-readable context: for a failed command, the command error
{"kind": "unknown"|"disabled"|"bad_params"|"failed"|"panicked", "id": …, …}; for a failed batch,
{"steps": [...]}.
Hardening (TCP)
The server is built so that a hostile local process or web page cannot drive or crash the app:
- Loopback only. Binds
127.0.0.1; the client only connects there. - Key. Once the port is bound the app writes 32 random bytes (64 hex digits) to
<dir>/control-key.<instance>, created with mode 0600, where<dir>is a folder the app chooses (its settings folder) and<instance>is the Flatpak instance id orlocal-<port>. The file is removed when the server is dropped; a stale one is replaced. Every request must carry the key, as the top-levelkeymember or once per connection withauth. A missing or wrong key gets{"ok":false,"error":"unauthorized"}and the connection is closed. Keys are compared in constant time.params.keyis an ordinary parameter for every method butauth(ui.key {"key":"Enter"}). - Strict lines. A line that is not a JSON object with a string
method(invalid UTF-8, bad JSON, an array, a number, an HTTP request line) gets one error reply and the connection is closed, so nothing after it runs. This is what stops a browser’s cross-originfetchPOST: its request linePOST / HTTP/1.1is rejected before the JSON body is read. - Bounded lines. Longer than
max_line(4 MiB default): error reply, the rest of the line is drained (at most 64 MiB, 2 s) so the client can read the error, then close. A peer can never make the server buffer more thanmax_line. - Connection cap. At most
max_connections(16) at once; extra ones get{"ok":false,"error":"too many control connections"}and are closed. - First-request deadline. A connection must send an authorised request within 30 s. It is one overall deadline: blank lines or a slow drip of bytes do not extend it. After that, no idle limit.
- Reply timeout. The app thread answers within 60 s or the client gets
timeout: …. - Never panics. No
unwrap/indexing on input; junk never reaches the app thread; a request the app drops unanswered replies with an error by itself.
Standard methods
Answered by ashlar_control::handle from any Dispatch (headless or windowed):
| Method | Params | Result |
|---|---|---|
auth | {key} or top-level key | {} (transport-level) |
app.ping | — | "pong" |
app.version | — | {name, version, protocol, methods: [...]}: methods lists every method this app answers, so clients know whether ui.* works |
engine.commands | {query?} (case-insensitive, matches id, label, menu path) | [CommandInfo]: {id, label, menu, shortcut, params_schema, params_text, enabled, disabled_reason, mutates} |
engine.execute | {command, params?} | the command’s result |
engine.batch | {commands: [{command, params?}], keepGoing?} | [{command, ok, result}]; on failure an error with detail.steps (stops at the first failure unless keepGoing; at most 10 000 steps) |
UI methods
Routed to the app’s UiHandler. A handler may keep a request and answer it on a later frame.
Headless sessions refuse them with a hint. The windowed runtime answers the app-level ones itself
and hands the debugger methods (next section) to ashlar_ui::devtools:
| Method | Params | Result |
|---|---|---|
ui.inspect | — | UI state plus every registered element {id, label, role, rect:[x,y,w,h], enabled, value?, selected?} |
ui.menu.list | — | the menu tree with enablement |
ui.menu.invoke | {id} | as engine.execute |
ui.set | app-defined view state ({theme?, workspace?, …}) | {} |
app.quit | {discard?} | {closing}, then the app quits |
app.stats | {reset?} | frame times, passes, jobs, wakes (frames and requests per reason) |
app.logs | {limit?, level?} | {lines: [{unix, level, target, message}]} |
app.clock | one of {pause: true}, {step_ms}, {resume: true}, or {} to read | {paused, time} |
ui.scene | {name}, or {} to list | {scene} / {scenes: [..]}; only apps that declare scenes (App::scene) list it |
app.clock holds the time the app’s frames see, to look at an animation at an exact moment.
pause freezes it at the current frame’s time; step_ms (0 < ms ≤ 60000; pauses first) moves the
frozen time on and runs one frame there; resume lets time run again from the frozen value, so
nothing jumps (the app’s time then lags the wall clock by the time spent paused). time is the
egui time in seconds the next frame sees. While paused:
- input still runs frames, at the frozen time. Two clicks are then 0 s apart, so egui counts them as a double click; step between clicks that must stay single.
- everything timed by egui time stands still: egui’s animations,
ashlar_ui::motion, tooltip delays, toast timeouts, the caret’s blink phase and other timed wakes. A timed wake still runs a frame about once per its delay (the frame finds it not yet due), and an egui animation that is mid-flight keeps asking for frames that draw the same picture. - settings and autosave run on the host’s clock, so saves are not held up. Background jobs and control requests run as usual.
ashlar-debug clock pause|step <ms>|resume (also a .ui script line) and the MCP tool ui_clock
send it. A headless session refuses it.
Apps may add their own methods (ui.timeline.hit…); list them in UiHandler::methods so they show
in app.version.
Debugger methods
Drive the UI the way a browser automation tool drives a page: snapshot the element tree with short
refs, act on refs, wait, read, screenshot. Implemented by ashlar_ui::devtools (an egui plugin),
so an app that does not use ashlar-app gets the same methods (the ashlar-debug CLI and the
MCP ui_* tools speak them; see debugging-uis.md) by passing requests to
devtools::handle(ctx, request) (see that module’s docs). app.version lists them when available.
Elements and refs
The element tree is egui’s AccessKit output (every egui widget: role, label, value, state, bounds)
merged with the ashlar automation registry (dotted ids, component roles). An element:
{"ref": "e12", "id": "notes.title", "role": "textbox", "name": "Title", "value": "Groceries",
"enabled": true, "focused": false, "selected": false, "checked": null, "visible": true,
"rect": [x, y, w, h], "parent": "e3"}
id, value, selected, checked and parent are omitted when they do not apply. rect is the
on-screen part in logical points. Roles: button checkbox switch radio radiogroup textbox searchbox slider spinbutton combobox link label image colorwell panel window dialog scrollbar separator progressbar menu menubar menuitem tab tablist list listitem tree treeitem table row cell columnheader toolbar status alert tooltip group generic.
A ref stays the same for an element across snapshots while it exists (keyed by its automation id,
else by its egui widget id and role); every ui.snapshot increments the generation. The first
debugger request turns egui’s AccessKit output on for the rest of the session.
Targets
Every method that acts on an element takes a target, as target: {...} or flat in the params:
| Target | Matches |
|---|---|
{ref: "e12"} (also "@e12", 12, or the bare string "@e12") | the element of a snapshot |
{id: "file.save"} | the (first visible) element with that automation id |
{text: "Save", role?: "button"} | the first visible element with that name: exact, then ignoring case, then containing it; interactive elements first. Not accepted flat (text is the typed text of ui.fill/ui.type) |
{x, y} | that point (logical points); the element there, if any |
The error text starts with the kind (stale_ref: …, not_found: …, timeout: …) and detail.kind
carries it: stale_ref ({kind, ref, generation}: the element of that ref is
gone; take a new snapshot), not_found ({kind, generation}), timeout ({kind, elapsed_ms}).
Methods
All answer once the frames they need have run; input methods return {} after the injected input
has been processed by a frame.
| Method | Params | Result |
|---|---|---|
ui.snapshot | {interactive?: false, compact?: false, scope?: target, depth?} | {generation, tree, elements: [element], focused: ref│null} |
ui.click | target, button? (primary/secondary/middle), count? (1–3), modifiers? | {} |
ui.hover / ui.move | target (ui.move: {x, y}) | {} |
ui.focus | target | {}: AccessKit focus, or a click for text fields |
ui.fill | target?, text, submit? | {}: click the target (not when it is a text field that has the keyboard already), select all, type text (or delete when empty), Enter when submit; without a target, the focused field |
ui.type | target?, text | {}: typed at the caret of the target (clicked first, unless it is a text field that has the keyboard already, so calls in a row add up) or of the focused widget |
ui.press | keys ("Mod+Shift+S", sequences "G G"; ashlar_command::shortcut syntax; key is accepted too) | {} |
ui.key | {key, modifiers?} | {} (one key; modifiers: {command, shift, alt, ctrl}, command = ⌘ on macOS, Ctrl elsewhere) |
ui.scroll | target? (default: the window centre), dx, dy in points; dy > 0 scrolls down (towards the end), dx > 0 right | {} |
ui.scroll_into_view | target | {} |
ui.drag | {from: target, to: target, steps?: 8, modifiers?} | {} |
ui.get | target, what: text (a field’s content, else the name), name, value, rect, enabled, visible, focused, checked, selected, role, element | {value} |
ui.wait | {for: element│gone│text│enabled│idle, target?, text?, timeout_ms?: 5000} (for defaults to text when text is given, else element) | {elapsed_ms}; error timeout |
ui.screenshot | target? (crop; also the legacy {id}), pad?, annotate?, highlight?: target, raise?: true | {png: <base64>, width, height, annotations?: [{ref, rect}]} |
ui.highlight | target, ms?: 1500 | {rect}: the element is outlined in the live window |
devtools.toggle | {open?} | the state below |
devtools.state | — | {open, generation, elements, pending: {input_frames, waits, screenshots}, repaints_while_open} |
The text outline of ui.snapshot, one element per line, two spaces of indent per level:
- button "New Note" [ref=e5] [id=notes.add]
- list [ref=e8] [id=notes.list]
- listitem "Groceries" [ref=e9] [id=notes.list.row.0] [selected]
- textbox "Title" [ref=e14] [id=notes.title] [value="Groceries"] [focused]
Flags: [disabled] [focused] [selected] [checked]│[unchecked] [offscreen]; names and values are
cut at 80 characters. interactive lists only visible controls; compact leaves out unnamed
non-interactive and off-screen elements. Both leave out unnamed generic elements (drag areas,
click catchers) that have no automation id.
Waits are checked on frames the app runs anyway plus one wake at the deadline: a pending wait
never makes the app repaint in a loop. idle means a frame asked for no repaint within 200 ms, no
injected input is pending and the app reports no work of its own (devtools::set_busy; the
runtime reports its background jobs). Screenshot annotations[].rect is in image pixels relative
to the returned image; annotate and highlight are painted into the captured frame. Screenshots are rendered off screen
when the app provides a renderer (devtools::set_offscreen; ashlar-app uses eframe’s wgpu
device), so they work for a window that is covered, in the background, minimized behind others or
on a locked screen; ashlar-app also runs the UI passes other ui.* requests need while such a
window draws none (Runtime::hidden_pass). Otherwise the backend captures a presented frame: the request is repeated every
0.25 s, the window is brought to the front once (unless raise: false), and after 1 s without a
frame it fails with an error saying why. Either way it never hangs and never spins: a failed
screenshot costs a bounded handful of frames. Pictures travel
inline, never through a predictable temp file.
The inspector
F12 (or devtools.toggle) opens an inspector window in the app: Pick (hover outlines an element
with its role, name, ref, id and rect; a click selects it and copies @eN), the element tree,
frame and wake counters with a live repaint counter, and panels the app provides
(devtools::section: the runtime adds its recent logs and the undo history). The inspector is not
part of snapshots. Closed, it costs nothing: no frames, no allocations.
MCP tools
ashlar-mcp exposes the same vocabulary as tools. Arguments marked ? are optional; a target is a
ref string ("@e3", "e3"), "id=…", "text=…", or an object as in the debugger methods.
| Tool | Method |
|---|---|
list_commands {query?} | engine.commands (compact: id, label, enabled, mutates) |
describe_command {command} | engine.commands, one entry in full, with its parameter schema |
execute {command, params?} | engine.execute |
batch {commands, keepGoing?} | engine.batch |
screenshot {target?, annotate?, highlight?, pad?, raise?, id?} | ui.screenshot, returned as an MCP image (plus {width, height, annotations?}); id is the legacy crop |
ui_snapshot {interactive?, scope?, depth?, compact?, elements?} | ui.snapshot: the outline as text; elements: true adds the element list as JSON |
ui_inspect | ui.inspect |
ui_click {target?, id?, x?, y?, button?, count?, modifiers?} | ui.click |
ui_hover {target} | ui.hover |
ui_focus {target} | ui.focus |
ui_fill {target, text, submit?} | ui.fill |
ui_press {keys} | ui.press |
ui_drag {from, to, steps?, modifiers?} | ui.drag |
ui_scroll {dx, dy, target?, x?, y?} | ui.scroll |
ui_scroll_into_view {target} | ui.scroll_into_view |
ui_get {target, what} | ui.get |
ui_wait {for, target?, text?, timeout_ms?} | ui.wait |
ui_highlight {target, ms?} | ui.highlight |
ui_key {key, modifiers?} | ui.key |
ui_type {text} | ui.type |
ui_menu_list | ui.menu.list |
ui_menu_invoke {id} | ui.menu.invoke |
ui_set {…} | ui.set (app-defined keys) |
app_stats {reset?} | app.stats |
app_logs {limit?, level?} | app.logs (level: error, warn, info, debug or trace) |
ui_clock {pause?, step_ms?, resume?} | app.clock |
ui_scene {name?} | ui.scene |
docs_categories {category?}, list_docs {category?, query?, recursive?}, read_doc {path} | the app’s documentation library (no control method) |
save_doc {path, content, replace_builtin?}, append_doc {path, text}, delete_doc {path} | the user’s own pages, when the server has a user folder |
screenshot, the ui_* tools (ui_clock and ui_scene included) and app_stats/app_logs are listed only when the backend’s
app.version reports their method, so an agent never sees a tool the app cannot serve. The
documentation tools are listed when the server has a library or a user folder. With
ToolMode::PerCommand every command is also its own tool (dots become _) with its real input
schema. crates/mcp/tests/protocol_doc.rs checks this table against the code.
CLI reply contract
<tool> exec <id> [json], describe, commands --json, run, version --json print one line:
{"ok":true,"result":…} or {"ok":false,"error":"…"} (plus detail). Exit codes: 0 ok, 1 the
command failed, 2 usage error.