14 KiB
Gateway API
The watch app talks to the gateway over HTTPS with JSON bodies. The TypeScript types in
gateway/src/contract.ts are the source of truth, and the watch
mirrors them in watch/WristCode/API/Models.swift. Change both together.
The base URL is https://wristcode.gbrown.org. Every route except GET /v1/health and
POST /v1/pair needs Authorization: Bearer <deviceToken>. Timestamps are ISO 8601 strings in UTC.
Errors return a non-2xx status with { "error": "<message for a person>" }.
| Status | Meaning |
|---|---|
| 400 | The request body is wrong |
| 401 | The device token is missing or revoked. Show the pairing screen |
| 404 | Unknown host, thread, or project |
| 409 | The request is stale, for example an approval that was already answered |
| 429 | Too many pairing attempts |
| 502 | The T3 server rejected the call. The message says why |
| 503 | That host is offline |
Pairing
wristcode pair on the gateway prints a 6 digit code that is good for 5 minutes. The watch sends it
once and stores the token in its Keychain.
POST /v1/pair
{ "code": "482913", "deviceName": "Gabriel's Apple Watch" }
200 { "deviceId": "9b0c...", "deviceToken": "wc_..." }
After pairing, and again whenever APNs hands the watch a new token:
PUT /v1/device/push
{ "apnsToken": "<hex>", "apnsEnvironment": "sandbox" }
204
Debug builds use sandbox, TestFlight and App Store builds use production.
To unpair, the watch revokes its own token:
DELETE /v1/device
204
Feed
GET /v1/feed
200 {
"needsYou": [FeedItem],
"working": [FeedItem],
"recent": [FeedItem],
"recentTotal": 12,
"hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null }]
}
needsYou holds threads waiting for an approval or an answer, oldest first. working holds running
threads, most recently updated first. recent holds threads that completed or failed and are not
settled yet, however old, newest first and at most 10. recentTotal counts all of them. When it is
larger, the rest come from GET /v1/recent. Host status is one of connecting, online, offline, auth_invalid, or
incompatible.
FeedItem {
"hostId": "...", "hostLabel": "Mac mini",
"threadId": "...", "projectId": "...", "projectTitle": "bill-tracker",
"title": "Receipt OCR",
"phase": "waiting_for_approval",
"updatedAt": "2026-11-01T09:41:00.000Z",
"lastMessage": "Parser and tests are written. I want to run the suite.",
"activity": { "kind": "command", "text": "bun test", "startedAt": "..." } | null,
"changes": { "files": 3, "additions": 42, "deletions": 8 } | null,
"request": PendingRequest | null,
"via": { "hostId": "...", "threadId": "...", "title": "Check Panama PATH" } | null
}
Threads an agent started for another thread (subagents and delegate_task children) do not get
their own cards. When one waits on you, its request moves onto the card of the thread that started
it. via names the subagent, request and phase are the subagent's, and the answer goes to the
subagent: POST /v1/threads/<via.hostId>/<via.threadId>/respond. A subagent keeps its own card
when the gateway cannot find its parent, or the parent already shows a request.
phase is one of starting, running, waiting_for_approval, waiting_for_input, completed, or
failed. lastMessage is the agent's latest message trimmed to about 280 characters. It can be null
for a moment after a thread changes.
activity is the newest step the agent has in progress. kind is command, edit, search,
web, subagent, thinking or tool, and text is the command's first line, the file name (without its
directory), the search, the subagent's title or the tool's name, trimmed to about 80 characters.
Monitors are left out, since they run for hours. It is null when no step is in progress. The gateway
rereads a running thread at most every 5 seconds, so it can lag that much.
changes totals the files the thread's latest run changed. It is null when the run changed no
files, and, like lastMessage, until the gateway has read the thread.
PendingRequest {
"id": "...",
"kind": "command",
"prompt": "bun test --filter ocr",
"reason": null,
"options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }],
"questions": []
}
An option may also carry warning, a short note about a risky choice such as "Applies to every
thread". For questions (kind: "user_input"), options is empty and questions lists
{ id, header, question, options: [{ label, value }], allowCustomAnswer, multiSelect }.
Thread
GET /v1/threads/:hostId/:threadId
200 { "item": FeedItem, "messages": [Message], "canStop": true, "askFirst": false,
"earlierCursor": "w12" | null, "subagents": [ThreadRow], "changedFiles": [ChangedFile] }
ChangedFile { "path": "gateway/Containerfile", "name": "Containerfile", "additions": 31, "deletions": 0 }
ThreadRow { "hostId": "...", "threadId": "...", "title": "Check PATH", "phase": "running" | null,
"settled": false, "updatedAt": "..." }
Message { "id": "...", "role": "assistant", "text": "...", "createdAt": "..." }
messages holds the last 20, oldest first, each trimmed to 2000 characters. changedFiles lists
the files the latest run changed, most lines changed first. subagents lists the
threads this one started, its own subagents and delegate_task children on any host, newest first.
A ThreadRow's phase is null for a thread that never ran or was stopped.
GET /v1/threads/:hostId/:threadId/history?cursor=<earlierCursor>
200 { "messages": [Message], "earlierCursor": "..." | null }
The messages before the ones the watch has, oldest first: 20 at a time at first, then one T3 history
page at a time, which can hold more. Pass the returned earlierCursor for
the page before that. It is null at the start of the thread. Cursors are opaque.
POST /v1/threads/:hostId/:threadId/messages
{ "text": "Yes, commit it" }
202
A message to a busy thread waits until the current turn ends. Nothing is interrupted.
POST /v1/threads/:hostId/:threadId/respond
{ "requestId": "...", "decision": "accept" }
or
{ "requestId": "...", "answers": { "<questionId>": "<value>" } }
202
Each answer is an option value or a typed answer. For a question with multiSelect: true, send an
array of option values instead, for example { "<questionId>": ["sqlite", "postgres"] }.
POST /v1/threads/:hostId/:threadId/stop
202
POST /v1/threads/:hostId/:threadId/settle
202
Settles a thread, as in T3. A settled thread that finished or failed leaves the feed.
POST /v1/threads/:hostId/:threadId/mode
{ "askFirst": true }
202
Turns Ask before acting on or off for a thread. On sets T3's approval-required mode. Off sets the
host's default mode, or full-access when that default is also approval-required. askFirst in
the thread detail says which mode the thread is in.
GET /v1/recent
200 { "items": [FeedItem] }
Every unsettled finished thread, newest first. lastMessage is null for threads the gateway has not
read yet, since it does not fetch them all for this list.
POST /v1/recent/settle
200 { "settled": 11, "failed": 0 }
Settles every completed thread in Recent, including those past the first 10. Failed threads stay.
failed counts threads T3 refused or whose host was offline.
Thread detail fills request and via the same way as the feed when a subagent waits on you.
Summary
What the watch face complication and Smart Stack widget show. It reads at most two threads, so it is cheap to call.
GET /v1/summary
200 { "needsYou": 1, "working": 3, "hostsOffline": 0,
"next": { "hostId": "...", "threadId": "...", "title": "Receipt OCR", "hostLabel": "Mac mini",
"request": "bun test --filter ocr" | null },
"current": { "hostId": "...", "threadId": "...", "title": "Phase 3 screens", "activity": Activity | null } }
next is the thread that has waited longest for you, or null. Its request is the command it wants
to run or its first question, trimmed to about 80 characters. current is the working thread that
changed most recently, or null when nothing is working.
Search
GET /v1/search?q=containerfile
200 { "hits": [SearchHit], "unsearched": ["Laptop"] }
SearchHit { "thread": ThreadRow, "projectTitle": "wristcode", "hostLabel": "Mac mini",
"snippet": "…rebuild from the Containerfile and…" | null }
Searches thread titles and messages on every online host at once, up to 20 hits per host, newest
match first. snippet is the text around a match in a message, or null when only the title
matched. unsearched names the hosts that were offline, failed, or took over 5 seconds. A query
needs at least two characters.
Browse
GET /v1/projects/:hostId/:projectId/threads
200 { "items": [ThreadRow] }
A project's newest 50 threads, settled ones included, newest first. Subagents are left out and
listed under their parents instead. Hosts and projects come from GET /v1/projects.
New thread
GET /v1/projects
200 { "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null,
"projects": [{ "id": "...", "title": "wristcode", "model": "claude-opus-5-5", "instanceId": "claudeAgent" }],
"providers": [AgentProvider] }] }
AgentProvider { "instanceId": "claudeAgent", "name": "Claude", "canAskFirst": true,
"models": [{ "slug": "claude-opus-5-5", "name": "Claude Opus 5.5" }] }
model and instanceId are the project's default, or null when it has none. providers lists the
providers that can start a thread now, without legacy models. canAskFirst is false when a provider
cannot run in approval-required mode.
POST /v1/threads
{ "hostId": "...", "projectId": "...", "text": "Add a complication for the waiting count",
"model": { "instanceId": "claudeAgent", "slug": "claude-sonnet-5-5" }, "askFirst": true }
201 { "hostId": "...", "threadId": "..." }
model and askFirst are optional. Without model, the thread uses the project's default model or
the host's, with its options, such as reasoning effort. A picked model the host does not offer is a
400. askFirst picks the mode the same way as the mode route above.
Notifications
The gateway pushes when a thread starts waiting for you, finishes, or fails. Each push has an
aps.category and the custom keys hostId, threadId (absent on host pushes), and, for
approvals and questions, requestId. An input push that asks one single-choice question also
carries question: { id, options: [{ label, value }], allowCustomAnswer }, with at most 4 options,
so the notification can offer them as buttons. Answer with { "<id>": "<value>" } on the respond
route. A test push carries no custom keys. A subagent's push is titled with the thread that started it,
its body starts with "Subagent