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 Host header must be an IP literal, localhost, a .local name or a .ts.net name; anything else answers 403. 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:

  • GET requests 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.

CapabilityWhat it means
feedSnapshotsGET …/feed and the feed socket exist
terminalStreamingthe terminal socket exists
terminalInputBytesthe terminal socket accepts inputBytes and binary frames
terminalInputTextthe terminal socket accepts input (UTF-8 text)
terminalResizethe terminal socket honours resize (clamped to 20…250 columns by 5…120 rows)
promptSegmentsPOST …/prompt accepts segments
attachmentStaging, attachmentDiscardthe attachment routes exist
mentionCatalogGET …/mentions exists
terminalThemesterminal cards carry theme
nativeNotesnode-addressed note routes, with revision / ifRevision
pairingCodespairing by six-digit code
canvasMirroringthe feed snapshot carries a canvas
canvasWritesall 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
noteStackWritesfichário rail routes exist
noteStackPagesfichário pages can be added and removed
noteStackFilingfichários can be made from a selection, a note filed in or moved between them, a spilled page given a landing point
deviceRolesroles are enforced and /api/info reports the caller's
drawingWritesdrawings can be added, moved and erased
workspaceActionspin, rename, wake, unload and attention-clear routes exist; workspace meta carries workingDirectory
workspaceManagementthe 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
terminalDraftsPOST …/terminals takes the New Terminal sheet's fields
roleManagementthe agent role routes exist; terminal settings carry icon, color, monitorActivity
nodeGroupsthe snapshot carries groups, nodes carry groupId, and the group routes exist
partiturasthe host's partitura library can be listed, previewed and stamped onto a floor
nodeBatchDeletePOST …/nodes/delete removes a selection in one request
canvasFilesPOST …/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
deviceRenamingPUT /api/devices/{id}/name renames this device, or another device for an owner
presencethe feed socket accepts presence; the host draws a cursor for the device on its canvas
textStylingPOST …/nodes/{id}/text takes the text toolbar's settings beside the text
nodeUnloadterminals and portals can be unloaded; restart and reload wake them
terminalFocusPOST …/terminals/{id}/focus exists: the host goes to the terminal as its own notification click does
cameraControlthe 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
nodeFocusPOST …/nodes/{id}/focus exists: the host's reveal for any canvas node
terminalFileMentionsGET …/terminals/{id}/files exists: the host composer's own file index for a terminal, for @ mentions from a client
fileTreesthe 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
cableTiesthe 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})
routinesthe 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
floorHooksthe host's floor hooks are read, replaced and run from a client (below): GET/PUT …/hooks, POST …/floors/{id}/hooks. Owner-only
floorManagementthe 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": "…"}}, with code one of invalidRequest, 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 answer 404.
  • 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

