Add T3 protocol reference and implementation plan
This commit is contained in:
1 parent
e7f5b7e45b
commit
0fd556e313
2 files changed
+535
No files matched your search
+203
@@ -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`.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user