Developer preview · Linux (X11)
The in&space driver contract
Version 0.8.0 (2026-10-26). Normative. nish-driver manifest and the daemon's metadata report it as contract_version; nd_core::CONTRACT_VERSION is the source. The words MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119.
This document is the contract between the in&space driver and its host apps. It covers the model-facing MCP tools, the host-facing doctor report, and the platform plans that shape them. Names such as nish-driver below are the driver's current working names in its own repository.
1. Versioning and compatibility
MAJOR.MINOR.PATCH. A MINOR bump adds tools, arguments, result fields or error codes; it never changes the meaning of a field a host already reads. A MAJOR bump may. 0.7.0 is additive over 0.6.0 except for one deliberate safety default (§4): synthetic input now needs a takeover session. 0.8.0 is additive over 0.7.0: the takeover hand-back (§4.3), with one new error code (user_took_over), one new host-control verb (takeover.resume), two host events, thetakeover.pausedfield,move_cursor {duration_ms}, thexinput2registry entry, thetakeover_handbackcapability and thexinput2_missingreason.- Hosts MUST ignore result fields they do not know. Drivers ignore arguments they do not know. Hosts MUST send listed arguments with the types tools/list declares: 0.7 refuses a wrong
inputwithinvalid_arguments, but most other arguments of a wrong type are treated as absent (for exampleskeleton: "true"is a normal read), not refused. - The doctor report has its own
schema_version(2, §6.4).tools/listkeepstools_list_schema_version1. - Unchanged from 0.6.0: tool names; element tokens
<snapshot_id>:<N>and their rules (design §3.1);effect,verified,path,took_cursor,escalation,diff,snapshot_id,settleon act results;error.codeanderror.message; the text of a failed call still starts with<code>: <message>.
2. The error envelope
2.1 Shape
Every failed tool call is an MCP result with isError: true whose structuredContent is:
{"ok": false,
"error": {"code": "ambiguous_target",
"message": "2 approved windows match app",
"disposition": {"retry": "never"},
"suggestion": "Pick one of the candidates and call again with its pid and window_id.",
"candidates": [{"window_id": 41943047, "pid": 5120, "app": "Slack"},
{"window_id": 41943112, "pid": 5121, "app": "Slack"}]}}code: one of the table in §2.2. A driver MUST NOT emit a code missing from it.message: one line for people and logs. It MAY name bounded identifiers (pid, window id, token, a count). It SHOULD NOT quote application text.disposition.retry:safe: nothing happened; the same call may be sent again (after a short wait).unsafe: something may have happened (keys partly typed, an app that hung mid-action); observe the state before acting again.after_refresh: read again first (get_window_state, list_windows or start_session), then retry with fresh tokens or targets.never: the same call will fail again; change the arguments or ask the person.
suggestion: exactly one actionable sentence for the model, fixed text per code (the table below). It is never built from application content, so an app cannot steer the model through an error.candidates(only withambiguous_targetandambiguous_window_target): at most 8{window_id, pid, app}of approved apps only;appis the app's own name, escaped and cut to 64 characters, and is untrusted app text.candidates_truncated: truesays there were more.- Other fields MAY ride along:
escalation({recommend, reason}),effect: "refused",typed_characters.
The MCP text content of a failed call is <code>: <message> [retry: <disposition>] <suggestion>. The daemon socket's own errors (unknown_method) carry the same error object.
2.2 Dispositions
Every code the core and the Linux backend can produce. The conformance test every_error_code_has_a_disposition scans the sources for codes and checks this table against nd_core::errors::ERRORS.
| Code | Retry | Suggestion (fixed text) |
|---|---|---|
accessibility_error | after_refresh | Read the window again with get_window_state, then retry once with a fresh element_token. |
ambiguous_target | never | Pick one of the candidates and call again with its pid and window_id. |
ambiguous_window_target | never | Pass pid and/or window_id; list_windows shows the approved windows. |
approval_denied | never | Do not repeat this call; ask the person what they want instead. |
background_unavailable | never | Use another element, or ask for a takeover session and retry with delivery_mode foreground. |
capability_not_consented | never | Call start_session asking for this capability; the person must tick it. |
consent_expired | after_refresh | Call start_session; the person must click to approve again. |
consent_required | never | Ask the person to open the app, then call start_session so they can approve it. |
foreground_unavailable | unsafe | Some keys may have been sent; read the window again before typing more. |
internal_error | unsafe | Read the window again to see what changed before acting again. |
invalid_arguments | never | Fix the arguments as the message says; tools/list has each tool's schema. |
permission_missing | never | Ask the person to run nish-driver doctor and fix the missing permission. |
point_outside_window | never | Use a point inside the target window, or an element_token. |
policy_denied | never | This action is not allowed in this session; do not retry it. |
rate_limited | safe | Wait about one second, then retry the same call. |
redaction_unavailable | after_refresh | Read the window again without a screenshot (include_screenshot false) and use the tree. |
requires_takeover | never | This needs visible control: call start_session with input takeover, which asks the person. |
screen_capture_failed | safe | Retry once; if it fails again, use the tree without a screenshot. |
screen_capture_unsupported | never | Use the accessibility tree without a screenshot. |
screenshot_context_missing | after_refresh | Call get_window_state for this window first. |
secure_field_refused | never | Ask the person to enter or read this secret themselves. |
secure_field_unknown | after_refresh | Read the window again; if the field still cannot be verified, ask the person to type it. |
session_ended | never | Stop; the session was ended or revoked. Check get_session_state before anything else. |
stale_element_token | after_refresh | Read the window again with get_window_state and use the new element_token. |
target_occluded | after_refresh | Another window covers the target; read the window again or ask the person to uncover it. |
target_unresponsive | unsafe | The app did not answer in time; read the window again before acting again. |
unknown_method | never | Use metadata, list or call on the daemon socket. |
unknown_tool | never | Use a tool name from tools/list. |
unsupported | never | This platform has no API for this; do not retry it. |
user_took_over | never | The person took control. Wait for them to click Continue, then start again. |
wayland_unsupported | never | This session is Wayland-only; ask the person to use an X11 session for now. |
window_id_not_found | after_refresh | Call list_windows and use a current window_id. |
window_owner_pid_mismatch | after_refresh | Call list_windows and use the pid that owns the window. |
window_target_not_found | after_refresh | Call list_windows; only windows of approved apps can be used. |
zoom_context_missing | after_refresh | Call zoom on this window first, or aim without from_zoom. |
New in 0.7.0: ambiguous_target, capability_not_consented, requires_takeover, unsupported. New in 0.8.0: user_took_over (§4.3). unknown_method is the daemon socket's, not a tool's.
2.3 Ambiguous targets
get_window_state and every tool that takes a window target accept app (a name or title fragment) instead of pid/window_id. Only approved apps' visible windows are considered. One match is used; several give ambiguous_target with candidates; none gives window_target_not_found. A call with no target at all keeps the 0.6 code ambiguous_window_target and now also carries candidates. With pid alone the topmost window of that pid is used, as in 0.6.
3. Skeleton snapshots and drill-down
For dense apps (Slack, Teams, WhatsApp Desktop, Electron generally) a full tree spends the model's context on rows it does not need.
get_window_state {skeleton: true}returns only containers and landmarks: the window; landmark roles (tool_bar,menu_bar,status_bar,tablist,list,list_box,tree,table,tree_table,document,split_pane,scroll_pane,landmark,navigation,form,article,header,footer,info_bar,notification,dialog) that have children; and generic containers (panel,filler,section,group(ing),layered_pane,internal_frame,viewport,tab) that have children and a name. Secure fields are never listed.- Each skeleton row:
element_index,element_token,role,label(≤ 80 characters),parent_index(the nearest skeleton ancestor),depth(skeleton depth),child_count,descendant_count,frame. Text lines read[N] role "label" children=K descendants=D. - Bounded:
max_elements(default 60, at most 200) rows andmax_depth(default- skeleton levels;
truncated: truewhen cut. No screenshot unlessinclude_screenshot: true(then the whole window is captured with every secure field blacked out and no marks).
- skeleton levels;
get_window_state {root: <token>}reads one region: the element and its descendants, with the normal limits counted from the root (max_elementsdefault 250,max_depthdefault 25 levels below it), androotechoed.- Tokens follow design §3.1 unchanged: skeleton, drill-down and full reads of the same window share stable row numbers; a token from any of them acts while the window's structure is unchanged; a structural change makes older tokens
stale_element_token(after_refresh). - Skeleton and drill-down reads read the whole window internally (5,000 elements, depth 200, the zoom budget) so their tokens share one structure. The savings are in the model's context, not in accessibility round trips.
- Measured: see CODEX-REPORT.md ("Skeleton token savings").
4. Input modes: headless and takeover
A session declares its input mode in start_session {input}.
4.1 headless (the default)
- Accessibility actions only (AT-SPI actions and EditableText on Linux; UIA patterns on Windows; AX actions on macOS).
- It never moves the pointer, never sends synthetic keys or buttons and never takes focus or raises a window.
- These are refused with
requires_takeoverbefore anything is sent:click/double_click/right_click/type_textwithdelivery_mode: "foreground";press_key;hotkey;scroll;drag;bring_to_front;move_cursorwithscope: "desktop"and foreground. The engine checks again right before every synthetic call. - NishBot's background work uses this mode.
4.2 takeover
- Synthetic pointer and keyboard are allowed (XTEST on X11), with every M0 check still in force (scope, secure fields, focus re-checks, occlusion, no drag, no middle-click paste).
- It needs its own line in the native consent dialog: "ALSO allow VISIBLE TAKEOVER", unticked by default, ticked only by a mouse click. A session that asks for takeover and is not granted it stays headless (
start_sessionreturnsinput: "headless",takeover: {on: false, reason}). - While on, a visible on-screen indicator is shown for the whole takeover: a red frame round the screen and a label "nish-driver: AI takeover active". It is click-through (empty input shape; the driver's own hit-testing skips it), it is left out of the driver's own captures (captures read a window's own contents), and it is removed on
end_session, on the kill switch or revocation, on lease expiry, when the connection closes, and whenstart_session {input: "headless"}is called — within about 100 ms. If the indicator cannot be shown, takeover is not granted. If it goes away on its own (another X client unmaps or destroys it, or its display connection fails), the takeover ends at once andtakeover.reasonsays so. - A takeover is granted only while it is on: after a switch to headless or a lost indicator,
start_session {input: "takeover"}shows the dialog (and a new indicator) again. start_sessionandget_session_statereportinputandtakeover: {on, indicator, paused, paused_reason?, reason?}(pausedis new in 0.8, §4.3).- Vixy's visible work uses this mode.
4.3 Hand-back: the person can always take control back
New in 0.8.0. During a takeover the person's own pointer or key input stops the driver and hands the computer back to them. Work resumes only after the person clicks Continue; never by voice, text or the model.
Detection. The person's input is:
- the pointer more than 8 px [E] (either axis) from where the driver last put it (or, before the driver moved it, from where it was when the takeover began or resumed), read at each check; or
- a key or button press the driver did not send itself.
The driver logs every pointer target and every key and button press before it sends it; input that is not in the log is the person's. Checks happen before every synthetic call (between actions), before every step of a pointer move (at least every 20 ms; move_cursor {duration_ms} glides in 10 ms steps), and between typed characters. An input watcher also reports the person's input as it happens, so a backend call already in progress stops at its own next step.
| OS | Source of the person's input | Permission |
|---|---|---|
| Linux/X11 (implemented) | A separate X connection selects XInput 2 raw events (XI_RawKeyPress, XI_RawButtonPress, XI_RawMotion) on the root window; since XI 2.1 they arrive whatever has the focus or a grab. A press from an XTEST slave device that the driver logged is its own; any other press is the person's. An XTEST motion's absolute target is matched against the logged targets; a physical device's motion counts once its deltas pass 8 px. On Xvfb every event comes through XTEST, so there an unlogged event is the person's. The engine also reads the pointer itself before every step (this catches a warp). | None beyond the X session. XInput 2.1 is required: without it takeover is not granted (xinput2_missing). |
| macOS (M1, planned) | A listen-only CGEventTap (kCGEventTapOptionListenOnly) at the session level for key down, mouse moved/dragged and button down. Events the driver posts carry its own kCGEventSourceUserData value and come from its private CGEventSource; events whose source state is the HID system's (kCGEventSourceStateHIDSystemState) and lack that mark are the person's. | Input Monitoring (kTCCServiceListenEvent) for the tap, besides Accessibility for posting. Without it takeover is not granted. |
| Windows (M2, planned) | Raw Input (RegisterRawInputDevices with RIDEV_INPUTSINK for mouse and keyboard; a WM_INPUT with a real hDevice is a physical device, injected input has none) or low-level hooks (WH_MOUSE_LL, WH_KEYBOARD_LL) where the LLMHF_INJECTED / LLKHF_INJECTED flags mark injected events; the driver's own SendInput carries its dwExtraInfo tag. | None for a Medium-IL process on its own desktop; input to higher-integrity windows (UIPI) and the secure desktop is out of reach and never driven. |
Stopping. On detection the batch stops at the next check: nothing more is sent; every key and button the driver holds is released (the release path of the kill switch: releases are sent even after cancellation, with bounded I/O); and the call returns user_took_over (retry never, suggestion "The person took control. Wait for them to click Continue, then start again."), with effect: "stopped", paused_reason and, for typing, typed_characters. An act whose synthetic part already finished before the person's input returns its normal result. While paused, every act of the session (every tool that is not a read or a session tool) is refused before anything is sent with the same code and effect: "refused"; reads still work. The audit log records takeover.paused (session, paused_reason, time) and takeover.resumed (session, by, time); never key names, characters or positions.
paused_reason is one of pointer_moved, key_pressed, button_pressed.
The paused indicator. The label changes to "You took over · Continue when ready", the frame dims, and a small Continue button appears under the label. The button is the only part of the indicator that takes input, and it accepts only a real mouse click, by the same rule as the consent dialog: a left-button press and release on it, not a SendEvent, and no driver button press in the last 1.5 s; no keyboard. Hit-testing skips it (the driver never targets it) and captures never contain it. If another X client unmaps or destroys it, the takeover ends, as for the rest of the indicator.
Resuming, only by a person's click. A paused takeover resumes when:
- the person clicks the indicator's Continue; or
- the host sends
takeover.resumeon the inherited control pipe, after a click in its own UI (NishBot's run view, a Vixy character line):{"method": "takeover.resume", "params": {"session": "<id>"}}(withoutsession, every paused session of this driver). The ack lists the sessions resumed:{"event": "ack", "method": "takeover.resume", "resumed": ["<id>"]}.
Nothing else resumes: no MCP tool, argument or text (takeover.resume is not a tool, and the daemon socket answers it with unknown_method), not voice or chat (they reach the driver only as MCP), not start_session, end_session or a new consent. Resuming does not extend the lease or widen the app scope. It makes every token issued before it stale (stale_element_token, after_refresh), so the next action reads the window again; row numbers of unchanged elements stay the same. If the lease ended during the pause the session needs a new consent (consent_expired), and it stays paused until the person's click.
With the other controls. The kill switch and session.revoke still stop everything, paused or not; a resume never undoes them. start_session {input: "headless"} while paused ends the takeover and removes the indicator at once; the session stays paused (its acts refused) until the host's takeover.resume, or until a new takeover's indicator, which comes up paused with its Continue button, is clicked.
State and events. start_session and get_session_state report takeover.paused (true|false) and, while paused, takeover.paused_reason. Hosts get events on the control output pipe, so they can show their own "You took over · Resume" without polling:
{"event": "takeover.paused", "session": "mcp:host:4242", "reason": "pointer_moved", "at_ms": 18234}
{"event": "takeover.resumed", "session": "mcp:host:4242", "by": "indicator", "at_ms": 21007}by is indicator or host. at_ms is the driver's monotonic clock (as in the session.revoke ack).
5. Notifications and clipboard
Each command maps to a capability the backend reports (§6.2). Where the OS gives no API the command returns unsupported and the capability says unsupported; a driver MUST NOT fake one (freedesktop, for example, gives no list of other apps' notifications).
5.1 Notifications
| Tool | Kind | Rules |
|---|---|---|
list_notifications {} | read | Only notifications whose sender pid is an approved app; at most 20; {id, pid, app, summary, body, redacted, actions:[{key,label}]}. One-time codes and credential-looking bodies have body: null, redacted: true; a summary that itself holds a code or a credential is returned as "" (also redacted: true). App text is untrusted (app_text_untrusted: true). |
dismiss_notification {id} | act | Host approval required (no host approval channel: approval_denied). The notification must belong to an approved app. |
invoke_notification_action {id, action} | act | As dismiss; action must be one of the notification's listed keys (else invalid_arguments, nothing is invoked). |
Linux/X11: all three are unsupported (reason no_platform_api). Windows (M2) and macOS plan to use the OS notification APIs where they exist.
5.2 Clipboard
| Tool | Kind | Rules |
|---|---|---|
clipboard_read {max_chars?} | sensitive read | Off by default: needs start_session {clipboard_read: true} and the person's tick on its own consent line, else capability_not_consented. Approved by the host on every call: preapproved and approve_reads: false do not skip it; without a host approval channel it is approval_denied. At most 64 KiB is read; max_chars (default 4,096, at most 16,384) is returned, with truncated. Credential-looking content (§5.3) is refused with secure_field_refused, never returned. |
clipboard_write {text} | act | Host approval required. Printable text, newlines and tabs, at most 16,384 characters. Credential-looking text is refused. The text is offered until the session ends (or another app takes the clipboard); on X11 it disappears when the session ends unless a clipboard manager kept it. |
Linux/X11 implements both with the CLIPBOARD selection (ICCCM): read converts to UTF8_STRING, then STRING, and refuses incremental (INCR) transfers; write owns the selection from a thread that stops with the session. The audit log records only lengths, never clipboard text.
5.3 Credential-looking text
The secure-field rules applied to text without a field: private key blocks; password=, secret:, api_key=, token: and similar pairs; known token prefixes (GitHub, GitLab, Slack, OpenAI-style sk-, Stripe, Google AIza, AWS access key ids); JSON web tokens; and a clipboard holding a single 16–512 character word that mixes at least three character classes. The heuristic errs towards refusing.
6. Backend registry and the doctor report
6.1 Registry
A backend per OS part, each available, unavailable or planned, with a reason_code and an action sentence a person can act on. required parts must all be available for ready.
| OS | Id | Required | For |
|---|---|---|---|
| Linux | x11 | yes | windows, focus, the local consent dialog |
| Linux | atspi | yes | trees, semantic actions, secure-field metadata |
| Linux | xres | yes | verified window ownership (XRes client pids) |
| Linux | composite | no | per-window capture |
| Linux | xtest | no | takeover input |
| Linux | xinput2 | no | takeover hand-back (§4.3; takeover needs it) |
| Linux | wayland-portal | no | planned (§10) |
| Windows (M2) | uia, com-office, dxgi-capture, sendinput, rawinput | — | planned |
| macOS | ax, screencapturekit, cgevent, cgeventtap | — | planned |
health_report (MCP) carries backends and capabilities; the doctor (§6.4) is the full report.
6.2 Capabilities
read_tree, act_semantic, capture_window, input_takeover, takeover_indicator, takeover_handback (new in 0.8: available on X11 with XInput 2.1, else unavailable with xinput2_missing; planned on macOS and Windows; input_takeover is available only with it), list_notifications, dismiss_notification, invoke_notification_action, clipboard_read, clipboard_write. Each has status (available, unavailable, unsupported, planned), reason_code, action, and consent: session (the app consent), takeover (its own line), separate (its own line plus host approval per call) or host_approval.
6.3 Reason codes
display_unreachable, display_not_local, x11_extension_missing, atspi_bus_unreachable, atspi_bus_not_advertised, xres_missing, composite_missing, xtest_missing, xinput2_missing (new in 0.8), no_platform_api, not_built, wayland_session_unsupported. Reserved for the Wayland task (§10): wayland_portal_not_built (used today by the planned entry), portal_unavailable, portal_screencast_denied, portal_remote_desktop_denied, portal_global_shortcuts_denied, compositor_unsupported.
6.4 nish-driver doctor --json
Versioned (schema: "nish-driver/doctor", schema_version: 2), network-free, and readable by a first-run wizard. Exit status 0 when ready, else 1. The JSON schema is nd_core::registry::doctor_schema(); the conformance tests validate the report against it, with and without a display.
{"schema": "nish-driver/doctor", "schema_version": 2,
"driver_version": "0.1.0", "contract_version": "0.8.0",
"platform": "linux", "session_type": "x11",
"ready": false,
"backends": [{"id": "x11", "status": "available", "required": true, "reason_code": null, "action": null},
{"id": "atspi", "status": "unavailable", "required": true, "reason_code": "atspi_bus_unreachable",
"action": "Turn on assistive technologies in your desktop's accessibility settings, then sign out and in again."},
{"id": "wayland-portal", "status": "planned", "required": false, "reason_code": "wayland_portal_not_built",
"action": "The Wayland portals backend is planned; use an X11 session for now."}],
"capabilities": [{"name": "clipboard_read", "status": "available", "reason_code": null, "action": null, "consent": "separate"}],
"permissions": [{"name": "accessibility", "status": "missing", "reason_code": "atspi_bus_unreachable", "action": "..."},
{"name": "screen_capture", "status": "not_applicable", "reason_code": null, "action": null}],
"next_steps": [{"reason_code": "atspi_bus_unreachable", "backend": "atspi", "permission": "accessibility", "required": true, "action": "..."}],
"network": {"code_paths": "none", "telemetry": false, "update_check": false},
"ok": false,
"probes": [{"label": "display server", "status": "ok", "message": "X11 :0"}]}session_type:x11,wayland,none(alsomock, andaqua/desktopreserved for macOS and Windows).next_steps: one entry per distinct reason code, with the unavailablebackendand/or missingpermissionit explains (a missing permission whose reason an unavailable backend already gave joins that entry); what the person must do, in plain words.okandprobesare the 0.6 doctor fields, kept for older readers (oknow equalsready).
7. Act-then-observe and safe readback
7.1 observe
Every act tool (click, double_click, right_click, type_text, set_value, press_key, hotkey, scroll, drag) accepts observe: {tree?: bool, screenshot?: bool} and returns today's result and diff plus observe:
snapshot_id,truncated; withtree:elementsandtree_textof the fresh read (the diff's settled read when there is one), with secure values redacted as in get_window_state, and at most what a default read shows (250 rows, depth 25,truncated: truewhen cut; for more, call get_window_state);- with
screenshot:imagemetadata and the image block, under the same rules as get_window_state (complete tree, known secure-field bounds, blacked out, capture rate limit 5/s); - an observation that cannot be made is reported as
observe.errororobserve.screenshot_error(an envelope withoutok) and never turns the act into an error: the act already happened. - an act with
observeis also a read: when the host approves reads one by one (approve_reads, the default), a pre-approved act that observes is sent to the host for approval, and the approval card's description carriesobserve: {tree, screenshot}.
7.2 set_value readback
set_value reads the value back and returns readback: match (then effect: "confirmed"), mismatch (never confirmed or verified, even when the diff shows the field changed), or unverified (the read failed, or the field is secure). Before reading, the driver re-checks the field's security live; a password or secure field is never read back. (Secure fields are refused before any write in the first place; the re-check covers a field that turns secure during the call.)
8. Tool schemas and conformance
tools/listinput schemas keep the 0.6 rules: roottype: "object"withproperties, no$ref, no root combinators, under 12,000 bytes each.- Output schemas of every tool (
structuredContentof a success) arend_core::schemas::output_schema(tool); failures follownd_core::schemas::error_schema(). They are not advertised as MCPoutputSchemayet (some MCP clients reject results that do not match an advertised schema exactly); a later minor version may. crates/core/tests/contract.rsexercises every tool against the mock backend and validates each input and output;crates/core/tests/handback.rscovers §4.3 on the mock, so it runs on every OS; the Linux backend's X11 tests cover the deltas it implements (§4 indicator, headless pointer and hand-back, §5 clipboard).
9. Windows (M2) plan: app APIs before clicks
Design only; no Windows code exists yet.
9.1 Order of preference
- The app's own automation API, when the app has one and the person approved that app: COM automation for Office documents (Word
Documents/Range, ExcelWorkbooks/Range, PowerPointPresentations/Slides/Shapes). These read and change documents exactly, in the background, without focus. - UI Automation patterns (Invoke, Value, Toggle, SelectionItem, ExpandCollapse, Text, Scroll) on the UIA tree, in the background.
- Synthetic input (SendInput) only in a takeover session (§4).
The tree the model sees is the UIA tree, merged with the app's object model where one exists: a UIA document element gains app_object handles (for example an Excel range Sheet1!A1:C10, a Word paragraph index) so app actions can target what the model sees. The merge is by stable identifiers (the window, UIA RuntimeId and the object model's own ids), never by matching visible text. For custom-drawn controls with no UIA element, a later version may add visually detected regions to the tree; they are marked detected: true, carry no semantic actions (only takeover clicks), and their results are never verified without a readback.
9.2 App actions (contract shape, reserved)
{"name": "list_app_actions", "input": {"pid": 0, "window_id": 0}}
-> {"app": "excel", "actions": [
{"action": "excel.set_range_values", "risk": "edit", "consequential": false,
"params": {"type": "object", "required": ["range", "values"],
"properties": {"range": {"type": "string"}, "values": {"type": "array"}}}},
{"action": "word.save_document", "risk": "write_file", "consequential": true, "params": {}}]}
{"name": "app_action", "input": {"pid": 0, "window_id": 0, "action": "excel.set_range_values",
"params": {"range": "Sheet1!A1:B2", "values": [[1, 2], [3, 4]]}}}
-> act result with "path": "app_action", "effect", "verified", "readback", "diff"Safety rules for app actions:
- Typed and declared. Only actions the driver declares, each with a JSON schema for its parameters; no free-form scripts, no VBA, no
Run/Executeof macros, no arbitrary COM method names from the model. - Scoped. Only on documents of apps the person approved in the consent dialog, re-checked by process identity on every call, as for UIA.
- Host-approved when consequential. Every action declares
consequential; consequential ones (save, save as, send, print, delete sheet/slide, close without saving, anything that leaves the document) need the host's approval on each call and cannot be pre-approved. Edits inside an open document follow normal act approval. - No file system or network reach. No path arguments from the model (save uses the document's own path), no external data connections, no add-in loading, no hyperlinks followed.
- Secure content stays secret. Password-protected documents and protected ranges are not unlocked; cells or fields that look like credentials are read as redacted, as in §5.3.
- Readback. Every edit reads the changed object back (
readbackas in §7.2) and returns a diff in object-model terms ({"range": "A1", "from": 1, "to": 2}). - Errors use the §2 envelope; a missing or unregistered COM server is
unsupportedwith the registry entrycom-officeunavailable.
10. Wayland (separate task)
Wayland is a separate L-sized task. This contract only reserves:
- the registry entry
wayland-portal(plannedtoday, reasonwayland_portal_not_built); - the doctor reasons
wayland_session_unsupported,portal_unavailable,portal_screencast_denied,portal_remote_desktop_denied,portal_global_shortcuts_denied,compositor_unsupported; - the expectation that on Wayland, headless mode is AT-SPI only, takeover uses the RemoteDesktop portal, and capture uses the ScreenCast portal with the person's OS-level grant shown in the doctor's
permissions.
11. What a host changes for 0.8
- Handle
user_took_over: stop the plan, tell the person the driver stopped because they took over, and wait. - Listen for
takeover.paused/takeover.resumedon the control output pipe and show "You took over · Resume" (NishBot's run view, a Vixy character line). On the person's click on Resume, send{"method": "takeover.resume", "params": {"session": ...}}; never send it on the model's, a voice or a chat request. - After
takeover.resumed, read the window again before acting (older tokens are stale).
12. What a host changes for 0.7
- Call
start_session {input: "takeover"}before any foreground or synthetic action; handlerequires_takeover. Background work needs no change. - Read
error.disposition.retryanderror.suggestioninstead of parsing messages; handleambiguous_targetcandidates. - Offer clipboard reading only with
start_session {clipboard_read: true}and approve eachclipboard_readon the host's approval channel. - Use
skeleton: truethenrootfor dense apps; useobserveto save a round trip after acting; readreadbackafterset_value. - Read
doctor --json(schema_version2) in the first-run wizard.