shuttle

Desk wire

One wire connects shuttle mcp to the daemon, the runtime (desk.mts) to the daemon, and the CLI (shuttle desk VERB) to the daemon. It is implemented once in desk_core::wire.

Endpoint

OS Default endpoint
macOS ~/Library/Caches/loomhand/shuttle/desk.sock, mode 0600
Windows \\.\pipe\loomhand-shuttle-desk, local clients only
Linux $XDG_CACHE_HOME/loomhand/shuttle/desk.sock, else ~/.cache/loomhand/shuttle/desk.sock, mode 0600

[daemon] socket_group in config.toml opens the macOS or Linux socket to that group: mode 0660, owned by the group (config.md). On Windows the pipe admits SYSTEM and its user, and socket_group names one more account or SID that may read and write it. SHUTTLE_SOCKET overrides the endpoint for the binary, and the daemon hands its endpoint to the runtime, the plugins and the services in the same variable.

Framing

Desk calls

Desk calls name desk_<verb> tools:

Verb Arguments Result
windows   the top-level windows: id, title, role, pid, active, and app (the app’s name on macOS, the executable on Windows and Linux) when the OS says; on Linux a modal dialog the screen does not show also has modal (hidden, off the screen or behind "TITLE") and blocks (the title of the window it blocks), and a window of a process that owns a dock or desktop window (_NET_WM_WINDOW_TYPE), such as the panel or the desktop, has shell: true; a macOS sheet, and a Windows window with an owner window, have owner, the id of the window that owns them. On macOS with background input it also lists the windows on another Space, a desktop or a full-screen app’s, with space: "other", never active; then snapshot, find, at and close on such a window fail with “is on another Space”, since accessibility reaches it only on the current Space. Optional fields are left out when unknown, so older readers of the wire are unaffected
unanswered   on Linux, the window _NET_ACTIVE_WINDOW names when AT-SPI lists no window of its process, as an app that stopped answering accessibility (busy, or blocked in a dialog) leaves it: title, pid, role and app, with id empty; null when there is none, when its app is outside the app allowlist, or on macOS and Windows
wait since (a windows result), timeout_ms changed and windows: answers once the window list differs from since, or with the same list once timeout_ms has passed (5 s at most), counting the time spent reading lists, with a last read at the deadline. The desktop desk checks a cheap on-screen window fingerprint every 10 ms and reads the full list at once when it changes, and otherwise every 100 ms or the last read’s duration, whichever is longer; the Android desk has no fingerprint and reads the full list every 10 ms or the last read’s duration, whichever is longer, never sleeping past the deadline; since and the list are compared after the app allowlist filters both
snapshot window?, pid?, id? (a window id from windows, in place of window), keys?, menus?, bounds?, text?, max_chars? (with text) a window’s tree as nodes (ref, id, parent, depth, role, name, states, …), or an outline with text; a Linux node read with keys also carries its raw AT-SPI key binding as keys; on macOS the app’s menu bar comes with a window only with menus (omitted or false: no app menu bar in the snapshot), and other backends ignore it
find name, role?, window?, pid? the window and the nodes whose name matches
read id, text? one node with its value and text
press id presses a node: select a tab or row; a menu entry runs its action; a hidden menu opens the menus above it and a menu is clicked at its centre; another node is clicked at its centre when a hit test there finds it, else runs its default action
act id, action (click, action, select, focus) one action on a node
at x, y, window?, pid? the node at a screen point, its ancestors, and the pid of its process, read in the window window or pid names, else in the topmost app window at the point (an error when there is none), which the allowlist gates; covered (title, pid) names another window on top at that point: on Linux the topmost window the window manager manages there, other than the panel and desktop windows (a popup menu or tooltip is not checked), counted as the target’s own when it is its process’s only window or that process’s one window with the target’s title; on macOS the window of the system-wide hit test; on Windows the node is already the top window’s
key keys (ctrl+s, cmd+shift+t, …), window?, pid? sends a key combination; with background input (config.md) to the window that window (a title) or pid names, which is then required
type text, window?, pid? types text, to the named window with background input as key; on an Android device typed says whether the field shows it (null: not checked, for a password field, a web view, or text the field changed otherwise)
click x, y, button?, count?, window?, pid? clicks a screen point; with background input in the window under it, or in the named window, without raising it. With window, x and y are pixels of that window’s shot image, on whichever display shows it
scroll direction (up, down, left, right), amount? (notches or key repeats, default 3, the driver caps it at 50), x?, y?, window?, pid? with x and y, wheel notches at that screen point, which scroll whatever is under it; without them, scroll keys to the front app window, by its pid and window id: the driver’s topmost window, or with an app allowlist the window the allowlist picks and checks when the driver lists it under the same title, else that app’s topmost window. On Windows, whose apps drop posted scroll keys, and on Linux, where the front window is that of the focused app, it wheels notches at that window’s center instead, and fails when another window covers that center. x without y is an error. With window, x and y are pixels of that window’s shot image. The allowlist gates the window at the point, or that front window
drag x, y, to_x, to_y, window?, pid? presses at (x, y), moves and releases at (to_x, to_y), in desk coordinates, or with window in pixels of that window’s shot image; the allowlist gates both ends; with background input in the named window, which is then required (as for scroll)
still timeout_ms? (default 600, at most 5000), hold_ms? (default 40) still, changed, ms and volatile: answers once no 8-pixel block of a 320-pixel capture has changed in a way that counts for hold_ms, or after timeout_ms with still: false; changed says whether one did during the wait. A change counts when a pixel moves by more than 24 in a channel and the block is not volatile: a block that changed through three or more contents during the wait (a clock, a spinner, a video) stops counting, and one that went back to a content it showed (a caret) counts only with new contents; volatile counts those blocks. timeout_ms is a polling deadline, checked between captures, so a slow capture can carry the answer past it. Each check costs one driver capture on macOS and Windows; it returns no pixels and watches the whole screen, whatever the allowlist
focus, close window, pid? focuses or closes a window
launch app starts an app; on Linux the result also carries scoped, false when the app stays in the daemon’s cgroup
shot x?, y?, w?, h?, path? (Linux), window? width and height, with the PNG of the screen or a rectangle in image_b64; with window, the image of that window alone as the driver captures it (picked as focus picks, within the app allowlist), on whichever display shows it, and x, y, w, h crop that image in its own pixels, with origin, the crop’s top left in the whole window image ([0, 0] uncropped); a rectangle off the main display fails and says to name the window, and a device answers that its shot takes no window; on Linux it is read over MIT-SHM, or from the driver’s capture when its cursor overlay shows or MIT-SHM is out of reach, and with path the PNG also goes to that file and the text carries path
screen   width and height in pixels

