7.7 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],
"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 in the last 24
hours, at most 10. 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
}
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 }
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": { "<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
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" }] }] }
A project with "model": null has no default model, so the watch should not offer it.
POST /v1/threads
{ "hostId": "...", "projectId": "...", "text": "Add a complication for the waiting count" }
201 { "hostId": "...", "threadId": "..." }
The new thread uses the project's default model, or the host's, and the host's default runtime mode.
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.
| 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 |
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" }
}
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] }