Add T3 protocol reference and implementation plan

This commit is contained in:
gib committed 2026-11-01 01:50:02 -05:00
1 parent e7f5b7e45b
commit 0fd556e313
2 files changed
+535

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
# 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, 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:
```sh
# 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
```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`.
- 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`.
+332
View File
@@ -0,0 +1,332 @@
# How a headless client talks to a T3 Code server (v0.1.5)
This is the reference the WristCode gateway is built against. Every claim cites the upstream source at tag `v0.1.5`, checked out at `WristCode-refs/t3code`. Citation prefixes:
| Prefix | Path in the T3 repo |
| --- | --- |
| `contracts/` | `packages/contracts/src/` |
| `client-runtime/` | `packages/client-runtime/src/` |
| `server/` | `apps/server/src/` |
| `shared/` | `packages/shared/src/` |
| `effect/` | `[email protected]` package, `src/` (the version the T3 catalog pins in `pnpm-workspace.yaml`) |
## 1. Transport
### Endpoints
All paths hang off the server's HTTP origin (for example `https://host:port`). The WebSocket uses the same origin with `ws:` or `wss:`.
| Method and path | Auth | Purpose | Source |
| --- | --- | --- | --- |
| `GET /.well-known/t3/environment` | none | Environment descriptor: `environmentId`, `label`, `serverVersion`, `orchestrationProtocolVersion`, `capabilities` | `contracts/environmentHttp.ts:528`, `contracts/environment.ts:159` |
| `GET /api/auth/session` | Bearer | Who am I: `authenticated`, `permissions`, `expiresAt` | `contracts/environmentHttp.ts:536`, `contracts/auth.ts:392` |
| `POST /api/auth/websocket-ticket/rpc` | Bearer | Mint a 5 minute WebSocket ticket | `contracts/environmentHttp.ts:565`, `server/auth/SessionStore.ts:428` |
| `POST /oauth/token` | none (pairing credential in body) | Exchange a pairing token for a session | `contracts/environmentHttp.ts:550` |
| `GET /ws?wsTicket=...&orchestrationProtocol=2` | ticket | The RPC WebSocket | `server/ws.ts:3268` |
| `GET /api/orchestration/shell` | Bearer, `orchestration:read` | All projects and active threads in one JSON document | `contracts/environmentHttp.ts:683`, `server/orchestration-v2/http.ts:168` |
| `GET /api/orchestration/threads/:threadId/bounded` | same | Recent window of one thread | `contracts/environmentHttp.ts:707` |
| `GET /api/orchestration/threads/:threadId/history?cursor=...` | same | Older pages of that window | `contracts/environmentHttp.ts:716` |
| `GET /api/orchestration/threads/:threadId` | same | Full thread projection (can be large) | `contracts/environmentHttp.ts:691` |
| `GET /api/projects` | Bearer | Full project records | `contracts/environmentHttp.ts:727` |
| `POST /mcp` | MCP client session only | MCP tools (`t3_thread_list`, `t3_thread_send`, ...). See section 3 | `server/mcp/McpHttpServer.ts:1142` |
The four `/api/orchestration/*` routes require the header `x-t3-orchestration-protocol: 2` (`contracts/environmentHttp.ts:80-84`, `contracts/environment.ts:13-16`). They are read only. Every write (send, launch, interrupt, respond) goes over the WebSocket.
### Presenting the bearer token
- HTTP: `Authorization: Bearer <token>` (`server/auth/EnvironmentAuth.ts:569`, `:618-625`). A `DPoP ` prefix means something else and fails for a bearer session (`:796-803`).
- WebSocket: the official clients never put the long-lived token in the URL. They `POST /api/auth/websocket-ticket/rpc` with the bearer header, receive `{ ticket, expiresAt }`, and open `/ws?wsTicket=<ticket>` (`client-runtime/authorization/remote.ts:188-189`, `:267-301`). The server reads `wsTicket` from the query and checks that the ticket's audience is `rpc` (`server/auth/EnvironmentAuth.ts:1223-1272`).
- If the upgrade request carries no `wsTicket`, the server falls back to normal request auth, so an `Authorization: Bearer` header on the upgrade also works (`server/auth/EnvironmentAuth.ts:1274`). Bun's `WebSocket` accepts a `headers` option, so the gateway could skip the ticket. The ticket path is what upstream documents and tests (`docs/internals/environment-auth.md:73-86`), so prefer it.
- Tickets expire after 5 minutes (`server/auth/SessionStore.ts:428`). They are checked only at upgrade time, so an open socket outlives its ticket. Mint a fresh one for every connect.
### Handshake
There is no hello message. The server rejects the upgrade with HTTP 426 unless the query has `orchestrationProtocol=2`:
```json
{ "code": "orchestration_protocol_incompatible", "message": "...", "orchestrationProtocolVersion": 2 }
```
(`server/ws.ts:3274-3284`, `server/ws.ts:635-640`). After the upgrade the socket speaks Effect RPC JSON (section 2) right away. The official client then does two things a gateway can copy or skip:
1. Opens `subscribeServerConfig` and waits for its first `snapshot` before calling the connection ready (`client-runtime/rpc/session.ts:269-343`).
2. Compares `config.environment.environmentId` with the id it expected and fails on a mismatch (`client-runtime/rpc/session.ts:334-341`).
Optional analytics query params (`clientSurface`, `clientAppVersion`, `clientDeviceType`, ...) are ignored when absent (`server/ws.ts:650-694`).
### Heartbeat and reconnect
- The client sends `{"_tag":"Ping"}` every 5 seconds. The server answers `{"_tag":"Pong"}` (`effect/rpc/RpcClient.ts:1249`, `effect/rpc/RpcServer.ts:880-881`). Any inbound frame counts as liveness.
- The official client drops the socket after 15 seconds without a frame (`client-runtime/rpc/protocol.ts:10-12`) and never reconnects a socket in place (`client-runtime/rpc/session.ts:216-237`). Its supervisor builds a new session with jittered exponential backoff from 1 s up to 5 min (`client-runtime/connection/supervisor.ts:42-43`, `:137-138`).
- After a reconnect, resume streams with `afterSequence` (section 5).
## 2. RPC framing
### Wire format
The server uses `RpcServer.makeProtocolWithHttpEffectWebsocket` with `RpcSerialization.layerJson` (`server/ws.ts:3301-3330`). Each WebSocket text frame is one JSON object, or a JSON array of them (`effect/rpc/RpcSerialization.ts:129-140`). The frame shapes are in `effect/rpc/RpcMessage.ts`:
```ts
// client to server
type FromClient =
| { _tag: "Request", id: string | number, tag: string, payload: unknown,
headers: Array<[string, string]>, traceId?: string, spanId?: string, sampled?: boolean }
| { _tag: "Ack", requestId: string | number } // after every stream Chunk
| { _tag: "Interrupt", requestId: string | number } // cancel a call or stream
| { _tag: "Ping" }
| { _tag: "Eof" }
// server to client
type FromServer =
| { _tag: "Chunk", requestId: string | number, values: [unknown, ...unknown[]] }
| { _tag: "Exit", requestId: string | number, exit: Exit }
| { _tag: "Defect", defect: unknown }
| { _tag: "Pong" }
| { _tag: "ClientProtocolError", error: unknown }
type Exit =
| { _tag: "Success", value: unknown }
| { _tag: "Failure", cause: Array<
| { _tag: "Fail", error: unknown } // a typed error, e.g. EnvironmentAuthorizationError
| { _tag: "Die", defect: unknown }
| { _tag: "Interrupt", fiberId: number | null } > }
```
- `tag` is the method name, for example `"orchestration.dispatchCommand"`. `headers` must be present, `[]` is fine. The official client uses an incrementing integer `id` (`effect/rpc/RpcClient.ts:219`, `:275`).
- A unary call gets one `Exit`. A stream call (`stream: true` in the contract) gets zero or more `Chunk` frames and then an `Exit` when the stream ends or fails.
- **Streams are flow controlled.** After sending a `Chunk` the server waits for `{"_tag":"Ack","requestId":...}` before it sends the next one (`effect/rpc/RpcServer.ts:441-460`, `supportsAck: true` at `:1575`). A client that forgets to Ack sees exactly one chunk per stream and then silence.
- Payloads and values are the schema's encoded JSON form. `DateTimeUtc` fields are ISO strings and branded ids are plain strings.
- Typed errors arrive inside `exit.cause[].error` with a `_tag`, for example `{"_tag":"EnvironmentAuthorizationError","message":"...","requiredScope":"orchestration:operate"}` (`contracts/auth.ts:340-347`).
### Method names
`contracts/orchestrationV2.ts:3500-3519` and `contracts/rpc.ts:489-599`. The ones the gateway needs:
| Method | Kind | Scope | Payload / result |
| --- | --- | --- | --- |
| `orchestration.subscribeShell` | stream | `orchestration:read` | `OrchestrationV2SubscribeShellInput` / `OrchestrationV2ShellStreamItem` |
| `orchestration.subscribeThread` | stream | `orchestration:read` | `OrchestrationV2SubscribeThreadInput` / `OrchestrationV2ThreadStreamItem` |
| `orchestration.getThreadProjection` | unary | `orchestration:read` | `{ threadId }` / thread projection |
| `orchestration.dispatchCommand` | unary | `orchestration:operate` | `OrchestrationV2Command` / `{ sequence }` |
| `orchestration.launchThread` | unary | `orchestration:operate` | `OrchestrationV2ThreadLaunchInput` / `OrchestrationV2ThreadLaunchResult` |
| `server.getConfig` | unary | `orchestration:read` | `{}` / `ServerConfig` (holds `settings.defaultModelSelection`, providers) |
| `server.probe` | unary | `orchestration:read` | `{}` / `{}` (cheap liveness check) |
| `subscribeServerConfig` | stream | `orchestration:read` | config snapshot then updates |
Scopes come from `server/auth/RpcAuthorization.ts:40-207`. The middleware checks them before any handler runs and fails with `EnvironmentAuthorizationError` (`:220-224`, `:275-286`). The two scopes in the gateway token cover every row above.
### Import the packages or write a client?
- `@t3tools/contracts` and `@t3tools/client-runtime` are `"private": true` and not on npm (`packages/contracts/package.json:4`, `npm view @t3tools/contracts` returns 404).
- `contracts` depends only on `effect` 4.0.5 (`packages/contracts/package.json:27-29`), and its sources import nothing else outside the package. It ships TypeScript sources with no build step (`"exports"` points at `./src/*.ts`). The license is MIT. Vendoring it is feasible, but `orchestrationV2.ts` alone is 155 KB and pins us to Effect 4 (a 51 MB unpacked dependency) and to T3's internal schema churn.
- `client-runtime` is the wrong layer. It pulls in relay, DPoP, cloud session, device identity and UI state services (`client-runtime/authorization/service.ts:106-114`).
**Recommendation.** Write a small client: about 150 lines of Bun TypeScript for the frame loop (request ids, pending map, Ack on Chunk, Ping timer, reconnect). Hand-write narrow types, or Zod or Valibot schemas, for only the fields we read: shell stream items, the thread shell, and the commands we send. Decode leniently and ignore unknown fields and unknown `kind` values, which is what upstream does for forward compatibility (`contracts/orchestrationV2.ts:3776-3806`). Keep the vendored contracts as a reference for regenerating types, not as a runtime dependency.
## 3. Auth
### Sessions
- `t3 auth session issue` runs against the server's database directly, not over HTTP (`server/cli/auth.ts:159-191`). It calls `issueSession` with method `bearer-access-token`, device type `bot` and the requested scopes (`server/auth/EnvironmentAuth.ts:1068-1095`).
- The default TTL is 30 days (`server/auth/SessionStore.ts:427`). `--ttl` accepts `5m`, `1h`, `30d` (`server/cli/auth.ts:49-51`). The CLI sets no maximum.
- The token is an HMAC-signed claim blob, `<base64url payload>.<signature>`. The server verifies the signature, then the stored row for revocation and expiry (`server/auth/SessionStore.ts:745-844`).
- **There is no refresh or renew.** `/oauth/token` only accepts a pairing (bootstrap) credential as `subject_token` (`contracts/auth.ts:219-229`). Before expiry, someone has to issue a new token, either the operator on the host or a client holding `access:write` via `POST /api/auth/pairing-token` followed by `/oauth/token`.
- **Expiry closes live sockets.** For as long as the socket is open the server races it against `sessions.awaitInvalidation`, which resolves at `expiresAt` or on revocation (`server/ws.ts:3364-3378`, `server/auth/SessionStore.ts:1037-1067`). Afterwards, ticket minting returns HTTP 401:
```json
{ "_tag": "EnvironmentAuthInvalidError", "code": "auth_invalid", "reason": "invalid_credential", "traceId": "..." }
```
(`contracts/environmentHttp.ts:99-102`, `:148-166`). A missing scope on HTTP returns 403 `{ "code": "insufficient_scope", "requiredScope": ... }` (`:168-180`). On RPC it is `EnvironmentAuthorizationError` in the Exit cause.
- **Revocation.** `t3 auth session revoke <session-id>` (`server/cli/auth.ts:214-234`), or the Connections UI. It kills the open socket immediately through the same `awaitInvalidation` race.
- `GET /api/auth/session` with the bearer header returns `expiresAt` and `permissions` (`server/auth/EnvironmentAuth.ts:811-830`). The gateway should poll it daily and warn the operator well before expiry.
### Pairing tokens (for later)
`t3 auth pairing create` or `POST /api/auth/pairing-token` issues a one-time credential that is valid for 5 minutes by default (`server/auth/PairingGrantStore.ts:249`). Exchange it with a form-encoded POST:
```
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<pairing credential>
&subject_token_type=urn:t3:params:oauth:token-type:environment-bootstrap
&requested_token_type=urn:ietf:params:oauth:token-type:access_token
&scope=orchestration:read orchestration:operate
&client_label=WristCode gateway
&client_device_type=bot
```
Response (`contracts/auth.ts:231-238`):
```json
{ "access_token": "...", "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer", "expires_in": 2592000, "scope": "orchestration:read orchestration:operate" }
```
The server grants the intersection of the requested scopes and the pairing grant, and fails if that is empty (`server/auth/EnvironmentAuth.ts:934-990`). Without a `DPoP` header the result is a 30 day bearer session. With one it is a 1 hour DPoP session (`:955-958`). Upstream sends this from `bootstrapRemoteBearerSession` (`client-runtime/authorization/remote.ts:142-167`).
### The MCP endpoint is a different audience
The `t3 thread` CLI does not use the WebSocket. It mints a short-lived MCP client session and calls `/mcp` tools over HTTP (`server/cli/thread.ts:200-245`). `/mcp` accepts only sessions with subject `mcp-client` (`server/auth/EnvironmentAuth.ts:72`, `:1277-1300`), and `/ws` plus every other route rejects that subject (`:598-605`). MCP clients get access through an OAuth flow with owner approval (`docs/internals/environment-auth.md:42-66`). Our gateway token cannot call `/mcp`. MCP has no push stream either, so it does not help with events.
## 4. Operations the gateway needs
All ids (`CommandId`, `MessageId`, `ThreadId`, ...) are non-empty strings (`contracts/baseSchemas.ts:246-252`). The official client uses a fresh UUID v4 for each `commandId` (`client-runtime/operations/commands.ts:258-264`). `dispatchCommand` returns `{ sequence }` (`contracts/orchestrationV2.ts:3588-3591`) or fails with `OrchestrationV2DispatchCommandError { commandId, commandType, message, detail? }` (`:3833-3842`).
### List projects and threads
Use one call, `GET /api/orchestration/shell` (HTTP), or the first chunk of `orchestration.subscribeShell` (WS). Both return `OrchestrationV2ShellSnapshot` (`contracts/orchestrationV2.ts:2164-2177`):
```ts
{ schemaVersion: number, snapshotSequence: number,
projects: Array<{ id, title, workspaceRoot, defaultModelSelection: ModelSelection | null, ... }>,
threads: OrchestrationV2ThreadShell[], archivedThreads: OrchestrationV2ThreadShell[] }
```
Project shape: `contracts/orchestrationProject.ts:8-22`. Filter threads by `projectId`. The thread shell fields the watch needs (`contracts/orchestrationV2.ts:2023-2101`):
```ts
{ id, projectId, title, updatedAt, createdAt, archivedAt, deletedAt,
status: "idle" | "preparing" | "queued" | "starting" | "running" | "waiting"
| "completed" | "interrupted" | "failed" | "cancelled" | "rolled_back",
activityRunStatus?: "preparing" | "starting" | "running" | "waiting" | null,
activeRunId: string | null, latestRunId: string | null,
pendingRuntimeRequest: { id, kind, createdAt, blocking?: boolean } | null,
latestVisibleMessage: { id, role: "user" | "assistant" | "system", text, updatedAt } | null,
lastError?: string | null, lastErrorClass?: ..., modelSelection, runtimeMode,
lineage: { relationshipToParent, ... }, settledOverride, settledAt }
```
- `status` is the latest run's status, and `"idle"` only means the thread has never run (`server/orchestration-v2/ProjectionStore.ts:1629-1647`). "Needs approval" is `pendingRuntimeRequest !== null`.
- Upstream already maps a shell to watch-friendly phases (`starting`, `running`, `waiting_for_approval`, `waiting_for_input`, `completed`, `failed`) in `resolveThreadAwarenessPhaseV2` (`shared/agentAwareness.ts:97-135`). It skips subagent children and settled finished threads (`:59-76`). Port this function as is.
- `omitIdleSubagents: true` and `omitOlderSettled: true` shrink the snapshot. Send them only to servers whose config advertises `shellThreadFamilies` and `shellOlderSettled` (`contracts/orchestrationV2.ts:3599-3613`).
### Read a thread's latest messages
- For a quick view, use `latestVisibleMessage` on the shell.
- For a page, call `GET /api/orchestration/threads/:id/bounded`, which returns `{ snapshotSequence, projection, historyCursor, hasMoreHistory }` (`contracts/orchestrationV2.ts:3727-3744`). Read `projection.messages` (`{ id, role, text, streaming, createdAt, updatedAt }`, `:1260-1290`), or `projection.visibleTurnItems` for tool and approval rows. Pass `historyCursor` to `/history` for older pages (`:3746-3765`). Leave the `compactIds`, `dedupeBodies` and `compactTurnItems` flags off, since each one changes the wire shape.
- For incremental updates, call `orchestration.subscribeThread` with `{ threadId, afterSequence: snapshotSequence }`. That gives `{ kind: "event", sequence, event }` items from then on (`contracts/orchestrationV2.ts:3697-3711`, `:3808-3831`). `message.updated` events carry the full `ConversationMessage` (`:1816-1960`). Without `afterSequence`, the first item is a `snapshot`.
### Send a message
```json
{ "type": "message.dispatch", "commandId": "<uuid>", "threadId": "<id>", "messageId": "<uuid>",
"text": "...", "attachments": [], "createdBy": "user", "creationSource": "mobile",
"dispatchMode": { "type": "queue_after_active" } }
```
(`contracts/orchestrationV2.ts:3082-3125`). Busy-thread semantics, from `server/orchestration-v2/Orchestrator.ts:5246-5516` and `server/orchestration-v2/CommandPolicy.ts:91-178`:
- With `start_immediately` or `queue_after_active` and no `deliveryIntent`, a message sent to a busy thread queues behind the active run, and one sent to an idle thread starts right away (`Orchestrator.ts:5510-5517`). Nothing is interrupted. `t3 thread send` does the same (`server/cli/thread.ts:626-640`).
- `deliveryIntent: "auto"` lets the server steer the running turn when the provider supports it, otherwise it queues (`CommandPolicy.ts:143-178`). `"steer"` and `"restart"` force those modes.
- `createdBy: "user"` with `creationSource: "mobile"` (or `"web"`) also releases a queue that is on hold after a failure (`Orchestrator.ts:5528-5550`). Use `"mobile"`.
### Start a thread
`orchestration.launchThread` (`contracts/orchestrationV2.ts:3556-3577`):
```json
{ "commandId": "<uuid>", "creationSource": "mobile", "projectId": "<id>",
"title": "<first line of the prompt>", "generateTitle": true,
"modelSelection": { "instanceId": "<provider instance>", "model": "<model>" },
"runtimeMode": "full-access", "interactionMode": "default",
"workspaceStrategy": { "type": "root" },
"initialMessage": { "messageId": "<uuid>", "text": "...", "attachments": [] } }
```
It returns `{ threadId, projection, resumed, sequence? }` (`:3579-3586`). Every field except `threadId` is required, and the server fills in no defaults. Resolve them like this:
- `modelSelection` is `project.defaultModelSelection`, falling back to `serverConfig.settings.defaultModelSelection`. Both can be null (`contracts/settings.ts:1186-1188`). The MCP launch path refuses when the project has none (`server/mcp/OrchestratorMcpService.ts:989-999`). Do the same.
- `runtimeMode` is `settings.defaultRuntimeMode`, which defaults to `"full-access"` (`contracts/settings.ts:1192-1194`, `contracts/providerPolicy.ts:25-32`). For an unattended watch, consider `"approval-required"` so approvals actually reach the wrist.
- `interactionMode` is `"default"` (`contracts/providerPolicy.ts:34-36`). `workspaceStrategy` is `{ "type": "root" }` (`contracts/orchestrationV2.ts:644-661`).
### Approvals and user input
- Find pending requests through the shell's `pendingRuntimeRequest` (`kind` is `command`, `file-read`, `file-change`, `mcp-elicitation`, `permission`, `user_input`, `dynamic_tool_call` or `auth_refresh`, `contracts/orchestrationV2.ts:1177-1201`, `contracts/providerPolicy.ts:38-44`).
- Details live in the thread's turn items. `approval_request` has `requestId`, `requestKind`, `prompt`, `reason` and `options` (`contracts/orchestrationV2.ts:1644-1655`). `user_input_request` has `requestId` and `questions[]` (`:1601-1608`, `:1301-1318`).
- Answer with `dispatchCommand` (`contracts/orchestrationV2.ts:3213-3228`):
```json
{ "type": "runtime-request.respond", "commandId": "<uuid>", "threadId": "<id>",
"requestId": "<id>", "decision": "accept" }
```
`decision` is one of `accept`, `acceptForSession`, `acceptAlways`, `decline` or `cancel` (`contracts/providerPolicy.ts:47-53`). For user input, send `answers: { [questionId]: value }` instead (`:68`). `{ "type": "thread.user-input.dismiss", commandId, threadId, requestId }` dismisses a question.
### Interrupt
```json
{ "type": "run.interrupt", "commandId": "<uuid>", "threadId": "<id>", "runId": "<activeRunId>" }
```
(`contracts/orchestrationV2.ts:3164-3171`). Take `runId` from the shell's `activeRunId`. When the server config advertises `capabilities.threadStop`, the official client sends `{ "type": "thread.stop", commandId, threadId }` instead, which needs no run id and also stops delegated tasks (`:2843-2851`, `client-runtime/operations/commands.ts:800-810`).
## 5. Events
Use `orchestration.subscribeShell` as the gateway's one event source per server. It covers every thread in one stream (`contracts/orchestrationV2.ts:2179-2211`):
```ts
type ShellStreamItem =
| { kind: "snapshot", snapshot: OrchestrationV2ShellSnapshot }
| { kind: "synchronized" } // only with requestCompletionMarker: true
| { kind: "project.updated", sequence, project }
| { kind: "project.removed", sequence, projectId }
| { kind: "thread.updated", sequence, location: "active" | "archive", thread: ThreadShell }
| { kind: "thread.removed", sequence, location, threadId }
```
`thread.updated` carries the whole new shell, and the server drops updates where nothing changed (`server/ws.ts:1078-1084`). Detect transitions by diffing the previous shell against the new one, keyed by `thread.id`:
| Signal | Condition on the new shell |
| --- | --- |
| Turn finished | `status` moves to `"completed"` from a live status. Ignore it if `latestRunStopRequested` is true or background tasks are still pending (`shared/agentAwareness.ts:68`, `:122-125`) |
| Needs approval | `pendingRuntimeRequest` becomes non-null with a kind other than `user_input` or `auth_refresh` |
| Needs input | `pendingRuntimeRequest.kind === "user_input"` |
| Errored | `status` becomes `"failed"`. Show `lastError` |
Easiest of all, compute `resolveThreadAwarenessPhaseV2` on both shells and push when the phase changes.
**Resume.** Store the highest `sequence` seen (and the snapshot's `snapshotSequence`). On reconnect, send `{ afterSequence }`. The server replays the missed items if the gap is at most 1000 events and the replay payload is under 8 MB, and otherwise sends a fresh `snapshot` (`server/ws.ts:580-583`, `:716-740`, `:1164-1175`). Handle a `snapshot` at any point in the stream by replacing local state and diffing against it, so transitions that happened while disconnected still fire. `subscribeThread` resumes the same way, with a 128 event replay cap (`server/orchestration-v2/ThreadStream.ts:15`).
Raw domain event types (on `subscribeThread` only): `thread.created`, `run.created`, `run.updated`, `runtime-request.updated`, `message.updated`, `turn-item.updated`, `provider-turn.updated`, and others (`contracts/orchestrationV2.ts:1816-1960`). The gateway does not need them for notifications.
## 6. T3's own push notifications
- T3 does send pushes, but only through **T3 Connect**, the hosted relay. When a server is linked to a T3 Connect account and activity publishing is on, `AgentAwarenessRelay` projects each thread with `projectThreadAwarenessV2` and POSTs a signed activity state to the relay (`server/relay/AgentAwarenessRelay.ts:1-80`, `contracts/relay.ts:119-131`). The relay sends APNs or FCM notifications and Live Activity updates to devices registered at `POST /v1/mobile/devices` with a push token and per-phase preferences (`contracts/relay.ts:22-56`, `:1024-1044`, `infra/relay/README.md:15-25`).
- "Relay" here means that hosted Cloudflare service. It handles account linking, managed tunnels, short-lived connect credentials and mobile notifications. It is not in the data path after a client connects (`infra/relay/README.md:3-12`).
- **We cannot reuse it.** APNs delivery is bound to the T3 app's bundle id and signing key, which the relay holds (`contracts/relay.ts:53-55`). The relay also requires a Clerk-backed T3 Connect account and DPoP-signed device registration. We can reuse the logic, though. Porting `resolveThreadAwarenessPhaseV2` (about 40 lines) and our own APNs sender gives the same notification behavior.
## 7. Gotchas
- **Protocol version.** `ORCHESTRATION_PROTOCOL_VERSION = 2` must match exactly on `/ws` and on the HTTP orchestration routes (`contracts/environment.ts:13`, `server/ws.ts:635-640`). Read `orchestrationProtocolVersion` from `/.well-known/t3/environment` first, and treat a missing value as 1 (`client-runtime/connection/compatibility.ts:9-27`). A future T3 release that bumps it will lock the gateway out until we update.
- **Forgetting Ack** stalls every stream after its first chunk (section 2).
- **Forward compatibility.** Enums keep growing (run statuses, request kinds, turn item types). Upstream decodes unknown thread events as `unknown-event` and keeps advancing the cursor (`contracts/orchestrationV2.ts:3776-3806`). Strict decoding in the gateway would break on minor upgrades.
- **Payload size.** A full thread projection holds every turn item and command output, which is large. Use the bounded snapshot plus `latestVisibleMessage`. Command output is withheld (`outputOmitted`) and has to be fetched separately (`contracts/orchestrationV2.ts:1621-1632`). Large `/transcript` reads fail with `thread_transcript_too_large` (`server/orchestration-v2/http.ts:205-207`).
- **Opt-in compact encodings** (`acceptCompactIds`, `acceptDedupedBodies`, `acceptCompactTurnItems`, `idTable`) change the JSON shape. Never send them.
- **One socket can carry many streams**, multiplexed by `requestId`. Interrupt a stream with `{"_tag":"Interrupt","requestId"}` instead of closing the socket.
- **Defects.** The server runs with `disableFatalDefects: true`, so a handler bug surfaces as one call's `Exit` with a `Die` cause, not as a socket-wide `Defect` (`server/ws.ts:3254-3257`).
- **Rate limits.** I found none on `/ws` or the orchestration routes in `server/ws.ts` or `server/orchestration-v2/http.ts`. Each `thread.updated` costs the server a shell read, which it batches at 512 events or 50 ms (`server/ws.ts:1078-1084`).
- **Cookie origins** only apply to browser cookies (`server/auth/EnvironmentAuth.ts:752-772`). Bearer requests from the gateway are not origin-checked.
- **Session expiry** kills the socket and there is no refresh (section 3). Use one token per server, track `expiresAt`, and alert the operator.
## Recommended gateway integration
- For each T3 server, store `{ httpBaseUrl, bearerToken, environmentId }`. Check `/.well-known/t3/environment` at startup and refuse on a `environmentId` mismatch or an `orchestrationProtocolVersion` other than 2.
- Connect with `POST /api/auth/websocket-ticket/rpc`, then `wss://host/ws?wsTicket=...&orchestrationProtocol=2`. Mint a fresh ticket on every reconnect.
- Hand-roll the Effect RPC JSON client: integer request ids, Ack every `Chunk`, Ping every 5 s, drop the socket after 15 s of silence, reconnect with jittered backoff capped at a few minutes.
- Open one long-lived `orchestration.subscribeShell` per server. Keep the thread shells in memory and persist the last `sequence` so restarts resume with `afterSequence`.
- Port `resolveThreadAwarenessPhaseV2` from `shared/agentAwareness.ts` and send an APNs push whenever a top-level thread's phase changes to `completed`, `failed`, `waiting_for_approval` or `waiting_for_input`.
- Serve the watch's list views (projects, threads, status, last message) from the in-memory shell. The watch never waits on T3 for a list.
- Serve thread detail with `GET /api/orchestration/threads/:id/bounded` and send only the last few `messages` plus any pending approval or question, trimmed for the watch.
- Map writes to `orchestration.dispatchCommand`: `message.dispatch` with `queue_after_active` and `creationSource: "mobile"`, `runtime-request.respond`, and `thread.stop` (or `run.interrupt` on servers without `threadStop`). Always generate UUID `commandId` and `messageId`.
- Map new threads to `orchestration.launchThread`, with defaults from `project.defaultModelSelection`, then `server.getConfig` settings, `workspaceStrategy: root` and `interactionMode: default`. Return an error to the watch if no model is configured.
- Decode leniently with narrow hand-written schemas, poll `/api/auth/session` daily, and alert the operator about 7 days before a token's `expiresAt`. The fix is a fresh `t3 auth session issue` on that host.
### Open questions
- I have not seen a live server answer `/ws` with an `Authorization` header and no ticket. The code path exists (`server/auth/EnvironmentAuth.ts:1274`), but the ticket flow is the tested one.
- `ServerConfig.environment.capabilities` lists `threadStop`, `shellThreadFamilies` and `shellOlderSettled`. I did not confirm their values on a running 0.1.5 host.
- I did not verify end to end how a `runtime-request.respond` for `mcp-elicitation` or `dynamic_tool_call` should be shaped. Approvals (`command`, `file-change`, `permission`) and `user_input` are covered above.