With background input (config.md), the results of key, type, click, scroll and drag carry unverified when cua-driver sent the input but could not confirm its effect: the driver’s effect, its note and the escalation it proposes. The input may or may not have landed, so read the result back before relying on it, and do not send it again unchecked. For a macOS window on another Space, which background input cannot read back, the note says not to type the text again and to ask the user to confirm the text before submitting it with Return or Send. Foreground results never carry it. A background press runs the node’s action first, except on Windows, where it clicks the node in its window, since a UI Automation action brings the window to the front; when that action fails, the press returns the error and sends no click, since the action may already have fired. A background press that clicks, and an act click, go to the node’s own window even where another window covers it, and fail when that window cannot be found; their results carry unverified too. The allowlist gates named background input by its target app, not by the window on top at the point. With auto input, the results of key, type, click, scroll, drag, press and act carry "input": "foreground" when the action ran in the foreground after a refusal (safety.md).

Computer calls

A vision agent drives the screen with these, by screenshot pixels and element indexes rather than desk ids. The desk answers them on Linux X11, macOS and Windows, and an Android device answers its own computer_state, computer_act, computer_end and look. On macOS and Windows the screenshots and the settle come from cua-driver’s captures, keys and typing go to the frontmost window, input at a screen point goes in the foreground since it moves the pointer, no modal lines appear, a left click on a text field asks cua-driver for a hit-checked verify_hit click, which the bundled cua-driver v0.38.0-loomhand.2 delivers: the driver brings the field’s window to the front and clicks the field’s centre only when the element on top there is that same field or inside it, so the keys that follow reach the field; when the driver refuses, the field gets its press action and, if the field refuses that, the driver’s refusal, never a click at a point; a cua-driver without verify_hit, such as an older one at its default socket that a build without a bundled driver uses, presses the field as before; a left click or double click on another element whose macOS app refuses its press action gets one verify_hit click, and when the driver refuses that too the result carries both errors, never a click at a point; a press that fails any other way is not repeated. With an app allowlist they are refused, since they see and click the whole screen. session names the driver session whose elements and screenshot scale a call uses. Without it, a call uses the caller’s own default session, named after its agent_id; calls with neither agent_id nor session, such as shuttle desk, share one default session. A host ends a session with desk_computer_end. The driver refuses an ended name for good, so once a default session ends, every caller’s next call without session opens its default under a new name, and its next element action needs a new computer_state.

