Docs: public README, setup guide and architecture, generic deploy files, and the T3 Code notice
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 20s

This commit is contained in:
gib committed 2026-11-02 13:51:19 -05:00
1 parent b08fd9bdb1
commit f8241e42bb
13 files changed
+345 -301

No files matched your search

+5 -5
View File
@@ -4,7 +4,7 @@ The watch app talks to the gateway over HTTPS with JSON bodies. The TypeScript t
[`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
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>" }`.
@@ -330,11 +330,11 @@ GET /v1/status
HostStatusDetail {
"id": "...", "label": "Laptop", "status": "online", "error": null,
"activeUrl": "http://laptopvpn.gib:3773",
"activeUrl": "http://laptop-vpn.lan: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 }
{ "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"
+100
View File
@@ -0,0 +1,100 @@
# Architecture
WristCode puts every T3 Code environment on an Apple Watch. From the watch you can see which agents
need you, answer their requests, reply, start and browse threads, and get notifications. It runs
over the watch's own cellular connection, so no phone is needed.
How a client talks to a T3 server is in [t3-protocol.md](t3-protocol.md), and the watch's API is in
[api.md](api.md).
## Shape
```
watchOS app --HTTPS + device token--> gateway (Bun) --WebSocket + bearer--> T3 server x N
|
+--HTTP/2--> APNs --> watch notifications
```
- The watch never talks to T3 directly. watchOS only allows WebSockets for audio apps, and every
T3 write goes over a WebSocket.
- The gateway keeps one `orchestration.subscribeShell` stream open per T3 server and holds every
thread shell in memory. It serves lists from memory, so the watch never waits on a T3 round trip
to draw the feed. Thread text comes from each thread's bounded snapshot, read on demand and
cached until the thread changes.
- The gateway is the only place that holds T3 credentials. The watch holds only its own device
token.
## Gateway
Bun and TypeScript, with no framework: `Bun.serve` for HTTP, `bun:sqlite` for state, and
`node:http2` for APNs. The T3 RPC client is hand-written and needs no Effect runtime or vendored
contracts.
| File | Job |
| --- | --- |
| `src/t3/rpc.ts` | Effect RPC JSON framing: request ids, pending calls, Ack on every Chunk, pings, and a silence timeout |
| `src/t3/host.ts` | One T3 server: route choice, ticket mint, connect, reconnect with backoff, shell state, and the HTTP reads |
| `src/t3/types.ts` | Narrow, lenient types for the fields the gateway reads. Unknown fields and values pass through |
| `src/hub.ts` | Every host in the database, kept connected and rereading the table for changes |
| `src/awareness.ts` | A port of T3's own awareness logic, which maps a thread shell to a phase |
| `src/views.ts` | The feed, thread detail, browse, search and summary responses |
| `src/notify.ts` | Watches phase changes and decides what to push |
| `src/push.ts` | Sends pushes to each paired watch that wants that kind |
| `src/apns.ts` | ES256 provider JWT and an HTTP/2 client for APNs |
| `src/api.ts` | The watch's REST API |
| `src/db.ts` | SQLite schema and queries |
| `src/cli.ts` | The operator CLI: `host add`, `host list`, `host remove`, `pair`, `device list`, `device revoke` |
### T3 credentials
`host add` takes a T3 pairing credential, not a long-lived token. The gateway exchanges it for a
30 day session limited to reading and driving threads, and stores that in SQLite. A pairing
credential works once and expires in minutes, so the session token never appears in a terminal.
T3 sessions cannot be refreshed, so the gateway checks each one daily and pushes a warning to the
watch a week before it expires. `scripts/renew-hosts.sh` renews them all in one go.
A host can have several routes, such as a LAN address, a VPN address and a public URL. The
gateway tries them in order on every connect and stays on the first that answers.
### Notifications
The gateway pushes when a thread starts waiting for you, finishes, or fails. On a cold start it
loads state without pushing, so a restart never replays old news. Each watch picks which kinds it
wants. A subagent's request is pushed under the thread that started it.
### Storage
```
hosts(id, label, url, alt_urls, environment_id, token, token_expires_at, created_at)
devices(id, name, token_hash, apns_token, apns_env, notifications, created_at, last_seen_at, revoked_at)
pairing_codes(code_hash, expires_at, used_at)
```
Device tokens and pairing codes are stored as SHA-256 hashes.
### Testing
Unit tests cover the parts that carry logic: RPC framing against an in-process fake server, route
choice and reconnects, the awareness port, feed ordering and subagent roll-up, notification
diffing, push filtering, and the API shapes. Run them with `bun test` in `gateway/`.
## Watch app
SwiftUI, watch only (`WKRunsIndependentlyOfCompanionApp`). XcodeGen generates the project from
`watch/project.yml`. A widget extension provides the complications and the Smart Stack widget. The
watch makes plain `URLSession` calls, and the device token lives in the Keychain, shared with the
widget.
The feed refreshes when it appears, when the app returns to the foreground, every 10 seconds while
it is on screen, and when a notification arrives.
[watch/README.md](../watch/README.md) covers building, the demo mode and the screenshot tests.
## Decisions
- Threads started from the watch use the project's default model and the host's default runtime
mode, unless the watch picks another model or turns on Ask before acting.
- An App Store app has to be an iOS app, so a watch-only app ships inside an empty iOS wrapper.
The watch app's own bundle ID is also its APNs topic.
- `delegate_task` children are not marked as subagents by T3, so the gateway recognizes them by
`delegatedFrom` and treats them as subagents of the thread that started them.
-207
View File
@@ -1,207 +0,0 @@
# WristCode implementation plan
WristCode puts every T3 Code environment on an Apple Watch. From the watch you can see which agents
need you, approve their requests, reply, start new threads, and get a notification when a turn
finishes. It runs over the watch's own cellular connection, so no phone is needed.
How a client talks to a T3 server is documented in [t3-protocol.md](t3-protocol.md). This plan
builds on it.
## Shape
```
watchOS app --HTTPS + device token--> gateway (Bun, junior.gib) --WebSocket + bearer--> T3 server x N
|
+--HTTP/2--> APNs --> watch notifications
```
- The watch never talks to T3 directly. watchOS only allows WebSockets for audio apps, and every
T3 write goes over a WebSocket.
- The gateway keeps one `orchestration.subscribeShell` stream open per T3 server and holds every
thread shell in memory. The gateway serves list views from memory, so the watch never waits on a T3
round trip to draw the feed.
- The gateway is the only place that holds T3 credentials, and the watch only holds its own device
token.
## Gateway
Bun and TypeScript, with no framework. `Bun.serve` for HTTP, `bun:sqlite` for state, and
`node:http2` for APNs. The T3 RPC client is hand-written, about 150 lines, as the protocol doc
recommends. It needs no Effect and no vendored contracts.
### Modules
| File | Job |
| --- | --- |
| `src/t3/rpc.ts` | Effect RPC JSON framing: request ids, pending calls, Ack on every Chunk, 5 s Ping, 15 s silence timeout |
| `src/t3/host.ts` | One T3 server: well-known check, ticket mint, connect, reconnect with jittered backoff, shell state, resume with `afterSequence` |
| `src/t3/types.ts` | Narrow, lenient types for the fields we read. Unknown fields and enum values pass through |
| `src/awareness.ts` | Port of upstream `resolveThreadAwarenessPhaseV2`. Maps a thread shell to a phase |
| `src/feed.ts` | Builds the watch feed across all hosts: needs you, then working, then recently finished |
| `src/notify.ts` | Diffs phases on every `thread.updated` and on resync snapshots, and decides what to push |
| `src/apns.ts` | ES256 provider JWT plus an HTTP/2 client for APNs |
| `src/api.ts` | The watch REST API |
| `src/db.ts` | SQLite schema and queries |
| `src/cli.ts` | `wristcode host add`, `host list`, `host remove`, `pair`, `device list`, `device revoke` |
### T3 credentials
`host add` takes a T3 pairing credential, not a long-lived token:
```sh
# on the T3 host
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
# on junior.gib
podman exec -it wristcode-gateway wristcode host add https://t3mac.gbrown.org <pairing credential>
```
The gateway exchanges the credential at `POST /oauth/token` for a 30 day bearer session and stores
it in SQLite. A pairing credential is single use and expires in 5 minutes, so the long-lived token
never appears in a terminal, a transcript, or a chat. Renewing works the same way with a fresh
pairing credential.
T3 sessions cannot be refreshed. The gateway checks `GET /api/auth/session` daily, and seven days
before a token expires it sends a push to the watch that names the host.
### Watch API
Every route except pairing and health takes `Authorization: Bearer <device token>`. The gateway
stores device tokens as SHA-256 hashes.
| Route | Purpose |
| --- | --- |
| `GET /v1/health` | Liveness, plus each host's connection state |
| `POST /v1/pair` | `{ code, deviceName }` returns `{ deviceToken }`. `wristcode pair` makes the codes. They are 6 digits, last 5 minutes, work once, and are rate limited |
| `PUT /v1/device/push` | `{ apnsToken, apnsEnvironment }` |
| `GET /v1/feed` | `{ needsYou, working, recent, hosts }`. Each item has host, project, title, phase, the last message trimmed to about 280 characters, the pending request summary, and `updatedAt` |
| `GET /v1/threads/:host/:thread` | The last 20 messages from `/bounded`, trimmed, plus the full pending request (prompt, command, options) |
| `POST /v1/threads/:host/:thread/messages` | `{ text }` sends `message.dispatch` with `queue_after_active` and `creationSource: "mobile"` |
| `POST /v1/threads/:host/:thread/respond` | `{ requestId, decision }` or `{ requestId, answers }` |
| `POST /v1/threads/:host/:thread/stop` | `thread.stop`, or `run.interrupt` when the host lacks `threadStop` |
| `GET /v1/projects` | Hosts, their projects, and each project's resolved default model, for the new thread screen |
| `POST /v1/threads` | `{ host, projectId, text }` calls `launchThread` with the resolved defaults |
Responses are JSON with stable field names. The watch decodes them with `Codable`, so this API is the
contract between the two halves and gets its own tests.
### Notifications
The gateway pushes when a top-level thread's phase changes to `completed`, `failed`,
`waiting_for_approval` or `waiting_for_input`. Each notification has a category the watch registers
actions for:
| Category | Actions |
| --- | --- |
| `approval` | Allow, Deny (calls `/respond`) |
| `finished` | Reply (text input, calls `/messages`), Open |
| `failed` | Open |
| `input` | Open |
On a cold start, the gateway loads state without pushing, so a restart never replays old news. After a
disconnect, the gateway diffs the resync snapshot against the last known state, so a turn that
finished while the socket was down still notifies once.
### Storage
```sql
hosts(id, label, url, environment_id, token, token_expires_at, last_sequence)
devices(id, name, token_hash, apns_token, apns_env, created_at, last_seen_at, revoked_at)
pairing_codes(code_hash, expires_at, used_at)
```
### Testing
Unit tests cover the parts that carry logic. They exercise RPC framing against an in-process fake WebSocket server
(including Ack flow and reconnect), the awareness port against upstream's own test cases, feed
ordering, notification diffing (cold start, resync, debouncing), and the API contract. The only end
to end check is a manual run against server-gib's own T3 before deploying.
## Container, CI and deploy
- `gateway/Containerfile` uses the official Bun image and a non-root user, and `/data` holds the
SQLite file. It is a plain OCI build, the same under podman and docker.
- `.gitea/workflows/gateway.yml` follows `secure-market`. On pushes touching `gateway/`, it runs
typecheck and tests on the `build` runner. On `main`, it also builds the image and pushes
`git.gbrown.org/gib/wristcode-gateway:<sha>` and `:latest`. It needs the repo secrets
`REGISTRY_USER` and `REGISTRY_PASSWORD`.
- The stack runs under rootless podman on junior.gib, the VPS, beside Gitea and most other
services. Its compose file and systemd user unit live in gib/Panama under
`server/vps/Wristcode/` and are symlinked into `~/Server/Wristcode/` and
`~/.config/systemd/user/`. It joins `nginx-bridge`, publishes no host ports, and bind mounts
`./data`. The APNs key and other config come from a mode 600 `.env`.
- junior's Nginx Proxy Manager serves `wristcode.gbrown.org` and forwards to
`wristcode-gateway:8080`. The Cloudflare record is proxied, as every VPS hostname must be.
- Deploys: after pushing the image, CI SSHes to junior's WireGuard address with a deploy key
pinned to `~/Server/ci-deploy.sh`, which pulls and restarts the stack. The nightly
`update-containers` timer is the fallback.
- Local development: `bun --watch src/main.ts` against real T3 hosts, with no local image builds.
## Watch app
SwiftUI, watch only (`WKRunsIndependentlyOfCompanionApp`), with the newest watchOS as the
deployment target. XcodeGen generates the project from `watch/project.yml`. The team is
`GQJNM5YK2M`. The Mac mini builds it with `xcodebuild` and runs it in the watch simulator, and
TestFlight installs it on the watch using the App Store Connect key already on the Mac mini.
### Screens (mock B)
| Screen | Content |
| --- | --- |
| Pair | Gateway URL (prefilled with `https://wristcode.gbrown.org`) and a 6 digit code. Shown until paired |
| Feed | Count of threads waiting on you, then cards: needs you (with Allow and Deny on the card), working (with the agent's last line), recent (greyed) |
| Thread | The agent's latest message, large. Older messages are one scroll up. Reply by dictation or Scribble. Stop while running |
| Approval | Inline on the feed card and on the thread: the command or file, and the server's options |
| New thread | Host and project (defaults to the last used), model shown read only, task by dictation |
The feed refreshes on appear, when the app returns to the foreground, every 10 seconds while it is
on screen, and when a notification arrives. The watch makes plain `URLSession` HTTPS calls, and the
device token lives in the Keychain.
### Code layout
```
watch/
project.yml
WristCode/
WristCodeApp.swift app entry, notification registration
API/GatewayClient.swift async URLSession calls, Codable models
API/Models.swift
Auth/DeviceStore.swift Keychain
Views/PairView.swift
Views/FeedView.swift
Views/ThreadView.swift
Views/NewThreadView.swift
Notifications/NotificationHandler.swift categories and actions
```
## Phases
Each phase ends with something usable.
1. **Gateway read path.** RPC client, host connection, shell state, awareness, `host add` through
pairing, and `GET /v1/feed`. Checked against server-gib's T3 from the Fedora desktop.
2. **Gateway write path and pairing.** Send, respond, stop, launch, the projects route, watch
pairing codes, and device tokens. Checked with curl against a real thread.
3. **Ship the gateway.** Containerfile, CI, registry, the compose stack on junior.gib, and the NPM
proxy host. All four hosts are added.
4. **Watch app MVP.** Pair, feed, thread, reply, approvals, and new thread, running in the simulator
against the deployed gateway.
5. **Notifications and the real watch.** An APNs key in the Apple Developer portal, the gateway's
APNs sender, notification actions, and a TestFlight build installed on the watch.
6. **Later.** Complications showing the count of threads waiting on you, a Smart Stack widget, token
expiry warnings, and multiple gateways.
Phases 1 and 2 happen on the Fedora desktop. Phase 4 runs on the Mac mini in parallel once the
phase 2 API is fixed. The gateway owns `gateway/`, the Mac mini owns `watch/`, and both pull before
they push to `docs/`.
## Decisions
- Threads started from the watch inherit the host's default runtime mode (`full-access` on these
hosts).
- The gateway runs on junior.gib, the VPS, which is more reliable than the home server. CI deploys
right away through junior's `~/Server/ci-deploy.sh`, with the nightly update timer as the
fallback.
- The App Store record uses `org.gbrown.wristcode`, an empty iOS wrapper the App Store requires
around a watch-only app. The watch app itself is `org.gbrown.wristcode.watchkitapp`, which is also
its APNs topic.
+111
View File
@@ -0,0 +1,111 @@
# Setting up WristCode
This guide runs the gateway, connects your T3 Code machines to it, builds the watch app, and pairs
your watch. It takes about an hour, most of it in Apple's developer portal.
## 1. Run the gateway
The gateway is one container with one SQLite file in `/data`. Run it on a machine that is always
on and can reach every T3 Code server.
```sh
cd deploy
cp .env.example .env # fill in after step 2, or leave the APNS_ lines empty for now
mkdir data
docker compose up -d --build
```
`compose.yml` builds the image from `gateway/` and listens on `127.0.0.1:8080`. Podman works the
same with `podman compose`. The image runs as uid 1000, which needs to be able to write `data/`.
The watch needs to reach the gateway over cellular, so put it behind a reverse proxy that serves
HTTPS on a public name, such as `wristcode.example.com`, and forwards to port 8080. Check it with:
```sh
curl https://wristcode.example.com/v1/health
```
Every route except health and pairing needs a device token, which only pairing hands out.
## 2. Set up notifications
The gateway sends notifications through Apple's push service (APNs) with a key from your developer
account.
1. In [Certificates, IDs & Profiles](https://developer.apple.com/account/resources/authkeys/list),
make a key with Apple Push Notifications service enabled, and download its `.p8` file.
2. Put the file in `deploy/data/apns.p8`.
3. Fill in `deploy/.env`: the key's ID, your team ID, and `APNS_TOPIC`, which is the watch app's
bundle ID from step 4.
4. Restart the gateway with `docker compose up -d`.
Without these settings the gateway still works. It logs each notification instead of sending it.
## 3. Connect your T3 Code machines
The gateway logs in to each T3 Code server with a one-time pairing credential. On the T3 Code
machine:
```sh
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
```
Then, within a few minutes, on the gateway machine:
```sh
docker compose exec gateway wristcode host add https://t3-desktop.example.com <credential> --label "Desktop"
```
The gateway trades the credential for a 30 day session that can read and drive threads, and the
credential stops working. The label is what the watch shows.
A machine you can reach several ways can list fallbacks with `--alt-url`, preferred first. The
gateway uses the first route that answers, so put a LAN or VPN address first and a public URL
last:
```sh
docker compose exec gateway wristcode host add http://desktop.lan:3773 <credential> \
--label "Desktop" --alt-url https://t3-desktop.example.com
```
`wristcode host list` shows each host and when its session expires. T3 sessions cannot be renewed,
so a week before one expires the watch gets a notification. To renew, run `host add` again with a
fresh credential, or let `scripts/renew-hosts.sh` do every host over SSH. Copy
`scripts/hosts.conf.example` to `scripts/hosts.conf` and list your machines in it first.
## 4. Build the watch app
You need a Mac with Xcode and [XcodeGen](https://github.com/yonaskolb/XcodeGen).
1. Copy `watch/Local.xcconfig.example` to `watch/Local.xcconfig`, which git ignores, and set your
team ID and a bundle ID prefix you own, such as `com.example`. The watch app's bundle ID is
`<prefix>.wristcode.watchkitapp`, which is the `APNS_TOPIC` from step 2.
2. Generate the project and open it:
```sh
cd watch
xcodegen generate
open WristCode.xcodeproj
```
3. Run the `WristCode` scheme on your watch. Xcode registers the bundle IDs and the push
capability for you with automatic signing.
To install it without Xcode attached, upload a build to TestFlight with
`watch/scripts/testflight.sh`. It needs an App Store Connect API key and an app record for
`<prefix>.wristcode`. [watch/README.md](../watch/README.md) has the details, plus a demo mode
that runs without a gateway.
## 5. Pair your watch
On the gateway machine:
```sh
docker compose exec gateway wristcode pair
```
It prints a 6 digit code that works once, for 5 minutes. On the watch, enter your gateway's URL
and the code. The watch then asks to send notifications. Allow it, and send a test notification
from the gear button, then Notifications, to check the whole chain.
`wristcode device list` shows paired watches, and `wristcode device revoke <name>` unpairs one.