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.
Endpoint
Section titled “Endpoint”wss://<brain>/ws/agentAuthorization: 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.
Frame envelope
Section titled “Frame envelope”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.
Agent to brain
Section titled “Agent to brain”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 }}event_batch
Section titled “event_batch”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>[] }}heartbeat
Section titled “heartbeat”{ type: "heartbeat", payload: {} }Default cadence is 10 seconds; the brain returns its preferred heartbeat_interval_seconds in hello_ack.
command_ack
Section titled “command_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.
command_result
Section titled “command_result”{ 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.
registry_delta
Section titled “registry_delta”Schedule / engine registry updates. The brain treats it as additive state.
Brain to agent
Section titled “Brain to agent”hello_ack
Section titled “hello_ack”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 }}event_batch_ack
Section titled “event_batch_ack”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 }}command
Section titled “command”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.
agent_status
Section titled “agent_status”Brain pushes status changes (e.g. another worker joined / left under the same agent_id).
Close codes
Section titled “Close codes”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.
Reconnect
Section titled “Reconnect”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.