Files
gib f8241e42bb
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 20s
Docs: public README, setup guide and architecture, generic deploy files, and the T3 Code notice
2026-11-02 13:51:19 -05:00

101 lines
5.0 KiB
Markdown

# 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.