Skip to content

WebSocket protocol

Protocol version: 2. v2 adds a per-frame HMAC envelope plus replay protection; v1 is not accepted on the wire. See the wire protocol concept for the narrative; this page is the schema reference. The canonical definitions live in z4j_core.transport.frames.

wss://<brain>/ws/agent
Authorization: Bearer <agent-token>

No WebSocket subprotocol is negotiated. JSON is carried in binary WebSocket messages in both directions; the brain also accepts incoming text messages. Clients must therefore be prepared to decode binary payloads. The dashboard socket is separate at /ws/dashboard.

Every frame:

{
v: 2, // protocol version
type: "<string>",
id: string, // correlation id stamped by the sending side
ts: string | null, // RFC 3339, optional
// signed frames also carry:
nonce: string, // shipped signer generates ~22 chars
seq: number, // strictly increasing in this session and direction
hmac: string, // base64; computed by FrameSigner
payload: { ... } // type-specific
}

Stateful frames (event_batch, event_batch_ack, heartbeat, command, command_ack, command_result, registry_delta, error, agent_status) are signed. The handshake pair (hello / hello_ack) is unsigned because the agent and brain are still negotiating which key to use. Replay state is per connection: signers and verifiers reset sequence numbers on reconnect, and the session id in the HMAC prevents cross-session replay. Two workers sharing one agent id have independent sessions.

The Pydantic models declare a 1..64-character id and a 32-character nonce cap, but signed-frame verification deliberately uses an HMAC-verified fast path that bypasses field constraints. Those caps are enforced for the unsigned handshake, not signed wire frames. For signed frames the outer frame-byte limit is the practical bound. Brain-issued command IDs also demonstrate that id is not always agent-generated.

First frame the agent sends. The brain validates protocol_version and accepts only "2".

{
type: "hello",
payload: {
protocol_version: "2",
agent_version: string,
framework: string, // free-form adapter name, max 40 chars
engines: string[], // up to 64
schedulers: string[], // up to 64
capabilities: Record<string, string[]>,
host: Record<string, any>,
// optional, worker-first protocol (one connection per worker):
worker_id?: string,
worker_role?: "web" | "task" | "scheduler" | "beat" | "other",
worker_pid?: number,
worker_started_at?: string
}
}

The hot path. Send no more than 5000 events. The signed-frame fast path does not reject a larger list: the brain keeps the first 5000, discards the tail, and acknowledges the frame, so an oversized sender loses the discarded tail. The shipped agent avoids this by putting one event in each event_batch frame; its send loop drains up to 500 buffered frames per cycle.

{
type: "event_batch",
payload: {
events: Record<string, any>[]
}
}
{ type: "heartbeat", payload: {} }

Default cadence is 10 seconds; the brain returns its preferred heartbeat_interval_seconds in hello_ack.

The agent sends this immediately after accepting a command, before executing it. The frame ID matches the command ID; the payload echoes the optional delivery_claim_token. The brain persists this first-stage receipt, while the later command_result reports the execution outcome.

{
type: "command_result",
payload: {
status: "success" | "failed",
result?: Record<string, any>,
error?: string,
delivery_claim_token?: string
}
}

The command id is the frame-level id, not a payload field.

Schedule / engine registry updates. The brain treats it as additive state.

Brain's response to a successful hello.

{
type: "hello_ack",
payload: {
protocol_version: "2",
brain_version: string,
agent_id: string,
project_id: string,
session_id: string,
heartbeat_interval_seconds: 10, // default
max_frame_size_bytes: 1048576 // default 1 MiB
}
}

Round-trip ack so the agent knows which buffered batch it can drop.

{
type: "event_batch_ack",
payload: {
acked_id: string, // matches the original event_batch.id
received: number,
accepted: number,
rejected: number
}
}

Dispatched in response to REST actions and internal control flows. action is an open set, not a one-to-one rendering of REST route names. REST currently issues retry_task, cancel_task, bulk_retry, purge_queue, restart_worker, pool_grow / pool_shrink, add_consumer, cancel_consumer, and rate_limit; internal paths also issue actions such as submit_task, reconcile_task, requeue_dead_letter, and schedule.*. Agents should return a failed command_result for an unknown action.

{
type: "command",
payload: {
action: string,
target: Record<string, any>,
parameters: Record<string, any>,
timeout_seconds: number,
issued_by?: string,
delivery_claim_token?: string
}
}

The command id is the frame-level id.

Carries {code, message, fatal}. The brain does not close automatically after sending one. A non-fatal error is informational and the session continues. For a fatal error the brain can withhold the corresponding batch acknowledgement; the client must close so it does not replay the offending frame forever. The shipped agent closes and classifies build-incompatibility codes on a slow retry schedule.

Brain pushes status changes (e.g. another worker joined / left under the same agent_id).

Observed on the brain side:

Code Meaning
1000 Normal closure.
1011 Internal error.
4400 Structurally unacceptable input: malformed/non-hello handshake, or a malformed, empty, oversized, wrong-version, or unknown mid-session frame.
4401 Bearer rejected.
4403 Signed-frame HMAC or replay verification failed.
4408 Idle timeout reached without a heartbeat.
4426 Protocol version not in SUPPORTED_PROTOCOLS.
4429 Either the pre-auth per-IP connect bucket (600/minute/IP) or the per-agent worker cap (default 64) was exceeded.

Dashboard-side (/ws/dashboard) uses: 4400 for a malformed/non-subscribe first frame; 4401 for a missing or invalid session; 4402 when the hub is stopping or one user already has 50 live dashboard sockets; 4403 for a rejected Origin, unsatisfied MFA enrollment/verification, or missing project membership; 4408 for idle timeout; and 1011 for an internal error.

The shipped agent retries every close class indefinitely so correcting a token or upgrading a deployment heals without restarting the host application. It uses a fast connection schedule (1 second to 30 seconds) for ordinary closes, including 1006, 1011, 4408, and 4429; an auth schedule (10 seconds to 10 minutes) for 4401/4403; and an incompatibility schedule (2 minutes to 1 hour) for 4426 and the reserved, deliberately-not-yet-sent 4427. Surface the latter clearly to the operator, but do not park the agent permanently.