Files
wristcode/docs/api.md
T
gib 3cc7e40852
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 21s
Gateway: per-watch notification switches, model list for new threads, and Ask before acting
2026-11-02 12:02:53 -05:00

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

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": { "<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

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