# Gateway API The watch app talks to the gateway over HTTPS with JSON bodies. The TypeScript types in [`gateway/src/contract.ts`](../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 your gateway's public HTTPS address, such as `https://wristcode.example.com`. Every route except `GET /v1/health` and `POST /v1/pair` needs `Authorization: Bearer `. Timestamps are ISO 8601 strings in UTC. Errors return a non-2xx status with `{ "error": "" }`. | 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": "", "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, "snoozedTotal": 2, "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 or snoozed 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`. `snoozedTotal` counts finished threads snoozed for later, which `GET /v1/snoozed` lists. 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, "snoozedUntil": "..." | 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///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" }], "files": [], "questions": [] } ``` For `kind: "file-change"`, `files` lists the files the agent wants to change as `{ path, name, additions, deletions, preview }`. `name` is the last part of `path`. Most providers, Claude included, send the diff only after the edit runs, so `additions` and `deletions` are usually null and `preview` is empty. When a diff comes with the request, `preview` holds its first 4 changed lines, each starting with `+` or `-`. 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= 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": { "": "" } } 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 `{ "": ["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. ``` GET /v1/snoozed 200 { "items": [FeedItem] } ``` Finished threads snoozed for later, the soonest to come back first, with `snoozedUntil` set. Like `GET /v1/recent`, it uses only cached summaries. ``` POST /v1/threads/:hostId/:threadId/snooze { "until": "2026-11-03T14:00:00.000Z" } 202 POST /v1/threads/:hostId/:threadId/unsnooze 202 ``` Snoozing hides a finished thread from Recent until `until`, which must be in the next 30 days. Then it comes back on its own. A snoozed thread that starts working or waiting on you shows as usual. 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 `{ "": "" }` 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 :", and its `hostId` and `threadId` are the subagent's. | Category | When | Actions the watch registers | | --- | --- | --- | | `approval` | A thread needs an approval | Allow (`accept`), Deny (`decline`) | | `input` | A thread asked a question | Answer, Open. With `question`: one button per option, Say an answer (text input) when `allowCustomAnswer`, Open | | `finished` | A turn completed | Reply (text input), Settle, Open | | `failed` | A turn failed | Open | | `host` | A host's T3 token expires within 7 days, or was rejected | none | | `test` | The watch asked for a test push | none | Each watch picks which pushes it gets. The status route returns its choices, and this route changes them: ``` PUT /v1/device/notifications { "subagents": false } 200 { "needsMe": true, "finished": true, "failed": true, "subagents": false } ``` Send any of the four. The ones left out keep their value, and all four start on. `needsMe` covers `approval` and `input` pushes from your own threads, and `subagents` covers them from subagents. `finished` and `failed` match their categories. `host` and `test` pushes always go out. ## Status Everything the settings screens show: each host and its routes, whether the gateway can push, and whether this watch registered for pushes. ``` GET /v1/status 200 { "hosts": [HostStatusDetail], "gateway": { "startedAt": "...", "pushConfigured": true, "lastPush": PushResult | null }, "device": { "name": "Apple Watch", "pairedAt": "...", "pushRegistered": true, "apnsEnvironment": "production", "notifications": NotificationPrefs } } HostStatusDetail { "id": "...", "label": "Laptop", "status": "online", "error": null, "activeUrl": "http://laptop-vpn.lan:3773", "routes": [ { "url": "http://laptop.lan:3773", "kind": "direct", "result": "failed", "error": "no answer in 5 s", "checkedAt": "..." }, { "url": "http://laptop-vpn.lan:3773", "kind": "vpn", "result": "ok", "error": null, "checkedAt": "..." }, { "url": "https://t3-laptop.example.com", "kind": "public", "result": "untried", "error": null, "checkedAt": null } ], "onlineSince": "...", "lastOnlineAt": null, "nextRetryAt": null, "tokenExpiresAt": "2026-12-01T00:00:00.000Z", "serverVersion": "0.1.5" } PushResult { "ok": true, "at": "...", "status": 200, "reason": null } ``` `routes` are in the order the gateway tries them, and their results come from its latest connect. A route is `untried` when an earlier one answered first. `kind` is a guess from the URL: `public` for HTTPS, `vpn` when the hostname contains "vpn", and `direct` otherwise. `activeUrl` is null unless the host is online. `nextRetryAt` is set while the gateway waits to try again. `lastPush` is Apple's answer to the most recent push to any watch. A `status` of 0 means the push never reached Apple. ``` POST /v1/hosts/:hostId/reconnect 202 ``` Connects again from the first route, skipping any backoff wait. Use it when a better route may be back, such as the LAN after getting home. The gateway otherwise stays on a working fallback until that connection drops. ``` POST /v1/device/test-push 200 PushResult ``` Sends a test notification to the watch asking and returns Apple's answer. A failure names its cause, for example `BadDeviceToken`, which usually means a push environment mismatch. ## Health ``` GET /v1/health 200 { "ok": true, "hosts": [HostSummary] } ```