376 lines
14 KiB
Markdown
376 lines
14 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 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 <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.",
|
|
"activity": { "kind": "command", "text": "bun test", "startedAt": "..." } | null,
|
|
"changes": { "files": 3, "additions": 42, "deletions": 8 } | 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/<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.
|
|
|
|
`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" }],
|
|
"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,
|
|
"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=<earlierCursor>
|
|
|
|
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": { "<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
|
|
|
|
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 `{ "<id>": "<value>" }` 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 <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 |
|
|
|
|
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] }
|
|
```
|