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, PATCH|DELETE /api/hooks/{id}, POST /api/hooks/{id}/test | Account-authenticated. Lists, creates, updates, or deletes the
account's webhooks, or queues a sealed test delivery. A hook
created with |
| GET /api/hook-presets | Account-authenticated. Sender presets as data: the settings a new hook starts from, such as GitHub's. |
| POST /h/… | The public webhook ingress. It takes no account credential: the
delivery's signature under the hook's secret, checked
with the hook's own |
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
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 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. The Mac serves three:
/api/stream,
/api/session-stream, and
/api/simulator-stream. The relay's
ticket list also names /api/browser-stream,
which no current Mac serves: it answers 404, and the local website
tunnel below is the browser surface 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. An optional
|
| 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. |
| queuedCancel, queuedRunNow | drive | Call off queued work by its |
| 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, the work queued to start, 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,
the choices its menu offers, and the prompt's
promptId.
Those last three 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, automations, or queued work: a queued command is a prompt by another name, and it names paths.
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.
Prompt identity and refusal codes
This is the current contract: a Mac that predates it ignores
promptId and answers whatever is waiting.
A waiting session's snapshot names its prompt with a
promptId that changes whenever the dialog
does, and the same dialog asked twice gets two names. An
approve or
answer that carries it acts only on that
prompt: once the dialog has moved on, the verb is refused with
promptChanged and nothing is typed, so an
answer sent from a notification minutes old can't land on the
question that replaced it. An absent
promptId means answer whatever is
waiting, which is what the Mac's own surfaces and older clients
send. An empty one is a 400: a client that meant to bind and lost the
name is not an unbound answer.
A refused control result carries a machine-readable
code beside its
message. The code is the contract and the
message is copy.
| Code | Means |
|---|---|
| promptChanged | The verb named a prompt that is no longer the one on screen. |
| noPrompt | The session has no prompt waiting. |
| noAnswerableMenu | The needs-you came from a bell or a wizard with no numbered choices, so there was nothing to type. |
| choiceUnavailable | The chosen number isn't on the live menu. |
Alerts and push
An alert is an edge: the fleet stream
carries one on each transition, and push delivers the same shape to a
phone without an open app. It names the session and project, the time,
and a kind of
needsYou,
finished,
failed,
readyForReview,
notice, or
allJobsFinished. A
notice alone carries a
title and
body, raised by a program over OSC 99 or
777, which is transcript by another name: the view tier learns that
something happened and no more, so both fields are removed for it.
A needsYou push may also carry the prompt,
sealed to the subscription's device key so the push provider sees
only generic copy. This is the current contract. The alert's
prompt is present only while an answerable
menu is on screen, and is read on the beat the push goes out, not the
beat the edge fell, so a push held while the user was at the Mac
describes the dialog still on screen. The view tier never receives it.
{ "sessionId": "…", "kind": "needsYou", "sessionName": "api", "projectName": "api", "at": 1785000000000,
"prompt": { "id": "Q2mV8bLw1Zc3kT9x",
"question": "Do you want to make this edit to auth.ts?",
"choices": [ { "number": 1, "label": "Yes", "role": "approve" },
{ "number": 2, "label": "Yes, and don't ask again this session", "role": "always" },
{ "number": 3, "label": "No, and tell Claude what to do differently", "role": "deny" } ] } }
A choice's role is
approve,
always, or
deny, set only when the Mac is sure of it
from the menu's own wording. The push names the notification
category whose buttons match every role, otherwise the plain reply
category whose only action is a free-text reply:
saggar.prompt.approve-always-deny,
saggar.prompt.approve-deny, or
saggar.terminal.reply. A button answers
with the prompt's id as its
promptId. To keep the sealed push small,
the question is cut at 400 characters and each label at 60, and a menu
with more than four choices sends no prompt, so the phone opens the app
on the session instead.
Between the Mac and the relay
The Mac dials the relay over a WebSocket and the two exchange JSON
frames, each with a t naming its kind.
Direction is part of the validation: the Mac decodes only what the relay
may send, and a malformed frame is dropped rather than closing the
socket. The protocol version, 2, travels in
hello.
| Frame | Direction | Carries |
|---|---|---|
| hello | Mac → relay | The Mac's |
| req | relay → Mac | A forwarded device request: |
| res | Mac → relay | A complete answer: the request's
|
| open, chunk, end | Mac → relay | A streamed answer: the status and headers, then base64
|
| cancel | relay → Mac | The device went away; stop work on that id. |
| hook | relay → Mac | One webhook delivery: a sealed
|
| ping | both | Keepalive. |
Limits are measured on decoded bytes: a
req or
res body is at most 32 KiB, a
chunk at most 64 KiB, and an
id at most 64 characters. A header map
holds at most 64 entries. Anything over is a malformed frame.
A delivery carries the whole verified
body, sealed at ingress to the Mac's key (P-256 ECDH, HKDF-SHA256
with info saggar-hook-v1, AES-256-GCM), so
the relay stores only ciphertext. Beside it travel which method verified
it, whether the body was JSON or a form, and at most three headers the
hook named: the event type, the delivery id, and the user agent. A
refusal carries a reason (
bad_signature,
stale_timestamp,
too_large, or
not_json) and how many there were, at
most once per hook and reason every ten minutes, and never a body.
{ "id": "72d3162e-cc78-11e3-81ab-4c9367dc0958", "hookId": "0123456789abcdef",
"at": 1756900000000, "verified": "hmac-body",
"headers": { "event": "workflow_run", "delivery": "72d3162e-…" },
"contentType": "json", "payload": { "v": 1, "key": "BIc6…", "sealed": "z8Fe…" } }