Skip to main content

Wire protocol

The Saggar phone app speaks one small JSON protocol and nothing else. It is declared in the app's protocol module and implemented on the Mac by the Swift control server. Contract tests on both sides decode the same fixture file, so a field renamed on one side fails the build on the other.

Through the relay

There is no Mac address. Every Mac route on this page is reached through the relay at {relay}/m/{macId}/api/…, and the relay forwards it over the connection that Mac dialed out on. Two credentials travel separately: the account session rides Authorization and authenticates the device to the relay, which strips it before forwarding; the Saggar grant travels inside the sealed envelope and authenticates the device to the Mac. The relay also answers routes of its own, at its root rather than under a Mac. An account without a remote control subscription is refused on every account-authenticated route with 402 and the code remote_subscription_required. There is no browser client, so the relay answers no CORS: a preflight, or a device request carrying an Origin header, is refused with 403 and origin_refused.

RouteDoes
GET /api/macs

Account-authenticated. Lists the Macs on this account: each one's id, name, whether it is online, and its sealing public key.

POST /api/stream-ticket

Account-authenticated. Mints the relay's single-use, short-lived stream ticket. Distinct from the Mac's route of the same name below, which mints the Mac's.

POST /api/replies

Account-authenticated. Holds one already-sealed lock-screen reply for a Mac for up to two minutes, drops a retry with the same reply id, and delivers it to that Mac's /api/session-input once it is connected.

GET|POST /api/settings

Account-authenticated. Reads or replaces the account's synced settings document. A write based on a stale revision is refused with the current copy rather than overwriting it.

GET|POST /api/hooks, DELETE /api/hooks/{id}Account-authenticated. Lists, creates, or deletes the account's webhooks.
POST /h/…

The public webhook ingress. It takes no account credential: the delivery's signature under the hook's secret is what authorizes it.

Sealing

In production the Mac accepts sealed requests only. The phone derives an AES-GCM key from its own P-256 key and the Mac's (the key in the pairing QR, and the key field of /api/macs) and posts the encrypted request as an envelope with the content type application/saggar-sealed+json. The carrier is always a POST to the route being called; the real method, the grant token, the JSON body, a timestamp, and a one-time nonce are inside:

{ "v": 1, "key": "<device P-256 public key>", "sealed": "<AES-GCM sealed bytes>" }

// decrypts to
{ "method": "POST", "path": "/api/control", "token": "<grant>",
"body": "{\"verb\":\"focus\",\"sessionId\":\"…\"}",
"timestamp": 1755500000, "nonce": "<uuid>" }

An unsealed request to any other route is answered 401 sealed request required. So is an envelope whose inner path differs from the route it was posted to, whose timestamp is outside a small clock-skew window, or whose nonce has been seen before. Responses come back in the same envelope, and each one names the request nonce so a reply cannot be swapped for another. On the three stream routes the SSE event names stay in the clear and each data field is sealed. The relay carries all of this but cannot derive the key, so it cannot read the grant, the command, or the reply. See the security model for what it can still see.

The three stream routes are the exception to the carrier rule: a server-sent events stream cannot post a body, so they authenticate with single-use tickets in the query string instead. Under sealing, POST /api/pair must also carry a deviceKey, and is refused as badRequest without one.

Endpoints

Every route here lives under /m/{macId} on the relay, and every one except the three streams travels sealed.

RouteDoes
POST /api/pair

Offer a code, a requested tier, a device name, the device's account session, and its deviceKey. Answers 202 with a handle while the Mac checks the account and asks a human; otherwise a refusal from the table below — wrongCode, staleCode, throttled, busy, remoteOff, or badRequest.

POST /api/pair/claim

Poll the handle. 202 while the account check and the prompt on the Mac are undecided; 200 with a token and the granted tier once authorized; otherwise a refusal carrying its reason — an account refusal, denied, or lapsed. Every new device is prompted for on the Mac once its account matches; there is no posture that skips the prompt.

POST /api/stream-ticketAuthenticated. Mints the Mac's single-use, short-lived stream ticket.
GET /api/stream?ticket=…&rt=…

Server-sent events: snapshot frames on every beat, alert frames on transitions. Two tickets and no token: ticket is the Mac's and rt is the relay's, which the relay redeems and strips before forwarding.

GET /api/session-stream?session=…&ticket=…&rt=…

The live terminal: server-sent screen, output, resize, and exit frames carrying one session's raw pty bytes. The same two tickets as the fleet stream, and gated at approve — watching a terminal is over-the-shoulder reading.

GET /api/state

Authenticated snapshot of the session tree, redacted to the token's tier.

POST /api/controlOne verb, refused if it sits above the token's tier.
POST /api/session-input

The live terminal's keystroke channel — the input verb only, on its own rate-limit budget so typing isn't mistaken for a flood. Needs drive.

GET /api/simulator-stream?device=…&ticket=…&rt=…

Server-sent frame events carrying a booted iOS Simulator's display, and a state event if it becomes unavailable. The same two tickets, gated at approve.

POST /api/simulators, /api/simulator/input, /api/simulator/text, /api/simulator/button

