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, // lowercase hex HMAC-SHA256; 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>, runtime_features?: string[], // up to 64 additive flags, e.g. "retry_by_reference" // optional, worker-first protocol (one connection per worker): worker_id?: string, // max 128 chars worker_role?: string, // max 32 chars, not validated; shipped adapters send "web", "task", "scheduler", "beat", or "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: { // every field is optional buffer_size?: number, last_flush_at?: string, dropped_events?: number, telemetry_loss?: { buffer_id: string, // required when present, 1 to 64 chars runtime_id: string, // required when present, 1 to 64 chars capacity_evicted_frames?: number, // every counter defaults to 0 content_rejected_frames?: number, event_records?: number, command_results?: number, other_frames?: number, unclassified_frames?: number, adapter_events?: Record<string, number> // at most 32 entries }, adapter_health?: Record<string, string> }}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.
agent_status
Section titled “agent_status”Periodic self-report the shipped agent emits alongside the heartbeat (opt out
with Z4J_AGENT_STATUS_DISABLED=1). The payload carries per-error-class
consecutive failure streaks (auth_failure_streak,
protocol_failure_streak, connection_failure_streak),
last_successful_connect_at, current_session_age_seconds, buffer depth and
oldest-entry age, agent_version, protocol_version, and the engines /
schedulers inventory. The brain persists eligible frames to
agent_status_history on a best-effort basis; rate limiting or a persistence
failure may drop a sample.
registry_delta
Section titled “registry_delta”Task-registry updates with payload {engine, added, removed, updated}. The
brain accepts the frame from a live agent, logs it at debug level, and
discards it; nothing is persisted.
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 issues
retry_task, cancel_task, bulk_retry, requeue_dead_letter,
purge_queue, restart_worker, pool_grow / pool_shrink, add_consumer,
cancel_consumer, and rate_limit; the dead-letter listing route issues
dlq.list; the schedule trigger route issues
submit_task for a schedule z4j-scheduler owns and schedule.trigger_now for
one an external scheduler owns. Internal paths also issue actions such as
reconcile_task and other schedule.* verbs. 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. target carries type and id, plus
engine when the brain resolved the command to one engine: bulk_retry
copies the engine of its filter, and dlq.list the engine of the queue it
reads. purge_queue carries no engine.
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. The one error the brain emits is scheduler_upgrade_required
(fatal: true): an event_batch carried a schedule projection from an
outdated scheduler adapter, and that batch's acknowledgement is withheld.
Close codes
Section titled “Close codes”Observed on the brain side:
| Code | Meaning |
|---|---|
| 1000 | Normal closure. |
| 1011 | Internal error. |
| 4003 | Agent revoked, or its project archived, after the connection authenticated. Always sent after hello_ack; the shipped agent treats it as an auth failure. |
| 4400 | Structurally unacceptable input: malformed/non-hello handshake, or a malformed, empty, oversized, wrong-version, or unknown mid-session frame. |
| 4401 | Bearer rejected, or a valid bearer whose project is archived. |
| 4403 | Source address outside Z4J_AGENT_IP_ALLOWLIST, sent right after accept and before the bearer is read; or signed-frame HMAC or replay verification failed on an established session. |
| 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, or a valid session
presented from outside Z4J_DASHBOARD_IP_ALLOWLIST; 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; a protocol schedule (1 second to
60 seconds) for wire-level faults such as a hello_ack that does not parse as
a frame, a version-skewed frame, or a fatal error frame whose code is not a
build-incompatibility code; an auth schedule (10 seconds to 10 minutes) for
4401/4403 and for 4003 on an established session; 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.