Verb Arguments Result
computer_state session?, pid?, window_id?, query?, max_edge?, workspace_only? the windows frontmost first (pid, window_id, app, title, geometry), a line per modal dialog the screen does not show, the elements [index] of the frontmost app window or of pid (its frontmost window unless window_id names one) from the driver’s window state, at most 300, with centres in screenshot pixels, and a screenshot of the whole screen scaled to a long edge of max_edge (default 1024, 0 for full size), WebP or JPEG, whose size and input tokens the text gives
computer_act session?, action (click, left_click, double_click, right_click, triple_click, type, key, hotkey, scroll, drag, wait, launch_app), element? or x?, y? (screenshot pixels), to_x?, to_y?, text?, key?, keys?, direction?, amount?, app?, button?, seconds?, foreground?, max_edge?, scale?; or actions (a list of such actions), max_edge?, scale? one action, or each of the list in order up to the first failure, waited out until the screen is still; the text names the windows the action opened, closed or brought to the front, and the screenshot follows. scale maps x,y by the screenshot the caller saw instead of the session’s latest; a list maps every step by the screenshot before it. A point off the screen and a hidden button of a GTK app are refused. typed N of M counts the characters that landed
computer_end session ends the driver session and forgets its elements and scale
computer_scale session? scale: screen pixels per pixel of the session’s latest screenshot, else of any session’s
look session?, max_edge?, workspace_only?, region? ({x, y, w, h} in screenshot pixels) a screenshot of the whole screen, or of region at full resolution with the mapping of its pixels to computer_act x,y, cut from the MIT-SHM frame on X11, else from a full-size driver capture
title_at x, y (screen pixels) title: whether the point shows a window title, in a desktop panel or a window manager’s title bar; false off X11

Runs

script_run (code or an absolute script path, which the MCP server resolves against the agent’s working directory; params, default {}; timeout_s up to 110, default 90) runs one module on the bundled Node, with the desk API as the global desk, and answers {result, logs, stderr, exit_code?, duration_ms, error?: {kind, message}} (no exit_code after a timeout or a stop): kind is import (the module or the desk API failed to load), no_main (the module exports no main function), exception (main threw; message is the stack), timeout, stopped (the user stopped shuttle) or exit (Node exited without saying why). A report that carries error answers with is_error: true and the whole report as its text, so an MCP client sees the run fail with its logs. gui_plan runs the runtime’s plan executor with its arguments and answers the plan’s result, or an error when the plan reports one. Both run with SHUTTLE_SOCKET naming the daemon’s endpoint, SHUTTLE_AGENT naming the run (the runtime sends it as agent_id, so the run’s own desk actions pass while it owns the desk) and without the model key variables (OPENROUTER_API_KEY and the configured api_key_env); logs and stderr keep their first 32 KiB. A timeout, a stop or a cancelled call ends the run on macOS and Linux with SIGTERM to its process group, so the script can clean up (release the keys it holds, close what it opened), and with SIGKILL to whatever is left of the group 2 s later; a client that closes the connection during that grace gets the group KILLed at once; on Linux the apps the desk moved into systemd scopes of their own are spared. The report then comes about 2.5 s after the timeout (the 2 s grace, then up to 0.5 s for its output pipes; a process stuck in the kernel delays the report until it leaves it), with kind timeout or stopped and the logs kept. On Windows the run’s Job object ends at once with every process in it, since Windows has no SIGTERM. A run that ends by itself leaves the processes it started. While config.toml fails to load, every call except safety_stop and safety_resume answers with its error, since the key variable and the safety controls are in it. safety_stop and safety_resume (no arguments, what shuttle stop and resume send) halt every call, ending the run and the desk and model calls in flight, and lift the halt; no MCP tool and no runtime call sends them, and on macOS and Linux the daemon answers them only to a peer that runs as its own user.