RouteAuthDescription
GET /api/workspacesread{"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=…readone FeedSnapshot
GET /api/workspaces/{ws}/iconreadthe 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}/activatewritemake it the host's active workspace (404 unknown, 409 licence-locked)
POST /api/workspaces/{ws}/floors/{floor|ground}/activatewritealso switch the host's active floor
POST /api/workspaces/{ws}/pinwrite{pinned}; idempotent
POST /api/workspaces/{ws}/renamewrite{name}; 400 empty
POST /api/workspaces/{ws}/wakewritestarts the workspace's terminals without activating it on the host
POST /api/workspaces/{ws}/unloadwritestops every terminal in the workspace
POST /api/workspaces/{ws}/attention/clearwriteclears the workspace's attention badge
POST /api/workspaceswrite{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}/updatewrite{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=owneron 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=…readlive 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 answer
  • note: { 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?.

  • id is the terminal id, which the terminal routes take; nodeId is its canvas node, which the node routes take. They are different ids.
  • preview is the terminal's last lines as plain text: enough to show what an agent is doing without a terminal emulator.
  • needsAttention is the host's own attention state (the agent finished, or asked for someone). isActive is the activity monitor's word for an agent working right now. isRunning says a process exists; isLive that the host has the terminal loaded.
  • isUnloaded is the host's Unload: the terminal put to sleep by hand until POST …/restart wakes 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: TerminalCard
  • note: NoteCard
  • text {text, fontSize, isMonospaced, color?, fontFamily?, fontWeight?, fontName?}: the host's stored text block; fontFamily is "serif" or absent, fontWeight is "medium", "bold" or absent, fontName a font the host has
  • file {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}}. device present means a device portal (a simulator, an emulator or a phone over adb): no address, no history, its snapshot is the device's screen; platform is ios, ipados or android. runtime is what the host's node shows now: a page is loading, ready, failed or empty; a device is idle, booting, connecting, live or unavailable, with the host's own sentence in runtimeDetail. Only ready and live have a snapshot worth fetching. isUnloaded is 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 floor
  • noteStack {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.

RouteBodyAnswer
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

RouteAuthDescription
GET /api/workspaces/{ws}/nodes/{id}/notereadNoteContent
PUT /api/workspaces/{ws}/nodes/{id}/notewrite{text, ifRevision?}NoteContent; 409 content-locked, 412 revision mismatch (the body is the current NoteContent)
PUT /api/workspaces/{ws}/nodes/{id}/note/lockwrite{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}/filereadraw bytes of a file node; honours Range (206, 416)
GET /api/workspaces/{ws}/nodes/{id}/file/previewreadan 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

RouteAuthDescription
WS /api/terminals/{id}/stream?token=…read (input needs Full control)the terminal stream, below
POST /api/terminals/{id}/promptwrite{text} or {segments: [{text} | {attachmentId}]}; 409 not running
POST /api/terminals/{id}/attachments?filename=…writeraw 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}/mentionsread{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 / rejectwriteanswers a pending Y/n prompt; 409 when there is none
POST /api/terminals/{id}/seenwritemarks the terminal seen, clearing its attention on both surfaces
POST /api/terminals/{id}/restartwritethe host's reload button; wakes an unloaded terminal
POST /api/terminals/{id}/unloadwritethe host's Unload (nodeUnload): the session torn down to its dormant card, scrollback kept, until restart
POST /api/terminals/{id}/focuswritethe 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}/killwriteends the process
GET / PUT /api/terminals/{id}/settingsread / 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).

RouteAuthDescription
GET /api/rolesread{roles: [Role]}
POST /api/roleswriteRoleWriteRole as stored
PUT /api/roles/{id}writeRoleWrite, 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

RouteAuthDescription
GET /api/portals/{id}/snapshotreadimage/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}/reloadwritethe 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 / forwardwritehistory, for a web portal
POST /api/portals/{id}/navigatewrite{url}
POST /api/portals/{id}/unloadwritethe 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

RouteAuthDescription
GET /api/devicesread{devices: [{id, name, lastSeenIP?, lastSeenAt?, createdAt, role?}]}
DELETE /api/devices/{id}writerevoke; a device may revoke itself
PUT /api/devices/{id}/rolewrite{role: "owner"|"guest"}; 409 when a device targets itself with guest
PUT /api/devices/{id}/nameread (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.

RouteAuthDescription
GET /api/partiturasread{partituras: [Partitura]}
GET /api/partituras/{id}/preview?appearance=light|darkreadthe host's own thumbnail as JPEG, with an ETag
POST /api/workspaces/{ws}/partituraswrite{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.

RouteAuthDescription
GET /api/workspaces/{ws}/nodes/{id}/tree?path=&hidden=ownerFileTreeListing 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=ownerthe 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/changesownerFileTreeChanges of the repository the root sits in; 404 when it is not in one
GET /api/workspaces/{ws}/nodes/{id}/tree/diff?path=&scope=ownerFileTreeDiff: 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=ownerFileTreeSearch: 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=ownerprefix (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/gitownerFileTreeGitOverview: the repository as the host's own git menu sees it; 404 outside a repository
POST /api/workspaces/{ws}/nodes/{id}/tree/gitownerFileTreeGitRequestFileTreeGitResult. 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.

RouteAuthDescription
GET /api/workspaces/{ws}/branchesowner{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}/floorsowner{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=ownerFloorLanding: 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}/landowner{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}/deleteowner{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}/updateowner{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}/unloadowner{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.

RouteAuthDescription
GET /api/workspaces/{ws}/hooksowner{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}/hooksownerthe hooks shape above → {ok}: replaced whole, as the host's sheet writes them; commands keep their ids
POST /api/workspaces/{ws}/floors/{id}/hooksowner{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.

RouteAuthDescription
GET /api/routinesowner{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/routinesownerRoutine{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}ownerRoutine{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}/actionowner{action: run | enable | disable}{ok}: the list's switch, and Run Now
DELETE /api/routines/historyowner{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

  • protocolVersion marks 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.