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.
| Route | Does |
|---|---|
| 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
|
| 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.
| Route | Does |
|---|---|
| POST /api/pair | Offer a code, a requested tier, a device name, the device's
account session, and its |
| 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, |
| POST /api/stream-ticket | Authenticated. Mints the Mac's single-use, short-lived stream ticket. |
| GET /api/stream?ticket=…&rt=… | Server-sent events: |
| GET /api/session-stream?session=…&ticket=…&rt=… | The live terminal: server-sent
|
| GET /api/state | Authenticated snapshot of the session tree, redacted to the token's tier. |
| POST /api/control | One verb, refused if it sits above the token's tier. |
| POST /api/session-input | The live terminal's keystroke channel — the
|
| GET /api/simulator-stream?device=…&ticket=…&rt=… | Server-sent |
| POST /api/simulators, /api/simulator/input, /api/simulator/text, /api/simulator/button | List booted Simulators at |
| POST /api/unread | Mark 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
|
| 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
|
| POST /api/directories | List folders inside a saved project at
|
| POST /api/directory-search | Search 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 |
| POST /api/project-commands, /api/project-command, /api/project-command/save | List, run, or save a project's saved commands. Needs
|
| 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
|
| POST /api/power | Read the Mac's stay-awake setting at
|
| POST /api/phone-push, /api/phone-push/test | Read the Mac's phone alert settings at
|
| 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 |
| POST /api/push/(un)subscribe | Register or drop the phone app's push subscription, owned by
the calling grant. Subscribing needs
|
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.
| Reason | Status | Means |
|---|---|---|
| badRequest | 400 | The body didn't decode, or the deviceKey is missing or malformed. |
| wrongCode | 401 | The code doesn't match. Counts toward the throttle. |
| staleCode | 401 | The code has rotated. Counts toward the throttle. |
| deviceSignedOut | 401 | The device sent no account session. |
| macSignedOut | 403 | The Mac isn't signed in, so there is no account to match. |
| accountMismatch | 403 | Both are signed in, to different accounts. Not retryable. |
| remoteOff | 403 | Remote access is off on that Mac. |
| denied | 410 | Someone at the Mac said no. |
| lapsed | 410 | The request stood unanswered until it expired. |
| throttled | 429 | Too many attempts from this peer, or globally. Carries a wait. |
| busy | 429 | The Mac already holds as many undecided pairing requests as it allows. |
| accountUnverified | 503 | The account backend didn't answer. The gate fails closed; try again. |
Verbs
| Verb | Tier | Effect |
|---|---|---|
| focus | approve | Bring a session forward on the Mac. |
| approve | approve | Answer a waiting prompt with its own default. Bounded. |
| approveAll | approve | The same, for every session currently waiting. |
| answer | approve | 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 —
|
| continue | approve | Nudge one stalled session onward. |
| continueIdle | approve | Nudge every idle agent at once. |
| attention | approve | Raise a flag on a session, with an optional message and a
severity of |
| send | drive | Type arbitrary text into a session. Unbounded, hence the tier. |
| input | drive | The live terminal's raw keystrokes (base64) — arrows, Ctrl-C,
a whole TUI. Unbounded like |
| monitor | drive | Spawn a shell in the session's project running the command
in |
| quick | drive | Spawn a shell beside the session running the command in
|
| create | drive | Start a shell session in a saved project or chosen folder, optionally running a command. |
| launch | drive | 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, close | drive | Rename or close a session. |
| projectRename, projectPin, projectMove, projectIcon, projectCustomize, projectGroupSet, projectGroupRename, projectGroupDelete | drive | Change 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.