Shard — Wire Protocol #
The contract between every client (web, orchestrator, SDK) and the daemon/agent. Defined once here, carried over three transports. Every message is plain, serializable JSON data — no functions, no class instances.
This is the linchpin reference. If a behavior isn't defined here, it isn't part of the
protocol. Cross-references to ARCHITECTURE.md use §N notation.
Status: v0.1 — protocol version 0.1.0. Expect churn until the first end-to-end agent
runs (build-order step 7). After that, changes are versioned per §7.
1. Scope & Design #
The protocol has two layers, each a closed message set:
| Layer | Between | Purpose |
|---|---|---|
| Per-agent | one client ↔ one agent process | drive a single agent: prompt, stream, permissions, UI, session ops |
| Orchestrator | one client ↔ daemon registry | lifecycle: list, create, kill, attach, status |
The daemon is a proxy + multiplexer: on a WebSocket connection it routes per-agent
messages by agent_id and handles orchestrator messages itself. Direct connections
(stdio, dedicated unix socket to one agent) carry only per-agent messages and omit
agent_id (the connection is bound to one agent).
Design rules #
- Wire-safe. Every message is JSON-serializable data. Errors carry
{code, message}, neverErrorobjects. - One envelope. All messages share the shape in §5. No special-cased frames.
- Tagged multiplexing. On a daemon connection, per-agent messages carry
agent_id; orchestrator messages do not. On a direct connection,agent_idis omitted. - Symmetric directions. Commands and events use the same envelope; the difference is direction and correlation (§5), not structure.
- Events are idempotent snapshots where it matters. Streaming deltas are for live
display;
message_complete/tool_resultreconcile state so a reconnecting client can resync without replaying every delta.
2. Transports & Framing #
| Transport | Framing | Auth | Used by |
|---|---|---|---|
| stdio | JSONL, LF-delimited | implicit (local trust) | headless agent, subagent↔parent, SDK subprocess |
| unix socket | JSONL, LF-delimited | implicit (filesystem perms on ~/.shard/daemon.sock) |
short-lived job clients ↔ daemon |
| WebSocket | one JSON message per WS text frame | token in upgrade handshake (§4) | web client, orchestrator |
2.1 JSONL rule (stdio, unix socket) #
Split on \n only. Do not use Node readline — it splits on U+2028/U+2029, which are
valid inside JSON strings. Strip a single trailing \r if present (tolerate CRLF input).
One JSON object per line.
2.2 WebSocket rule #
One JSON message per WS text frame — the frame boundary is the record boundary, so no newline delimiting is needed. Binary frames are unused in v0.1. Keepalive via WS ping/pong (server pings every 30s; client must respond pong or be considered dead). Clients reconnect with exponential backoff (1s, 2s, 4s, …, capped 30s) and re-attach to agents (§6.4).
2.3 Encoding #
UTF-8 throughout. Messages may be large (tool results, file reads); there is no hard size
limit, but agents should avoid streaming multi-MB blobs inline — prefer truncation with a
recall/expand affordance, mirroring the recall tool's pagination model (ARCHITECTURE §12).
3. Connection Lifecycle #
sequenceDiagram
participant C as Client
participant S as Server daemon or agent
C->>S: open transport with token WS only
S->>C: hello event protocol version and capabilities
C->>S: agent.attach one or many daemon path only
S->>C: streamed events tagged with agent_id
C->>S: commands tagged with agent_id
Note over C,S: client may detach or drop
S->>C: events continue until detach or agent exits
- Open. WS upgrade carries the token (§4); stdio/socket connections open with implicit local trust.
hello. The server emitshelloimmediately on connect (§9.1). The client checks the protocol version; on mismatch it may disconnect or attempt negotiation (§7).- Attach (daemon path only). The client sends
agent.attachfor each agent it wants to watch. Direct connections are already bound to one agent — no attach needed. - Stream. Events flow, tagged with
agent_idon the daemon path. Commands flow back, also tagged. - Detach / drop.
agent.detachstops a stream; closing the connection detaches from all. Detach does not immediately stop active work. Inactive Runtime generations are automatically reaped after the daemon's idle-runtime timeout and restored on demand. - Terminate. A client that owns the agent lifecycle sends
agent.terminateon clean exit. An inactive agent stops immediately; an active agent finishes its current work and stops when it next becomes inactive.
4. Authentication & Authorization #
4.1 WebSocket handshake (daemon) #
- Browser authentication establishes a principal and session using the owner-only,
versioned durable security store under
SHARD_HOME(default~/.shard). It persists only credential hashes and metadata; raw credentials and tokens are never persisted or logged. - The session is represented by a
__Host-Secure, HttpOnly, SameSite=Strict cookie. - The client obtains a short-lived, single-use WS ticket. The ticket is bound to the principal,
session, and exact serialized
Origin; it is consumed during the upgrade and cannot be replayed or used from another origin. - The daemon rejects an upgrade with
401for a missing, expired, mismatched, or replayed ticket, and with400if theOriginheader is absent or not inallowed_origins. - Host validation is exact (including the port) and accepts only the configured loopback Host; DNS-rebinding aliases, suffix matches, and alternate loopback spellings are rejected.
4.1.1 Loopback mode (default, unchanged) #
With no remote flags set the daemon binds 127.0.0.1:<port>, expectedHost is
127.0.0.1:<port>, the implicit http://127.0.0.1:<port> origin is the only allowlist entry,
no forwarded header is honored, the session cookie is HttpOnly; SameSite=Strict without
Secure, and the bullet list above describes the full behavior. This mode is byte-identical to
the original hug1mc web client and is regression-guarded by the existing web/security tests.
4.1.2 Remote mode (saga vr7kgx.9) #
Remote mode lets a browser on a different machine reach the web client. It is opt-in,
selected by shard daemon flags, and validated loudly at startup — the daemon never silently
degrades into an unsafe remote configuration. There are three configurations:
| Config | Bind | Transport | Trusted proxy | Required flags |
|---|---|---|---|---|
| Loopback (default) | 127.0.0.1 |
plaintext, loopback-only | none | (none) |
| Direct non-loopback | <ip> (non-loopback) |
plaintext, network-facing | none | --web-host <ip>, --public-host <host:port>, --allowed-origin <origin>, --allow-insecure-remote |
| Trusted reverse proxy | 127.0.0.1 (or a configured <ip>) |
TLS at the proxy; plaintext to the daemon | --trust-proxy <upstream> |
--trust-proxy <upstream>, --public-host <host:port>, --allowed-origin <origin> (plus --allow-insecure-remote iff the proxy fronts plaintext) |
Trusted-proxy semantics (D2). --trust-proxy is opt-in and disabled by default. When set,
forwarded headers are honored only when the request's direct peer matches the configured
upstream (loopback = 127.0.0.1/::1, or an exact IP literal). From any other peer the
forwarded headers are ignored and the request is treated as direct.
X-Forwarded-Protois honored only from the configured upstream; the effective scheme ishttpsiff that header ishttps(case-insensitive), otherwisehttp.X-Forwarded-Hostis cross-checked against the pinned--public-hostand the request is rejected (400) on mismatch — it is never used to deriveexpectedHost. The pinnedpublicHostis always the effective host. This closes the spoofing hole even when the peer is trusted: a compromised proxy that forwards the wrong host is rejected rather than silently re-trusted.X-Forwarded-Foris never trusted for any security decision; the peer address is the trust anchor. (The consumed header names default toX-Forwarded-Proto/X-Forwarded-Hostand are configurable for deployments that rename them.)
publicHost is always required in remote mode (D3). Whenever --web-host is non-loopback
or --trust-proxy is set, both --public-host <host:port> and --allowed-origin <origin>
are mandatory and the startup check fails loudly if either is missing. expectedHost is the
pinned publicHost; there is no X-Forwarded-Host derivation path. The implicit
http://127.0.0.1:<port> origin is added to the allowlist only in pure loopback mode, so a
remote request that arrives without honored forwarded headers is rejected rather than
silently falling back to loopback trust.
--allow-insecure-remote opt-in and cookie-exposure risk (decisions 4–5). Any non-https
remote operation requires an explicit --allow-insecure-remote flag. Setting --web-host to a
non-loopback address alone does not imply consent — the daemon refuses to start until the
flag is also set, making the cookie-exposure risk a deliberate, acknowledged choice. When the
flag is set the daemon logs a one-line plaintext-cookie-exposure warning at startup. The
operator is responsible for confining non-TLS remote to a trusted network, VPN, or
SSH-forwarded path; on an untrusted network the session cookie is knowingly interceptable.
Secure / __Host- cookie enforcement on the effective scheme (D4). The session cookie is
issued in its Secure, __Host- form iff the issuing request's effective scheme is https;
on an http effective scheme (loopback, or remote under --allow-insecure-remote) it is
non-Secure. A Secure-form cookie arriving over a request whose effective scheme is not
https is rejected at /bootstrap (no ticket issued), and enrollment refuses to set a
Secure cookie over plaintext. On the --allow-insecure-remote http path the cookie is
non-Secure by design, so a misconfigured proxy cannot mint Secure cookies for a hijacked
plaintext session.
hug1mc HTTPS-required invariant — amendment. The hug1mc clause "any future remote mode
must require HTTPS and Secure cookies" is relaxed to permit an explicit, two-step opt-in
non-TLS remote mode (--web-host <non-loopback> --allow-insecure-remote) for use on trusted
networks, VPNs, or SSH-forwarded setups. All other hug1mc invariants stand unchanged
(per-browser enrollment, the Sec-WebSocket-Protocol ticket, exact Origin matching,
frame-ancestors 'none', and selected-agent-only interactive attachment).
For concrete deployment recipes (Caddy, nginx, direct non-loopback) see the Web deployment
cookbook at WEB-DEPLOYMENT.md. See ARCHITECTURE §14.3 for the architecture-level threat
model and trust-anchor summary.
4.2 stdio / unix socket #
No in-band auth — local trust. The unix socket's filesystem permissions (0600, owned by
the user) are the access control. stdio inherits the spawning process's privileges.
4.3 Authorization (v0.1: single trust level) #
Any authenticated client may create/kill agents, send prompts, and answer permission/UI
requests for any agent. This is acceptable only because the daemon is loopback-bound
(ARCHITECTURE §14.1). The PermissionGate still governs every tool call (ARCHITECTURE
§4.3) — a client can make the agent ask, but writes still route through the gate, headless
policy, and existing allow_always grants. Per-agent ACLs are a future item gated on remote
mode.
5. Message Envelope & Correlation #
Every message — command or event — shares this shape:
interface Envelope {
/** Optional correlation id. If set on a command, the response carries the same id. */
id?: string;
/** Message type, e.g. "prompt", "tool_call", "agent.list", "response". */
type: string;
/** On daemon connections, identifies the target agent for per-agent messages. */
agent_id?: string;
/** Message payload — type-specific fields. */
[key: string]: unknown;
}
5.1 Two correlation modes #
| Mode | When | How |
|---|---|---|
| Request/response | Commands needing a direct reply (agent.list, agent.create, session_op, set_mode, list_modes, abort, list_models, set_model) |
Client sets id; server replies with a response message carrying the same id and command (§9.3). |
| Fire-and-forget / streamed | Commands whose real outcome arrives via events (prompt, steer, permission_response, ui_response) |
Client may set id; server replies with a response indicating acceptance (success: true = accepted/queued). The actual result streams as events. |
Events (tool_call, status, etc.) never carry a correlation id — they are not responses.
The exception is permission_resolved / ui_resolved, which carry request_id to close
the loop on a broadcast request (§10).
5.2 agent_id rule #
- Daemon connection: every per-agent command and event carries
agent_id. Orchestrator messages (agent.list,agent.spawned, …) do not — they're about the registry. Subagent job commands (subagent.create/wait/steer/relaunch/status/stop, §10.3) are also orchestrator-level: they carry aparent_agent_idpayload field (the owning durable parent) and no routingagent_id. - Direct connection (stdio / dedicated socket to one agent):
agent_idis omitted. The connection is bound to exactly one agent; there's nothing to multiplex.
A client that speaks to the daemon can be attached to many agents simultaneously; it demuxes
inbound events by agent_id.
6. Subscriptions & Multi-Client #
6.1 Attach model (replaces a standalone subscribe command) #
Subscription is implicit in agent.attach (daemon path) or in connection-open (direct
path). There is no separate subscribe message — this collapses a concept. The set of
agents a daemon client watches is exactly the set it has agent.attached and not yet
agent.detached.
6.2 Fan-out #
The daemon maintains, per agent, the set of attached clients. Every event the agent emits is
fanned out to all attached clients, tagged with agent_id. This is what makes the
multi-client model work: two clients watching the same agent see the same stream.
6.3 Broadcast requests, first-response-wins #
permission_request and ui_request are broadcast to all attached clients. The first
permission_response / ui_response the daemon accepts wins; late responses are
discarded silently (not errored). The agent then emits permission_resolved /
ui_resolved to all clients so they can dismiss pending dialogs (ARCHITECTURE §6).
For a delegated child with no interactive responder of its own, the daemon routes the pending
permission request to the nearest interactive ancestor while retaining the child's agent_id;
the response therefore resolves the child request. Delegation is preserved across ancestor
reconnect/sync. Observer attachments and internal subscribers do not count as responders. If
no interactive responder exists anywhere in the hierarchy, the daemon's headless compatibility
path supplies permission_response { decision: "deny", source: "headless" } for permission
requests (and ui_response { value: null } for UI requests).
sequenceDiagram
participant A as Agent
participant D as Daemon
participant C1 as Client 1
participant C2 as Client 2
A->>D: permission_request request_id req-7
D->>C1: permission_request req-7
D->>C2: permission_request req-7
C2->>D: permission_response req-7 allow_always
D->>A: forward first response drop late
A->>D: permission_resolved req-7 allow_always
D->>C1: permission_resolved req-7
D->>C2: permission_resolved req-7
Note over C1,C2: both dismiss their dialogs
6.4 Reconnect & resync #
agent.attach resolves durable identity, coalesces startup of a Runtime generation, then
keeps the connection reserved while it obtains authoritative sync and drains buffered live
Events. The order is always sync first, followed by each buffered event exactly once; only
then is the connection promoted to attached. NOT_FOUND only means the durable Agent/session is
absent; startup/readiness failures use RUNTIME_START_FAILED and retain identity for retry.
Runtime stop/exit, agent.terminate, and agent.kill affect only the Runtime generation and
retain session JSONL. The daemon also reaps Runtime generations after the configured period
in which the agent is inactive (idle or terminal error). Active states (streaming,
waiting_for_permission, waiting_for_ui, compacting) never expire. New work or attach
restores a stopped Runtime from durable identity.
On reconnect, a client:
- Waits for
hello, validates version. - Re-sends
agent.attachfor each agent it was watching. The daemon re-adds it to the fan-out set. - Receives a
syncevent per agent (§9.2) carrying the agent's current status and active mode, the live message window (post-compaction), and any pendingpermission_request/ui_requestit should re-show. Pending requests—including delegated child requests surfaced through an ancestor—survive the disconnect (ARCHITECTURE §6.2–§6.3); they stay open until resolved or the headless timeout fires.
This means a client can drop and reattach without losing the thread of an in-flight permission prompt.
7. Versioning & Capability Negotiation #
The hello event (§9.1) carries:
{ type: "hello", protocol_version: "0.1.0", server_version: "<shard build>",
capabilities: string[] }
protocol_versionis semver. v0.1 may break freely until the first end-to-end agent. After that, breaking changes bump the major; additive changes bump minor/patch.capabilitiesis informational in v0.1 — a list of feature strings like["streaming-deltas", "model-catalog", "model-switch", "mode-catalog", "mode-switch", "ui:select", "ui:editor"]. Servers advertisemode-catalogwhenlist_modesis available (always — every runtime has aModeController) andmode-switchwhenset_modeis available;model-catalogwhenlist_modelsis available andmodel-switchwhenset_modelis available (the latter two are conditional on a model controller being wired). Clients must not infer any of these capabilities from the protocol version alone. Note the two capability scopes (saga vr7kgx.6): the connection-levelhello.capabilitiesis transport-scoped (the daemon advertiseswebsocket/streaming-deltas, not per-runtime model support); an attached runtime's exact capabilities arrive insync.capabilities(§9.1), projected from that child runtime's ownhello. Gate controls on thesyncstring, not the top-levelhello.- A client that doesn't understand the major version disconnects. There is no in-band down-negotiation in v0.1; pick a compatible client/daemon pair.
8. Error Model #
Two error surfaces:
| Surface | When | Shape |
|---|---|---|
| Command error | A request/response command fails synchronously | response with success: false and error: { code, message, data? } (§9.3) |
| Async error | Something fails mid-turn (provider outage, tool crash) | error event with { code, message, fatal? } (§9.4) |
Error codes (extensible registry) #
| Code | Meaning |
|---|---|
INVALID_REQUEST |
Malformed message, bad JSON, failed validation |
NOT_FOUND |
No durable Agent/session identity exists |
NOT_ATTACHED |
Per-agent command sent on a daemon connection without a valid agent_id attachment |
PERMISSION_DENIED |
The server refused the operation (rare in v0.1 — the gate runs in-agent) |
PROTOCOL_ERROR |
Version mismatch, message out of sequence |
TIMEOUT |
An ask hit the headless timeout (ARCHITECTURE §6.2) |
RUNTIME_START_FAILED |
Durable Agent exists but its Runtime generation failed to start or become ready; retry is safe |
SESSION_CORRUPT |
A selected session exists but its metadata/transcript cannot be safely restored |
INTERNAL_ERROR |
Unexpected server-side failure |
PROVIDER_ERROR |
The LLM provider call failed (mid-turn) |
TOOL_ERROR |
A tool returned an error (also surfaces as tool_result with is_error) |
CONFLICT |
The operation conflicts with current runtime/session state (e.g. session_op: compact while a turn is queued or in flight) |
9. Per-Agent Protocol — Message Catalog #
All commands accept an optional
idfor correlation. Events never setid.
9.1 Lifecycle events #
hello (event, first on connect)
{ type: "hello", protocol_version: "0.1.0", server_version: string, capabilities: string[] }
sync (agent bootstrap event, sent after agent.attach or immediately after direct hello)
{ type: "sync", agent_id?: string, status: AgentStatus, mode: string,
capabilities?: string[], runtime_epoch?: number,
live_window: { messages: Message[], compaction_index?: number },
pending_requests: (PermissionRequest | UiRequest)[] }
mode is the agent's authoritative active mode. agent_id is present on daemon connections;
direct connections have one implicit agent and omit it.
capabilities is the attached runtime's secret-free feature set (saga vr7kgx.6):
["streaming-deltas", "mode-catalog", "mode-switch", ...] plus, when a model controller is
wired, ["model-catalog", "model-switch"]. Current servers always emit it; the field is
optional so an older or malformed runtime degrades to no controls rather than forcing a
client to infer support from the protocol version (§7). On a daemon connection the daemon
projects it from the child runtime's own hello.capabilities into this daemon-authored
sync — the top-level daemon hello (above) stays transport-scoped (websocket, etc.) and
does not advertise per-runtime model support globally. On a direct connection sync
mirrors the child's own hello.capabilities. Clients gate model/mode controls on these
strings, never on the protocol version.
runtime_epoch is a daemon-authored, non-negative child-runtime generation token. It starts
at 0 and increments when a replacement process takes over the same agent_id. After the
replacement reaches hello + sync readiness, the daemon emits a fresh authoritative
sync to existing attachments with the new epoch and capabilities. Clients use the tuple
{ connection generation, agent_id, runtime_epoch } to discard catalogs and responses from
the superseded process. The field is optional for compatibility and is normally omitted on
direct connections.
Gives a reconnecting client enough to render the current state without replaying history.
status (event)
{ type: "status", agent_id: string, status: AgentStatus, detail?: string }
type AgentStatus =
| "idle" | "streaming" | "waiting_for_permission" | "waiting_for_ui"
| "compacting" | "error";
9.2 Drive commands #
prompt (command, streamed) — send a user message.
{ type: "prompt", agent_id?: string, message: string, images?: Image[],
streaming_behavior?: "steer" | "follow_up" }
streaming_behavioris required if the agent is mid-turn; omit when idle."steer"— deliver after the current turn's tool calls, before the next model call."follow_up"— wait until the agent fully stops, then deliver.
- Returns
response { success }meaning accepted/queued. The turn plays out via events.
steer (command, streamed) — shorthand for prompt with streaming_behavior: "steer".
{ type: "steer", agent_id?: string, message: string, images?: Image[] }
abort (command, request/response) — cancel the current turn.
{ type: "abort", agent_id?: string }
// → response { success: true }
set_mode (command, request/response) — switch mode (plan/build/…).
{ type: "set_mode", agent_id?: string, mode: string }
// → response { success: true, data: { previous_mode: string, mode: string } }
An idempotent set (same mode) is acknowledged by its response without a broadcast. A
real change emits the mode_changed event (§10.2) so every attached client updates without
polling; the event is emitted before/alongside the successful response. An unknown mode
returns a NOT_FOUND command error and preserves the old mode (no event).
list_modes (command, request/response, saga vr7kgx.6) — return the secret-free mode
catalog and the session's authoritative active mode. The mode controller is mandatory, so
this is always available on a live runtime (unlike list_models). It is read-only and
never restores/wakes a stopped runtime on the daemon path: it is only routed when a live
stream exists, and the daemon does not revive a runtime for list_modes.
{ type: "list_modes", agent_id?: string }
// → response { success: true, data: { modes: ModeDescriptor[], active: string } }
modes lists every registered built-in and extension mode in registration order as a
secret-free { name, description } descriptor (mode functions/policy hooks never cross the
wire); built-in and extension modes are described identically. active is the current mode's
name and MUST occur in modes. Clients build mode options exclusively from this catalog —
there is no free-form/custom mode entry — and the runtime performs final validation for races.
list_models (command, request/response) — return the secret-free model catalog and the session's authoritative active selection.
{ type: "list_models", agent_id?: string }
// → response { success: true, data: { models: ModelDescriptor[], active: ModelDescriptor } }
set_model (command, request/response) — queue a catalog selection for the session.
{ type: "set_model", agent_id?: string, model: string }
// → response { success: true, data: {
// previous: ModelDescriptor, active: ModelDescriptor,
// effective: "next_provider_call" } }
A valid selection is acknowledged immediately and emits the model_changed event below.
It never interrupts an in-flight provider stream: the pending provider is applied at the
next provider-call boundary (including the next iteration after tool execution), and remains
the active model for subsequent calls. Unknown selections or provider-construction failures
return a NOT_FOUND command error and do not change the active model.
Reasoning level controls (request/response) — available only when the active model
advertises reasoning-catalog and reasoning-switch. Clients must use the server-authored
catalog and must not hard-code effort names.
{ type: "list_reasoning_levels", id: string, agent_id?: string }
// → { model: ModelDescriptor, levels: { id: string, label: string, description?: string }[],
// active: string, effective: "next_provider_call" }
{ type: "set_reasoning_level", id: string, agent_id?: string,
model: string, effort: string }
// → { model: ModelDescriptor, previous: string, active: string,
// effective: "next_provider_call" }
model is the active descriptor ID and acts as an optimistic-concurrency precondition; a
stale picker returns CONFLICT. Unknown efforts return NOT_FOUND. Listing never wakes a
stopped runtime; setting follows set_model restoration behavior. A genuine change emits
reasoning_level_changed with { model, previous, active, effective }; an idempotent set
emits no event. Changes are session-scoped, do not interrupt a stream, and apply at the next
provider-call boundary.
session_op (command, request/response) — compact / fork / list.
{ type: "session_op", agent_id?: string,
op: "list" | "compact" | "fork",
args?: SessionOpArgs }
// → response { success: true, data: SessionOpResult }
SessionOpArgs/Result are op-specific. compact and fork are fully specified below.
branch/rewind were removed from the protocol before implementation (saga 3aliod):
fork-to-new-session is the single session mutation primitive — an in-place fork is
redundant with rewind, and rewind creates history the model cannot see and has no
impetus to recall.
session_op: compact #
Fire compaction on demand (saga fijex6). Manual compaction runs the same deterministic
pipeline as automatic compaction but bypasses both the auto usage threshold and
compaction.enabled — enabled:false disables only automatic triggering; the
compactor/trigger/config seams must still be wired. Compaction is a between-turn
operation at the orchestration level, so it is rejected with CONFLICT while a turn
is queued or in flight (compaction is never queued).
// client → server
{ type: "session_op", op: "compact",
args?: { keep_recent_tokens?: number } } // override config.keepRecentTokens for this call
// server → client (success — a CompactionEntry was appended + a compaction event emitted)
{ type: "response", success: true,
data: { compacted: true, index, tokens_before, tokens_after,
first_kept_entry_id, message_range: [string, string] } }
// server → client (successful no-op — empty/too-small window, no safe cut boundary)
{ type: "response", success: true,
data: { compacted: false, reason: "no_safe_cut" } }
Lifecycle: the server emits a status {status:"compacting"} immediately before running
the pipeline, then (on success) the existing compaction event, then the correlated
response, then status {status:"idle"} in finally — so direct and daemon-attached
clients observe a real running interval and never see a stuck compacting indicator, including
on no-op and error outcomes.
Error mapping:
| Outcome | Response |
|---|---|
keep_recent_tokens is present but not a positive integer |
INVALID_REQUEST |
| Turn work queued or in flight | CONFLICT |
| Compactor/trigger/config seams not wired (not configured) | NOT_FOUND |
| Compactor threw or produced an empty summary (best-effort; no append/event) | INTERNAL_ERROR |
session_op: fork #
Split the conversation at a past entry into a new session (saga 3aliod). The live
window at from_entry_id is materialized into a fresh session JSONL under a forks/
subdirectory of the source session's directory: a fork-marker checkpoint row carrying
provenance (artifactKind: "fork" + { forkId, sourceSessionId, sourceEntryId }) roots
the new chain, followed by copies of the window's message/tool_result rows with fresh
ids. The source session is never mutated — no appends, no head move. To start talking
on the fork, a session-first client refreshes session.list, selects the returned
session_id, then uses session.activate to adopt/start the forked session. Legacy
administrative clients may still use agent.create with the returned opaque id.
// client → server
{ type: "session_op", op: "fork",
args: { from_entry_id: string } } // the entry to fork at (must exist in this session)
// server → client (success)
{ type: "response", success: true,
data: { fork_id: string, session_id: string, head_entry_id: string } }
Error mapping:
| Outcome | Response |
|---|---|
from_entry_id missing or not a non-empty string |
INVALID_REQUEST |
| Turn work queued or in flight (between-turn operation) | CONFLICT |
| No entry with that id in this session | NOT_FOUND |
| Fork file could not be written (IO) | INTERNAL_ERROR |
9.3 Response envelope #
{ type: "response", id: string, command: string, success: boolean,
data?: unknown, error?: { code: string, message: string, data?: unknown } }
command echoes the command type being responded to. id correlates with the command's
id.
9.4 Streaming & turn events #
turn_start (event)
{ type: "turn_start", agent_id?: string, turn_id: string }
message_delta (event, high-bandwidth) — streaming content block deltas.
{ type: "message_delta", agent_id?: string, message_id: string,
block_index: number,
kind: "text" | "tool_call" | "thinking",
// for text/thinking:
text_delta?: string,
// for tool_call (on first delta of the block):
tool_call_id?: string, name?: string,
args_delta?: string }
- Append
text_deltato blockblock_index. - For a
tool_callblock: the first delta carriestool_call_id+name; subsequent deltas carryargs_delta(a JSON string fragment — concatenate, then parse when the block completes). - Clients render live from deltas; reconcile against
message_complete.
message_complete (event) — final snapshot of a message.
{ type: "message_complete", agent_id?: string, message_id: string, message: Message }
tool_call (event) — a tool is about to execute (after permission, if required).
{ type: "tool_call", agent_id?: string, tool_call_id: string, name: string,
args: Record<string, unknown>, render?: RenderDescriptor,
read_only?: boolean }
render is the start-time RenderDescriptor (ARCHITECTURE §4.5) for clients that want a
rich preview before the result lands.
read_only mirrors the registered tool's isReadOnly (§4.1 Tool contract) — true for
read-only tools (read, grep, find, ls), false for mutating tools (write,
edit, bash). Clients use it for per-tool-class glyph/indent (a read-only read reads
differently at a glance from a mutating write). Absent → treat as mutating (the safer
visual).
tool_result (event) — a tool finished.
{ type: "tool_result", agent_id?: string, tool_call_id: string,
content: ToolResultContent, is_error: boolean,
exit_code?: number, duration_ms?: number, render?: RenderDescriptor }
The server sends tool-result info as data; the client decides how to render it (saga
26haas). exit_code (shells/processes) and duration_ms (wall-clock) are structured fields
the client renders itself — previously bash trapped them inside a command
RenderDescriptor that pre-rendered and hid the output. content is the pure output; the
model still sees the exit code as [exit code: N] text, injected at the history-assembly
seam (the core owns the provider-facing tool-result text format). Both fields are
optional/additive — pre-26haas clients omit them and fall back to the plain ↳ footer.
turn_end (event)
{ type: "turn_end", agent_id?: string, turn_id: string, usage?: Usage }
usage (event) — emitted after each provider call (finer-grained than turn-end).
{ type: "usage", agent_id?: string, input: number, output: number,
cache_read?: number, cache_write?: number, cost?: number,
context_tokens?: number, model?: string }
compaction (event) — a deterministic compaction ran. Fires between turns (the
default tier: context above the reserve threshold, ~80% by default, compacted once the
agent goes idle) and mid-turn at an iteration boundary when the context has reached 100%
of the window (the emergency tier: the next provider call would otherwise overflow).
{ type: "compaction", agent_id?: string, index: number,
tokens_before: number, tokens_after: number,
first_kept_entry_id: string, message_range: [string, string] }
message_range is [firstId, lastId] of the summarized span — what recall uses to map a
compaction index back to the exact message range (ARCHITECTURE §12).
error (event, async)
{ type: "error", agent_id?: string, code: string, message: string, fatal?: boolean }
fatal: true means the agent can't continue (e.g. provider down, session corrupt); the
client should show the error and offer recovery. fatal: false is recoverable mid-turn.
9.5 Permission & UI requests #
permission_request (event, broadcast)
{ type: "permission_request", agent_id?: string, request_id: string,
tool_name: string, input: unknown, prompt: string,
options: ("allow" | "allow_always" | "deny")[],
details?: PermissionDetail[], choices?: PermissionChoice[] }
options is the legacy semantic presentation. details and choices are optional,
server-defined presentation data; clients render them but do not author policy. A detail is
{ label?: string, value: string, format?: "text" | "command" | "path" | "code" }.
A choice is { id: string, label: string, description?: string, accelerator?: string, intent: "allow" | "persist" | "deny" }. Choice IDs are non-empty opaque strings: their
meaning and any persisted rule are trusted server semantics, not a client-side DSL.
permission_response (command, fire-and-forget) — client answers a request. Exactly
one response form is permitted: the structured opaque choice form or the legacy semantic
form; they are mutually exclusive.
{ type: "permission_response", agent_id?: string, request_id: string,
choice_id: string, reason?: string }
// or
{ type: "permission_response", agent_id?: string, request_id: string,
decision: "allow" | "allow_always" | "deny", reason?: string }
A client capable of structured permissions uses choice_id in preference to the legacy
decision form. Clients never submit rules. The legacy allow_always is always-exact: it
means repeat this exact request, not a user-authored or inferred scope. Structured choices
may carry other server-defined persistence semantics through their intent.
Resolution is first-valid-response-wins. The daemon validates a choice_id against the
currently pending request before consuming the winner; an unknown ID is invalid and leaves
the request non-terminal, so another response may win. Direct stdio/gate handling has the
same behavior and ignores unknown IDs while waiting. Once a valid response wins, later
responses are discarded silently (§6.3).
permission_resolved (event, broadcast) — closes the loop for all clients.
{ type: "permission_resolved", agent_id?: string, request_id: string,
decision: "allow" | "allow_always" | "deny", source?: string }
// or
{ type: "permission_resolved", agent_id?: string, request_id: string,
choice_id: string, source?: string }
source optionally identifies which client answered (for UI like "answered from web").
Permission presentation is generic, but runtime policy remains server-side. Backend grant
candidate providers construct the available choices; registered runtime matchers interpret
persisted rule kinds. Unknown or malformed kinds fail closed. The bash_signature kind is a
runtime-grant-only specialized representation, not an authored configuration DSL: version 1
records executable names and an exact sorted flag set, and matches only when the analyzed
command's executable/signature set satisfies those records. Other rule kinds are likewise
interpreted by registered matchers rather than by clients.
ui_request (event, broadcast) — agent asks for a UI dialog.
{ type: "ui_request", agent_id?: string, request_id: string,
kind: "select" | "confirm" | "input" | "editor" | "notify",
title?: string, message?: string,
options?: string[], placeholder?: string, prefill?: string,
notify_type?: "info" | "warning" | "error" }
Fields are kind-specific (e.g. options for select, prefill for input/editor).
ui_response (command, fire-and-forget)
{ type: "ui_response", agent_id?: string, request_id: string,
value: string | string[] | null }
ui_resolved (event, broadcast)
{ type: "ui_resolved", agent_id?: string, request_id: string, value: unknown }
10. Orchestrator Protocol — Message Catalog #
Only on daemon connections. Never carry
agent_id(they're about the registry itself).
10.1 Commands #
agent.list (request/response)
{ type: "agent.list", id: string }
// → response { success: true, data: { agents: AgentInfo[] } }
agent.create (request/response) — spawn a new agent process.
{ type: "agent.create", id: string, cwd: string,
session_id?: string, // opaque legacy session to adopt; daemon resolves ownership/path
model?: string, provider?: string, mode?: string,
tools?: string[], skills?: string[],
headless_policy?: "deny" | "allow_if_rule_matches" | "queue",
name?: string, parent_agent_id?: string,
// Managed-child manifest fields (daemon sets these; clients do not pass them):
system_prompt?: string, // byte-bounded delegated child system prompt
depth?: number, // durable depth (0 for root)
launch_policy?: { maxTasks: number; maxDepth: number },
alias?: string, definition_name?: string, display_name?: string,
names?: string[] // pinned friendly-alias pool for this child's launches
}
// → response { success: true, data: { agent_id: string } }
When session_id is supplied, agent.create is the legacy-adoption operation. The daemon
canonicalizes and ownership-checks cwd, resolves the opaque id, and atomically writes one
Agent manifest before starting the Runtime. It never accepts a filesystem path from the
client and never rewrites the existing JSONL. Repeating adoption returns INVALID_REQUEST
(session is already adopted); missing state returns NOT_FOUND; malformed state returns
SESSION_CORRUPT. A Runtime failure returns RUNTIME_START_FAILED while retaining the
binding for retry. Managed create omits session_id and creates a fresh session id.
parent_agent_id set => this is a subagent; the parent's subagent tool uses this.
agent.attach (request/response) — subscribe to an agent's event stream.
{ type: "agent.attach", id: string, agent_id: string,
role?: "interactive" | "observer" } // default interactive; observers never answer prompts
// → response { success: true, data: { status: AgentStatus, info: AgentInfo } }
On success, the client begins receiving that agent's events (tagged with agent_id),
including a sync event (§9.1) for current state.
agent.detach (request/response)
{ type: "agent.detach", id: string, agent_id: string }
// → response { success: true }
agent.terminate (request/response)
{ type: "agent.terminate", id: string, agent_id: string }
// → response { success: true, data: { deferred: boolean } }
Normal client-owned teardown. deferred:false means the Runtime was already inactive and
shutdown began immediately; deferred:true means active work will finish before shutdown.
agent.kill (request/response)
{ type: "agent.kill", id: string, agent_id: string, force?: boolean }
// → response { success: true }
agent.status (request/response) — poll one or all agents.
{ type: "agent.status", id: string, agent_id?: string }
// → response { success: true, data: { agents: AgentInfo[] } }
Omit agent_id for all agents.
daemon.shutdown (request/response)
{ type: "daemon.shutdown", id: string, force?: boolean }
// → response { success: true }
Graceful by default: signals agents to finish turns, then exits. force kills immediately.
10.1a Workspace commands (protocol v1 additive) #
Workspace commands are daemon-level request/response messages and carry no routing agent_id.
They use opaque workspace_id values; response projections do not expose daemon filesystem paths
or raw repository/worktree paths. version?: 1 is accepted for forward-compatible clients.
{ type: "workspace.create", id: string, version?: 1, cwd: string,
label?: string, branch?: string, base_ref?: string, integration_target?: string,
title?: string, name?: string, model?: string, provider?: string, mode?: string }
{ type: "workspace.attach", id: string, version?: 1, workspace_id: string,
title?: string, name?: string, model?: string, provider?: string, mode?: string }
{ type: "workspace.list", id: string, version?: 1, cwd?: string, include_retired?: boolean }
{ type: "workspace.status", id: string, version?: 1, workspace_id: string }
{ type: "workspace.retire", id: string, version?: 1, workspace_id: string,
branch_disposition?: "delete_if_merged" | "keep" | "delete_confirmed" }
Retirement is explicit and safe by default. delete_if_merged only deletes a branch when the
daemon proves reachability from the recorded integration target; keep retires without branch
deletion. An unproven deletion request returns CONFLICT with typed
{ kind: "confirmation_required", workspace_id, reason, confirmation_disposition: "delete_confirmed" }
and requires an explicit retry using delete_confirmed. Live, busy, dirty, missing-authority,
and unsafe workspaces are refused with typed refusal data.
workspace.create and workspace.attach always launch a fresh daemon-generated Agent and
Session; clients cannot provide either identity. Successful responses include agent_id,
session_id, and workspace detail. Creation is managed-only in this slice. Workspace responses
contain transport-safe summaries/details: opaque id, label, worktree cwd, ownership, lifecycle,
fingerprints, branch/integration metadata, timestamps, counts, and (for detail) redacted binding
ids and tombstone/conflict status. Common-dir and git-dir paths remain omitted. The additive
repository_cwd field carries the ORIGINAL PROJECT's main-checkout root (the shared repository
root, derived from the common-dir — never the common-dir/git-dir itself) so clients can group
sibling worktrees under, and attribute each worktree to, the project it belongs to. It is the
one repository path the projection exposes; older daemons omit the field and older clients
ignore it.
10.2 Events #
model_changed (event, broadcast) — emitted after a valid set_model is accepted. On a daemon connection it carries the routing agent_id; direct connections omit it.
{ type: "model_changed", agent_id?: string,
previous: ModelDescriptor, active: ModelDescriptor,
effective: "next_provider_call", capabilities?: string[] }
The event describes an accepted pending switch; effective explicitly records that the
provider changes only at the next provider-call boundary, never in the middle of a stream.
Newer runtimes include the active model's post-switch capabilities, allowing clients to
reveal or hide model-specific controls immediately. Clients remain compatible with older
runtimes that omit this additive field.
mode_changed (event, broadcast, saga vr7kgx.6) — emitted by a runtime after a
successful set_mode that actually changed the active mode. An idempotent set (same mode)
is acknowledged by its set_mode response without a broadcast. On a daemon connection it
carries the routing agent_id; direct connections omit it. The mode authority is the
runtime's ModeController; the event mirrors the new effective mode to every attached
client so a second browser updates without polling. It is emitted before/alongside the
successful set_mode response; an error preserves the old mode and emits no event.
{ type: "mode_changed", agent_id?: string,
previous_mode: string, mode: string }
Workspace lifecycle events (events, daemon broadcast) — workspace_created,
workspace_attached, workspace_retired, and workspace_conflict carry the corresponding
transport-safe workspace projection. They have no correlation id; workspace_attached also
identifies the new agent_id and session_id, while conflict carries a sanitized reason.
agent_spawned (event)
{ type: "agent_spawned", agent_id: string, info: AgentInfo }
agent_exited (event)
{ type: "agent_exited", agent_id: string, runtime_state: "failed", reason: string,
exit_code?: number }
agent_status_changed (event)
{ type: "agent_status_changed", agent_id: string, status: AgentStatus,
runtime_state: RuntimeState, detail?: string }
agent_activity (event, global sanitized overview)
{ type: "agent_activity", agent_id: string, timestamp: number,
phase: "idle" | "thinking" | "streaming" | "tool" | "waiting" |
"compacting" | "retrying" | "error",
status: AgentStatus, tool_name?: string }
This is broadcast independently of agent.attach and contains only coarse activity. It never
contains prompts, model text, tool arguments/results, paths, descriptors, or transcripts.
Receiving it does not attach, wake, restore, or register a permission responder.
Note: these events do not carry a top-level agent_id field on the message envelope
for routing (they're orchestrator-level), but they do carry agent_id in their payload to
identify which agent changed. This is the one place agent_id is payload, not routing.
10.3 Subagent job commands (background jobs) #
Daemon connections only. These are orchestrator commands: they carry a
parent_agent_idpayload (the owning durable parent) and no routingagent_id(§5.2). Ownership never crosses: a parent may target only jobs it launched and children whose manifest carries itsparent_agent_id. A handle is an opaque string the daemon resolves — a typed id (job-…/run-…/agent-…) or a friendly alias. Resolution is exact typed IDs first, then parent-scoped aliases (case-insensitive NFKC). Task text is private: it appears only insubagent.create/relaunchpayloads and the daemon-private job record — never in response data, events,AgentInfo, logs, or list broadcasts. Full semantics live in ARCHITECTURE §9; this catalog is the wire contract.
subagent.create (request/response) — accept a durable background job. Single form
{ name, task }; batch form { tasks:[{name,task}…], strategy }. The daemon validates the
entire request before side effects, preallocates durable job_id/run_ids + agent_ids
and reserved aliases, commits the job record, and returns immediately — it never waits
for child startup/output/cleanup.
{ type: "subagent.create", id: string, parent_agent_id: string,
name?: string, task?: string, // single form
tasks?: { name: string; task: string }[], // batch form (≥1)
strategy?: "single" | "parallel" | "chain", // default single; required for batch
model?: string, provider?: string, mode?: string } // inherited/override model
// → response { success: true, data: {
// job_id: string, strategy: JobStrategy,
// runs: [{ run_id, index, name, state: "queued", agent_id,
// alias, definition_name?, display_name? }] } }
subagent.wait (request/response) — wait for handles to reach a terminal state.
any returns when any supplied handle is terminal; all (default) when all are. Omitted
timeout waits indefinitely; 0 is a non-blocking poll. Timeout stops waiting only — it
never cancels work. Agent/alias targets snapshot to the current/queued run, else the latest
terminal run, at acceptance (they do not follow a future relaunch). Parent abort/disconnect
cancels only the waiter.
{ type: "subagent.wait", id: string, parent_agent_id: string,
handles: string[], // ≥1 job_id | run_id | agent_id | alias
mode?: "any" | "all", // default all
timeout?: number } // seconds; omitted = forever, 0 = poll
// → response { success: true, data: {
// timed_out: boolean, aggregate_state: JobState | "mixed",
// runs: RunSnapshot[] } } // full ordered all-settled detail; final_text for owned parent
subagent.steer (request/response) — strict active-steer. target is run_id,
agent_id, or alias. Accepted only while the resolved run is actively executing
(including waiting on permission/UI). It drains into the target turn_id at the next
provider boundary (injected); if the turn ends first it resolves conflict and is removed
— it never silently becomes a future prompt and never leaks into the idle queue or a
relaunch. Queued/terminal/unknown/non-owned targets return CONFLICT/NOT_FOUND.
{ type: "subagent.steer", id: string, parent_agent_id: string,
target: string, message: string } // lineage-true user message (private)
// → response { success: true, data: {
// status: "injected" | "conflict", run_id: string, turn_id?: string,
// correlation_id: string } }
subagent.relaunch (request/response) — relaunch a stopped/idle direct child with no
queued/active run on the same durable Agent/session/config/grants/alias; new
job_id/run_id and a new task. Active duplicates conflict. Conversation history is
preserved. target is agent_id, run_id, or alias.
{ type: "subagent.relaunch", id: string, parent_agent_id: string,
target: string, task: string }
// → response { success: true, data: {
// job_id, run_id, agent_id, alias, definition_name?, display_name?, state: "queued" } }
subagent.status (request/response) — authoritative Agent/job/run snapshots.
Omitting targets lists the parent's latest 50 directly-owned jobs, newest first (handle +
alias recovery after a parent Runtime restart).
{ type: "subagent.status", id: string, parent_agent_id: string,
targets?: string[] } // job_id | run_id | agent_id | alias
// → response { success: true, data: {
// agents: [{ agent_id, alias?, definition_name?, display_name?,
// parent_agent_id?, run?: RunSnapshot }],
// jobs?: JobSnapshot[] } } // omitted when targets supplied; private task specs absent
subagent.stop (request/response) — cancel a job or run. A job_id handle cancels
queued + active runs; a run_id/alias handle cancels only that Agent's active/queued run
(parallel siblings continue; stopping the active step of a chain cancels the remaining
chain). Returns after cancellation is accepted (queued → cancelled; active →
abort→graceful→kill escalation). It never deletes durable Agents/sessions/jobs/completed
records.
{ type: "subagent.stop", id: string, parent_agent_id: string,
handle: string } // job_id | run_id | alias
// → response { success: true, data: {
// cancelled: [{ run_id, agent_id?, state: RunState }], job_id?: string } }
subagent_run_changed (event, broadcast) — emitted to the owning parent's
connections after a run-state transition is persisted (so waiters/status views update).
Orchestrator-level: no routing agent_id; parent_agent_id is a payload field. Private
final task text is never included.
{ type: "subagent_run_changed", parent_agent_id: string, job_id: string, run_id: string,
agent_id?: string, alias?: string, definition_name?: string, display_name?: string,
state: RunState, previous_state?: RunState, error?: string, ended_at?: number }
Async flow + completion delivery + lease guard. subagent.create/relaunch/stop
resolve once the durable record is committed / cancellation accepted — never after the run
plays out, which is daemon-owned and asynchronous. After a terminal run transition is
persisted, the daemon injects a structured completion report into the owning parent: steer
when its turn is active, otherwise prompt to restore/wake it. subagent.wait blocks its
socket only and is not required for normal completion delivery. The Broker consults a
SubagentLeaseGuard before forwarding per-agent commands to a leased child: direct prompt
→ CONFLICT (one run at a time); permission/UI responses pass; direct human steer/abort
→ delegated through the coordinator's strict active-turn / cancellation path and recorded
against the run. Direct prompt is therefore blocked while a run is leased; unleased agents
are unaffected.
Restart rules. Terminal job/run records survive daemon and parent restarts (parent
Runtime restart never affects execution). On daemon process restart, formerly nonterminal
runs are marked interrupted and dependent chain work fails/cancels rather than replaying a
possibly side-effecting prompt; the parent may explicitly subagent.relaunch the durable
Agent. Automatic exactly-once crash replay is a non-goal for v1.
The shared JobState/RunState/RunSnapshot/JobSnapshot types are pinned in §11.2.
11. Shared Types #
These map to src/types/ (L0) and src/contracts/ (L1). Typebox schemas in code will
mirror these shapes exactly, yielding JSON-schema-for-model + TS types + validation from one
source (ARCHITECTURE decision #3).
interface Image { type: "image"; data: string; mime_type: string } // data = base64
interface ModelDescriptor {
id: string; provider: string; model: string;
context_window?: number;
}
interface Message {
id: string;
role: "user" | "assistant";
content: ContentBlock[];
}
type ContentBlock =
| { type: "text"; text: string }
| { type: "tool_call"; id: string; name: string; args: Record<string, unknown> }
| { type: "tool_result"; tool_call_id: string; content: ToolResultContent; is_error?: boolean }
| { type: "thinking"; text: string };
type ToolResultContent =
| { kind: "text"; text: string }
| { kind: "json"; value: unknown }
| { kind: "error"; message: string };
type RenderDescriptor =
| { kind: "text"; text: string }
| { kind: "kv"; pairs: { k: string; v: string }[] }
| { kind: "diff"; path: string; hunks: Hunk[] }
| { kind: "tree"; root: TreeNode }
| { kind: "progress"; label: string; current: number; total?: number }
| { kind: "status"; label: string; tone: "info" | "warn" | "error" }
| { kind: "command"; command: string; exitCode?: number; durationMs?: number }
| { kind: "path_list"; paths: { path: string; kind: "file" | "dir"; hint?: string }[] }
| { kind: "code"; path?: string; lang?: string; text: string };
interface Usage {
input: number; output: number;
cache_read?: number; cache_write?: number;
cost?: number; context_tokens?: number; model?: string;
}
interface AgentInfo {
agent_id: string;
name?: string;
cwd: string;
model?: string; provider?: string; mode?: string;
status: AgentStatus;
runtime_state: RuntimeState;
created_at: number;
usage?: Usage;
parent_agent_id?: string; // present if a subagent
/** Durable parent-scoped friendly alias (display-only; never an ID). */
alias?: string;
/** Definition name this Agent was launched from. */
definition_name?: string;
/** Derived `definition_name · alias` display string. */
display_name?: string;
}
Hunk and TreeNode are intentionally underspecified in v0.1 — they follow standard
unified-diff / nested-list shapes; pin them when the first diff/tree descriptor ships.
command, path_list, and code were added in S3 (additive per §4.5: new kinds never
break existing clients). command previews a shell command (the closed-set kind remains
available; bash no longer emits it — saga 26haas moved bash to structured
exit_code/duration_ms data that the client renders itself, so the command shows once
via the call line and the result shows the output + a status footer); path_list
is a flat path listing emitted by find/ls/grep (content mode, hint = matched
line); code is a file body under an optional path header emitted by read. lang on
code is informational in v0.1 (no syntax highlighting; the architecture avoids a color
dependency).
11.2 Subagent job/run types #
Pinned for §10.3. Opaque IDs carry prefix families (job-, run-, agent-) and are never
derived from display strings. Task text is private and never appears in these wire types.
type JobState = "queued" | "running" | "completed" | "failed" | "cancelled";
type RunState =
| "queued" | "starting" | "active"
| "completed" | "failed" | "timed_out" | "cancelled" | "interrupted";
type JobStrategy = "single" | "parallel" | "chain";
interface RunSnapshot {
job_id: string; run_id: string; index: number; name: string;
agent_id?: string; alias?: string; definition_name?: string; display_name?: string;
state: RunState; deadline?: number; final_text?: string; // final_text only to the owning parent
usage?: { input: number; output: number };
error?: string; started_at?: number; ended_at?: number;
}
interface JobSnapshot {
job_id: string; parent_agent_id: string; strategy: JobStrategy; state: JobState;
runs: RunSnapshot[]; chain_step?: number; stopped_at?: number;
created_at: number; ended_at?: number; // private task_specs omitted
}
interrupted is the post-daemon-restart reconciliation state (§10.3 restart rules); terminal,
no outgoing transitions, and explicitly relaunchable.
12. Worked Examples #
12.1 First prompt, happy path (direct stdio connection) #
sequenceDiagram
participant C as Client
participant A as Agent
C->>A: hello received
C->>A: prompt message read the config
A->>C: response success true accepted
A->>C: turn_start
A->>C: message_delta text_delta Reading config...
A->>C: tool_call id tc1 name read args file config.json
A->>C: tool_result tc1 content text ... is_error false
A->>C: message_delta text_delta The config says ...
A->>C: message_complete
A->>C: turn_end usage
12.2 Permission broadcast, first-response-wins (daemon, two clients) #
sequenceDiagram
participant A as Agent
participant D as Daemon
participant C1 as Client 1
participant W as Client 2 Web
A->>D: permission_request req-7 rm -rf build
D->>C1: permission_request req-7
D->>W: permission_request req-7
W->>D: permission_response req-7 allow_always
D->>A: forward allow_always drop late
A->>D: permission_resolved req-7 allow_always
D->>C1: permission_resolved req-7
D->>W: permission_resolved req-7
12.3 Attach to a running agent, then detach (tmux-style) #
sequenceDiagram
participant C as Client
participant D as Daemon
participant A as Agent running
C->>D: agent.attach id r1 agent_id a-3
D->>C: response success true status streaming
D->>C: sync agent_id a-3 status streaming live_window ...
D->>C: message_delta agent_id a-3 ...
D->>C: tool_result agent_id a-3 ...
C->>D: agent.detach id r2 agent_id a-3
D->>C: response success true
Note over A: agent keeps running
12.4 Orchestrator multi-agent view #
sequenceDiagram
participant O as Orchestrator web
participant D as Daemon
participant A1 as Agent 1
participant A2 as Agent 2
O->>D: agent.list
D->>O: response agents a-1 a-2
O->>D: agent.attach a-1
O->>D: agent.attach a-2
D->>O: tool_call agent_id a-1 ...
D->>O: status agent_id a-2 streaming
Note over O: one socket two streams demuxed by agent_id
12.5 Background subagent launch (daemon-managed) #
sequenceDiagram
participant P as Parent agent
participant D as Daemon JobCatalog/Coordinator
participant S as Subagent child
P->>D: subagent.create parent_agent_id P name task (unix socket)
D->>D: validate, preallocate IDs + alias, commit job record
D-->>P: response job_id run_id agent_id alias (before startup)
Note over P: parent continues/finishes its turn; jobs keep running
D->>S: materialize durable Agent (spawn shard agent headless)
D-->>P: subagent_run_changed state active
P->>D: subagent.wait handles run_id
D-->>P: subagent_run_changed state completed
D->>P: completion report as steer if active, otherwise prompt to wake
D-->>P: response wait final_text (if parent also chose to wait)
The parent transcript receives no child progress; only the terminal completion report is
injected into the parent model conversation. subagent.wait/subagent.status still return
structured results for explicit synchronization and recovery. A direct child dashboard
(separate agent.attach to the child) is the
only place child progress streams; delegated permission/UI controls route up the hierarchy
(ARCHITECTURE §6.3). Compare the pre-managed agent.create+agent.attach flow, which still
exists for low-level administrative clients outside the model-tool ownership policy.
12.6 Reconnect after a drop #
sequenceDiagram
participant C as Client
participant D as Daemon
Note over C: connection drops mid permission ask
C->>D: reconnect with backoff
D->>C: hello protocol version
C->>D: agent.attach a-3
D->>C: sync a-3 status waiting_for_permission pending_requests req-7
D->>C: permission_request req-7 re-shown
C->>D: permission_response req-7 allow
D->>C: permission_resolved req-7
13. Open Protocol Questions #
| # | Question | Notes |
|---|---|---|
| P1 | args_delta framing for tool calls |
Concatenate-then-parse is simple but fragile if a delta splits a token. Acceptable for v0.1; revisit if we see parse failures. Alternative: per-arg field deltas. |
| P2 | Backpressure on slow clients | If a client can't drain events fast enough, does the daemon drop, buffer, or close? v0.1: buffer up to a cap, then drop the client. Pin the cap. |
| P3 | session_op sub-spec |
Resolved (saga 3aliod): ops are list/compact/fork; branch/rewind dropped pre-implementation. |
| P4 | Binary asset transport | Images are base64 in Image today. If we stream large binary tool results, decide between base64-inline vs. a separate asset fetch endpoint. |
| P5 | Per-agent ACLs | v0.1 is single trust level (§4.3). When remote mode lands, the protocol needs an authz layer (who can touch which agent). |
| P6 | Hunk / TreeNode shapes |
Underspecified (§11). Pin when the first diff/tree RenderDescriptor ships. |
| P7 | Streaming cancellation granularity | abort cancels the whole turn. Do we need cancel-single-tool-call? Probably not in v0.1. |
14. Relationship to the Architecture #
This spec is the concrete form of ARCHITECTURE §7 (The Protocol). Decisions referenced:
- Transports + framing — ARCHITECTURE §7.1, decision #9, #16
- Multi-client broadcast / first-response-wins — ARCHITECTURE §6, decision #11
- Headless policy & timeouts — ARCHITECTURE §6.2, decision #12
- Auth token + Origin gate — ARCHITECTURE §14.2–14.3, decisions #13, #17
- Subagent jobs/runs, aliases, lease guard, strict steer, restart rules — ARCHITECTURE §9
- Attachment roles + hierarchical permission routing — ARCHITECTURE §6.3
RenderDescriptorneutrality — ARCHITECTURE §4.5, decision #4- Compaction
message_range→recall— ARCHITECTURE §12
When this spec and ARCHITECTURE.md disagree, this spec wins on wire matters (it's the
finer-grained contract) and ARCHITECTURE.md wins on architectural invariants. Reconcile
discrepancies by editing the appropriate doc, not by silent divergence.