Maestri Wire

Maestri Wire is the protocol a Mac running Maestri speaks to other devices and tools. It is how the Maestri Remote app for iPhone and iPad follows your agents, and it is open to anything you build yourself: a script that pings you when an agent needs attention, a Stream Deck or macropad that approves prompts, a dashboard on a spare screen, or a full client of your own.

This page is the complete contract. It is written so you can hand it to an agent and get a working integration back: every route, every shape, every rule the Mac enforces.

Note

Maestri Wire is in beta. Everything below is what the Mac enforces today. New features arrive as capabilities and optional fields; nothing here is removed without a protocol version change.

The short version

  • The Mac 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 Mac'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 Mac'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 Mac, 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 Mac 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 Mac's address from the QR code (which also carries the Mac'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 Mac over its private network.

Transport rules the Mac 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 Mac generates a key once and keeps it in its login keychain; 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 Mac 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 Mac 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 Mac (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 Mac.

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 Mac, 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": "Evert’s MacBook Pro",
  "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 Mac'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
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
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 Mac'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 on a floor as a file node; GET …/nodes/{id}/file/preview answers an image node with a card-sized JPEG
deviceRenamingPUT /api/devices/{id}/name renames this device, or another device for an owner
presencethe feed socket accepts presence; the Mac 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 Mac goes to the terminal as its own notification click does

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]}, in the Mac's sidebar order
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 Mac's active workspace (404 unknown, 409 licence-locked)
POST /api/workspaces/{ws}/floors/{floor|ground}/activatewritealso switch the Mac'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 Mac
POST /api/workspaces/{ws}/unloadwritestops every terminal in the workspace
POST /api/workspaces/{ws}/attention/clearwriteclears the workspace's attention badge
WS /api/feed/stream?ws={ws}&floor=…readlive snapshots and mutation events

floor is absent (mirror the Mac's active floor), ground, or a floor UUID. Viewing a floor through the Wire never moves the Mac; only the activate routes do. Subscribing to a workspace's feed as an owner wakes its terminals, so a workspace the Mac is not showing still streams; a guest's subscription wakes nothing.

FeedSnapshot

{
  workspace: WorkspaceMeta,
  floors: [ { id?: UUID, name, color?, isActive } ],
  items: [ FeedItem ],
  canvas: CanvasSnapshot,
  epoch: UUID
}

WorkspaceMeta: id, name, icon?, color?, activeFloorId?, ropeRouting ("avoidNodes"|"behindNodes"|"circuit"), selectionStyle?, isPinned, groupName?, folderName?, terminalCount, runningTerminalCount, attentionCount, hasActivity, isLoaded, isLocked?, iconRevision?, workingDirectory?. icon is an SF Symbol name or a single emoji. iconRevision is present when the Mac has a custom picture for the icon; its value changes with the picture, so key a cached copy on it.

A workspace the Mac'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 Mac'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 Mac has the terminal loaded.
  • isUnloaded is the Mac'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 Mac 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?}]
}

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 Mac's stored text block; fontFamily is "serif" or absent, fontWeight is "medium", "bold" or absent, fontName a font the Mac has
  • file {displayName, caption?, isImage, isVideo}
  • link {url, title?}
  • 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 Mac's node shows now: a page is loading, ready, failed or empty; a device is idle, booting, connecting, live or unavailable, with the Mac's own sentence in runtimeDetail. Only ready and live have a snapshot worth fetching. isUnloaded is the Mac'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 Mac draws such a rope lit.

Drawing is the Mac'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 Mac 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 Mac 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"}

presence (capability presence) says where on the workspace the device is, and the Mac 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.

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", floorId?, x?, y?, text?, color?, mutationId?}{ok, nodeId} (note 260×200, text 240×60)
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 Mac'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}/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 Mac's ⌘G; 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 Mac'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}/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 Mac'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)
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 as JPEG, longest side 1100px, with an ETag

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 Mac 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
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 Mac's reload button; wakes an unloaded terminal
POST /api/terminals/{id}/unloadwritethe Mac's Unload (nodeUnload): the session torn down to its dormant card, scrollback kept, until restart
POST /api/terminals/{id}/focuswritethe Mac 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 Mac does. Whether an answer later raises attention again is the Mac'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 Mac's PTY to your grid, clamped to 20…250 columns by 5…120 rows; the Mac'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 Mac, 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 Mac's Unload. A web portal's page, or a device portal's screen
POST /api/portals/{id}/reloadwritethe Mac'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 Mac'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 Mac 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 Mac's library of saved canvas arrangements, browsed and stamped from a client; creating and editing stay on the Mac.

RouteAuthDescription
GET /api/partiturasread{partituras: [Partitura]}
GET /api/partituras/{id}/preview?appearance=light|darkreadthe Mac'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 Mac fills the terminals and adopts the embedded roles

Partitura: id, name, summary, icon, color, workspaceId?, roles: [{id, name, icon, color}].

Recipes

Each of these is a complete integration. MAC 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://MAC: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://MAC: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://MAC:7434/api/workspaces/WS/feed

# 3. On a button press: bring the Mac to it, as its notification click does
curl --insecure -X POST -H "Authorization: Bearer TOKEN" https://MAC:7434/api/terminals/TERMINAL_ID/focus

The same loop drives a status light (hasActivity and attentionCount) or a macropad (approve / reject on the terminal a pendingPrompt item names). For a live feed instead of polling, hold the feed socket; a snapshot arrives whenever anything changes.

Send a prompt to an agent

curl --insecure -X POST -H "Authorization: Bearer TOKEN" \
  -H 'Content-Type: application/json' \
  https://MAC: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 Mac delivers it the way its own Prompt Composer does, including to terminals running over SSH.

Watch a terminal in a browser

Open wss://MAC: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. That is the whole of what the Maestri Remote app does for a terminal.

A minimal Python client

import json, ssl, urllib.request

MAC, 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(MAC + 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 changes only for an incompatible change. Clients refuse a Mac with a different version.
  • Everything else is additive, signalled by capabilities. Existing shapes only ever gain 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 Mac notifies on every new pairing so an unexpected one is visible.