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) |
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 |
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": "…"}}, withcodeone ofinvalidRequest,unauthorized,forbidden,notFound,conflict,preconditionFailed,payloadTooLarge,rateLimited,internalError,unavailable,unknown. The HTTP status carries the same information. - 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 local workspace with git-isolated floors is moved onto an environment |
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: [ { id?: UUID, name, color?, isActive, isGitIsolated?, branch?, isCloneMissing? } ],
items: [ FeedItem ],
canvas: CanvasSnapshot,
epoch: UUID
}
isGitIsolated and branch (floorManagement) are present once a floor is created: whether it runs on its own copy-on-write clone, and the branch checked out there. isCloneMissing is true when that clone's folder has gone from disk.
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.
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?.
idis the terminal id, which the terminal routes take;nodeIdis its canvas node, which the node routes take. They are different ids.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?}: the chip standing in for a terminal on another floornoteStack {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), 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; the connect and disconnect route do not take its endpoints. 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?, mutationId?} | {ok, nodeId, terminalId} (600×420); 404 preset or role not found, 400 nothing to run or a colour that is not #RRGGBB |
POST / DELETE /api/workspaces/{ws}/connections | {fromNodeId, toNodeId, mutationId?} | {ok}; 400 for a pair the canvas does not link, or across floors |
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?}]} for the terminal create route. 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.
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?}; PUT takes {command, roleId?, maestroMode, icon?, color?, monitorActivity?} as a full replace; a changed role restarts a running terminal |
Mention: kind, id, name, tokenName?, icon?, color?, platform?, floorId?, floorName, isOnActiveFloor, depth, section, sectionOrder, order.
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.
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}].
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 |
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}]. remoteBranches are remote-tracking branches with no local counterpart, shown as origin/x; checking one out makes the local branch.
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). FileTreeGitResult: ok, output.
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}: the project's local branches and the one the ground is on, for the New Floor sheet's picker; isGitRepository false means no isolation is possible |
POST /api/workspaces/{ws}/floors | owner | {name, branch?, existingBranch?, gitIsolation?, copyGround?} → {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 copy-on-write clone of the project on branch, made new or, with existingBranch, checked out as it is; an absent branch is a slug of the name. Where the project cannot host a clone (not a git repository, not on APFS) the host makes a plain floor and answers isGitIsolated: false; on a workspace on another environment an explicit branch is 400. copyGround starts the floor with the ground's layout. A plain floor is made before the answer. 409 for a name or branch already taken |
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). 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 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.