Files
wristcode/docs/plan.md
T

10 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, server-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:

# on the T3 host
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
# on server-gib
docker 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/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.
  • On server-gib the stack lives in ~/Server/Wristcode/compose.yml, modeled on ~/Server/Gbrown. It joins nginx-bridge, publishes no host ports, bind mounts ./data, and carries the watchtower label. The APNs key and other config come from a mode 600 .env.
  • Nginx Proxy Manager gets a proxy host for wristcode.gbrown.org that points at wristcode-gateway:8080, using the *.gbrown.org certificate. DNS and the Cloudflare tunnel already cover the subdomain.
  • Deploys: Watchtower pulls :latest nightly. A wristcode entry in ~/Server/ci-deploy.sh would make deploys immediate after CI.
  • 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 server-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/.

Open decisions

  • Runtime mode for threads started from the watch. Either inherit the host's default (full-access on these hosts) or force approval-required, so that anything risky waits for a tap on the wrist.
  • Instant deploys. Add a wristcode entry to ~/Server/ci-deploy.sh with a CI deploy key, or rely on Watchtower's nightly pull.
  • Bundle ID. For example org.gbrown.wristcode.