30 KiB
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). ADPoPprefix 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/rpcwith the bearer header, receive{ ticket, expiresAt }, and open/ws?wsTicket=<ticket>(client-runtime/authorization/remote.ts:188-189,:267-301). The server readswsTicketfrom the query and checks that the ticket's audience isrpc(server/auth/EnvironmentAuth.ts:1223-1272). - If the upgrade request carries no
wsTicket, the server falls back to normal request auth, so anAuthorization: Bearerheader on the upgrade also works (server/auth/EnvironmentAuth.ts:1274). Bun'sWebSocketaccepts aheadersoption, 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:
{ "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:
- Opens
subscribeServerConfigand waits for its firstsnapshotbefore calling the connection ready (client-runtime/rpc/session.ts:269-343). - Compares
config.environment.environmentIdwith 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:
// 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 } > }
tagis the method name, for example"orchestration.dispatchCommand".headersmust be present,[]is fine. The official client uses an incrementing integerid(effect/rpc/RpcClient.ts:219,:275).- A unary call gets one
Exit. A stream call (stream: truein the contract) gets zero or moreChunkframes and then anExitwhen the stream ends or fails. - Streams are flow controlled. After sending a
Chunkthe server waits for{"_tag":"Ack","requestId":...}before it sends the next one (effect/rpc/RpcServer.ts:441-460,supportsAck: trueat: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.
DateTimeUtcfields are ISO strings and branded ids are plain strings. - Typed errors arrive inside
exit.cause[].errorwith 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/contractsand@t3tools/client-runtimeare"private": trueand not on npm (packages/contracts/package.json:4,npm view @t3tools/contractsreturns 404).contractsdepends only oneffect4.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, butorchestrationV2.tsalone is 155 KB and pins us to Effect 4 (a 51 MB unpacked dependency) and to T3's internal schema churn.client-runtimeis 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 issueruns against the server's database directly, not over HTTP (server/cli/auth.ts:159-191). It callsissueSessionwith methodbearer-access-token, device typebotand the requested scopes (server/auth/EnvironmentAuth.ts:1068-1095).- The default TTL is 30 days (
server/auth/SessionStore.ts:427).--ttlaccepts5m,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/tokenonly accepts a pairing (bootstrap) credential assubject_token(contracts/auth.ts:219-229). Before expiry, someone has to issue a new token, either the operator on the host or a client holdingaccess:writeviaPOST /api/auth/pairing-tokenfollowed by/oauth/token. - Expiry closes live sockets. For as long as the socket is open the server races it against
sessions.awaitInvalidation, which resolves atexpiresAtor on revocation (server/ws.ts:3364-3378,server/auth/SessionStore.ts:1037-1067). Afterwards, ticket minting returns HTTP 401:
{ "_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 sameawaitInvalidationrace. GET /api/auth/sessionwith the bearer header returnsexpiresAtandpermissions(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):
{ "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):
{ 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):
{ 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 }
statusis the latest run's status, and"idle"only means the thread has never run (server/orchestration-v2/ProjectionStore.ts:1629-1647). "Needs approval" ispendingRuntimeRequest !== null.- Upstream already maps a shell to watch-friendly phases (
starting,running,waiting_for_approval,waiting_for_input,completed,failed) inresolveThreadAwarenessPhaseV2(shared/agentAwareness.ts:97-135). It skips subagent children and settled finished threads (:59-76). Port this function as is. omitIdleSubagents: trueandomitOlderSettled: trueshrink the snapshot. Send them only to servers whose config advertisesshellThreadFamiliesandshellOlderSettled(contracts/orchestrationV2.ts:3599-3613).
Read a thread's latest messages
- For a quick view, use
latestVisibleMessageon the shell. - For a page, call
GET /api/orchestration/threads/:id/bounded, which returns{ snapshotSequence, projection, historyCursor, hasMoreHistory }(contracts/orchestrationV2.ts:3727-3744). Readprojection.messages({ id, role, text, streaming, createdAt, updatedAt },:1260-1290), orprojection.visibleTurnItemsfor tool and approval rows. PasshistoryCursorto/historyfor older pages (:3746-3765). Leave thecompactIds,dedupeBodiesandcompactTurnItemsflags off, since each one changes the wire shape. - For incremental updates, call
orchestration.subscribeThreadwith{ threadId, afterSequence: snapshotSequence }. That gives{ kind: "event", sequence, event }items from then on (contracts/orchestrationV2.ts:3697-3711,:3808-3831).message.updatedevents carry the fullConversationMessage(:1816-1960). WithoutafterSequence, the first item is asnapshot.
Send a message
{ "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_immediatelyorqueue_after_activeand nodeliveryIntent, 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 senddoes 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"withcreationSource: "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):
{ "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:
modelSelectionisproject.defaultModelSelection, falling back toserverConfig.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.runtimeModeissettings.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.interactionModeis"default"(contracts/providerPolicy.ts:34-36).workspaceStrategyis{ "type": "root" }(contracts/orchestrationV2.ts:644-661).
Approvals and user input
- Find pending requests through the shell's
pendingRuntimeRequest(kindiscommand,file-read,file-change,mcp-elicitation,permission,user_input,dynamic_tool_callorauth_refresh,contracts/orchestrationV2.ts:1177-1201,contracts/providerPolicy.ts:38-44). - Details live in the thread's turn items.
approval_requesthasrequestId,requestKind,prompt,reasonandoptions(contracts/orchestrationV2.ts:1644-1655).user_input_requesthasrequestIdandquestions[](:1601-1608,:1301-1318). - Answer with
dispatchCommand(contracts/orchestrationV2.ts:3213-3228):
{ "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
{ "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):
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,
AgentAwarenessRelayprojects each thread withprojectThreadAwarenessV2and 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 atPOST /v1/mobile/deviceswith 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. PortingresolveThreadAwarenessPhaseV2(about 40 lines) and our own APNs sender gives the same notification behavior.
7. Gotchas
- Protocol version.
ORCHESTRATION_PROTOCOL_VERSION = 2must match exactly on/wsand on the HTTP orchestration routes (contracts/environment.ts:13,server/ws.ts:635-640). ReadorchestrationProtocolVersionfrom/.well-known/t3/environmentfirst, 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-eventand 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/transcriptreads fail withthread_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'sExitwith aDiecause, not as a socket-wideDefect(server/ws.ts:3254-3257). - Rate limits. I found none on
/wsor the orchestration routes inserver/ws.tsorserver/orchestration-v2/http.ts. Eachthread.updatedcosts 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/environmentat startup and refuse on aenvironmentIdmismatch or anorchestrationProtocolVersionother than 2. - Connect with
POST /api/auth/websocket-ticket/rpc, thenwss://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.subscribeShellper server. Keep the thread shells in memory and persist the lastsequenceso restarts resume withafterSequence. - Port
resolveThreadAwarenessPhaseV2fromshared/agentAwareness.tsand send an APNs push whenever a top-level thread's phase changes tocompleted,failed,waiting_for_approvalorwaiting_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/boundedand send only the last fewmessagesplus any pending approval or question, trimmed for the watch. - Map writes to
orchestration.dispatchCommand:message.dispatchwithqueue_after_activeandcreationSource: "mobile",runtime-request.respond, andthread.stop(orrun.interrupton servers withoutthreadStop). Always generate UUIDcommandIdandmessageId. - Map new threads to
orchestration.launchThread, with defaults fromproject.defaultModelSelection, thenserver.getConfigsettings,workspaceStrategy: rootandinteractionMode: default. Return an error to the watch if no model is configured. - Decode leniently with narrow hand-written schemas, poll
/api/auth/sessiondaily, and alert the operator about 7 days before a token'sexpiresAt. The fix is a fresht3 auth session issueon that host.
Open questions
- I have not seen a live server answer
/wswith anAuthorizationheader and no ticket. The code path exists (server/auth/EnvironmentAuth.ts:1274), but the ticket flow is the tested one. ServerConfig.environment.capabilitiesliststhreadStop,shellThreadFamiliesandshellOlderSettled. I did not confirm their values on a running 0.1.5 host.- I did not verify end to end how a
runtime-request.respondformcp-elicitationordynamic_tool_callshould be shaped. Approvals (command,file-change,permission) anduser_inputare covered above.