# Control protocol The [universal panel foundation](panel-foundation.md) supplies typed layout actions and retained runtime guards. The application-layout commands below are registered engine commands reached through `engine.execute`, `engine.batch`, `engine.script` and MCP's existing `execute`/`batch` tools. They require an injected application backend; enabling the internal panel runtime remains an explicit integration step. Current product defaults and the production shell are unchanged. ### Application panel and layout commands Every command has input and output schemas through ordinary command discovery. Inputs are strict at every parameter-check level: unknown fields, including nested placement fields, are errors. As with other engine calls, omitted/null parameters mean an empty object. IDs use the foundation's bounded lowercase ID domains; dimensions are finite positive outer logical units, at most 32768. Requests are bounded to 16384 encoded bytes before cloning or deserialising. No command opens a dialog, edits the document, changes dirty/undo/revision, evaluates or tessellates a model, fits a body, follows a camera request or selects a ribbon/tool/document tab. Layout revision is independent of document revision. Canonical existing targets are typed: `{"target":{"type":"instance","id":"log"}}`. `section`, `workspace` and `native_host` are the other foundation target types; each command accepts only the domains listed below. A legacy `id` may replace `target`, but both together are rejected. It first resolves an exact instance ID. A kind ID resolves only when its registration is single-instance and exactly one canonical instance exists, including retained closed state. Multi-instance kinds are rejected even when only one instance currently exists. No first-match guessing or implicit instance creation occurs. `panel.open` instead requires separate `kind` and `instance` fields. All mutations accept optional `expected_revision:u64`. A stale revision produces the foundation `revision` rule and leaves the command's layout and catalogue unchanged. Successful mutations return the committed `LayoutReply` (layout, revision, interaction, effects, capabilities and availability), never an echoed placement request. `layout` includes all retained instances with `requested_open`, ordered sections and their active tabs. Missing availability entries mean available. Query replies contain no new host effects. | Command | Parameters and behaviour | |---|---| | `panel.list` | `{}` → `{state:LayoutReply,kinds:[PanelSpec]}` from the live sink and registry. | | `panel.open` | `{kind,instance,destination?,expected_revision?}`. New instances require a destination; closed instances can reuse remembered placement. | | `panel.close` | `{target:instance,expected_revision?}`. Explicit guarded close retains placement and can refuse cancellation. | | `panel.move` | `{target:instance,destination,expected_revision?}`. Existing open instances only. | | `panel.resize` | `{target:section|instance,size:{width,height},expected_revision?}`. Instance must have an open section; all tab bounds intersect. | | `panel.collapse` | `{target:section|instance,collapsed:bool,expected_revision?}`. Keeps requested dimensions. | | `panel.float` | Same as move, requiring a `new_section` with `floating` location. | | `panel.popout` | Same as move, requiring a `new_section` with `window` location and return destination. Desktop/headless without native capability refuse; web explicitly lowers to `floating`. | | `panel.focus` | `{target:instance,expected_revision?}`. Reopens retained closed state, activates, expands and requests focus. | | `panel.activate` | `{target:instance,expected_revision?}`. Activates an open tab without expanding or requesting focus. | | `panel.peek` | `{target:instance,expected_revision?}`. Temporary presentation of a collapsed tab, without persistence changes to size/collapse. | | `panel.dismiss` | `{expected_revision?}`. Dismisses transient peek. | | `panel.resize_pair` | `{first:SectionId,second:SectionId,first_size:{width,height},second_size:{width,height},expected_revision?}`. Adjacent sections of one dock; conserves split-axis extent and cross-axis dimensions. | | `panel.reset_pair` | `{first,second,expected_revision?}`. Resets adjacent sections to the registered default ratio within intersected bounds. | | `panel.native_close` | `{target:{type:"native_host",id:HostId},expected_revision?}`. Native window close redocks its whole section; it does not explicitly close/cancel content. | | `layout.save` | `{name,overwrite?:false,expected_revision?}` → `{name,overwritten,state}`. Saves the complete current envelope in the injected catalogue. | | `layout.apply` | `{name,expected_revision?}`. Applies a named preset to the **current** workspace, rebinding its known layout workspace ID. | | `layout.reset` | `{expected_revision?}`. Applies the selected workspace's registered immutable default. User presets and other workspace histories remain. Missing defaults return a capability error. | | `layout.get` | `{}` → `{state,workspaces:[{id,title_key,default_preset,has_remembered_layout}]}`. Selection is only `state.layout.workspace`. | | `layout.workspace.select` | `{workspace:WorkspaceId,expected_revision?}` → `{previous_workspace,source:"remembered"|"default_preset"|"unchanged",state}`. Registered descriptors only; no implicit creation. | Destinations are distinct foundation operations, not aliases: ```json {"type":"new_section","section":"logs","index":0,"location":{"type":"dock","zone":"left"},"size":{"width":280,"height":240}} {"type":"join_tab","section":"logs","index":1} ``` New-section indices address the global section order after detachment; join indices address the destination's tab list after detachment. Joining keeps the destination's activation, collapse and dimensions. Locations are `dock` (left/right/bottom), `floating` (`origin:{x,y}`), or `window` (`host`, `origin`, `return_to`). Native `return_to` contains `{zone,index,size,collapsed,join?:{section,index}}`; it never recursively contains another native host. Pins and placement/size intersections retain the foundation's structured refusal rules. Preset names trim surrounding whitespace, require 1..64 Unicode scalar values and reject controls anywhere in the supplied name. Raw input is capped at 1024 UTF-8 bytes before trimming; the trimmed name is at most 256 bytes. Names are case-sensitive. There are at most 64 user presets. Collisions reject unless `overwrite:true` is explicit; built-ins are immutable. No production kinds, preset contents or workspace descriptors are invented. Headless starts with an empty registry/catalogue and an internal current-layout identity, not a registered product workspace. Fixtures and peer integrations can inject synthetic or real registrations. Missing capabilities return errors rather than guessed defaults or disk/native success. Workspace selection stages the outgoing full envelope, destination and selected workspace together, then uses one guarded `Replace` on the retained runtime. Same-workspace selection is a true no-op, preserving revision, focus, peek and effects even if storage staging is unavailable. Applying/resetting a preset never selects a workspace. Replacements hide omitted content without implicit Close or Cancel; retained editors, conversations and hidden services survive A→B→A. Catalogue preparation uses encoded full `LayoutEnvelope` records and explicit aggregate caps: 64 registered workspaces, 64 built-ins, 64 remembered envelopes, 64 user presets, 16384 metadata bytes, and 4194304 aggregate envelope/recovery bytes. Each envelope/rejected recovery buffer remains at most 262144 bytes and obeys the foundation node/depth/collection caps. Quarantine, retained data, shared section snapshots including projected sections, complete section order and bounded rejected recovery intent survive save/apply/reset. Conflicting opaque identities, snapshots, retained data or recovery intent reject atomically. Restoration uses the foundation decoder and reappearance logic. Storage preparation finishes before live dispatch; catalogue publication is infallible after successful native/controller guards. Revision changes during staging reject the staged catalogue. No fallible storage write after live commit is treated as an atomic success. The shipped catalogue is in-memory; production preference durability and migrations remain peer work. Atomic layout batches contain 1..64 commands, using the same parser/executor and one final guarded Apply or Replace plus one catalogue publication. Queries and saves may accompany mutations; save/apply/reset/workspace sequences can stage together. Replies describe the **final committed** state, not hypothetical intermediate live states. Every `expected_revision` in an atomic batch refers to the initial live revision. Explicit Close/Open conflicts remain `lifecycle` errors. Atomic batches cannot combine a snapshot replacement with explicit panel mutations, or mix document commands with application layout; they return `capability` before publishing anything. This preserves the foundation's explicit lifecycle intents. Use `rollback:false` for sequential per-command transactions across these domains; failures report `rolled_back:false`, `undone:0` and completed results. Scripts use the same injected backend sequentially and stop at the first failure. A Session-only executor (including ordinary CLI design scripts without an injected backend) returns a structured missing-capability error for layout commands. The GUI route uses the enabled runtime's actual content-service provider and an injected `NativeHostAdapter`, never a reconstructed LayoutService. Headless MCP owns one persistent application backend distinct from Session; Remote forwards to the app and keeps no mirrored layout. Auto mode does not discard headless layout work when an app appears. Foundation errors retain `{rule,detail}` with bounded details: control replies add `panel_error` alongside the existing error string; MCP tool errors add `structuredContent.panel_error`. The error string also encodes that object, preserving identity through older string-only backend boundaries. Rust integrations implement `ActionSink::project(LayoutAction)` as a pure projection on the authoritative service. Its default returns `capability` so an older inspect/dispatch-only sink cannot accidentally publish unstaged commands. `LayoutService` and `RuntimeActionSink` provide it using the foundation's own preparation path. `with_live_layout` refreshes the envelope's shared section memory and projections without discarding opaque fields, then encodes against the same foundation limits before publication. Storage consumers can enumerate `CatalogueState::user_presets()` read-only and round-trip a complete validated catalogue with `export()` and `CatalogueState::hydrate(bytes,registry)` or `InMemoryCatalogue::from_snapshot`. Hydration is independent of live commands; restored users remain explicitly overwritable users, while built-ins remain immutable. Workspace descriptors, remembered full envelopes, and rejected recovery bytes are included. The versioned snapshot uses `SCPCAT01`, then length-prefixed little-endian u32 counts/UTF-8 names and raw byte records, in built-ins/users/workspaces/remembered order. Each envelope record contains length-prefixed envelope JSON, a recovery-presence byte (0/1), and, when present, length-prefixed exact recovery bytes. Workspace descriptors contain ID/title-key/default-name strings. The snapshot has an additional 8192-byte framing allowance above the existing aggregate payload/metadata caps (4218880 bytes total). Every count/string/record is bounded before allocation, and hydration rejects duplicate names across users/built-ins, duplicate workspace keys, corrupt framing, trailing data, invalid envelope identities and all existing aggregate/node/depth limits before a candidate can be published. This seam performs no disk storage. Reading a retained envelope does not consume quarantine merely because the registry has grown. `decode_retained_record` validates an already-retained record exactly; `decode_retained` additionally checks its workspace. Neither reader generates section order/projections or performs quarantine migration/reappearance. Hydration preserves the original validated record bytes and rejected binary data, including records at the per-envelope or aggregate cap when optional panel kinds disappear or return. Missing registrations do not invalidate unrelated users, built-ins or workspace memories. Retained validation checks structural ownership, placement bounds and all applicable known-kind constraints, without invented descriptors. An exact retained layout can still contain absent kinds and is not live admission. `StoredEnvelope::project_envelope` (or foundation `project_retained_record`) stages missing-kind quarantine before a guarded live use, preserving full section order, activation and opaque records. This staging does not consume existing quarantine; ordinary save/live overlays therefore keep reappearance retryable after registry growth. Foundation `decode` also stages migration and attempts restoration, with its existing fallback/rejected-input result contract. Materialization may legitimately exceed a cap and reject its staged candidate; reading/hydrating the accepted original record does not materialize it. Apply/reset/workspace replacement explicitly stage restoration, and consume it only with a successful authoritative Replace and catalogue publication. A failed action leaves the restore obligation retryable. `panel.popout` requires the panel's `features.popout` on both web and native policies before any enabled web fallback; `panel.float` remains separate. Application command, atomic-batch and GUI action boundaries refresh the current read-only provider/document environment and availability. A document or contextual model change earlier in a sequential batch/script is visible to the next panel step without a tick. Queries and failed actions cannot borrow the previous document's context. No content tick, document edit, geometry work or camera tail is added to this boundary. All application GUI and command routes use one stored native adapter, installed once; redock its native sections before disabling it. Unsupported atomic intent mixtures are rejected by pure structural preflight before any context/controller publication as well as before layout/native effects. At MCP tool level, an atomic batch containing any panel/layout command must reach the authoritative batch transaction. If the endpoint is unavailable or the encoded request exceeds its transport margin, a structured zero-effect capability/size refusal replaces the old sequential fallback. No document checkpoint or individual layout command is sent. Explicit `rollback:false` keeps the sequential fallback and reports completed layout changes honestly. Document-only batch fallback keeps its existing checkpoint/rollback behaviour. Explicit null parameters normalize to an empty object in single, atomic, sequential and script routes for GUI/headless. Compatibility: `view.layout` still means Floating/Compact **surface styling**. History-in-Browser, History Bodies/Origin and Render search/options remain content preferences. Existing `view.history_layout`/`view.render_dock` controls continue to serve the existing shell. There is no safe universal alias without registered instance identities: `browser`/`under_browser` combine content with placement, and Render dock changes can affect a pair. They are not silently mapped to universal instances. Integrations with explicit identities can issue typed `panel.move`, `panel.resize` and `panel.collapse` requests for safe placement changes. No command implicitly selects a layout workspace on a document, ribbon, tool or context change. For UI-kit review, `ui.status_fixture {"preset":"mode"}` shows bounded status specimens in the real viewport. Presets are `off`, `mode`, `result`, `attention`, `row`, `kinds`, `many` and `key`; the reply carries the preset and item count. This scenario fixture does not change engine state or open a dialog. `off` clears it. The published `viewport_status::item`, `:setting`, `:key` and `:close` handles support the ordinary `ui.at`, `ui.move`, `ui.click` and keyboard controls. With `viewport_status_summary` enabled, `kinds` (three) and `many` (four) exercise the summary component, using explicit specimen newest metadata. The `viewport_status:summary` and `menu:viewport_status_clear_all` handles open its native menu and activate its footer. These specimens are not persistent engine results; actual production sources currently supply at most three Mode items. Toasts the user may close have a close (×) at their top right instead of a Close button, published as `toast::close`: `toast:status:close`, `toast:space_mouse_offer:close` and `toast:stand_up:close` (#7971). Those that go by themselves draw a thin ring around it that shrinks over their time; the ring is full again while the pointer is on a toast that waits for it. Live details publish `details:field:`, `details:show_all` and `details:action:copy` handles. `details_findings` and `details_readout` are explicitly labelled component-only `ui.status_fixture` presets. Real card settings execute the normal analysis/Section commands; agents can read the same structured settings and retained Continuity records with the pure `view.status_details` engine command. Its `current` flag guards geometry targets, and its per-record limits prevent mixed measurements from being mistaken for one global tolerance. The control channel is **on by default**. SolidCraft listens on `127.0.0.1` only, for JSON lines, so agents can drive the app without a launch flag. Every connection still needs this launch's token (below); nothing else on the machine, and nothing off it, can send a command. Internal rendering harness: `ui.blur {radius_px?, surface_alpha?}` queries or sets session-only panel blur. Radius is finite physical pixels in 0..40; the optional capture tint is 0..1 (`null` restores ordinary fills). This is not a saved preference. The reply contains requested radius, capability/reason and cumulative actual effect counters, including live resources and encoded passes. Unsupported software/web/source contexts stay on ordinary painting. Use `ui.screenshot` for panel evidence; `ui.render` remains a model-only capture. Activation, full egui GPU fixture and manager-run evidence commands are in [blur-behind.md](blur-behind.md). | Launch | What listens | |---|---| | `solidcraft --control PORT`, or `SOLIDCRAFT_CONTROL_PORT=PORT` | exactly `127.0.0.1:PORT` (the flag wins over the variable), whatever the setting | | neither, and Preferences > Assistant > "Let AI agents connect (control channel / MCP)" on (the default) | the default port **27878**, or the next free one up to 27893 when it is taken (a second window, another program) | | neither, and the setting off | nothing | The setting is read at start-up. Old preference files, which don't have it, load with it on. A launch with `--control` or the variable behaves as it always did (the start page stays hidden, the window doesn't ask for the foreground), because a scripted launch expects that; the default channel changes neither for a normal launch. ### Finding the port Each app that opens its channel writes the port to a discovery file beside the token files, `control/current.port` (same folders and permissions as below), and removes it at exit if no later app has replaced it: it names another port, or another app now holds that port's token file (two Flatpak windows, each with its own loopback, can listen on the same port number). It holds only the port, never the token. `solidcraft-cli mcp` with no arguments reads it, checks that something listens there and bridges to that app; with no app running, or with `--headless`, it runs a session of its own. `--connect HOST:PORT` and the other arguments still decide first. With several windows open, the most recently started one is the one found; use `--connect` for another. Requests are one line `{"id": …, "method": "…", "params": {…}}`; replies are described below. Each request is one line `{"id": …, "method": "…", "params": {…}}`; each reply is one line `{"id": …, "ok": true, "result": …}` or `{"id": …, "ok": false, "error": "…"}`. Requests are handled on the UI thread between frames; pointer and keyboard input is injected as real egui events, one pointer event per frame. A connection that sent `control.subscribe` is also sent event lines `{"event": "…", "params": {…}}`, which have no `id` ([Following the design](#following-the-design)). ### Token Every connection must authenticate. At start the app makes a random 256-bit token for this launch and writes it, as one line of hex, to a file only the user can read: | System | Token file | |---|---| | Linux | `$XDG_DATA_HOME/solidcraft/control/PORT.token` (`~/.local/share/solidcraft/control/PORT.token`) | | macOS | `~/Library/Application Support/SolidCraft/control/PORT.token` | | Windows | `%LOCALAPPDATA%\SolidCraft\control\PORT.token` | On Unix the folder is `0700` and the file `0600`; on Windows the file's inherited permissions are replaced by a single entry for the user. `SOLIDCRAFT_CONTROL_DIR` moves the folder. If Windows doesn't finish restricting the file within five seconds, the default folder (private to the user) keeps it, but a custom folder gets no token file, and clients then need `SOLIDCRAFT_CONTROL_TOKEN`. The file is removed when the app exits, unless another launch has written its own token there since, and the next launch writes a new token. The token file's path is printed to stderr at start-up. Beside it, `.pid` holds the app's process id (and, on Linux, its start time). An app that is force-quit can't remove its files, so a later client may send that launch's token. When the app refuses it, `solidcraft-cli mcp` names the token file it read and when it was written, and says the file is stale when the process in `.pid` has exited. Delete the file or restart SolidCraft. On Windows, an app or client started from inside an MSIX-packaged app (and the tools it launches) has its AppData redirected: it writes and reads `%LOCALAPPDATA%\Packages\\LocalCache\Local\SolidCraft\control\` before the folder above. The first request on a connection must carry the token as a top-level `"token"` member (`{"id": 1, "method": "ui.inspect", "params": {}, "token": "…"}`); later requests on the same connection may leave it out. A first line without the right token is answered with `{"ok": false, "error": "unauthorized: …"}` and the connection is closed, without running anything. Tokens are compared in constant time. A connection that hasn't sent that first request within five seconds is closed without a reply, and blank lines don't extend the time. So connect when you have something to send; once authenticated, a connection may stay idle. `solidcraft-cli mcp --connect 127.0.0.1:PORT` reads the token file for that port by itself (`--token T` or `SOLIDCRAFT_CONTROL_TOKEN` give one explicitly). For a test harness, or a sandboxed package whose data folder the client can't see, start the app with `SOLIDCRAFT_CONTROL_TOKEN` set to at least 32 hex characters (for example `openssl rand -hex 32`) and the app uses that token instead of making one; the file is still written. A one-line client in Python: ```python token = (Path.home() / ".local/share/solidcraft/control/7878.token").read_text().strip() sock.sendall((json.dumps({"id": 1, "method": "ui.inspect", "params": {}, "token": token}) + "\n").encode()) ``` ### Limits The channel is untrusted input. A request line may be at most 16 MiB; a longer one is answered with `{"ok": false, "error": "request too large"}` and the connection is closed, as are connections that send eight lines that are not JSON. At most 16 connections are served at once (further ones get `{"ok": false, "error": "too many connections"}`), at most 64 queued requests run per frame so a flood cannot starve drawing, and a request that the UI thread has not answered within 60 seconds (3600 seconds for `engine.batch`) is replied to with `{"ok": false, "running": true, "error": "timeout: … is still running …"}`. The same 16 MiB line limit applies to the MCP server. A timed-out request has not failed: it is still running in the app, and a change still applies when it finishes (a long fillet, say), so don't simply send it again. The timeout's text says what it still does: a request that only reads (`ui.inspect`, `document.inspect`, an inspection command) "only reads: nothing changes"; one that only writes a file (`file.export`, `file.save`, `file.save_as`, `file.save_mesh`, another export given a `path`, `ui.render`, or `ui.screenshot` with a `path`) "will still write the file when it finishes" and leaves the design as it is; any other request "will still apply when it finishes". The connection keeps its reply (#1618). Ask for it with `control.pending`: `{"pending": , "method": …, "done": false}` while it runs, then `{"pending": , "method": …, "done": true, "reply": {…}}` (its own reply, once), and `{"pending": null}` when nothing is outstanding. Until it lands, any other request on that connection is refused at once with `{"ok": false, "busy": true, "error": "busy: …"}` and not run, so nothing is queued to apply unseen behind it. `control.pending` belongs to the connection: a new connection has nothing pending. The bridged MCP server does this itself ([mcp.md](mcp.md#long-running-requests)). `control.pending` answers only for its own connection. `control.running` answers for the whole app, the same on every connection, at once (it never waits behind a running request, and a connection may ask it while it is `busy`): every request the app has been sent and has not answered yet, oldest first, from any connection, including a timed-out one whose connection has since closed: ```text {"id":1,"method":"control.running","token":"…"} → {"id":1,"ok":true,"result":{"running":[ {"connection":3,"id":17,"method":"engine.execute","command":"file.export","running_s":312.4,"queued":false}, {"connection":5,"id":2,"method":"engine.batch","steps":12,"running_s":4.1,"queued":true,"self":true}]}} ``` `running_s` counts seconds since the request arrived; `queued` is true while it still waits its turn behind another. `command` is the command id of `engine.execute` (a known one only) and `steps` the length of `engine.batch` or `engine.script`; parameters are never shown, so file paths and expressions stay with the connection that sent them. `self` marks the asking connection's own request. `{"running": []}` when the app is idle. `control.capabilities` says what this app's control channel does beyond the methods every version has, so a client can find out before it relies on it: `{"param_check": ["off", "warn", "strict"]}`, the levels a request's own `param_check` takes (below). It is answered at once, like `control.running`, also on a `busy` connection. An app older than it answers ``unknown method `control.capabilities` ``; a feature added later is listed here too. Any request's `params` may carry `allow_paths: ["/abs/folder", …]`: every file that request reads, writes or deletes (command paths such as `file.export`'s `path` or `doc.recovery_*`'s `dir`, `ui.render`, `ui.screenshot`) must then lie inside one of those folders after `..` and symlinks are resolved, or the request fails with `… outside the folders this agent may use …`. The app adds its own recovery folder and, when it lies safely inside the home folder and not in a hidden folder, the open design's folder (whose hidden files stay out of reach). With `allow_paths`, `design_folders: ["/abs/folder", …]` (at most 16) names folders the agent opened or saved a design in; each counts on the open design folder's terms. The bridged MCP server sends its folders this way on every request ([mcp.md](mcp.md)). Without `allow_paths` file paths are not limited: a client holding the token can do what the user can. `engine.execute`, `engine.batch` and `engine.script` may carry `param_check: "off" | "warn" | "strict"` in their `params`: the commands of that request are checked against their typed contracts at that level, whatever the app's own (which is `off`), as a headless session's `--param-check` checks them (#9184). `warn` runs a call that breaks its contract and adds `param_warnings` to its result; `strict` refuses it (a batch before its first step), and the design is left as it was. Every reply to such a request says the level it was checked at as `param_check`, beside `ok`. A command that switches to another design (`component.edit_in_place`, `component.finish_edit`, `view.next_tab`, `view.previous_tab`) has its parameters checked at that level before it runs, and then runs at the app's own level, as each design keeps its own. Any other word is refused; other methods ignore the field. An app older than this ignores the field and its reply has no `param_check`, so a client that needs the check asks `control.capabilities` first and treats a reply without `param_check` as unchecked. The bridged MCP server asks once per connection ([mcp.md](mcp.md)). ## Engine | Method | Params | Result | |---|---|---| | `engine.execute` | `command`: command id, `params`: JSON object | the command's result | | `engine.script` | `commands`: `[{command, params}, …]` | results, stops at the first error | | `engine.batch` | `commands`: `[{command, params}, …]` (at most 10 000), `rollback?`: bool (default true), `timings?`, `profile?`, `coalesce_parameters?`: bool (default false) | `{completed, results, undone}`, plus `failed: {index, command, error}` when a step fails, and with `timings` `timings: [{index, command, ms}]` (the failing step included) and `total_ms`; `profile` adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`. Runs the steps in one request, so within one frame, each as `engine.execute` runs it. The first failure stops the batch, and with `rollback` the design goes back to how it was before it (`undone`: the steps taken back, `rolled_back`: whether the design is back as it was, `false` only when the checkpoint could not restore it). Refused before anything runs, with an error starting `engine.batch unavailable`, while the agent cursor is shown, when a step loads a file (`doc.open`, `file.insert_step`, `file.insert_mesh`) or when it goes to another design's tab (`component.edit_in_place`, `component.finish_edit`, which `engine.script` refuses too). Send such steps one by one. A step the batch would certainly refuse at its turn is found before the first step runs (#5853): a step without a command, an unknown command id (with the closest ids) or, with strict parameter checking, a call that breaks its command's contract. The reply is then that step's failure with `completed: 0`, `undone: 0`, `not_run` listing every step and a `note`; nothing has run. With `coalesce_parameters`, each run of consecutive plain parameter edits (`parameters.change` with only a name and an expression, or with `changes`) is set as one `parameters.change {changes}`, so the design rebuilds once for the run. Every step still gets its own result (with `coalesced`: the run's length), and the run is one undo step. The run skips the states between its edits: an edit that would be refused on its own, but not once the run's later edits are in, goes through. A run whose combined edit is refused (a new parameter, a bad expression) runs step by step, so the refused step is named. Answered when done, for up to 3600 s | | `engine.commands` | — | every command: id, label, tab, panel, icon, shortcut (the user's current first key, or null when cleared), params doc, enabled | | `document.revision` | — | `{document_id, revision}`: the active design's id and a number that moves whenever the design changes, for programs that follow it ([Following the design](#following-the-design)) | | `control.subscribe` | — | `{document_id, revision}`, as `document.revision`; the connection is then sent a `design.changed` line whenever they change ([Following the design](#following-the-design)) | | `document.inspect` | `measure?`: bool | parameters, timeline (errors, timings), bodies, sketches, selection (assembly joints: `engine.execute` `joint.inspect {joint?}`, which reads only: each joint's two origins with the occurrence, the snap as saved, the feature, face, edge or sketch curve its names refer to, and whether it resolves or why not; kind, offset, angle, flip, values, limits, expressions and motion links) | Commands never open dialogs when run this way. `solidcraft-cli commands` prints the same registry. ### Following the design A program that follows the open design (a renderer kept in step, a live link) asks `document.revision`, which only reads and does not wait behind design changes (a slow command computing, a file loading, the agent cursor acting one out), and exports only when the answer changes (#6646): ```text {"id":1,"method":"document.revision","token":"…"} → {"id":1,"ok":true,"result":{"document_id":"18f3a0c2d4e5b6a7-1","revision":3}} ``` - `document_id` names the active design (its tab) while it stays open, through edits, saves and renames. Two open designs have different ids even with the same name, and each design started or opened gets a new one, also one opened or started in the same tab. Ids of one run never match those of another. Treat it as an opaque string. - `revision` moves whenever the design changes: an edit, a parameter, undo or redo, a design opened or started, what is hidden or shown (saved with the design), a component moved and not captured yet. It stays the same for reads, a selection, a camera move, a section or an analysis view. Revisions come from one counter for the whole run, so a revision never names two states, and it never goes back: changes undone before the next ask that end where the design was need no new one (its revision still names that design). Switching tabs changes `document_id`; back on a tab its revision is the one it had, if nothing changed it meanwhile. Compare both values, and export when either differs from the last answer. The same answer comes from `engine.execute` `document.revision` (and so from scripts and MCP `execute`), which waits its turn behind design changes like any command. `engine.execute` with `view.status_items` and `{}` reads the persistent viewport effects without evaluating the model or changing the design, undo history or view revision. It returns `{items: [...]}` in oldest activation order. Each item contains a stable slot `id` (`surface_analysis`, `section`, or the legacy independent `continuity`), `kind`, source `command`, structured `setting`, `has_key` and a `clear: {command, params}` request that can be executed unchanged. Settings retain numbers: lengths use millimetres, Draft's `angle_deg` uses degrees, directions are normalised, and Continuity's `edge_count` counts the seams currently kept for shading. Labels and units are formatted by clients. `engine.describe {command: "view.status_items"}` exposes both schemas. By default, surface analyses, including Continuity, share one slot; successful replacement keeps that slot's position and ends the previous analysis. Section stacks independently. Clearing and re-enabling a slot makes it newest. Ordering metadata belongs only to the running session; effects and ordering are not saved in the design. Existing analysis clear semantics apply: any surface-shading command with `{clear: true}` clears the active surface shading, whereas Continuity clears its seam records. Section clear never clears an analysis. The standard **Graphics → Viewport → Use a single surface analysis** preference is `single_surface_analysis` (default `true`). `ui.prefs {"single_surface_analysis": false}` restores the legacy behavior: surface shading and Continuity remain independently active and clearable, and `view.status_items` faithfully lists both, using a separate stable `continuity` ID. Together with Section there are at most three slots. Turning the preference back on keeps the last successfully started or changed analysis at the oldest analysis-group position beside Section. Queries never normalize state. Graphics Restore Defaults restores `true`; other page resets leave it alone. It applies immediately to all open designs without document edits or undo. Cancel restores the opening preference value, but does not restart an analysis already stopped by switching to single mode. Run that analysis again to restore coexistence after cancelling. Successful Continuity measurements with no seams create no item: legacy mode keeps existing surface shading; single mode ends the previous analysis as for other successful replacements. Headless SDK clients use `Session::set_single_surface_analysis(bool)` and `Session::single_surface_analysis()`. Configuration survives scratch/new/open session lifecycle; effects and chronology still follow their existing transient lifecycle. The policy is an application preference/session setting, never saved design-format data. Policy changes update the view revision only when visible effects or their declarations change. A client need not ask again and again: after `control.subscribe` on its connection, the app sends it a line whenever the active design changes (#6985): ```text {"id":1,"method":"control.subscribe","token":"…"} → {"id":1,"ok":true,"result":{"document_id":"18f3a0c2d4e5b6a7-1","revision":3}} {"event":"design.changed","params":{"document_id":"18f3a0c2d4e5b6a7-1","revision":4}} {"event":"design.changed","params":{"document_id":"18f3a0c2d4e5b6a7-2","revision":5}} ``` - The reply is what `document.revision` answers then (and, like it, does not wait behind design changes); no event comes before it. From there on, each `design.changed` line carries what `document.revision` would answer, sent when its `document_id` or `revision` differs from the last one this connection was told: an edit, undo or redo, a design opened or started, a tab switched. - Lines are coalesced, not sent per frame: at most about ten a second, and changes made faster (a parameter dragged) are followed by one line with the latest. Once the design stops changing, the last line names it as it is. - An event has `event` and `params` and no `id`; every reply still has its request's `id`. Each event is a whole line of its own, between replies, never inside one. Ignore events you don't know. - Only connections that subscribed get events, until they close (or shut their sending side). A second `control.subscribe` on a subscribed connection is refused with `already subscribed`; ask `document.revision` for the design now. A `control.subscribe` that timed out or failed did not subscribe the connection: send it again. ### Slow commands and the window (#1989) With the preference **Preferences > AI > Keep the window responsive while agents run commands** on (`ui.prefs {"agent_off_thread": true}`; off by default), a design change in the desktop app that takes longer than about 150 ms (an `engine.execute` of a modelling command, an `engine.batch` of them) computes off the window thread, so the window keeps drawing and answering while it runs: - The command is evaluated ahead on a copy of the design, which also makes the display meshes of the bodies it ends with. Then it runs for real on the window thread, reusing every feature the evaluation computed (kept for it, whatever the bounded evaluation cache dropped meanwhile), so the window waits only for the bookkeeping. Its reply, undo step, autosave and crash journal are the same as before. With the agent cursor shown, the evaluation runs while the pointer glides to the button, and the pointer waits there until it is done. - Meanwhile `ui.*` requests, `document.inspect` and `engine.commands` are answered at once (inspection shows the design as it was), also while the agent cursor acts a command out. Later design changes, from any connection, wait their turn and run in the order they arrived. - A person's edit made meanwhile goes first. The agent's command is then computed again for the design as it is and lands after that edit, never in the middle of it. If the design was replaced (another tab made active, a new file opened, its tab closed), the request fails at once with `… the design was replaced … nothing was applied`, and the requests behind it go on. - A chip in the view names the command, with **Stop**. Stop answers `{"ok": false, "stopped": true, "error": "stopped: …; nothing was applied"}` and the requests waiting behind it go on. The computation finishes unseen in the background. - `file.export` and `file.save_mesh` change nothing in the design. They run on a copy of the design as it was when they were asked for, and nothing waits behind them. - Batches with `timings` or `profile` still run on the window thread, so the times they report measure the real work. Timeouts and `control.pending` work as described above. A slow command is still answered when it is done, from a later frame. With the preference off, commands run on the window thread, and the window waits for each one. Product images use `render.preflight/start/status/cancel/export` through `engine.execute`. Keep the returned session-scoped job ID for subsequent calls; status does not block or render. See [render jobs](render-jobs.md) for capture, cancellation and export semantics. While an interactive native file picker is open, `ui.inspect` reports `file_picker_busy: true`. Engine commands, `ui.file` and `ui.home` requests wait for its answer; window resizing, inspection and screenshots remain available. Every `sketch.*` command that changes a sketch adds `created: {points, curves, constraints, dimensions}` to its result (and `wires` for 3D curves): the ids of what it added, in creation order, next to whatever else the command returns (`curves`, `sides`, `corners`…). Sketch ids are stable and never reused, even after a delete (`l1`, `l2`, then `l3` once `l2` is gone; only undo rolls the numbering back with the design), so they can go straight into later commands: ```text {"id":7,"method":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,0],[40,0]]}}} → {"curves":["l1"],"created":{"points":["l1.start","l1.end"],"curves":["l1"],"constraints":["k1"],"dimensions":[]},…} {"id":8,"method":"engine.execute","params":{"command":"sketch.dimension","params":{"entities":["l1"],"value":50}}} ``` A bare numeric `value` given to `sketch.dimension` (from the control channel, scripts or MCP) is read in the design's units, exactly as if it were typed into the dimension box: in an inch design `"value": 2` makes the line 2 in (50.8 mm) long. Point coordinates stay in millimetres. To pin a unit regardless of the design, pass an expression with one: `"value": "50 mm"` or `"value": "0.5 in"`. Angles are degrees unless the expression says otherwise. Any expression parameter of any command can also define a user parameter as it sets the value: `"distance": "depth = 15"` adds `depth` (15, read as the bare value would be) and stores `depth` in the input, in the same undo step. The reply names it in `defined` and lists every definition in `definitions: [{name, expression, unit}]`; a taken or reserved name fails the command and changes nothing. `parameters.change` with `"expression": "height = 12"` renames the parameter it changes instead (see the parameters guide). The viewport also shows an X/Y/Z-aligned dimension chain between the endpoints of the currently shown distance (centre, minimum or maximum), with each world-axis distance labelled separately as an absolute value (including zero). It uses the design's units and length precision, independently of the view or sketch plane. Preferences → Graphics → **Show X/Y/Z distances** controls these extra lines and labels (default on, saved between runs). Set `ui.prefs {"measure_axis_distances": false}` to hide them while keeping the total distance and the dialog's ΔX/ΔY/ΔZ values. These overlays remain visible until Measure closes. Overlay handles `measure:label:x`, `measure:label:y` and `measure:label:z` identify the extra labels; `prefs:measure_axis_distances` identifies the checkbox. A `ui.drag` from `measure:label` or one of the axis labels moves that label (screen offset, kept while the measured items stay the same; a double-click puts it back). It picks nothing and starts no box selection. ## UI | Method | Params | Notes | |---|---|---| | `ui.inspect` | — | UI state, pending file picker (`file_picker_busy`; compatibility alias `file_dialog_open`, no path), what is hidden (`visibility`: bodies, origin and construction items, sketches, joints (`hidden_joints`, joint ids); set it with the `browser.visibility` command), viewport rect, camera, the active tool (its command id), the open dialog (`dialog`, below), the windows open beside the model (`windows`, each named by what opens it: the `ui.start` command `parameters.change`, `document.metadata`, `sketch.inspect`, `assembly.bom` or `material.properties.validate`, or the `ui.help` item `about`, `updates`, `shortcuts`, `crashes`, `report`, or `messages`), what is under the pointer (`hover`, in the form of a selection item: `{"type": "edge", "body", "index", "mid", "point"}`, or null), the status line (`status`: text and whether it is an error), renderer, timings, what the GPU drew last frame (`draw`: `draw_calls`, `triangles`, `line_segments`, `supersampled`; null without a GPU) and the graphics `adapter`, live preview (`active`, `busy`, `error`, `ms`, bodies it stands in for) | | `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `dark`, `layout`, …), and the browser's view of the design, saved with it: `showOrigin` (the Origin folder's eye), `showSketches` (the design's Sketches folder, `c0/sketches` in `hiddenFolders`), `hiddenBodies`, `hiddenOrigin`, `hiddenSketches`, `shownSketches`, `hiddenJoints`, `hiddenFolders`, `hiddenProfiles`, `shownDims`, `lockedBodies` (whole lists; `browser.visibility` changes single items) | `dark: false` switches to the light theme; `layout: "floating"` (default) or `"compact"` (`"docked"` is also accepted) selects the transient shell layout without saving a preference. Scenarios use `{"call":"ui.set","params":{"layout":"docked"}}` | | `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | | | `ui.camera` | `target?`: `[x, y, z]` (mm), `yaw?`, `pitch?`, `roll?` (radians), `distance?` (mm), `fov?`, `up?`; `fit?`: `true`, `false` or `[occurrence id or name…]` (`root`: the bodies outside every component) | the viewport's orbit camera: it looks at `target` from `distance` away; `yaw` 0 looks along +Y (from the front), `pitch` is the elevation (±π/2: straight down or up), `roll` turns the image. Fields given are set first (the others keep their values), then `fit` frames the listed occurrences (or everything), keeping the direction. The reply, passed back, sets the same view again, so a framing can be stored and replayed. `fov` is the angle the view is drawn with: 0 (parallel) with Perspective view off or while a sketch is open, else `perspective_fov`; in Render, the Render camera's own. `fov` and `up` follow the perspective setting (`ui.set`), its angle (`ui.prefs {perspective_fov}`) and the up axis preference (`ui.prefs`) and can't change here; a refused field changes nothing | | `ui.start` | `command`, `params?` | like a toolbar click: starts the sketch tool or dialog for the command. `params` sets what the dialog opens on, and nothing runs until OK: `joint.drive` takes `joint` (id or name; without it, the joint picked in the browser, else the first joint that moves; a rigid joint opens to say why it has nothing to drive, with an Edit Joint button, handle `drive:edit_joint`) and `values` (numbers in degrees or millimetres, or expressions); `motion.study` takes `study` (id or name), which opens that study to edit and chart. A command that takes no params is refused before it starts; a param the dialog doesn't take is refused and the dialog closes. Replies what it opened: `tool`, `dialog` and `windows` as `ui.inspect` gives them, `status` (where a command that starts nothing says why), and `pending: true` when the start waits for the history to compute and opens on a later frame | | `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input | | `ui.move` | `x`, `y`, `shift?`, `alt?`, `ctrl?`, `cmd?` | pointer movement without a button; modifiers apply until the next synthetic move/click; a move with omitted modifiers releases them. With `nav: "touchpad"`, Shift pans and Alt/Option orbits. | | `ui.drag` | `x0`, `y0`, `x1`, `y1`, `via?`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; `via` is a list of `[x, y]` points passed through on the way (`steps` moves to each); a left drag on the model is a box selection (left to right: window, right to left: crossing), or with `lassoSelect` on (`ui.set`, the SELECT panel's Lasso) a freehand lasso that takes what is wholly inside it; with OpenSCAD navigation, plain left-drag on empty space orbits, Shift+left-drag box-selects, and drags starting on sketch geometry edit it; `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) | | `ui.menu` | `x?`, `y?`, `target?`, `close?` | with a point: open a context menu there as a right-click would (the viewport's marking menu, or a browser menu with `target: {"type": "body", "name"} \| {"type": "sketch", "id"} \| {"type": "component", "id"}`); without: the open menu (`radial` and `items`: id, label, shortcut, enabled, icon, placeholder; blank icons on headings/separators have `placeholder: false`) | | `ui.icon_audit` | `reset?`, `stop?` | `reset: true` enables collection on the UI thread and clears previous requests; returns `{requested: [{name, master, placeholder}]}` sorted by requested id. Each read drains names collected since the previous read/reset. Collection stays enabled until `stop: true` (which returns pending names before disabling it). Includes aliases, variable/table ids and cached paints. | | `ui.menuPick` | `item`: id or label | runs an item of the open menu, as clicking it does | | `ui.selectOther` | `x?`, `y?`, `pick?`, `add?`, `hover?`, `close?` | Select Other: with a point, list compatible hidden and visible geometry near the pointer by default (as holding the left button does; disable `select_other_hidden` for the original visible-only list); the popup fits the available window space and scrolls only for overflow; `pick`: select row `pick` (`add`: as Shift-click; with a dialog open, into its active input); `hover`: light a row up; without: the open list (`open`, `at`, `total`: everything under the cursor, `rows`: label, icon, sel, at most 20 per kind, nearest first) | | `ui.rename` | `text?`, `commit?` (default true) | finishes the rename box a Rename item opened | | `ui.selection` | | the selection, the open dialog's inputs, the hovered item, and the active sketch's drawn dimensions (`dimensions`: id and text centre, `dimension_selected`, `dimension_editing`), and finished sketches' dimensions shown in the model view (`shown_dimensions`: sketch, id and text centre; a double-click there edits the value) | | `ui.worldToScreen` | `points`: `[[x, y, z]…]` world points | their screen points | | `ui.triad` | | screen points of the 3D sketch move triad's handles (`x`, `y`, `z`, `plane`) | | `ui.sketchToScreen` | `points`: `[[x, y]…]` in active-sketch coordinates | their screen points (null when behind the camera) | | `ui.dialogInput` | `index` | what clicking an input's Select chip does: picks go to that input | | `ui.at` | `world: [x,y,z]` \| `sketch: [x,y]` \| `plane: "XY"` \| `axis: "Z"` \| `dimension: "d1"` (its value text) \| `handle: "arrow"` (a manipulator handle, see `scenario::publish_handle`) | the screen point (a scenario step's target; see `crates/ui-egui/src/scenario.rs`) | | `ui.pick` | `pixels?: [[x,y]…]`, `rays?: [{origin, dir}…]`, `frame?: "window"\|"model"`, `width?`, `height?`, `edges?` (default true), `edge_px?` (default 6), `all?`, `include_ghosted?` | read-only geometry under each pixel or ray (#6304): one entry per input, `null` where nothing is hit — body, face index, exact world `point` and outward `normal`, `depth`, `occurrence`/`component` for placed parts, `edge`/`vertex` within `edge_px` of a pixel, `section_cap: true` on a cut face. `window` pixels are physical pixels of a window screenshot; `model` pixels are pixels of a `ui.render` of `width`×`height` (default 960×600), so it works headless. As `view.pick` through `engine.execute`/`engine.batch`. See `crates/ui-egui/src/view_pick.rs` | | `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:` (the button alone: `button:`; a drop-down row: `dropdown:`, highlighted `dropdown_hot:`, its pin `dropdown_pin:`; Customize Toolbar's check boxes `customize:`), ribbon tabs `tab:` (MODEL, SURFACE, ASSEMBLE, INSERT, INSPECT, MANAGE, VIEW, SHEET METAL, WELDMENTS; contextual SKETCH; workspace RENDER), the toolbar menu button `ribbon:add_tab`, the hint when every tab is hidden `ribbon:tabs_hidden`, Preferences tab check boxes `prefs:toolbar_tab:`, panel labels `panel::` (also `ribbon:section::
`), document overflow list `tab:overflow`, workspace column `ribbon:workspace`, command-search launcher `ribbon:search`, browser rows `row: