# 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 `https://wristcode.gbrown.org`. 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, "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.", "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. ``` 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 } Message { "id": "...", "role": "assistant", "text": "...", "createdAt": "..." } ``` `messages` holds the last 20, oldest first, each trimmed to 2000 characters. ``` 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. Thread detail fills `request` and `via` the same way as the feed when a subagent waits on you. ## Summary The counts behind the watch face complication. It reads no thread pages, 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" } } ``` `next` is the thread that has waited longest for you, or null. ## 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`. 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 | Open | | `finished` | A turn completed | Reply (text input), 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://laptopvpn.gib:3773", "routes": [ { "url": "http://laptop.gib:3773", "kind": "direct", "result": "failed", "error": "no answer in 5 s", "checkedAt": "..." }, { "url": "http://laptopvpn.gib:3773", "kind": "vpn", "result": "ok", "error": null, "checkedAt": "..." }, { "url": "https://t3laptop.gbrown.org", "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] } ```