11 KiB
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. 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.subscribeShellstream 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:
# 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
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/Containerfileuses the official Bun image and a non-root user, and/dataholds the SQLite file. It is a plain OCI build, the same under podman and docker..gitea/workflows/gateway.ymlfollowssecure-market. On pushes touchinggateway/, it runs typecheck and tests on thebuildrunner. Onmain, it also builds the image and pushesgit.gbrown.org/gib/wristcode-gateway:<sha>and:latest. It needs the repo secretsREGISTRY_USERandREGISTRY_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 joinsnginx-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.organd forwards towristcode-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 nightlyupdate-containerstimer is the fallback. - Local development:
bun --watch src/main.tsagainst 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.
- Gateway read path. RPC client, host connection, shell state, awareness,
host addthrough pairing, andGET /v1/feed. Checked against server-gib's T3 from the Fedora desktop. - 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.
- Ship the gateway. Containerfile, CI, registry, the compose stack on junior.gib, and the NPM proxy host. All four hosts are added.
- Watch app MVP. Pair, feed, thread, reply, approvals, and new thread, running in the simulator against the deployed gateway.
- 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.
- 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-accesson 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 isorg.gbrown.wristcode.watchkitapp, which is also its APNs topic.