Plugins

A plugin adds one tool that runs scripts with script_run’s contract and its own program in place of Node. This section is the schema’s source of truth.

A neutral example, tools.d/demo.json:

{"name": "demo_run", "description": "Run a demo script.", "bin": "/usr/local/bin/demo-runner", "resources": [{"uri": "demo://GUIDE.md", "path": "/usr/local/share/demo/GUIDE.md", "mime": "text/markdown", "description": "The demo guide"}]}

Service plugins

A service plugin is a long-lived program that serves its own tools on the desk wire. The daemon runs it as its child and hands it the connection of every call of one of its tools. This section is the schema’s source of truth.

A neutral example, tools.d/notes.json:

{"service": "notes", "bin": "/usr/local/bin/notes-service", "args": ["--serve"], "tools": [{"name": "notes_read", "description": "Read a note.", "input_schema": {"type": "object", "properties": {"title": {"type": "string"}}, "required": ["title"]}}, {"name": "notes_watch", "description": "Stream note changes.", "input_schema": {"type": "object"}, "mcp": false}]}

Model calls

The daemon answers these with the models of config.md (crates/models), so a script calls a model without holding a key. Each reply carries usage: {model, input_tokens, output_tokens, cached_tokens}.

Tool Arguments Result
model_decide state (text, JSON, or a list of text and {type: "image_url", image_url: {url: "data:image/png;base64,..."}} parts), questions ({key: {type: "noul" \| "choice", instructions, criteria?}}), image (the state holds a screenshot: the verify role answers, else decide), timeout_ms (caps the configured timeout; desk.look passes 5000) answers ({key: {probability}} for a yes question, {key: {choice, confidence, probabilities}} for a choice), served ({model, build, id}: the model name asked, and the build and generation id that answered, null when the endpoint names none)
model_ask prompt, images (PNGs as base64) text
model_ground png_b64, target, area ({left?, top?, width, height}) x, y; a point outside 0 to 1000 is an error, and 1000 maps onto the last pixel of area

Coordinates

Desk coordinates are the space desk_screen reports and desk_click, desk_at, desk_shot and element bounds use: screen pixels on Windows (the desk host is per-monitor DPI aware) and on Linux, and on macOS the main display’s pixels (AX points times its backing scale), negative left of or above it. A screenshot need not have that size: desk_shot may return a downscaled image, and a Retina capture can be 2x. desk_click, desk_scroll and desk_drag with window take pixels of the whole image desk_shot with that window returns instead: the window’s own capture, 2x on a Retina display, whichever display shows it. Pixel (px, py) of a cropped shot is (origin[0] + px, origin[1] + py). A title that matches two listed windows alike (both equal to it, or both containing it) is refused there, since a shot by that title may show the other window: pass a longer title that only one window matches, give the window a title of its own, or pass desk coordinates without window. On an Android device they stay screen pixels.

model_ground takes area, the desk rectangle the PNG shows, and returns x, y in desk coordinates: the model answers a point from 0 to 1000 across and down the image, and the daemon maps it onto area, whatever the PNG’s own size. Pass the rectangle the PNG was cropped from, never the PNG’s width and height.