Gateway: T3 RPC client, host connection, awareness port, storage, API contract
This commit is contained in:
1 parent
0fd556e313
commit
972f6e4be5
13 files changed
+1332
-9
No files matched your search
+164
@@ -0,0 +1,164 @@
|
||||
# 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`.
|
||||
|
||||
## 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": []
|
||||
}
|
||||
```
|
||||
|
||||
For questions (`kind: "user_input"`), `options` is empty and `questions` lists
|
||||
`{ id, header, question, options: [{ label, value }], allowCustomAnswer }`.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
```
|
||||
POST /v1/threads/:hostId/:threadId/stop
|
||||
202
|
||||
```
|
||||
|
||||
## 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`, and, for approvals and questions,
|
||||
`requestId`.
|
||||
|
||||
| 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 |
|
||||
|
||||
## Health
|
||||
|
||||
```
|
||||
GET /v1/health
|
||||
200 { "ok": true, "hosts": [HostSummary] }
|
||||
```
|
||||
Reference in new issue
Block a user