Maestri Wire
Maestri Wire is the protocol a computer running Maestri (the host below) speaks to other devices and tools. It is what the Maestri Remote app for iPhone and iPad uses, and it is open to anything you build: a script that pings you when an agent needs attention, a button that approves a prompt, a small dashboard.
This page is the contract as the current build enforces it, written so it can be handed to an agent as well as read.
Note
Maestri Wire is in beta, and the host can be a Mac or a Windows PC. Capabilities and optional fields are added as the host grows, and shapes may still change before it leaves beta; protocolVersion marks an incompatible change.
The short version
- The host listens on one TCP port, 7434 by default, and speaks HTTPS and WSS only. Plain HTTP is not served.
- The certificate is self-signed. Clients pin the host's security key, the SHA-256 of its certificate's public key, instead of asking a certificate authority.
- A client pairs once with a six-digit code (or the host's password) and receives a long-lived device token, sent as a bearer token on every request.
- Every paired device has a role: Full control (
owner) or Read-only (guest). Guests can read everything and refit a terminal to their screen; every write is refused. - Everything is JSON. Optional features are announced as capabilities; a client checks them and never probes routes.
- Two WebSockets: the feed (a workspace's live state) and a terminal stream (raw PTY bytes, both ways).
Turning it on
On the host, open Settings → Wire. The switch at the top starts the server, and the header shows whether it is running.
- Pairing shows a QR code and a six-digit code. The code is valid for five minutes, usable once, and only issued while this tab is open.
- Manual is for anything that cannot scan a QR code. It is gated by a password: set one and the tab shows the addresses to type and the security key to verify. Without a password, manual pairing is off.
- Network shows where the host can be reached: this Wi-Fi, and its private network address when Tailscale is up. "Private network only" refuses everything that does not arrive over Tailscale.
- Devices lists every pairing: its name (editable in place), where it last connected, whether it is connected now, its role, and a way to revoke it.
Addresses and transport
There is no discovery. A client learns the host's address from the QR code (which also carries the host's other addresses) or from the Manual tab, and refreshes the list from GET /api/info on every connection. Among the addresses, the .local name resolves through the system's own mDNS and survives a new DHCP lease; the 100.64.0.0/10 address reaches the host over its private network.
Transport rules the host enforces:
- TLS 1.2 minimum, HTTP/1.1 only, no ALPN. Every response carries
Connection: close. - Requests larger than 8 MiB answer
413. A connection that has not delivered a complete request within 20 seconds is dropped. At most 64 connections are served at once. - The
Hostheader must be an IP literal,localhost, a.localname or a.ts.netname; anything else answers403. This defeats DNS rebinding from a browser. - No CORS headers are ever sent, so a web page cannot read the API cross-origin. A client of the Wire is an app or a script, not a page in a browser.
Trusting the certificate
The host generates a key once and keeps it in the operating system's credential store; the certificate is self-signed by it. Clients pin the SHA-256 of the certificate's SubjectPublicKeyInfo (DER). The QR code carries it base64-encoded as serverKeyHash; the Manual tab shows the same 32 bytes as colon-separated hex, the Security key. Compare what your client sees on first connect against that value, then pin it.
With curl, either pin the key or skip verification while you experiment:
# Pin the key (the base64 from the QR code, or the hex from the Manual tab converted to base64)
curl --pinnedpubkey 'sha256//BASE64_KEY_HASH' https://192.168.1.20:7434/api/info
# Or, on your own network, while trying things out
curl --insecure https://192.168.1.20:7434/api/info
A host fronted by a reverse proxy with a publicly trusted certificate works too; a client may accept any certificate its operating system trusts.
Pairing
Pairing exchanges a short-lived credential for a long-lived device token: 64 lowercase hex characters, the only secret a client stores. The host stores only its SHA-256.
The QR code
The Pairing tab's QR code encodes this JSON:
{
"protocolVersion": 1,
"scheme": "https",
"host": "192.168.1.20",
"port": 7434,
"pairingCode": "483920",
"expiresAt": "2026-08-20T15:04:05Z",
"serverKeyHash": "base64…",
"alternateHosts": ["100.101.102.103"]
}
alternateHosts lists the other hosts the same server answers on, same port and same key. Keep them with the pairing and try them when host stops answering.
POST /pair
Unauthenticated. Content-Type: application/json is required.
{ "deviceName": "Living room dashboard", "code": "483920" }
or, with the password from the Manual tab:
{ "deviceName": "Micro bridge", "password": "…" }
Exactly one of code or password must be present. deviceIdentifier (optional) is a stable id for the device itself; a device that pairs again with the same identifier replaces its own record on the host (same device id and role, a new token) instead of adding another.
Response 200:
{
"token": "64 hex chars",
"deviceId": "UUID",
"deviceName": "Living room dashboard",
"protocolVersion": 1,
"role": "owner"
}
Errors: 401 wrong credential, 415 wrong content type, 429 throttled (with Retry-After in seconds), 503 password pairing not enabled on this host.
Throttling is per source address (5 free failures, then 30 seconds doubling to an hour, decaying after 15 idle minutes) and server-wide (10 failures, same schedule). A server-wide lockout also retires the live pairing code. Every successful pairing raises a notification on the host, and the new device appears in the Devices tab under the name it sent.
Tip
Name your pairing for what it is. The name you send is what the Devices tab shows, and it is where the person revokes or demotes it later. "Micro bridge" is better than "Python 3.12".
Sending the token
Send Authorization: Bearer <token> on every request under /api/. Two exceptions exist for places that cannot set headers:
GETrequests may carry?token=<token>instead. Mutating verbs must use the header.- WebSocket upgrades always carry
?token=<token>.
A device idle for 30 days is forgotten and must pair again. A revoked or forgotten token gets 401 everywhere; stop retrying and ask the person to pair again.
Roles
Every paired device is an owner (Full control) or a guest (Read-only). A guest is refused every route marked write below with 403, and its terminal input is dropped; it can read everything and send resize on a terminal stream, since a fit is how a small screen reads a terminal. Roles are changed by an owner through PUT /api/devices/{id}/role or in the Devices tab. A device cannot make itself a guest.
Tip
Pair an integration as Read-only unless it needs to type or change the canvas. A notifier, a dashboard or a status light needs nothing a guest cannot do, and a guest token that leaks can do nothing to your agents.
GET /api/info
Unauthenticated; reveals more when a valid token is presented.
{
"name": "Studio",
"protocolVersion": 1,
"capabilities": ["feedSnapshots", "…"],
"requiresPassword": true,
"activeWorkspaceId": "UUID (authenticated only)",
"role": "owner (authenticated only)",
"hosts": ["192.168.1.20", "100.101.102.103"]
}
Check protocolVersion == 1 first. name is the host's own name, the title a client gives everything it gets from it. hosts lists every address this server answers on; refresh the alternates you keep beside the pairing from it. Unknown capability names must be ignored.
Capabilities
Capabilities are how the protocol grows without a version bump. Gate every optional affordance on the matching flag, and never probe a route to find out whether it exists.
| Capability | What it means |
|---|---|
feedSnapshots | GET …/feed and the feed socket exist |
terminalStreaming | the terminal socket exists |
terminalInputBytes | the terminal socket accepts inputBytes and binary frames |
terminalInputText | the terminal socket accepts input (UTF-8 text) |
terminalResize | the terminal socket honours resize (clamped to 20…250 columns by 5…120 rows) |
terminalChat | the terminal chat snapshot and socket, its history, mode, read, thread and delete routes and its retained attachments exist (Terminal chat, below); terminal cards may carry chatPreview |
promptSegments | POST …/prompt accepts segments |
attachmentStaging, attachmentDiscard | the attachment routes exist |
mentionCatalog | GET …/mentions exists |
terminalThemes | terminal cards carry theme |
nativeNotes | node-addressed note routes, with revision / ifRevision |
pairingCodes | pairing by six-digit code |
canvasMirroring | the feed snapshot carries a canvas |
canvasWrites | all canvas write routes exist; absence means write nothing. PUT …/nodes/{id}/note/lock is inside this flag rather than carrying one of its own, so a host from before that route answers 404 where a newer one takes the write |
noteStackWrites | fichário rail routes exist |
noteStackPages | fichário pages can be added and removed |
noteStackFiling | fichários can be made from a selection, a note filed in or moved between them, a spilled page given a landing point |
deviceRoles | roles are enforced and /api/info reports the caller's |
drawingWrites | drawings can be added, moved and erased |
workspaceActions | pin, rename, wake, unload and attention-clear routes exist; workspace meta carries workingDirectory |
workspaceManagement | the host's New, Edit and Delete Workspace exist: POST /api/workspaces, POST …/workspaces/{ws}/update, DELETE …/workspaces/{ws}; GET /api/workspaces carries layout, GET /api/directories completes or lists folders on the host's own disk, and workspace meta carries environment. Owner-only |
terminalDrafts | POST …/terminals takes the New Terminal sheet's fields |
roleManagement | the agent role routes exist; terminal settings carry icon, color, monitorActivity |
nodeGroups | the snapshot carries groups, nodes carry groupId, and the group routes exist |
partituras | the host's partitura library can be listed, previewed and stamped onto a floor |
nodeBatchDelete | POST …/nodes/delete removes a selection in one request |
canvasFiles | POST …/files puts a file from the client on a floor as a file node, the host's Finder-drop rule with the bytes embedded; GET …/nodes/{id}/file/preview answers an image node with a card-sized picture, JPEG or PNG by whether it has transparency to keep |
deviceRenaming | PUT /api/devices/{id}/name renames this device, or another device for an owner |
presence | the feed socket accepts presence; the host draws a cursor for the device on its canvas |
textStyling | POST …/nodes/{id}/text takes the text toolbar's settings beside the text |
nodeUnload | terminals and portals can be unloaded; restart and reload wake them |
terminalFocus | POST …/terminals/{id}/focus exists: the host goes to the terminal as its own notification click does |
cameraControl | the feed socket accepts camera: a pan in screen points and a zoom factor steer the host's own camera, for a joystick, a gamepad or a wheel |
nodeFocus | POST …/nodes/{id}/focus exists: the host's reveal for any canvas node |
terminalFileMentions | GET …/terminals/{id}/files exists: the host composer's own file index for a terminal, for @ mentions from a client |
fileTrees | the file tree routes exist (below): a tree node's folder browsed and searched, a file's bytes fetched with Range, its repository's changes and diffs read, and the host's own git menu driven by named actions; POST …/nodes takes kind fileTree, GET …/workspaces/{ws}/directories completes a folder path, and canvas nodes of kind fileTree carry a fileTree payload. Owner-only |
cableTies | the floor's cable ties ride the canvas snapshot as cableTies, and a client makes, re-seats and cuts one (POST …/ties, POST …/ties/{id}/position, DELETE …/ties/{id}) |
routines | the host's routines are listed, made, replaced, removed and run from a client (below): GET/POST /api/routines, PUT/DELETE /api/routines/{id}, POST …/routines/{id}/action, DELETE /api/routines/history. Owner-only |
floorHooks | the host's floor hooks are read, replaced and run from a client (below): GET/PUT …/hooks, POST …/floors/{id}/hooks. Owner-only |
floorManagement | the host's New, Land, Delete and Unload for a floor exist (below): POST …/floors, GET …/branches, GET …/floors/{id}/landing, POST …/floors/{id}/land, POST …/floors/{id}/delete, POST …/floors/{id}/update, POST …/floors/{id|ground}/unload, DELETE …/floors/pending/{id}; floors on the feed carry isGitIsolated, branch and isCloneMissing, and clones still copying ride it as pendingFloors. Owner-only |
floorStatus | floor reports and sidebar summaries on the feed and workspace list (below); POST …/floors/{id|ground}/status replaces or clears a report. Reads follow workspace visibility; writes are owner-only |
pullRequestChecks | POST …/floors/{id}/pull-request/check asks the project's host about the floor's pull request now (below), and floors' pullRequest carries attention and url. Owner-only |
pullRequestLanding | the landing sheet's Pull Request side (below): GET …/floors/{id}/pull-request/context, POST/DELETE …/floors/{id}/pull-request, POST …/pull-request/merge, /remove, /push, /hand-off; floors' pullRequest carries title, base, review, checks, hasConflicts, mergeMethods. Owner-only |
pullRequestReview | a floor made to review an open pull request (below): GET …/workspaces/{ws}/pull-requests[?query=], POST …/floors with pullRequest; floors' pullRequest carries isReview and isReadOnly, and /hand-off takes assess. Owner-only |
agentAccounts | agent accounts are picked from a client as the host's terminal sheets pick them (Agent accounts, below): GET …/terminals/{id}/settings carries accountId, accounts, accountNotice, accountApplies and accountRestart, PUT …/settings takes accountId and restartForAccount, POST …/workspaces/{ws}/terminals takes accountId, GET …/workspaces/{ws}/agent-accounts and GET …/terminals/{id}/agent-accounts list them for a command, and presets carry accountId and accountName. Adding, signing in and removing accounts stay on the host |
fileTreePullRequest | a file tree's Open Pull Request… (below): GET …/nodes/{id}/tree/pull-request/context and POST …/nodes/{id}/tree/pull-request open one from the tree's repository as the host's sheet does for a folder; a floor's own checkout answers with that floor, opened through pullRequestLanding. Owner-only |
chatThreadPreviews | GET …/terminals/{id}/chat/threads exists (Terminal chat, below): the latest entries of a chat's other threads, read-only, for a glance before switching |
push | push notifications through Maestri's relay: PUT /api/push/registration takes {grant, key, keyId, kinds} and replaces the calling device's own registration, DELETE /api/push/registration turns it off. Any paired device, for itself only; guests receive only attention, maestroRequest, floorStatus and pullRequest. Nothing is pushed while the person is at the host's computer |
Conventions
- JSON everywhere, UTF-8. Dates are ISO 8601 strings. UUIDs are uppercase-hex strings as Foundation prints them: echo ids verbatim, never re-format them.
- Success bodies are
{"ok": true}unless a richer shape is listed. - Errors are
{"error": {"code": "…", "message": "…", "reason"?: "…"}}, withcodeone ofinvalidRequest,unauthorized,forbidden,notFound,conflict,preconditionFailed,payloadTooLarge,rateLimited,internalError,unavailable,unknown. The HTTP status carries the same information.messageis for logs, except under areason: an open string naming a refusal the client can explain in the host's words, whosemessageis then the host's own sentence, in its language, to show as is. Today the one reason isaccountRefused(Agent accounts); ignore any other. - Path ids that are not UUIDs answer
400; paths that match nothing answer404. - Enumerations are open strings from a client's point of view: treat an unknown value as "some other kind", never as a failure. The server is stricter and rejects unknown values in requests with
400. - Optional fields are omitted, never sent as
null. - In the route tables below, read means any paired device, write means Full control only. In paths,
{ws}is a workspace id and{id}the node, terminal, portal or device the section is about.
Workspaces and the feed
| Route | Auth | Description |
|---|---|---|
GET /api/workspaces | read | {"workspaces": [WorkspaceMeta], "layout"?: SidebarLayout}, in the host's sidebar order; layout (workspaceManagement) is {folders: [{id, name}], groups: [{id, name, folders: [{id, name}]}]}, the sidebar's top-level folders and its groups, the places a workspace can be filed |
GET /api/workspaces/{ws}/feed?floor=… | read | one FeedSnapshot |
GET /api/workspaces/{ws}/icon | read | the workspace's custom picture as PNG, with an ETag; 304 on If-None-Match, 404 when the icon is a glyph |
POST /api/workspaces/{ws}/activate | write | make it the host's active workspace (404 unknown, 409 licence-locked) |
POST /api/workspaces/{ws}/floors/{floor|ground}/activate | write | also switch the host's active floor |
POST /api/workspaces/{ws}/pin | write | {pinned}; idempotent |
POST /api/workspaces/{ws}/rename | write | {name}; 400 empty |
POST /api/workspaces/{ws}/wake | write | starts the workspace's terminals without activating it on the host |
POST /api/workspaces/{ws}/unload | write | stops every terminal in the workspace |
POST /api/workspaces/{ws}/attention/clear | write | clears the workspace's attention badge |
POST /api/workspaces | write | {name, icon?, color?, workingDirectory?, createDirectory?, environmentOf?, placement?} → {ok, workspaceId}: the host's New Workspace sheet. Runs on the host, or with environmentOf on the same environment as that workspace, whose connection it copies whole; workingDirectory is on that environment, ~ allowed. placement is {groupId?, folderId?} from layout: a folder, else a group, else the top level. 404 a working directory that isn't there, the cue to retry with createDirectory: true; 400 empty name, a bad colour, a path that is a file, or an unknown environmentOf, group or folder; 403 when the host's plan allows no more |
POST /api/workspaces/{ws}/update | write | {name?, icon?, color?, workingDirectory?, createDirectory?, runsOn?: {environmentOf?}, placement?} → {ok}: the host's Edit Workspace sheet; an absent field is left as it is. runsOn moves the workspace onto the named workspace's environment, or onto the host when environmentOf is absent inside it. 409 when a workspace with git-isolated floors, or a floor being created, would change its folder or its connection (the host to an environment, an environment to the host or to another one): those checkouts belong to its project where it is now; the sheet's own refusal, held again when the edit lands |
DELETE /api/workspaces/{ws} | write | {ok}: the host's Delete Workspace, without its confirmation, which is the client's to ask |
GET /api/directories?prefix= / ?path= | owner | on the host's own disk: DirectorySuggestions for prefix, or a DirectoryListing for path ({path, name, directories: [{name, path}], isTruncated}, hidden folders left out, "" for the home folder); the panel a client browses with New and Edit Workspace |
WS /api/feed/stream?ws={ws}&floor=… | read | live snapshots and mutation events |
floor is absent (mirror the host's active floor), ground, or a floor UUID. Viewing a floor through the Wire never moves the host; only the activate routes do. Subscribing to a workspace's feed as an owner wakes its terminals, so a workspace the host is not showing still streams; a guest's subscription wakes nothing.
FeedSnapshot
{
workspace: WorkspaceMeta,
floors: [ FloorMeta ],
pendingFloors?: [ PendingFloor ],
items: [ FeedItem ],
canvas: CanvasSnapshot,
epoch: UUID
}
WorkspaceMeta: id, name, icon?, color?, activeFloorId?, ropeRouting ("avoidNodes"|"behindNodes"|"circuit"), selectionStyle?, isPinned, groupName?, folderName?, terminalCount, runningTerminalCount, attentionCount, hasActivity, isLoaded, isLocked?, iconRevision?, workingDirectory?, environment?. icon is an SF Symbol name or a single emoji. iconRevision is present when the host has a custom picture for the icon; its value changes with the picture, so key a cached copy on it. environment (workspaceManagement) is {kind, name}, the environment the workspace runs on other than the host: kind one of ssh, docker, sandbox, customRuntime (tolerate others), name the host's own label for it; absent for the host itself.
floorStatus adds optional floors, pendingFloors, groundStatus, and groundFloorBranch to WorkspaceMeta. FloorMeta is {id?: UUID, name, color?, isActive, isGitIsolated?, branch?, isCloneMissing?, status?: FloorStatus, pullRequest?: FloorPullRequest, attentionCount?, hasActivity?, isDormant?, cloneKind?, seeding?, folder?, isRemoving?}. cloneKind is how a git-isolated floor's folder holds its checkout (worktree, copy, remoteWorktree; tolerate others), seeding how its files were laid down (copyOnWrite, checkout; absent when not recorded), folder its path on the host or the workspace's machine, and isRemoving that the host is removing it right now. Ground has no id. See Floors for pending creation fields and Floor status and sidebar summaries for status, summary visibility, ordering, and compatibility.
A workspace the host's free plan has locked is listed with isLocked: true, and nothing else about it is served: its feed, nodes, notes, terminals, portals and both sockets answer 403.
FeedItem is discriminated by kind:
terminal:{ kind, terminal: TerminalCard }pendingPrompt:{ kind, terminal: TerminalCard, prompt: String }: the agent is waiting on a Y/n answernote:{ kind, note: NoteCard }
Items span the ground level and every floor of the workspace; the canvas below is one floor.
TerminalCard: id, name, agentType, icon, color?, status, floorId?, floorName, lastActiveAt, isRunning, cols, rows, preview: [String], isManager, needsAttention, isLive, roleName?, roleColor?, roleIcon?, theme?: TerminalTheme, nodeId, isUnloaded?, isActive?, accountName?, maestroRequest?.
idis the terminal id, which the terminal routes take;nodeIdis its canvas node, which the node routes take. They are different ids.accountNameis the agent account the terminal runs on when it isn't its CLI's Default one, shown after the name ("· Work"); it is absent for Default and from a host that predates accounts.maestroRequest(push) is{message, createdAt}: what a Maestro asked the person for while that request stands.needsAttentionis true alongside it, and the message is what sets it apart from an agent that merely finished. It is absent when no request stands and from a host that predates it.previewis the terminal's last lines as plain text: enough to show what an agent is doing without a terminal emulator.needsAttentionis the host's own attention state (the agent finished, or asked for someone).isActiveis the activity monitor's word for an agent working right now.isRunningsays a process exists;isLivethat the host has the terminal loaded.isUnloadedis the host's Unload: the terminal put to sleep by hand untilPOST …/restartwakes it.
NoteCard: nodeId, fileName, displayName, color, customColor?, floorId?, floorName, preview: [String], lastModifiedAt, isExternal, isContentLocked?, hasCustomName?. The host names a note from its first line so its file always has one, but shows the name on the card only when hasCustomName (or isExternal); draw the header by the same rule.
TerminalTheme: background, foreground, cursor?, cursorText?, selectionBackground?, selectionForeground?, ansiPalette: [16 hex strings].
CanvasSnapshot
{
origin: {x, y}, zoom, bounds?: {x, y, width, height},
nodes: [CanvasNode], connections: [Connection], drawings: [Drawing],
groups?: [{id, name, colorHex?}],
cableTies?: [{id, position: {x, y}, memberConnectionIds: [UUID]}]
}
cableTies (cableTies) are the floor's zip ties: each bundles the ropes it names by Connection.id, in ribbon order, so a client running the host's rope physics clamps them through position side by side, as the host does. A tie left with no rope is dropped by the host.
CanvasNode: id, frame {x,y,width,height}, zIndex, isNodeLocked, kind, title, subtitle?, icon?, color?, groupId? plus exactly one per-kind payload:
terminal: TerminalCardnote: NoteCardtext {text, fontSize, isMonospaced, color?, fontFamily?, fontWeight?, fontName?}: the host's stored text block;fontFamilyis"serif"or absent,fontWeightis"medium","bold"or absent,fontNamea font the host hasfile {displayName, caption?, isImage, isVideo}link {url, title?}fileTree: FileTreeNode(see File trees, below)portal {portalId, name, url?, currentURL?, canGoBack, canGoForward, status, chromeHidden, isUnloaded?, runtime?, runtimeDetail?, device?: {platform, deviceName?, symbol}}.devicepresent means a device portal (a simulator, an emulator or a phone over adb): no address, no history, its snapshot is the device's screen;platformisios,ipadosorandroid.runtimeis what the host's node shows now: a page isloading,ready,failedorempty; a device isidle,booting,connecting,liveorunavailable, with the host's own sentence inruntimeDetail. Onlyreadyandlivehave a snapshot worth fetching.isUnloadedis the host's Unload: a page stopped, or a device's display detached while it runs.connector {remoteTerminalId, remoteFloorId?, remoteWorkspaceId?}: the chip standing in for a terminal on another floor, or, whenremoteWorkspaceIdis set, in another workspace, where itsiconandcolorare that workspace's and itssubtitleis that workspace's name. Following it means opening that workspace onremoteFloorIdand landing on the terminalnoteStack {name, hasCustomName, tabs: [{nodeId, title, color?}], frontNoteId?, front?: NoteCard, uniformColor?}: a fichário
kind is one of terminal, note, text, file, link, fileTree, portal, connector, noteStack. A node of an unknown kind still has a frame and title; draw it as a plain box. Notes filed in a fichário are not in nodes; they remain in items and stay addressable by node id through every note route. groups are the floor's node groups; a member names its group by groupId.
Connection: id, fromNodeId, toNodeId, kind (terminal|note|noteToNote|portal|portalToPortal|crossFloor|crossWorkspace), isActive, points: [{x,y}], fromNoteNodeId?, toNoteNodeId?. When the drawn endpoint is a fichário standing in for one of its pages, the …NoteNodeId field names the real page. A crossFloor rope runs from a terminal to the connector chip for a terminal on another floor; its id is the connector's, and the connect and disconnect route do not take its endpoints. A crossWorkspace rope is the same drawing for a terminal in another workspace: this workspace's half of the connection, from its terminal to the connector standing in for the far agent, keyed by that connector, and cut by deleting it. When ropeRouting is circuit, points may be stale; draw from the endpoints. isActive is true while something travels the rope; the host draws such a rope lit.
Drawing is the host's stored DrawingPath, verbatim: id, tool, points: [[x, y]], origin: [x, y], originalSize: [w, h], size: [w, h], color, lineWidth, opacity, zIndex, isLocked, rotation, textContent?, textFontSize, sourceConnection?: {drawingId, edge}, targetConnection?, strokePattern?, fillStyle?, cornerRadius?, controlPointOffset?: [x, y], groupId?. Points are local to origin and scale by size / originalSize. tool is one of pen, brush, smartDraw, arrow, highlighter, line, rectangle, ellipse, triangle, diamond, hexagon, oval, parallelogram, star, cloud, heart, xBox, checkBox, blockArrowLeft, blockArrowUp, blockArrowDown, blockArrowRight; edge is top, bottom, left, right; strokePattern is solid, dashed, dotted; fillStyle is noFill, hachure, crossHatch, tint, solidOpaque.
The grid step is 20 points; the host snaps frames it stores to it.
Feed socket: server to client
{"type": "feed", "snapshot": FeedSnapshot}
{"type": "mutation", "mutation": MutationEvent}
{"type": "pong"}
A full snapshot arrives on connect, on every change (coalesced to at least 750 ms apart), and otherwise not at all: unchanged snapshots are not re-sent. Ignore unknown types.
MutationEvent:
{
mutationId?: String, // echoed verbatim from the originating request
sequence: Int, // per workspace, dense, monotonic for one epoch
epoch: UUID, // identity of this server run
floorId?: UUID, // where the request went through; never filter on it
actor?: { deviceId, deviceName, role? },
kind: { kind: "…", … }
}
Kinds: nodeMoved {nodeId, frame}, nodeRenamed {nodeIds, name}, nodeCreated {nodeId, nodeKind}, nodeRemoved {nodeId}, nodeLockChanged {nodeId, isLocked}, noteColorChanged {nodeId, color, customColor?}, connectionAdded {fromNodeId, toNodeId}, connectionRemoved {fromNodeId, toNodeId}, noteStackRailChanged {stackNodeId, memberNodeIds, frontNoteId?}, drawingAdded {drawingId}, drawingRemoved {drawingId}, drawingMoved {drawingId, origin}.
Events exist for attribution and for retiring optimistic writes. They are not a replication log: changes made on the host itself produce no event, and the snapshot remains the source of truth. Dedupe on (epoch, sequence), never on mutationId. A new epoch means resync from the snapshot.
Feed socket: client to server
{"type": "ping"}
{"type": "presence", "nodeId": "…"}
{"type": "presence", "point": {"x": 120, "y": 340}}
{"type": "presence"}
{"type": "camera", "pan": {"dx": 12, "dy": -4}, "zoom": 1.02}
presence (capability presence) says where on the workspace the device is, and the host draws a cursor for it on its canvas, in the device's colour with its name. Send the nodeId of the node the person has open (a terminal's node id, not its terminal id), or a point in canvas coordinates if your client has a real pointer. Both absent means nowhere; send it on leaving a node. The report lives with the socket, so re-send your last report after every reconnect. Any role may report.
camera (capability cameraControl) is one frame of a joystick, a gamepad or a wheel, steering the host's own camera. pan is in screen points and moves the viewport (dx right, dy down; the content slides the other way, as under a trackpad); zoom multiplies the host's zoom about the viewport's centre. Each is optional. The host clamps a frame (a pan to 4000 points, a factor to 0.25 to 4) and its zoom to its own range (0.1 to 3). It steers the screen, so it takes an owner and lands only while the socket's workspace is the host's active one and its floor the one on screen (a socket without floor mirrors it and always qualifies); anything else is dropped without an answer. Send small deltas at the rate the device produces them, a stick at thirty to sixty frames a second; there is no reply, and the snapshot's origin and zoom follow with the host's own autosave, not per frame.
Canvas writes
All write. Each body may carry mutationId (any string you mint), which comes back verbatim on the broadcast event so you can retire an optimistic change.
| Route | Body | Answer |
|---|---|---|
POST /api/workspaces/{ws}/nodes/{id}/frame | {x, y, width, height, bringToFront?, mutationId?} | {ok, frame}: the frame as stored (snapped, clamped; a text block or connector keeps its own size); 409 locked |
POST /api/workspaces/{ws}/nodes | {kind: "note"|"text"|"fileTree", floorId?, x?, y?, text?, color?, path?, mutationId?} | {ok, nodeId} (note 260×200, text 240×60, fileTree 320×400); fileTree (fileTrees) takes path, the folder absolute on the workspace's environment, ~ allowed; on the host's own disk a path that is not a folder answers 400 |
POST /api/workspaces/{ws}/terminals | {presetId?, floorId?, x?, y?, name?, command?, icon?, color?, monitorActivity?, maestroMode?, roleId?, accountId?, mutationId?} | {ok, nodeId, terminalId} (600×420); 404 preset or role not found, 400 nothing to run or a colour that is not #RRGGBB, 400 an accountId the terminal can't run on (agentAccounts, Agent accounts) |
POST / DELETE /api/workspaces/{ws}/connections | {fromNodeId, toNodeId, mutationId?, fromWorkspaceId?} | {ok}; 400 for a pair the canvas does not link, or across floors. On POST, toNodeId may be a connector: the terminal is wired to the far agent that connector stands for, on its floor or in its workspace, and this end of the rope lands on that connector, so several agents can share one. fromWorkspaceId names the source's workspace when it is not {ws}: a cross-workspace connection, POST only, terminals only, resolved as {fromWorkspaceId}/fromNodeId → {ws}/toNodeId, with the host making a connector in each workspace. DELETE refuses it (400): delete the connector instead |
DELETE /api/workspaces/{ws}/nodes/{id} | {ok}; a fichário's pages stay on the canvas | |
POST …/nodes/delete | {nodeIds, mutationId?} | {ok}; the host's batch rule: one undo step, a fichário takes its pages with it; every id must exist |
POST …/nodes/{id}/duplicate | {ok, nodeId} | |
POST …/nodes/{id}/lock | {locked, mutationId?} | {ok} |
POST …/nodes/{id}/focus | — (nodeFocus) | {ok}: the host's own reveal, the one its toolbars and search hits run — the app to the front, the workspace and floor made active, the camera on the node, a terminal focused, anything else selected; 404 unknown node |
POST …/nodes/{id}/rename | {name, mutationId?} | {ok}; 400 not renamable, 409 external note |
POST …/nodes/{id}/text | {text, fontSize?, fontWeight?, fontFamily?, isMonospaced?, color?, fontName?, mutationId?} | {ok}; text nodes only. The style fields need textStyling; an absent one leaves the block as it is. fontSize clamps to 8…200, fontWeight is regular/medium/bold, fontFamily is sans/serif, color is #RRGGBB or "" for the theme's colour, fontName a face by name or "" to drop a custom face |
PUT …/nodes/{id}/note/color | {color, customColor?} | NoteContent |
POST /api/workspaces/{ws}/groups | {nodeIds, drawingIds?, name?, floorId?} | {ok, groupId?}: the host's Group command; two or more nodes become a node group, two or more drawings a drawing group |
POST /api/workspaces/{ws}/ungroup | {nodeIds, drawingIds, floorId?} | {ok} |
DELETE /api/workspaces/{ws}/groups/{id} | {ok}; the members stay where they are | |
POST …/groups/{id}/rename | {name} | {ok}; empty clears |
PUT …/groups/{id}/color | {colorHex?} | {ok}; #RRGGBB or null for the default |
PUT …/nodes/{id}/stack/front | {frontNoteId, mutationId?} | {ok, memberNodeIds, frontNoteId?}: turns a fichário to a page |
PUT …/nodes/{id}/stack/order | {memberNodeIds, mutationId?} | same; a permutation of the current pages |
POST …/nodes/{id}/stack/pages | {afterNoteId?, mutationId?} | {ok, nodeId}: a blank page, facing front |
DELETE …/nodes/{id}/stack/pages/{noteId} | {x, y, mutationId?} (optional) | {ok, memberNodeIds, frontNoteId?}; the note stays on the canvas, at x,y when given |
PUT …/nodes/{id}/stack/pages/{noteId} | {mutationId?} | same; files a loose note in, or moves a page here from another fichário |
POST …/note-stacks | {nodeIds, floorId?, mutationId?} | {ok, stackNodeId}: the host's "Place in Fichário" for a selection |
POST …/files?filename=&x=&y=&floor=&mutationId= | the file's bytes under its Content-Type (8 MB) | {ok, nodeId}: a file node centred on x,y; a .md/.txt becomes a note |
POST /api/workspaces/{ws}/drawings | {floorId?, drawing: Drawing, mutationId?} | {ok, drawingId}; stored under the id it was sent with; 409 if that id exists |
DELETE /api/workspaces/{ws}/drawings/{id} | {ok}; 409 locked | |
POST …/drawings/{id}/origin | {x, y, mutationId?} | {ok}; the drawing's new top-left |
POST /api/workspaces/{ws}/ties | {floorId?, tie: {id, position, memberConnectionIds}} | {ok, tieId}; the host's option-drag across ropes, stored under the id it was sent with, on the floor named (absent or ground for the ground). memberConnectionIds are Connection.ids the floor has, in the order the client crossed them across the bundle; 400 when any is not a rope the workspace has, 409 if that id already exists |
POST …/ties/{id}/position | {x, y} | {ok}; the tie re-seated, stored as sent; 404 no such tie |
DELETE …/ties/{id} | {ok}; 404 no such tie | |
POST /api/workspaces/{ws}/partituras | {partituraId, x, y, floorId?, mutationId?} | {ok}: stamps a partitura centred on x,y |
GET /api/agent-presets (read) lists {presets: [{id, name, agentType, icon, isManager, isDefault, command?, accountId?, accountName?}]} for the terminal create route. accountId and accountName (agentAccounts) are the agent account the preset pins, present only while its command launches that account's CLI; absent is Default. A create body with none of the New Terminal fields spawns the preset (the default one when presetId is absent); a body with any of them builds on that preset, or on no preset when presetId is absent, exactly as the host's sheet does.
The host applies the preset's appearance when it creates the terminal: colour, terminal theme and font, and chat font and size. Explicit create-body fields still win over their preset counterparts. Editing a preset changes later creations, never terminals already using it. Presets and terminals also store startInChatMode, false when absent; a terminal separately remembers the face it was last in as prefersChatMode, and falls back to startInChatMode only when it has no remembered face.
The preset list also carries themes: [{id, name, isLight}], a defaultAppearance, and each preset's resolved appearance and optional color. A client hides those controls when appearance and defaultAppearance are absent, which is how an older host reads.
Notes
| Route | Auth | Description |
|---|---|---|
GET /api/workspaces/{ws}/nodes/{id}/note | read | NoteContent |
PUT /api/workspaces/{ws}/nodes/{id}/note | write | {text, ifRevision?} → NoteContent; 409 content-locked, 412 revision mismatch (the body is the current NoteContent) |
PUT /api/workspaces/{ws}/nodes/{id}/note/lock | write | {locked} → {ok}; sets the note's content lock, the one a save answers 409 against, and the one the host's Lock Contents sets. Idempotent: the desired state, not a toggle. Not POST …/nodes/{id}/lock, which is the canvas node's own lock and a different property. The new state is not broadcast as a mutation; it rides the note card's isContentLocked on the next feed. 404 when the node is not a note this host holds |
GET /api/workspaces/{ws}/nodes/{id}/file | read | raw bytes of a file node; honours Range (206, 416) |
GET /api/workspaces/{ws}/nodes/{id}/file/preview | read | an image file node, longest side 1100px, with an ETag; 304 on If-None-Match; 404 when the file is not an image the host can decode (canvasFiles). PNG (image/png) when the picture has pixels that are actually transparent, JPEG (image/jpeg) otherwise: JPEG carries no alpha channel, so a transparent PNG served as one arrives flattened onto a colour the encoder picked. Read the Content-Type; do not assume JPEG |
NoteContent: nodeId, fileName, displayName, text, color, customColor?, revision, lastModifiedAt, isExternal, isContentLocked. revision is the first 16 hex characters of SHA-256 over the UTF-8 text; send it back as ifRevision to make a save conditional, so a note edited on the host in the meantime is never overwritten blind.
Terminals
| Route | Auth | Description |
|---|---|---|
WS /api/terminals/{id}/stream?token=… | read (input needs Full control) | the terminal stream, below |
POST /api/terminals/{id}/prompt | write | {text} or {segments: [{text} | {attachmentId}]}; 409 not running |
POST /api/terminals/{id}/attachments?filename=… | write | raw body with its Content-Type → {attachmentId, fileName, mimeType, expiresAt} (15 min, ≤ 8 MiB, ≤ 64 files) |
DELETE /api/terminals/{id}/attachments/{attachmentId} | write | {ok} |
GET /api/terminals/{id}/mentions | read | {mentions: [Mention]} for an @ picker |
GET /api/terminals/{id}/files?query=&scope=&limit= | owner | {query, root, entries: [{relativePath, name, isDirectory}], isTruncated}: the host composer's own file index for the terminal, rooted at its working directory on whatever environment it runs on, filtered as the host's own picker filters it inside scope (a folder relative to the working directory, empty for the top; an empty query lists that folder's own children); limit at most 200; 404 when the terminal has no directory to read |
POST /api/terminals/{id}/approve / reject | write | answers a pending Y/n prompt; 409 when there is none |
POST /api/terminals/{id}/seen | write | marks the terminal seen, clearing its attention on both surfaces |
POST /api/terminals/{id}/restart | write | the host's reload button; wakes an unloaded terminal |
POST /api/terminals/{id}/unload | write | the host's Unload (nodeUnload): the session torn down to its dormant card, scrollback kept, until restart |
POST /api/terminals/{id}/focus | write | the host goes to the terminal (terminalFocus): the app to the front, the terminal's workspace and floor made active, the camera on the node, the terminal focused |
POST /api/terminals/{id}/kill | write | ends the process |
GET / PUT /api/terminals/{id}/settings | read / write | {command, workingDirectory, roleId?, roles: [{id, name, icon, color}], maestroMode, icon?, color?, monitorActivity?, appearance?, themes?, accountId?, accounts?, accountNotice?, accountApplies?, accountRestart?}; PUT takes {command, roleId?, maestroMode, icon?, color?, monitorActivity?, appearance?, accountId?, restartForAccount?} as a full replace (except accountId, see Agent accounts) and echoes the applied settings. The command applies on the next launch or reload; the rest at once; a changed role restarts a running terminal. 400 unknown role, bad colour, or an accountId the terminal can't run on |
GET /api/terminals/{id}/agent-accounts?command= | read | AccountList (agentAccounts): the accounts the terminal could run command on where it runs, for an edit sheet whose command is being edited; without command, its saved one |
Mention: kind, id, name, tokenName?, icon?, color?, platform?, floorId?, floorName, isOnActiveFloor, depth, section, sectionOrder, order. kind is agent, floor, note, portal, maestro, or an action: createNote, createPortal, createDevicePortal, and for a Maestro createFloor, notifications, routines, workspaces; tolerate others. A client sends an action back only when the catalog listed it.
Appearance, carried by terminal settings, chat snapshots and create bodies alike: {theme, customThemeId?, darkThemeId?, terminalFontSize?, chatFont, chatTextSize, startInChatMode}. theme is system, dark, light or custom; a custom id must be one the host has (400 otherwise), and darkThemeId pairs a light theme with a dark one automatically. terminalFontSize is a client-side override, 8 to 24 points, and omitting it restores automatic sizing. chatFont is system, monospaced, rounded or serif; chatTextSize is system, small, medium, large, extraLarge or extraExtraLarge. The typography fields are the client's own: they never change the host's fonts. themes on a settings response lists what customThemeId may name, as [{id, name, isLight}].
Note
Sending a prompt or input from a device clears the terminal's attention, exactly as clicking into it on the host does. Whether an answer later raises attention again is the host's activity monitor's decision, on both surfaces.
The terminal stream
On connect the server sends {"type":"geometry","cols":N,"rows":N} followed by a binary frame with the current screen (ANSI). Afterwards every binary frame is raw PTY output. geometry may arrive again at any time and is always followed by a fresh screen; reset your emulator on it.
Client to server, as JSON text frames:
{"type": "ping"}
{"type": "inputBytes", "data": "base64"}
{"type": "input", "data": "utf-8 text"}
{"type": "resize", "cols": 100, "rows": 40}
A binary frame from the client is raw input bytes. Frames from the client must be masked (RFC 6455); a frame larger than 1 MiB, or an unmasked one, closes the socket. The server answers {"type":"pong"} and ignores unknown types.
resize refits the host's PTY to your grid, clamped to 20…250 columns by 5…120 rows; the host's own node shrinks its font to keep showing the whole grid, and hands the grid back to itself twenty seconds after the last viewer leaves. Read-only devices may resize; their input is dropped.
You need a terminal emulator to render the bytes (xterm.js in a web client, for example). If you only want to know what an agent is doing, the feed's preview lines are plain text and need none.
Agent roles
Capability roleManagement. Roles belong to the host, not to a workspace: the list is every role it has, and workspaceId marks the ones scoped to one workspace (absent means global).
| Route | Auth | Description |
|---|---|---|
GET /api/roles | read | {roles: [Role]} |
POST /api/roles | write | RoleWrite → Role as stored |
PUT /api/roles/{id} | write | RoleWrite, full replace → Role; a changed prompt restarts every running terminal holding the role |
DELETE /api/roles/{id} | write | {ok}; unassigns the role everywhere |
Role: id, name, prompt, icon, color, workspaceId?, terminalsInUse. RoleWrite: {name, prompt, icon, color, workspaceId?}; 400 blank name or prompt, bad colour or unknown workspace; 409 a name another role in the same scope already has.
Terminal chat
Capability terminalChat. A terminal's second face, not a separate agent or a separate provider session. Every target resolves across the ground and every floor without activating the host's workspace, reads and the socket stay available while the process is stopped, and workspace locks, pairing revocation and owner-only writes apply exactly as they do on the terminal routes.
| Route | Auth | Description |
|---|---|---|
GET /api/terminals/{id}/chat | read | ChatSnapshot: the bounded live tail and the current state |
WS /api/terminals/{id}/chat/stream?token=… | read | an immediate {"type":"chat","snapshot":ChatSnapshot} text frame, then a replacement snapshot whenever the observed state changes |
PUT /api/terminals/{id}/chat | write | {enabled, showsShellWindow?} → ChatSnapshot; enters or leaves the chat face through the host's coordinator, keeping history. With showsShellWindow present the face is left alone and only that setting changes, so one route carries both |
PUT /api/terminals/{id}/chat/thread | write | {slot} → ChatSnapshot; selects one of the seven fixed slots (0…6); 400 for an invalid slot, 409 while switching is blocked |
DELETE /api/terminals/{id}/chat/thread | write | {slot} → {ok}; clears that slot without selecting it or removing its place, under the same validation and busy guards |
GET /api/terminals/{id}/chat/threads | read | ChatThreadPreviews (chatThreadPreviews): {threads: [{id, conversationId, entries: [ChatEntry]}]}, every thread other than the selected one that holds messages, by slot id, with its newest 40 entries. For a glance before switching, as the host shows a thread while its dot is hovered: it selects nothing, marks nothing read, and works while switching is blocked. A slot missing from the list is empty. Attachments in it are fetched through the attachment route, which reaches every thread |
GET /api/terminals/{id}/chat/history | read | {conversationId, entries: [ChatEntry]}, the whole retained transcript, asked for rather than pushed |
POST /api/terminals/{id}/chat/history | read | {conversationId, beforeEntryId} → {ok}; asks the host to read the page before the oldest entry the tail holds, for a reader who reached the top. Those entries arrive as the next replacement snapshot on the socket, never in this response. 400 for an unreadable body, 409 once the conversation moved on or the anchor is no longer loaded |
POST /api/terminals/{id}/chat/read | write | marks the chat messages read; {ok} |
DELETE /api/terminals/{id}/chat/entries/{entryId} | write | deletes that durable entry through the host's coordinator; {ok}, and every observer receives the changed tail |
DELETE /api/terminals/{id}/chat/history | write | clears Maestri's transcript and its retained attachments, not the provider's own memory; {ok}; 409 while a turn or an import prevents it |
GET /api/terminals/{id}/chat/attachments/{attachmentId} | read | the retained attachment's bytes, addressed by UUID inside this conversation; 404 when absent. Never a caller-supplied filesystem path |
DELETE /api/terminals/{id}/chat/attachments/{attachmentId} | write | removes the file from every entry holding it and from the host's storage; {ok}; 409 while a clear is tearing the record down. An entry left with nothing but that file goes with it |
ChatSnapshot: terminalId, conversationId, enabled, status, entries: [ChatEntry], unreadCount, canClear, talkingToAgentName?, answeringAgentName?, threads?, appearance?, turnInterrupted?, showsShellWindow?, hasOlderOnRecord?. status is idle, working, needsAttention, notRunning or unloaded, and enabled reports whether the chat face is on. turnInterrupted says the person stopped the turn with Escape; it rides beside status, which reads needsAttention, so an older client keeps decoding. showsShellWindow is the terminal's own chevron: whether a turn opens the shell's window under the caption row. Absent means a host that predates it and always opened that window. The inter-agent names explain why the agent is busy, rather than leaving a client to infer it from terminal output.
The live tail is seeded with the newest 80 entries when a conversation loads and then grows with the exchange. It is never trimmed from the front while the conversation is open, because entries vanishing above a reader shift the thread under them. It also grows backwards, a page of 60 at a time, when a client asks through POST …/chat/history; the page is prepended, so nothing already rendered moves. hasOlderOnRecord says the record still holds entries older than the tail: offer a load-earlier affordance only while it is true, and treat a host that omits the field as one that predates paging and keeps the whole transcript behind GET …/chat/history. Omission from the tail is not deletion from durable history.
ChatEntry: id, role: user | agent, ts, kind: message | progress | final | divider, text, segments, authorName?, agentType?, clientMessageId?. text is canonical, without the delivery envelope. User segments keep composer order, each exactly one of {"text":"words"} or {"attachment":{"id":"UUID","isImage":true,"filename":"photo.png","contentType":"public.png"}}. Agent entries and dividers carry an empty segment list. An attachment reference holds neither a host path nor an expiring staging token.
threads, when present, enables the thread routes above: {activeSlot, canSelect, items: [{id, color, hasMessages}]}. There are seven stable slot ids, 0…6, and the host supplies a fixed palette in settings order from blue, as the colour keys blue, purple, pink, red, orange, yellow, green or graphite; a client localizes the names. That order never changes: a colour is a thread's identity, to the person and to the agent alike. Switching and clearing are refused while canSelect is false, and the server rechecks that guard on every request. A host without threads speaks the original single-conversation interface, so a client must not offer thread controls or call the new routes.
History, attachments, read marks, an ordinary clear, and prompts all resolve against the selected thread. A switch changes conversationId: replace the transcript and reset its scroll and send-flight state instead of merging it into the thread before. Switching from a client does not activate the host's workspace or floor.
Sending still goes through POST …/prompt, with text or ordered staged-attachment segments. While the host is in the chat face its coordinator records the user turn and queues delivery, and working does not forbid another composed message. An optional clientMessageId UUID correlates a local send flight with the entry id the host assigns. It is ephemeral presentation metadata, not an idempotency token: never retry an ambiguous send automatically. Terminal output is never parsed into chat messages; only the coordinator's canonical transcript is synchronized.
The chat socket never resizes or wakes a terminal and never accepts terminal input. Use WebSocket ping/pong for liveness, and skip unknown type values and pong the way the feed socket asks. Reconnecting always hands you a full replacement tail, removals and status included, so do not merge snapshots as append-only events, and never let a snapshot received over HTTP overwrite a newer one already received on the ordered socket. Keep one stream with the terminal session, including while it is shown in PiP; close it on backgrounding and reconnect on foregrounding.
A canvas terminal card may carry chatPreview: {conversationId, entries: [ChatEntry], isWorking, unreadCount} while it is showing chat: at most four recent entries for a thumbnail, not a second history store. Opening the terminal uses the chat socket and the history route.
Agent accounts
Capability agentAccounts. An agent account is a signed-in identity for one agent CLI, beside the person's own login, which is the CLI's Default account. Which CLIs have accounts is the host's to decide from the command. Claude Code, Codex and Antigravity are built in, and any other CLI gets accounts once the person adds a provider file for it. A client never matches commands itself; it asks. Each account lives in one place, the host or one remote environment, and a terminal can run only on an account that lives where it runs: the host, or the workspace's or terminal's environment. Default is never stored and has no id: an absent or null accountId means Default everywhere. Account ids are strings; echo them verbatim.
| Route | Auth | Description |
|---|---|---|
GET /api/workspaces/{ws}/agent-accounts?command= | read | AccountList for a new terminal in the workspace running command, where the workspace runs: the New Terminal sheet's picker. 404 unknown workspace |
GET /api/terminals/{id}/agent-accounts?command= | read | AccountList for the terminal running command where it runs (its own environment, else its workspace's): the edit sheet's picker. 404 unknown terminal |
command is the command as typed; the host takes its first non-empty line, trimmed, as its sheets do. AccountList is {accounts: [{id?, name}], notice?, accountApplies?}:
accountsis the host's picker list: Default first, with noidand named with the person's own label for it (else the host's word for Default), then that CLI's accounts living there, byidandname. Empty when the command launches no CLI that has accounts.noticeis the host's own sentence when the place signs the CLI in itself (a Docker Sandbox with its own sbx secret): whatever is picked, the place's sign-in is what runs. A Docker Sandbox always has one for a CLI whose accounts can't run there: its accounts refuse to launch in it.accountApplies, withnotice, istruewhen the place signs only some of the CLI's providers (OpenCode talks to many): the account still applies to the rest, so the choice stays offered and the sentence names the providers the place signs. Absent orfalse, the place's sign-in replaces the account.
A client shows an Account picker only when accounts holds more than one entry, the host's rule: otherwise there is nothing to choose. With notice it shows the choice disabled, with the sentence beneath it, unless accountApplies is true, where the choice stays enabled over the sentence. A selection that is not in the list (the command changed CLI since it was picked) reads as Default, the account it would launch on. Ask again as the command changes; debounce typing.
Settings. GET …/settings carries accountId (the account the terminal launches on, resolved: a binding left over from another CLI or another place reads as Default, so absent), accounts, accountNotice and accountApplies (the AccountList fields for the saved command), and, while the terminal runs, accountRestart: {resumesSession, isKeptAlive}. On PUT, accountId is the one field that is not a full replace, because it has three states: absent leaves the terminal's account as it is (a client from before agentAccounts never moves a terminal by saving something else), null is Default, a string is that account. A client that shows the picker always sends it.
A running agent stays on the account it started with for the life of its process. When the sent account differs from the one a running terminal launched on, the host's edit sheet asks "Restart on {account}?" with Restart Now and At Next Launch; a client asks the same before saving, when accountRestart is present, and answers with restartForAccount:
true(Restart Now): the agent exits the way quitting does, its resume line harvested, then starts again on the new account and resumes its session whenresumesSession(the host's "resume sessions" setting). The response comes first; the restart follows and the card shows it on the feed. A role change in the same save already restarts the terminal on the new account, and nothing restarts twice.- absent or
false(At Next Launch): the account applies at the terminal's next launch or reload. WhenisKeptAlive(a session kept alive in tmux) that next launch starts the session over, without resuming, since reattaching would bring back the agent on the old account; say so.
Moving a terminal that isn't running asks nothing; the account applies at its next launch.
Creation. POST …/workspaces/{ws}/terminals takes accountId with the same three states. Absent takes the preset's account (presetId, or the default preset for a body with no draft fields), where it lives, and Default elsewhere, as the host's sheet seeds it; null is Default; a string is that account. It is not one of the draft fields: it lays over whatever the rest of the body makes. A New Terminal sheet seeds its picker from the picked preset's accountId and sends the effective selection whenever it shows the picker.
Validation. A sent accountId is checked against the command the terminal will run (the body's, else the saved or the preset's) and where it runs. Each refusal is 400 with reason: "accountRefused" and a message that says which, naming the account and where it lives: an id that is no account of the host's, a command that launches no CLI with accounts, an account of another CLI, an account that lives somewhere other than where the terminal runs. Show that message as the reason the save or the creation failed; it is not a connection problem, and retrying repeats it. The host never falls back to Default for a sent id. Default (null) is always accepted.
Note
A workspace export (transfer schema 2) and a version-2 backup carry chats/<conversation UUID>/ for every terminal on the ground and on all floors, inactive threads and retained attachments included. An import remaps conversation ids into independent storage, and restoring a legacy backup without chat assets leaves existing chat storage alone.
Portals
| Route | Auth | Description |
|---|---|---|
GET /api/portals/{id}/snapshot | read | image/jpeg with an ETag; 304 on If-None-Match; 404 while the portal has no live picture, including the host's Unload. A web portal's page, or a device portal's screen |
POST /api/portals/{id}/reload | write | the host's own reload, which doubles as wake: an unloaded page is loaded again, an unloaded device's display reconnected, wherever the portal is |
POST /api/portals/{id}/back / forward | write | history, for a web portal |
POST /api/portals/{id}/navigate | write | {url} |
POST /api/portals/{id}/unload | write | the host's Unload (nodeUnload): a page stopped, a device's display detached while it runs; isUnloaded on the node until reload |
Snapshots are pictures, not a stream: the host renders the current page or framebuffer on each request. Poll with the ETag and an unchanged picture costs a 304 with no body.
Devices
| Route | Auth | Description |
|---|---|---|
GET /api/devices | read | {devices: [{id, name, lastSeenIP?, lastSeenAt?, createdAt, role?}]} |
DELETE /api/devices/{id} | write | revoke; a device may revoke itself |
PUT /api/devices/{id}/role | write | {role: "owner"|"guest"}; 409 when a device targets itself with guest |
PUT /api/devices/{id}/name | read (self) / write (others) | {name}, trimmed, 1 to 80 characters; a device renames itself whatever its role |
Partituras
Capability partituras. The host's library of saved canvas arrangements, browsed and stamped from a client; creating and editing stay on the host.
| Route | Auth | Description |
|---|---|---|
GET /api/partituras | read | {partituras: [Partitura]} |
GET /api/partituras/{id}/preview?appearance=light|dark | read | the host's own thumbnail as JPEG, with an ETag |
POST /api/workspaces/{ws}/partituras | write | {partituraId, x, y, floorId?, mutationId?}: the layout centred on x,y; the host fills the terminals and adopts the embedded roles |
Partitura: id, name, summary, icon, color, workspaceId?, roles: [{id, name, icon, color}].
Partitura format 2 can embed all seven chat threads and their attachments, as the clipboard's terminalChatArchives payload. Creating or replacing one offers Include chat history, off by default, and importing needs its own opt-in; leaving a terminal out leaves its history out with it. Stamping copies accepted history into fresh conversations rather than linking back to the terminal it came from. Format 1 stays readable, and an older app rejects format 2 instead of quietly dropping the embedded history.
File trees
Capability fileTrees, owner-only: a guest sees the node on the canvas but is refused every route below with 403. A file tree node's folder, browsed and searched from a client, and its repository driven through the host's own git menu, wherever the tree points: the host's own disk, an SSH host, a container, a sandbox.
Paths on these routes are opaque: root-relative, /-separated, "" for the root. A client shows name and echoes path exactly as the host handed it, and never joins, splits or normalises one. A path that would leave the root (.., an absolute path, or on the host's disk a symlink out of the root) answers 400 or 403.
| Route | Auth | Description |
|---|---|---|
GET /api/workspaces/{ws}/nodes/{id}/tree?path=&hidden= | owner | FileTreeListing of the folder at path; hidden (true/false) overrides the node's own hidden-files setting; 404 a folder that is gone; 502 with the environment's own words when it could not be read |
GET /api/workspaces/{ws}/nodes/{id}/tree/file?path= | owner | the file's bytes under a Content-Type guessed from its name (source and config files are text/plain); honours Range (206, 416); streamed on the host's own disk, read whole under a 64 MB cap elsewhere (413 past it) |
GET /api/workspaces/{ws}/nodes/{id}/tree/changes | owner | FileTreeChanges of the repository the root sits in; 404 when it is not in one |
GET /api/workspaces/{ws}/nodes/{id}/tree/diff?path=&scope= | owner | FileTreeDiff: git's own unified diff of one changed file, path repository-relative as FileTreeChanges lists it, scope one of staged, unstaged, combined (default); 404 when the file has no such change |
GET /api/workspaces/{ws}/nodes/{id}/tree/search?query=&hidden= | owner | FileTreeSearch: files and folders anywhere under the root whose name matches, through the environment's own search and ranked as the host's own tree ranks them, at most 200 (isTruncated past that); hits are FileTreeEntry rows without size or date |
GET /api/workspaces/{ws}/directories?prefix= / ?path= | owner | prefix (fileTrees) answers DirectorySuggestions: folders on the workspace's environment whose path starts with it (~ may start it), at most 30, for a field naming the folder a new tree should browse. path instead (workspaceManagement) answers a DirectoryListing of the folders inside it, the same shape GET /api/directories returns, but resolved on the workspace's environment, where paths stay ~-relative |
GET /api/workspaces/{ws}/nodes/{id}/tree/git | owner | FileTreeGitOverview: the repository as the host's own git menu sees it; 404 outside a repository |
POST /api/workspaces/{ws}/nodes/{id}/tree/git | owner | FileTreeGitRequest → FileTreeGitResult. A NAMED action, never an argument list: the host runs the same git its own menu and sheets run, and nothing else. ok: false with git's own words when git refused; 400 for a request the host will not turn into an invocation (a missing message, a branch name it will not take, a path that climbs out of the repository); 404 outside a repository. Slow by nature for pull, push, sync, fetch: a client waits |
GET /api/workspaces/{ws}/nodes/{id}/tree/pull-request/context?base= | owner | FileTreePullRequestContext (fileTreePullRequest): what the host's sheet shows before a pull request opens from the tree's repository; 404 outside a repository. A repository that is a floor's own checkout, on any floor of the workspace, answers {floorId, bases: [], supportsDraft: false} and nothing else: a client opens that floor's pull request (Floors, below) instead |
POST /api/workspaces/{ws}/nodes/{id}/tree/pull-request | owner | {base, title, body, draft?, onWebsite?} → {url?, openedOnWebsite} (fileTreePullRequest): pushes the repository's branch and opens its pull request, as the host's sheet does for a folder; nothing records or follows it afterwards. A page on the project's host is returned for the client to open, never opened on the Maestri host. 502 means the provider refused after the push: only then may a client offer onWebsite, which needs the branch on the project's host (409 otherwise). 409 for uncommitted work, nothing new to propose, a folder off a branch or partway through a merge or rebase, or another open from the same folder still running; 404 outside a repository; 503 with the project's host's words when it couldn't be asked |
FileTreeNode (on a canvas node of kind fileTree): rootPath, currentPath, environment?, showsHiddenFiles, pinnedPaths, preview?, branch?. rootPath is the host's absolute path, for display; currentPath is where the tree was last navigated to, root-relative; environment is absent for the host's own disk, else ssh, docker, sandbox or customRuntime; pinnedPaths are root-relative. preview is the first rows (at most 12) of the folder the tree is on, [{name, isDirectory, gitStatus?}], for a card to draw the tree in miniature; absent for a tree on another environment. branch is the repository's branch as the tree last saw it.
FileTreeListing: path, name, entries: [FileTreeEntry], isTruncated, git?. Entries come folders first, in the host's own order for the tree. FileTreeEntry: name, path, isDirectory, isSymbolicLink, size, modifiedAt?, gitStatus?; gitStatus is the porcelain letter (M A D R C ? ! U), for a folder the worst of its children. git: branch?, changedCount, hasUpstream, absent outside a repository.
FileTreeChanges: branch?, staged: [FileTreeChange], unstaged: [FileTreeChange]; a file changed on both sides is in both. FileTreeChange: path, name, directory, status, isStaged, additions?, deletions?. FileTreeDiff: path, scope, patch.
FileTreeGitOverview: branch?, hasUpstream, remotes, localBranches, remoteBranches, stashes: [{ref, message}], source?: {name, branches}. remoteBranches are remote-tracking branches with no local counterpart, shown as origin/x; checking one out makes the local branch. source is the project repository a floor's clone was made from, its folder name and its local branches. It is present only for a tree on a git-isolated floor, absent on the ground, on a plain floor, and from a host that predates it, so offer Fetch from Source Repo there and nowhere else.
FileTreeGitRequest: action plus what it takes: commit (message, and paths to stage first or stageAll), stage / unstage (paths), pull, push (with an upstream), publish (remote: the current branch pushed with --set-upstream), sync (pull, then push if the pull went through), fetch (--all), checkout (branch), newBranch (branch, made from the current one), deleteBranch (branch, force?), merge (branch into the current, --no-edit), stashPush (message?, untracked files included), stashApply / stashPop / stashDrop (stash: the ref), fetchFromSource (branch: one of the overview's source.branches, fetched from a git-isolated floor's source repository into its clone under the same name, as the host's own sheet does; 400 without a source, or for a branch the source does not have). FileTreeGitResult: ok, output.
FileTreePullRequestContext: floorId?, branch?, bases, base?, isOnBase?, title?, body?, providerName?, account?, supportsDraft, problem?, commitCount?, files?. bases are where the pull request can go, in the host's order: the branch the push remote's HEAD names, then main or master, then the remote's other branches (as last fetched or pushed, no network), then ones only the repository has; never its own branch. base is the one previewed, base as asked or else the first of bases, absent when there is none. isOnBase marks a repository on the branch its pull requests go into (main, whose remote's HEAD is main): problem then says so in the host's words, and a client shows that instead of the form. providerName and account are the provider and account that open it (none: the project's host's website); problem is why it can't open now. commitCount and files: [{path, additions, deletions, isBinary}], the landing preview's own shape, are what the branch adds to the remote's copy of base (a local branch of that name when the remote has none), absent without a base or when it isn't there to compare with. There is no unpushedCommits and no agents: a folder follows no pull request after it opens.
Floors
Capability floorManagement, owner-only. The host's New Floor, Land Floor, Delete Floor, Rename and a floor's Unload, from a client, through the same operations its sheets and its floor sidebar run. Landing and deleting take a real floor: the ground has no id and cannot be landed or deleted. Unload takes the ground as well, as ground. A land that needs a person first is refused with 409 in the host's own words, which name the files: uncommitted work on the floor, uncommitted work on the ground, or a merge that conflicts. A client shows the words and does not retry on its own.
| Route | Auth | Description |
|---|---|---|
GET /api/workspaces/{ws}/branches | owner | {isGitRepository, current?, branches, canCopyIndependently?}: the project's local branches and the one the ground is on, for the New Floor sheet's picker, read where the project lives (the host, or the workspace's SSH, Docker or custom-runtime machine); isGitRepository false means no isolation is possible, as in a Docker sandbox. canCopyIndependently says the New Floor sheet may offer Independent copy (the project on the host, on APFS, with a repository of its own); absent from older hosts |
GET /api/workspaces/{ws}/pull-requests?query= | owner | {repository, pullRequests: [{number, title?, author?, branch?, isDraft?, isFork?, url?, floorName?}]}: the project's open pull requests for the New Floor sheet's Pull Request side, listed through the provider for the project's host (pullRequestReview), the most recently active first and capped (50 for the built-in providers). With query, the open ones that host finds for those words instead, for what the cap left out; empty when the provider can't search. A client filters the list as the person types and asks with query once typing pauses. repository is where (github.com/acme/app); floorName the floor already following one, which can't be made twice; isFork one whose branch the provider says is on a fork, which a floor can read but not push to. 409 with Maestri's own reason when nothing can list them (no provider with a list command, a workspace set to the project's host's website, no remote); 503 with the project's host's words when it couldn't be asked |
POST /api/workspaces/{ws}/floors | owner | {name, branch?, existingBranch?, gitIsolation?, copyGround?, independentCopy?, pullRequest?} → {ok, floorId, branch?, isGitIsolated, isPending?}: the host's New Floor, split as its own sheet splits it: the name, the branch and the clone's validations answer at once, and a clone then copies behind a pending pill (isPending), riding the feed as pendingFloors: [{id, name, branch, completedItems, totalItems, error?}] until the floor lands under the same floorId. A copy that fails leaves the pill with error until DELETE …/floors/pending/{id} dismisses it (409 while still copying, 404 unknown). With gitIsolation (the default) the floor is a git worktree of the project on branch, made new or, with existingBranch, checked out as it is; an absent branch is a slug of the name. The worktree is made where the project lives, on the host or on the workspace's SSH, Docker or custom-runtime machine, and is seeded copy-on-write from the ground's files where that disk can clone (APFS, btrfs, XFS). A worktree cannot take the branch the ground has checked out: that existingBranch is 409. Where the project cannot host one (not a git repository) the host makes a plain floor and answers isGitIsolated: false; in a Docker sandbox workspace an explicit branch is 400. copyGround starts the floor with the ground's layout. independentCopy, where canCopyIndependently allows it, makes an APFS copy of the whole project with a repository of its own instead of a worktree. A plain floor is made before the answer. 409 for a name or branch already taken. pullRequest (pullRequestReview) is an open pull request to review on the floor: its number, #42 or its link. The host asks the project's host about it and brings its branch into the project first, then checks that out (branch and existingBranch don't apply), and the floor follows it as pullRequest with isReview; an empty name becomes "#42" and its title. The floor has the pull request's own branch only when the project's host confirms it's in the project's repository and its commit is the pull request's; otherwise a copy of its commits (isReadOnly). 400 for text that names no pull request of the project, 404 for one that isn't there, 409 for one that isn't open, is already on a floor or being made into one, changed while it was fetched, can't be fetched by its provider, or whose local branch went its own way or pushes elsewhere; 503 when the project's host or the fetch couldn't be done |
GET /api/workspaces/{ws}/floors/{id}/landing?target= | owner | FloorLanding: what the landing sheet shows. branch? is the branch checked out in the floor's clone; groundBranch the ground's own; branches the project's local branches; target the one previewed (target as asked, else the floor's own); isMerge whether landing on target merges rather than only bringing the branch in; commitCount and files: [{path, additions, deletions, isBinary}] against target when merging, else against groundBranch; conflicts the paths a merge would leave in conflict. 400 for a branch the project does not have |
POST /api/workspaces/{ws}/floors/{id}/land | owner | {targetBranch?, deleteBranch?, keepFloor?} → {ok, branch, mergedInto?}. The floor's branch is brought into the project; targetBranch other than the floor's own merges into it, and deleteBranch then deletes the floor's branch (best effort, as the host does it), except on a worktree floor that keepFloor keeps, which goes on working on that branch. A merge runs in the checkout that has targetBranch checked out, the ground or another floor, and is refused there like any merge that would overwrite uncommitted work, or with 409 when that checkout is partway through a merge or rebase of its own. The floor is then removed with its branch kept, unless keepFloor. 409 as above, and also when the branch landed but the floor's clone could not be removed, in which case the floor stays and a delete with force removes it; 404 no such floor; 400 a floor without a clone |
POST /api/workspaces/{ws}/floors/{id}/delete | owner | {deleteBranch, force?} → {ok}: the sidebar's Delete Floor, teardown hooks and all. A clone that cannot be removed is 409 with the reason and the floor untouched; force: true removes the floor anyway, as the host's own prompt offers |
POST /api/workspaces/{ws}/floors/{id}/update | owner | {name?} → {ok}: the floor's fields, as the workspace update takes a workspace's; an absent field is left as it is. name is the sidebar's Rename, trimmed; 400 empty, 404 no such floor |
POST /api/workspaces/{ws}/floors/{id|ground}/unload | owner | {ok}: the sidebar's Unload for one floor, the workspace unload (POST …/{ws}/unload) narrowed to it. Its terminals are stopped and its portals let go of their resources, and they come back on the next visit; the other floors keep running. 404 no such floor |
Floor status and sidebar summaries
floorStatus is additive to floorManagement; clients gate Set Status on this capability and owner role, not on floorManagement alone. No protocol-major change. Older hosts omit these optional fields and clients keep their existing floor overview; unknown status states must not prevent a workspace or feed from decoding.
FloorStatus: {state, text, progress?, author?}. Known states are working, blocked, review, done. text is the short report, trimmed by the host and bounded to 120 Swift Characters (extended grapheme clusters); longer text becomes its first 119 characters plus …. progress is a finite fraction in 0…1, not a percent. author is the terminal name for agent reports; manual status edits clear it. Clients preserve unknown state strings, show the report text and a neutral idle marker, and offer only the four known states when editing.
FloorPullRequest: {state?, isDraft, number?, attention?, url?, title?, base?, review?, checks?, hasConflicts?, mergeMethods?, recordId?, host?, isReview?, isReadOnly?}, the pull request the floor's branch is in review as, whoever opened it: the host follows ones its agents open with a project's host CLI as well as its own. Known states are open, merged, closed; state is absent while the host hasn't read it from the project's host (one opened in the browser), and number while it doesn't know it. The floor's marker follows it, the host's rule: merged shows done and closed the idle ring, over any report; open shows the report, or review without one (underway for a draft); absent or unknown states leave the report alone. The host clears the floor's report when its pull request becomes merged or closed, since the report was about work the project's host settled; otherwise the report is never rewritten, and a client still edits it as before. attention is the one mark the host's chip shows on an open pull request: conflicts, checksFailing, changesRequested, approved, checksRunning (tolerate others and draw nothing for them). url is its page on the project's host. isReview marks a floor made to review someone's pull request (pullRequestReview): a client offers Ask an Agent's assess and a Done Reviewing that deletes the floor through the usual delete. isReadOnly marks one whose floor has a copy of its commits rather than its branch (a fork, or a branch the project's host didn't confirm is the project's): the floor can't push to it, so a client offers neither Push nor a fix. Older hosts omit the field, and attention and url before pullRequestChecks.
| Route | Auth | Description |
|---|---|---|
POST /api/workspaces/{ws}/floors/{id}/pull-request/check | owner | {ok: true} once the project's host answered; the result rides the feed. The host's own Check Pull Request, through the same per-floor queue as its periodic checks. 404 for a missing workspace or floor, or a floor with no pull request; 503 with the project's host's words when it couldn't be asked or refused (a rate limit among them) |
GET /api/workspaces/{ws}/floors/{id}/pull-request/context?base= | owner | {title, body, base, providerName?, account?, supportsDraft, unpushedCommits?, problem?, agents: [{id, name, isRunning}]}: the sheet's prefilled title and description, the provider and account that open it (none: the project's host's website), why it can't open now, and the floor's agents for Ask an Agent (pullRequestLanding) |
POST /api/workspaces/{ws}/floors/{id}/pull-request | owner | {base, title, body, draft?, onWebsite?} → {url?, openedOnWebsite}: pushes and opens it as the host's sheet does. A page on the project's host is returned for the client to open, never opened on the Maestri host. 502 means the provider refused after the push: only then may a client offer onWebsite, which needs the branch on the project's host (409 otherwise). 409 for uncommitted work, nothing new to propose, a floor that already has one, or another operation on the floor's pull request still running |
DELETE /api/workspaces/{ws}/floors/{id}/pull-request?record= | owner | {ok}: Open a New Pull Request, forgetting a closed one or one opened on the website |
POST /api/workspaces/{ws}/floors/{id}/pull-request/merge | owner | {method, recordId?}, one of mergeMethods → {ok}. 409 for a draft, a method the provider doesn't offer now, or a pull request that isn't open |
POST /api/workspaces/{ws}/floors/{id}/pull-request/remove | owner | {confirmExtraWork?, recordId?} → {ok, branch, mergedInto?}: a merged pull request's floor removed with its branch kept, as a land answers. 412 when the floor has work the merged pull request doesn't, until confirmed; 409 for uncommitted work. A branch other than the floor's, with no mergedInto, is where the work was kept, worth telling the person |
POST /api/workspaces/{ws}/floors/{id}/pull-request/push | owner | {recordId?} → {ok}: the floor's new commits pushed to its pull request |
POST /api/workspaces/{ws}/floors/{id}/pull-request/hand-off | owner | {terminalId, task, note?, recordId?} → {ok}: Ask an Agent. task is review, checks or conflicts, and must be a problem the pull request has now (409), or assess on a floor made to review it (isReview, pullRequestReview): a first review, reported rather than pushed. On one isReadOnly, only assess is taken, since a fix can't reach it; the host writes its own sheet's prompt and sends it to that agent, which must be one of the floor's and running (404, 409) |
Every request about an existing pull request may name the record it saw (recordId, or record= on the DELETE); the host refuses it with 409 once the floor's pull request is another one. Opening, merging, pushing and removing run one at a time per floor, from the host's sheet or any client; another one meanwhile is 409.
Every feed floors entry, ground included, gains optional status, attentionCount, hasActivity, and isDormant. Attention counts terminals needing attention on that floor; activity means at least one of its terminals is working; dormant means the floor has terminals but none running. Empty floors are not dormant. Ground's existing branch is the workspace's groundFloorBranch.
WorkspaceMeta, both in GET /api/workspaces and inside a feed, gains optional floors: [FloorMeta], pendingFloors: [PendingFloor], groundStatus, and groundFloorBranch. The summaries use the same shape as the feed, so a sidebar can show every workspace's floors without fetching every canvas. floors contains ground (absent or null id) first, then real floors oldest to newest; pendingFloors is oldest to newest. Display pending creations newest first, then real floors newest first, then ground. In the workspace list, isActive reflects the host's floor; in a scoped feed it reflects that feed's viewed floor, as before. The phone's explicit floor selection remains local and takes precedence when highlighting sidebar rows. Locked workspaces omit all four new workspace fields. Absent arrays mean unavailable, not an authoritative empty floor list.
| Route | Auth | Description |
|---|---|---|
POST /api/workspaces/{ws}/floors/{id|ground}/status | owner | RemoteSetFloorStatusRequest ({status: FloorStatus} or {status: null}) → {ok: true}. Replaces the whole report or clears it. Does not activate the workspace or floor |
The status key is required: {} is 400, never an implicit clear. Empty or whitespace-only text, unknown states, malformed bodies, and progress outside 0…1 are 400. Text is normalized as above; supplied author is ignored because the edit is manual. A missing workspace or real floor is 404, guest devices and locked workspaces are 403, and an unready workspace manager is 503. Clearing a real floor still checks it exists. Ground is addressed only by the literal ground, never an invented UUID.
The host calls the same persisted setFloorStatus operation as its own sheet and marks the feed dirty; subscribed clients receive the normal coalesced full snapshot with the updated summaries. Workspace-list clients see it on their next refresh. Agent and host edits also arrive through the existing periodic feed push. An editor preserves progress while the state stays unchanged, drops it when the state changes, and keeps the sheet open on a refused write.
Floor hooks
Capability floorHooks, owner-only: setup, run and teardown commands, read and replaced whole, and run or stopped on a floor. Only the host runs them.
| Route | Auth | Description |
|---|---|---|
GET /api/workspaces/{ws}/hooks | owner | {hooks: {isEnabled, autoRunSetup, setup, run, teardown: [{id, command, isEnabled}]}, runs: [{commandId, floorId?, isRunning, exitCode?, finishedAt?}]}: the workspace's floor hooks as the host keeps them, and every run the host still remembers on its floors |
PUT /api/workspaces/{ws}/hooks | owner | the hooks shape above → {ok}: replaced whole, as the host's sheet writes them; commands keep their ids |
POST /api/workspaces/{ws}/floors/{id}/hooks | owner | {action: run | stop | runSection, commandId?, section?} → {ok}: the host's hooks button on that floor: one command run or stopped, or a section's enabled commands run; 404 unknown floor or command |
Routines
Capability routines, owner-only. The host's scheduled prompts and reminders, through the same scheduler its own window drives. Global, as the host keeps them: a routine names a workspace and, when it is a command rather than a reminder, a terminal in it.
| Route | Auth | Description |
|---|---|---|
GET /api/routines | owner | {routines: [Routine], history: [FireRecord], targets: [{id, name, icon, workspaceId, floorId?, floorName?}]}: every routine, the fire history oldest first, and every terminal a routine can target, by workspace and floor as the host's picker lists them |
POST /api/routines | owner | Routine → {ok}: made under the id it was sent with. 400 empty name, a reminder with no notes, 404 unknown workspace or a terminal not in it, 409 the id already exists |
PUT /api/routines/{id} | owner | Routine → {ok}: replaced whole, as the host's sheet saves one; the host keeps its own fire counters and resets them when the timing changed. 400/404 as on create |
DELETE /api/routines/{id} | owner | {ok}; 404 unknown |
POST /api/routines/{id}/action | owner | {action: run | enable | disable} → {ok}: the list's switch, and Run Now |
DELETE /api/routines/history | owner | {ok}: the fire history cleared; routines are untouched |
Routine: id, name, workspaceId, terminalId?, prompt, preRunScript?, skipIfBusy, schedule, endRepeat, notifyOnFire, isEnabled, fireCount, lastFiredAt?, createdAt. schedule is {kind: once, at}, {kind: interval, seconds}, {kind: daily, hour, minute} or {kind: weekly, weekdays: [1…7, Sunday 1], hour, minute}; daily and weekly are wall-clock times on the host. endRepeat is {kind: never}, {kind: afterCount, count} or {kind: onDate, date}. createdAt is the schedule's start for a repeating routine. A routine with no terminalId is a reminder: it notifies and runs no command. fireCount and lastFiredAt are the host's and are ignored on a write.
FireRecord: id, routineId, routineName, prompt, firedAt, outcome, terminalName?; outcome is sentToTerminal, terminalNotRunning, reminder, skippedByScript or skippedWhileBusy.
Recipes
Each of these is a complete integration. HOST is the address, TOKEN the device token from pairing, and --insecure stands in for pinning the key.
Pair a script
# The six-digit code from the Pairing tab, or the password from the Manual tab
curl --insecure -X POST https://HOST:7434/pair \
-H 'Content-Type: application/json' \
-d '{"deviceName":"Attention bridge","code":"483920"}'
# → {"token":"…","deviceId":"…","deviceName":"Attention bridge","protocolVersion":1,"role":"owner"}
Store token. Then demote the pairing to Read-only from the Devices tab if it only needs to watch.
Know when an agent needs you, and go there
Poll the workspace list; attentionCount says how many terminals are waiting. When it is not zero, read the feed to find which, then send the desk there.
# 1. Anything waiting?
curl --insecure -H "Authorization: Bearer TOKEN" https://HOST:7434/api/workspaces
# → workspaces[].attentionCount, hasActivity, runningTerminalCount
# 2. Which terminal (items[].terminal.needsAttention), and what it last said (preview)
curl --insecure -H "Authorization: Bearer TOKEN" https://HOST:7434/api/workspaces/WS/feed
# 3. On a button press: bring the host to it, as its notification click does
curl --insecure -X POST -H "Authorization: Bearer TOKEN" https://HOST:7434/api/terminals/TERMINAL_ID/focus
The same fields drive a status light (hasActivity, attentionCount) or a button that answers a prompt (approve / reject on the terminal a pendingPrompt item names). To avoid polling, hold the feed socket; a snapshot arrives whenever something changes.
Send a prompt to an agent
curl --insecure -X POST -H "Authorization: Bearer TOKEN" \
-H 'Content-Type: application/json' \
https://HOST:7434/api/terminals/TERMINAL_ID/prompt \
-d '{"text":"Run the tests and report what fails."}'
To attach a file, stage it first (POST …/attachments?filename=… with the bytes) and send segments: [{"text": "…"}, {"attachmentId": "…"}]; the host delivers it as its own Prompt Composer does.
Watch a terminal in a browser
Open wss://HOST:7434/api/terminals/TERMINAL_ID/stream?token=TOKEN, feed every binary frame to xterm.js, reset the emulator on each geometry message, and send {"type":"resize","cols":…,"rows":…} with your own grid. Send {"type":"input","data":"…"} to type.
A minimal Python client
import json, ssl, urllib.request
HOST, TOKEN = "https://192.168.1.20:7434", "…"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE # pin the security key instead in anything you keep
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(HOST + path, data=data, method=method)
req.add_header("Authorization", f"Bearer {TOKEN}")
if data: req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, context=ctx) as r:
return json.load(r)
info = call("GET", "/api/info")
assert info["protocolVersion"] == 1
for ws in call("GET", "/api/workspaces")["workspaces"]:
if ws["attentionCount"]:
feed = call("GET", f"/api/workspaces/{ws['id']}/feed")
for item in feed["items"]:
t = item.get("terminal")
if t and t["needsAttention"]:
print(ws["name"], "→", t["name"], "|", " ".join(t["preview"][-2:]))
Versioning
protocolVersionmarks an incompatible change. A client should refuse a host with a different version.- Additions are signalled by capabilities and by optional fields. A client must tolerate unknown fields, unknown enum values, unknown feed item kinds, unknown mutation kinds and unknown socket message types.
- Requests are strict: unknown enum values in a request body are refused with
400.
Warning
Anything a paired device can do, a leaked token can do until it is revoked in the Devices tab or idles out after 30 days. Keep tokens out of source control, prefer Read-only pairings for integrations that only watch, and remember that the host notifies on every new pairing so an unexpected one is visible.