Files
wristcode/docs/t3-protocol.md
T

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). 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:

{ "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:

// 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:
{ "_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):

{ "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 }
  • 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

{ "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):

{ "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):
{ "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, 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.
  • 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.