List booted Simulators at approve. Touch, text, and hardware-button input need drive and share the keystroke channel's rate-limit budget.

POST /api/unreadMark a session read or unread. Needs approve.
POST /api/changes, /api/diff

A session checkout's changed files, and one file's unified diff. Read-only, audited, and gated at approve.

POST /api/artifacts, /api/artifact-folder, /api/artifact-chunk

Files a session's transcript mentions: the list, a folder's contents, and a file's bytes in chunks. Needs approve.

POST /api/directories

List folders inside a saved project at approve, or anywhere on the Mac at drive.

POST /api/directory-searchSearch the Mac's folders by name. Needs drive.
POST /api/project-create

Create a project and its Git repository on the Mac, and start an agent on it. Needs drive.

POST /api/project-commands, /api/project-command, /api/project-command/save

List, run, or save a project's saved commands. Needs drive.

POST /api/image-upload/start, /chunk, /finish

Attach a file to a session's input line in bounded chunks, on a rate-limit budget of their own. Needs drive.

POST /api/power

Read the Mac's stay-awake setting at approve; change it at drive.

POST /api/phone-push, /api/phone-push/test

Read the Mac's phone alert settings at approve; change them, or send a test alert, at drive.

POST /api/browser-tunnel/…

The local website tunnel: open, request, response, and close, plus WebSocket open, send, receive, and close. A localhost origin opens at approve and a private-network one at drive; mutating requests and WebSocket sends need drive.

POST /api/push/(un)subscribe

Register or drop the phone app's push subscription, owned by the calling grant. Subscribing needs approve.

Pairing refusals

A refused pairing answers with { ok: false, reason, message }. The reason is the contract and clients branch on it; the message is prose and may change. A caller without a valid code learns nothing about the account behind a Mac: the account and prompt refusals are reachable only once a valid code has been offered.

ReasonStatusMeans
badRequest400The body didn't decode, or the deviceKey is missing or malformed.
wrongCode401The code doesn't match. Counts toward the throttle.
staleCode401The code has rotated. Counts toward the throttle.
deviceSignedOut401The device sent no account session.
macSignedOut403The Mac isn't signed in, so there is no account to match.
accountMismatch403Both are signed in, to different accounts. Not retryable.
remoteOff403Remote access is off on that Mac.
denied410Someone at the Mac said no.
lapsed410The request stood unanswered until it expired.
throttled429Too many attempts from this peer, or globally. Carries a wait.
busy429The Mac already holds as many undecided pairing requests as it allows.
accountUnverified503The account backend didn't answer. The gate fails closed; try again.

Verbs

VerbTierEffect
focusapproveBring a session forward on the Mac.
approveapproveAnswer a waiting prompt with its own default. Bounded.
approveAllapproveThe same, for every session currently waiting.
answerapprove

Answer a waiting prompt with a chosen option, by its menu number. Bounded the same way: the Mac refuses a choice its live menu doesn't offer, off the same read that produced the buttons. This is how a device says no — approve takes the first option, which both CLIs reserve for the conservative yes.

continueapproveNudge one stalled session onward.
continueIdleapproveNudge every idle agent at once.
attentionapprove

Raise a flag on a session, with an optional message and a severity of blocked (the default) or note. It asks for the user and cannot type, execute, or answer anything, which is why it sits in the bounded tier. There is no verb to clear it.

senddriveType arbitrary text into a session. Unbounded, hence the tier.
inputdrive

The live terminal's raw keystrokes (base64) — arrows, Ctrl-C, a whole TUI. Unbounded like send, and the same tier.

monitordrive

Spawn a shell in the session's project running the command in text, docked as a monitor and left running. An optional discreet flag docks it at half weight. This is send's power by a longer road, so it is priced the same.

quickdrive

Spawn a shell beside the session running the command in text, one-shot. Same power, same tier.

createdriveStart a shell session in a saved project or chosen folder, optionally running a command.
launchdrive

Start an agent session with an optional prompt. The agent and model are ids from the Mac's own catalog, never a shell command.

rename, closedriveRename or close a session.
projectRename, projectPin, projectMove, projectIcon, projectCustomize, projectGroupSet, projectGroupRename, projectGroupDeletedriveChange a saved project's name, pin, order, icon, description, or group.

Snapshots

A snapshot carries the Mac's name, the time it was produced (which doubles as the liveness signal), the Mac's Marginal Utility account if it has one, and the sessions. Each session carries its status band, the agent detected in it, whether an answerable prompt is waiting, git state, its last activity, and — above the view tier — the last few transcript lines, the question a waiting prompt is asking, and the choices its menu offers.

Those last two are read off the transcript, so the view tier loses them with it. A grant that couldn't read the tail but could read the question in it — and the filenames and commands a question carries — would be a redaction in name only. The view tier also receives no structured conversation, saved projects, launch options, custom terminals, or automations.

Optionality is part of the contract, not an accident: a client must render correctly when the transcript tail is missing and the project path is empty, because that is exactly what a view-tier grant receives, and when the account is absent, because that is what a signed-out Mac sends.