# Control protocol `solidcraft --control PORT` (or `SOLIDCRAFT_CONTROL_PORT=PORT`) listens on `127.0.0.1:PORT` for JSON lines. 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. ### 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. The file is removed when the app exits, and the next launch writes a new token. The token file's path is printed to stderr at start-up. 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. `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 will still apply (a long fillet, say), so don't simply send it again. 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` 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. 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). 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 | 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?`: 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 or when a step loads a file (`doc.open`, `file.insert_step`, `file.insert_mesh`). Send such steps one by one. Answered when done, for up to 3600 s | | `engine.commands` | — | every command: id, label, tab, panel, icon, shortcut, params doc, enabled | | `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. ### 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), the request fails with `… the design was replaced … nothing was applied`. - 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. 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. ## 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, active tool/dialog, the status line (`status`: text and whether it is an error), hover, renderer, timings, live preview (`active`, `busy`, `error`, `ms`, bodies it stands in for) | | `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `dark`, `layout`, …), and `hiddenBodies`, `hiddenOrigin`, `hiddenSketches`, `shownSketches`, `hiddenJoints`, `hiddenFolders` (whole lists; `browser.visibility` changes single items) | `dark: false` switches to the light theme; `layout: "floating"` (default) or `"docked"` 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` and `up` follow the perspective setting (`ui.set`) 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) 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 | | `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`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); 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 everything pickable there (as holding the left button does); `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`) | | `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.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; contextual SKETCH; workspace RENDER), panel labels `panel::` (also `ribbon:section::
`), document overflow list `tab:overflow`, workspace column `ribbon:workspace`, command-search launcher `ribbon:search`, browser rows `row: