# 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, "error": "timeout"}`. The same 16 MiB line limit applies to the MCP server. 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) | `{completed, results, undone}`, plus `failed: {index, command, error}` when a step fails. 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). 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 | Commands never open dialogs when run this way. `solidcraft-cli commands` prints the same registry. 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`, …), and `hiddenBodies`, `hiddenOrigin`, `hiddenSketches`, `shownSketches`, `hiddenJoints` (whole lists; `browser.visibility` changes single items) | `dark: false` switches to the light theme | | `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). 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) | | `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:`), panel labels `panel::`, browser rows `row: