Reference
Protocol
The wire protocol - events, commands, REST shapes, queue frames, versioning, and forward compatibility.
@workerdeck/protocol is the wire
protocol shared by the server and every client: typed session events, commands, and REST shapes.
Dependency-free and browser-safe - it depends on nothing, and everything depends on it. Anthropic
API message content is modeled structurally (ApiMessage, ContentBlock) so browsers can render
transcripts without the Agent SDK.
Most of it is types. The runtime exports are the few things both sides must agree on rather than
each guess: PROTOCOL_VERSION, the per-engine ENGINE_CAPABILITIES records, and the shared
rules - transcriptActivity, the sessions-list view model and the
unread model. They live here because a client that computed them
differently would disagree with the server about what it is showing.
The model
One session = one ordered stream of SessionEvents, each stamped with a monotonically
increasing seq (starting at 1) and an epoch-ms ts, plus a small SessionCommand set.
Clients attach over WebSocket, optionally replaying from a known seq, and drive the session
with commands.
Versioning and skew detection
PROTOCOL_VERSION (currently 7) is bumped on any breaking change to events, commands, or REST
shapes. The server reports it in the attached (and queue_attached) frame so clients can
detect skew:
import { PROTOCOL_VERSION, type ServerFrame } from '@workerdeck/protocol'
ws.onmessage = ({ data }) => {
const frame = JSON.parse(data) as ServerFrame
if (frame.type === 'attached' && frame.protocolVersion !== PROTOCOL_VERSION) {
throw new Error('protocol mismatch')
}
}
Events (server → client)
SessionEventBody variants:
| Event | Meaning |
|---|---|
system_init |
SDK init handshake: sdkSessionId, model, cwd, apiKeySource, tools, skills, slash commands, permissionMode, CLI version, MCP servers. |
status_changed |
SessionStatus transition (starting, running, awaiting_approval, idle, parked, failed, closed) with optional detail. parked means the session is waiting on a deferred execution - non-terminal, and the host’s cue to snapshot it. |
capabilities |
Models (ModelOption[]) and slash commands (SlashCommandInfo[]) available to the session, fetched from the CLI after init, plus defaultModel - the wire id this session’s default resolves to, which is how a client can name the running model before the first turn. The model list is shaped server-side: the CLI’s own default row is dropped (it is a choice, not a model), each row is named from its resolved id, and primary marks the newest of each family so a picker can file the rest under “more models”. |
model_changed |
Model switched via set_model; model undefined = back to default. |
permission_mode_changed |
Mode switched via set_permission_mode. |
context_usage |
Context-window snapshot (ContextUsage), polled after each turn. |
rate_limit |
Subscription rate-limit window update (RateLimitInfo). API-key sessions may never emit one - render nothing, not 0%, and treat an absent utilization as unknown. |
plan_info |
Which claude.ai plan those windows belong to (subscriptionType: ‘pro’, ‘max’, …). Emitted from the same poll as rate_limit, once per change, and never for an API-key session. |
assistant_message / user_message |
An ApiMessage (plain Anthropic content blocks) plus parentToolUseId, replay (resumed-history backfill), and for user messages synthetic (tool results), attachments, and origin (absent for the human; { kind: 'peer', sessionId, name?, engine?, hops? } when another session sent it through peers_send). The last is deliberately a list of references (MessageAttachment: id, name, media type, size) and never the bytes: this log is replayed to every attaching client and captured into parking snapshots, so an inlined photo would be paid for on every attach, forever. Fetch GET /v1/sessions/:id/attachments/:id to render one. |
stream_delta |
Raw Anthropic streaming event; emitted only with includePartialMessages. |
turn_result |
End of a turn: subtype, isError, durationMs, numTurns, totalCostUsd (both session-cumulative), result text, per-turn usage. |
permission_requested / permission_resolved |
The pending-approval flow - see Permissions. |
execution_dispatched / execution_result / execution_failed |
Tool-execution lifecycle, correlated by executionId. deferred: true on dispatch means the execution may outlive the runner (the session parks); expiresAt is when the host’s watchdog fires. A failure is fed back as tool output, not a session error. |
skills |
The skills this session can reach (SkillInfo[]), listed after init. Drives the / composer completion and the skills panel. |
tool_titles |
Human-readable labels for tool calls, keyed by wire name: what the host declared and what each MCP server calls its own tools. Built-in capability names resolve client-side; a tool with no title anywhere keeps its wire name. |
file_delivered |
The agent handed over a file from its scratch filesystem (deliver_file); download it under GET /sessions/:id/files/<path>. |
file_produced |
The engine wrote a file on the host filesystem and reported the path - codex’s image_gen saving a PNG is the case that motivated it. The host-filesystem sibling of file_delivered. Carries an opaque fileId, the absolute path as the engine reported it, and a media type where the runner could determine one; fetch it at GET /sessions/:id/produced/:fileId. This event is the allowlist for that route - nothing else can add a path to it. |
sdk_event |
Forward-compatible passthrough for any SDK message this protocol version doesn’t model first-class (task progress, compaction boundaries, auth status, …). |
conversation_reset |
The conversation was cleared in place - same session id, same watermarks, empty context - carrying the engine session id the fresh conversation runs under when the engine reported one. Its replay rules are load-bearing: a re-attach skips transcript content strictly below the latest reset while every state-bearing event still replays, so a client cannot resurrect a cleared conversation but also does not come back with no model list and no cwd. activityCount stays monotonic across it on purpose - it is an unread cursor, not an item count. |
session_error / session_closed |
Terminal errors and closure (`reason: ‘client’ |
Commands (client → server)
SessionCommand variants: user_message (text, plus optional attachmentIds naming files
uploaded ahead of it), permission_decision (requestId,
behavior: 'allow' | 'deny', allow-only updatedInput, deny-only message/interrupt),
interrupt, set_permission_mode, set_model (omit model for the default),
clear_context, close, and the two answers to a bridged tool call - tool_call_result /
tool_call_error, each carrying the executionId it is answering.
clear_context resets the conversation in place and is answered with a conversation_reset
event. Send it only where EngineCapabilities.clearContext says so (absent = false, so an older
gateway hides the control rather than offering one that errors). Sent while a turn is running it
queues behind that turn rather than cutting it short - a clear is not an interrupt; interrupt
first if the intent was to stop the work as well as forget it. Each engine reaches the same state
its own way: Claude sends the /clear its CLI already lists, codex starts a fresh thread (the old
one is not deleted - it stays resumable from the sessions picker), and the provider engine drops
its in-process message array.
WebSocket framing: the server sends attached (protocol version + SessionInfo snapshot +
replayingFrom), then event frames, with protocol_error for bad input; the client sends
bare SessionCommands. Two further server frames belong to the browser tool bridge:
tool_call_request (execute this tool in your own sandbox - toolName, input, an optional
vfsSeed and limits, and an expiresAt after which the server gives up) and
tool_call_canceled (abandon it; the turn was interrupted, it timed out, or the session closed).
Only sandbox-benefiting tools are ever bridged - authoritative ones (MCP, secret-bearing APIs)
execute server-side and never appear on this channel.
An attach is full control, not a read level: a client holding one can send user_message,
permission_decision, interrupt and close. Anything that decides who may watch a session
must decide it before the attach, not after.
REST shapes
CreateSessionRequest-profile(which engine and config this runs as),cwd,prompt,permissionMode,allowDangerouslySkipPermissions,allowedTools/disallowedTools,mcpServers(McpServerConfigWire: stdio/http/sse),settingSources,model,reasoningEffort,maxTurns,maxBudgetUsd,resume/forkSession,capabilities,includePartialMessages,approvalTimeoutMs,questionBehavior,meta,scope.cwdis optional, not required: an engine whose capability record clearshostCwd(the provider engine) has no host directory to name, and for itallowedCwdRootsis not the boundary. The gateway rejects fields the chosen engine’s capability record forswears rather than accepting them into a silent no-op.SessionInfo- server id (≠sdkSessionId), status, cwd,profile,engineand itscapabilitiesrecord, model, permission mode,canBypassPermissions(fixed at creation: the CLI refuses to switch into bypass unless the process was spawned for it, so a picker can disable the mode rather than offer a refusal),apiKeySource,lastSeq,pendingPermissionCount,title, cumulativetotalCostUsd/numTurns,activityCount,lastActivityAt,scope,subagents, andproject- the.workerdeck.jsonname/root/icon the gateway resolved by an ancestor walk from the session’s realpath’d cwd, since no client can see that filesystem. Its icon is an address (a glyph name, or a media type plus a content hash whose bytes come fromGET /sessions/:id/project/icon), never bytes: this record rides every row of a 1.2s poll. Absent means no project; see the Project identity guide.UpdateSessionRequest- thePATCHbody behind renaming.titleis three-state: a string setsmeta.title, an explicitnullclears it so the derived title (the first prompt) comes back, and omitting it leaves the name alone. A rename never reaches the model.
Session scope
scope is a bag of opaque string tags, assigned at create and immutable after. It is the only
intra-deployment scoping primitive in the protocol, and the split is deliberate: WorkerDeck
stores and enforces the tags, and the embedder’s authorizeSession(principal, session) decides
what they mean - { user, space } is one app’s vocabulary and the next has tenants. A principal
that fails the check gets 404, never 403: a 403 confirms the session exists.
JobInfo.scope carries the same tags through the queue. See
Embed WorkerDeck in your app for the policy half.
Capability records
EngineCapabilities is what a client renders from, and ENGINE_CAPABILITIES pins one record per
engine (claude, codex, provider) as a runtime constant, so an affordance an engine lacks is
hidden rather than shown as a control that silently does nothing. It is also the gateway’s
request filter: a create request naming something the record forswears is a 400. Every
SessionInfo carries its session’s record, and the server serves each profile’s record, its
static model catalog and its credential-availability verdict from the first request - a real
model picker with no warm-up session needed.
ResolvePermissionRequest- the REST counterpart of thepermission_decisioncommand.SubmitExecutionResultRequest/SubmitExecutionResultResponse- a deferred executor delivering its outcome ({ status: 'ok', output } | { status: 'failed', reason, error }). Applied idempotently byexecutionId; the response’sapplied: falsemeans it was already settled.ListSessionFilesResponse/SessionFileInfo- the session’s deliverables.SdkSessionSummary- the Agent SDK’s on-disk sessions, listed to offer resume.- Response wrappers:
ListSessionsResponse,CreateSessionResponse,GetSessionResponse,ResolvePermissionResponse,ListSdkSessionsResponse,ErrorResponse. SessionNotification/SessionNotificationType/SessionWebhookConfig- the out-of-band channel for a person who isn’t attached (permission_requested,turn_completed,session_error,session_closed), carrying theSessionInfosnapshot, the event’sseq, a one-linepreview, and - on a permission - the full request to answer over REST. See Notifications.
The shared view models
Three rules ship here as code rather than as prose each client re-implements, because a client that computed them differently would announce work it is hiding - or hide work it is announcing.
transcriptActivity(event) is the row-count rule: how many transcript rows an event is worth.
The React reducer renders by it and the runners count with it (SessionInfo.activityCount).
Change one, change both.
session-list.ts is the sessions-list view model. Sessions bucket into four states -
attention, working, idle, ended (sessionState) - and the list is filtered, grouped and
sorted along three facets: gateway, adapter and state. filterRows / groupRows /
subsetSummary / clearFilters are the operations, ViewConfig is the persisted shape, and
subsetSummary is what lets a client say “12 of 30” and name the cause instead of silently
showing a short list. Workspace scope has one non-obvious containment rule: a scope root tagged
with a gateway scopes only that gateway, and an untagged one scopes only loopback -
because a remote gateway’s identical-looking path is another machine’s directory.
watermarks.ts is the unread model: monotonic marks behind a WatermarkStore seam (so a
host supplies localStorage, VS Code’s globalState, or UserDefaults), and unseenCount’s
arithmetic in rows, not turns - five tool calls in one turn is one turn but many rows, and
lastSeq counts every stream delta. Monotonic matters: without it a context compaction
resurrects rows the reader already read.
The dashboard, the VS Code extension and the iOS app all render from these; the Swift mirrors are
SessionList.swift and Watermarks.swift in WorkerDeckKit.
Queue frames
Used when the server mounts the @workerdeck/queue
routes: CreateJobRequest / JobInfo (with JobStatus, JobUsage, JobResult) /
JobEvent (job_submitted, job_started, job_progress + JobProgress, job_parked,
job_resumed, job_retrying, job_completed) / QueueStats, and the QueueServerFrame union for the one-way queue
WebSocket (queue_attached, job_event, queue_stats). Details in
Job queue.
Forward compatibility
Deliberate patterns so older clients keep working as the SDK grows:
- Unknown content blocks fall back to
UnknownBlock({ type: string, … }). - Unions the SDK may grow (
apiKeySource, rate-limitstatus/rateLimitType) staystring. - Unmodeled SDK messages ride through as
sdk_eventrather than breaking older clients - the rule is to promote what UIs need to first-class events instead of parsing payloads client-side.