Files
wristcode/docs/api.md
T
gib 2a127327bf
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 23s
Gateway: keep finished threads in Recent until settled, with a full list and settle-all route
2026-11-01 22:26:41 -05:00

280 lines
9.2 KiB
Markdown

# 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 <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 }
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.
```
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" }] }] }
```
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. A subagent's push is titled with the thread that started it,
its body starts with "Subagent <title>:", 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 |
## 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] }
```