Files
wristcode/docs/t3-protocol.md
T

333 lines
30 KiB
Markdown

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