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, 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 verify settings and the Mac's key is verified generically and delivered sealed; the secret is answered only on create.

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 verify settings, 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 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.

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. Add conversation=revision here or on /api/state and each session's conversation entries are dropped from the snapshot while conversationRevision stays, for a client that reads the tail off the session stream instead.

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. Opened with conversation=stream, it also carries conversation events: a reset after the screen (no baseRevision, every entry), then a delta each time the tail changes, with ids as the whole ordered tail and entries holding only what is new or changed. A client whose revision isn't the delta's baseRevision restarts the stream for a fresh reset.

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.
approveapprove

Answer a waiting prompt with its own default. Bounded. An optional promptId names the prompt the sender was shown; see prompt identity.

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. Takes the same optional promptId as approve.

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.
queuedCancel, queuedRunNowdrive

Call off queued work by its queuedId, or start it without waiting for what it is queued behind. Cancelling closes a waiting pane and running now starts a command, hence the tier.

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, 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.

CodeMeans
promptChangedThe verb named a prompt that is no longer the one on screen.
noPromptThe session has no prompt waiting.
noAnswerableMenuThe needs-you came from a bell or a wizard with no numbered choices, so there was nothing to type.
choiceUnavailableThe 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.

FrameDirectionCarries
helloMac → relay

The Mac's macId, name, sealing key, and version. Sent once on connect.

reqrelay → Mac

A forwarded device request: id, method, path with its query, headers, and a base64 body. An absent body is empty; a present but unreadable one is a malformed frame.

resMac → relay

A complete answer: the request's id, status, headers, and base64 body. Also how the Mac acknowledges a hook.

open, chunk, endMac → relay

A streamed answer: the status and headers, then base64 data chunks, then the end. Streams use it, and so does any response body over the frame limit.

cancelrelay → MacThe device went away; stop work on that id.
hookrelay → Mac

One webhook delivery: a sealed delivery, or a throttled refusal, below. The relay keeps an unacknowledged one for the Mac's next attach.

pingbothKeepalive.

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…" } }