# Hankweave WebSocket Protocol The Hankweave server speaks WebSocket. That single channel carries everything a client needs: a handshake that registers the connection, a small set of commands that drive or inspect the run, and a stream of journaled events that report what the server is doing. The built-in terminal UI is one such client; a custom dashboard, CI integration, or monitoring tool can be another, using the same messages. This page is the protocol reference. It covers how to find and reach the server, the handshake, the command set with its payload shapes and refusal behavior, the event categories and their delivery rules, and the journal synchronization and buffering patterns a client needs to avoid missing events. If the task is to run or author a hank rather than build a tool against a running server, use the CLI; it is the supported path for that work, and this protocol is not required for it. ## Connecting to the server The server exposes its WebSocket API for custom tools, user interfaces, and integrations. Before a client can send anything, it needs the endpoint, and it needs to know what the server does with connections that are not WebSocket handshakes. ### Find the endpoint and port The endpoint is `ws://localhost:`. Port `0` lets the operating system assign a port; `-p/--port` selects one explicitly. After binding, the server writes the assigned port to `.hankweave/runtime.lock`, a JSON file under the execution directory's `.hankweave/` directory. The server binds without an explicit hostname, so a published port must be treated as a control surface rather than an anonymous monitor. The CLI's `--attach` option is boolean and never takes a URL. Its port lookup order is: 1. An explicit `--port` value. 2. The `port` field in `/.hankweave/runtime.lock`. 3. Port `7777` when no execution path is supplied. Attach mode constructs the TUI with the discovered port and requests a `readandwrite` handshake. The attached TUI disables its `n`, `s`, `f`, and `r` keys and makes `q` disconnect without stopping the server; those are client-side UI guards, not server-enforced authority. A protocol client must not treat `readonly` as a security boundary: the channel has no authentication, and its readonly allowlist includes both shutdown commands. Keep a published port on a trusted local or private network; for a Docker-published port, bind the host side to `127.0.0.1` where appropriate, and put authenticated access control in front of remote use. ### Expect a WebSocket-only server The server accepts WebSocket connections only. An HTTP request receives `400 Bad Request`, the following JSON body, and an `Access-Control-Allow-Origin: *` header: ```json { "error": "HTTP API not available", "message": "This server only accepts WebSocket connections", "websocket": "ws:///ws", "help": "Connect to the WebSocket endpoint to interact with Hankweave" } ``` The `websocket` field is shown as returned in the HTTP error body; clients use the `ws://localhost:` endpoint described above. ### Observe startup and connection state Multiple WebSocket clients can connect at the same time. Each connection receives a unique `clientId` and is tracked with `connectionTime`, `lastActivity`, and `handshakeComplete` state. The runtime setting `autostart` defaults to `true`. A codon is a unit the server starts and completes in its sequence. In normal mode, the server starts codons after the handshake; headless mode triggers autostart before the handshake, and an idempotency guard prevents a second trigger. `--no-autostart` disables it. With autostart disabled, the server emits `server.idle` with `reason: "startup"` and message `"Server ready. Waiting for commands (autostart disabled)."`. The complete `server.idle` reason set is `startup`, `codon-completed`, `all-codons-completed`, and `rollback-completed`. `codon-completed` is emitted between codons, `all-codons-completed` when every codon finishes, and `rollback-completed` when rollback completes. Connecting to a completed execution starts the server, logs `Shutting down server: all codons completed`, and exits `0` without naming which execution was resumed. The startup banner confirms the bound endpoint: ```text ══════════════════════════════════════════════════ Hankweave Server Started WebSocket: ws://localhost: ══════════════════════════════════════════════════ Running in headless mode on port ``` After the handshake, `server.ready` is a connection-state event sent only to the connecting client. Its data contains `serverVersion`, `executionPath`, `agentRootPath`, `dataPath`, and `port`, plus optional `proxyPort` and `outputDirectory` fields since `0.4.1`; an optional field is omitted when undefined. The event is not journaled. ## Complete the handshake before commands Once connected, a client must identify itself before it can do anything else. A handshake is required before any command. A non-handshake message received before `handshakeComplete` produces an `error` event with message `Handshake required before sending commands`. The request and response shapes, and the two client modes, are defined in the server types: ```ts export interface HandshakeRequest { type: "handshake"; data: { mode: ClientMode; sendPreviousEvents?: boolean; // Whether to send event history (defaults to false) }; } /** * Handshake response sent by server after processing request */ export interface HandshakeResponse { type: "handshake.response"; data: { clientId: string; mode: ClientMode; // Granted mode (may differ from requested) eventHistory: ServerEvent[]; // Limited by handshakeHistoryLimit totalEvents: number; // Total events in journal }; } ``` ```ts export enum ClientMode { READONLY = "readonly", READANDWRITE = "readandwrite", } ``` The two modes are `readonly` and `readandwrite`. The server grants the requested mode without authentication or another mode restriction, so the response's `mode` matches the request in this release. `sendPreviousEvents` defaults to `false`. When it is `true`, `eventHistory` contains up to the `handshakeHistoryLimit` most recent journal events, whose default limit is `50`; `totalEvents` reports the total journal count. The connection sequence is: 1. Open the WebSocket at `ws://localhost:`. 2. Send a handshake with the selected mode, setting `sendPreviousEvents: true` when recent history is needed. 3. Wait for `handshake.response`; send commands only after that response. `server.ready` is a separate post-handshake connection-state event; its payload is described in [Connecting to the server](#connecting-to-the-server). ## Client commands and payloads With the handshake complete, the client can send commands. The runtime imports `clientCommandSchema` from `command-schemas.ts`, a Zod discriminated union (a schema that selects one variant by `type`) with 14 variants. Send each command as a flat JSON object with an `id` string and a `type` discriminant; include `data` only where the schema shows it. The runtime-imported schema is authoritative for the shipped artifact. `event-schemas.ts` contains a stale 13-variant copy that omits `server.force_shutdown`. The full schema, reproduced from the source the runtime imports: ```ts export const clientCommandSchema = z.discriminatedUnion("type", [ z.object({ id: z.string(), type: z.literal("codon.start"), data: z.object({ codonId: codonIdSchema, skipPreCommands: z.boolean().optional(), }), }), z.object({ id: z.string(), type: z.literal("codon.next"), }), z.object({ id: z.string(), type: z.literal("codon.skip"), }), z.object({ id: z.string(), type: z.literal("codon.redo"), }), z.object({ id: z.string(), type: z.literal("server.shutdown"), data: z .object({ reason: z.string().optional(), }) .optional(), }), z.object({ id: z.string(), type: z.literal("server.force_shutdown"), data: z .object({ reason: z.string().optional(), }) .optional(), }), // Query checkpoints z.object({ id: z.string(), type: z.literal("checkpoint.list"), data: z .object({ runId: z.string().optional(), // Defaults to current run }) .optional(), }), // Force stop current codon z.object({ id: z.string(), type: z.literal("codon.forceStop"), data: z .object({ reason: z.string().optional(), }) .optional(), }), // Rollback to specific checkpoint z.object({ id: z.string(), type: z.literal("rollback.toCheckpoint"), data: z.object({ checkpointSha: z.string(), autoRestart: z.boolean().optional().default(false), }), }), // Rollback to codon + checkpoint type z.object({ id: z.string(), type: z.literal("rollback.toCodon"), data: z.object({ codonId: codonIdSchema, checkpointType: z.enum(["start", "end", "rig-setup", "completed", "error", "skipped"]), autoRestart: z.boolean().optional().default(false), }), }), // Rollback to last successful codon z.object({ id: z.string(), type: z.literal("rollback.toLastSuccess"), data: z .object({ autoRestart: z.boolean().optional().default(false), }) .optional(), }), // Ping commands for testing z.object({ id: z.string(), type: z.literal("ping"), }), z.object({ id: z.string(), type: z.literal("ping.broadcast"), }), // History synchronization z.object({ id: z.string(), type: z.literal("history.sync"), }), ]); ``` The 14 command types are `codon.start`, `codon.next`, `codon.skip`, `codon.redo`, `server.shutdown`, `server.force_shutdown`, `checkpoint.list`, `codon.forceStop`, `rollback.toCheckpoint`, `rollback.toCodon`, `rollback.toLastSuccess`, `ping`, `ping.broadcast`, and `history.sync`. Most payload constraints are visible directly in the schema; the ones that need interpretation are: * `codon.start` takes `codonId` and optional `skipPreCommands`; the schema gives that flag no default, and its absent value is falsy when consumed. * `server.shutdown` and `server.force_shutdown` take optional `reason` data. A graceful signal or client-request shutdown normally computes exit `0`; force shutdown schedules exit `1` and skips checkpoints, telemetry, and state transitions. For `server.shutdown`, the handler uses `client request` when `reason` is omitted or empty: the recognized `SIGINT`, `SIGTERM`, and `client request` reasons compute exit `0`, but an unrecognized nonempty reason such as `maintenance` computes `1`. The `all codons completed` reason uses the run status (`failed` or `crashed` computes `1`, otherwise `0`); the teardown path does not change, and the watchdog uses the same computed code. Omit `reason` for the normal client-request status, or handle the exit status deliberately when supplying a custom reason. The supplied capture of two shutdown signals ended `0` because graceful shutdown won the race, so that capture is not evidence that force shutdown completed first. Since `0.3.0`, the second signal is the designed escalation path. * `checkpoint.list` has optional `runId`, defaulting to the current run. When checkpoint creation receives an `exit` status, it stores `checkpointType: "skipped"`; that state label is distinct from the Git commit SHA or the process exit code. `ping` and `ping.broadcast` have no data fields. * Rollback commands have optional `autoRestart` with schema default `false`. `rollback.toCodon` accepts `start`, `end`, `rig-setup`, `completed`, `error`, and `skipped`. Here, `rig-setup` names the setup checkpoint. `start` and `end` are convenience aliases resolved against the target codon's own checkpoints: `start` uses its `rigSetupCheckpoint` when present, otherwise its status-appropriate terminal checkpoint; `end` uses its terminal checkpoint by status, with the rig-setup checkpoint as fallback. * A command `id` is for client-side correlation only. No server response echoes it: `pong` has a newly generated event id, and `ping.broadcast` carries the server-assigned connection `clientId` rather than the command id. The source excerpt is the canonical shape. For shutdown commands, graceful shutdown has a 30-second `SHUTDOWN_WATCHDOG_MS` backstop since `0.7.3`; it force-exits if graceful shutdown does not complete in that interval. The value is a compile-time `30000`-millisecond constant in `TIMEOUTS`, with no CLI flag or `hank.json` key to change it. Only `server.force_shutdown` carries a version annotation in this release (added in `0.3.0`); the other command types are documented without per-command version annotations. Package exports belong to [client and exported types](/integrate/client-and-exported-types); `@hankweave/types` and deep server imports are not public API. > **VersionNote:** The old protocol page listed 13 commands and 27 events. Version 0.10.0 ships 14 commands, including `server.force_shutdown`, and 36 event types; `budget.summary`, `archive.completed`, and `archive.partial` are part of the current event catalog. ## Readonly versus readandwrite The mode requested in the handshake gates which commands the server will execute. A `readonly` client can send exactly five commands. Readonly is an execution permission mode, not a security boundary: the server accepts the requested mode without authentication, and the five-command set includes shutdown requests. The allowlist, as defined in the runtime source: ```ts private readonly READ_ONLY_COMMANDS = new Set([ "checkpoint.list", "server.shutdown", // Special case - always allowed "server.force_shutdown", // Special case - always allowed (escalated shutdown) "ping", "history.sync", // Read-only history pagination ]); ``` The allowlist is `checkpoint.list`, `server.shutdown`, `server.force_shutdown`, `ping`, and `history.sync`. `server.force_shutdown` is always allowed as an escalated shutdown; `ping.broadcast` is not in the allowlist. A valid state-modifying command from a readonly client produces an `error` event with message `Cannot execute state-modifying commands in read-only mode` and context `Attempted command: `. Rollback is a second command gate: while rollback is in progress, a state-modifying command produces an error with code `ROLLBACK_IN_PROGRESS`. The three refusal boundaries are distinct, and a client can tell them apart by where in the pipeline the rejection happens: 1. A valid `codon.next` before the handshake produces `Handshake required before sending commands`, with no error code. 2. The malformed envelope `{type: "command", command: {type: "start"}}` after a readonly handshake produces `Invalid command format`, with no error code. 3. The schema-valid flat command `{"id":"readonly-next","type":"codon.next"}` after a readonly handshake reaches the permission gate and produces `INSUFFICIENT_PERMISSIONS` with `Cannot execute state-modifying commands in read-only mode`. For the complete error severity and protocol-code definitions, see [errors and exit codes](/reference/errors-and-exit-codes). ## Route server events by category Commands flow client to server; events flow back. The server-to-client catalog contains 36 event types across four categories. For payloads, see [the event reference](/reference/events); the delivery rules follow below. A **sentinel** is a background observer or watcher attached to runtime or codon events: a parallel observation agent that processes the event stream, configured per codon with `sentinels: [{sentinelConfig, settings}]`. The category assignments come from the event schema, and the table summarizes how each category is delivered: ```ts const SERVER_STATE_EVENT_TYPES_ARRAY = [ "codon.started", "codon.completed", "codon.extended", "state.snapshot", "server.idle", "token.usage", "info", "error", "checkpoint.list", "rollback.started", "rollback.progress", "rollback.codonCheckpoint", "rollback.completed", "rollback.rigCleanup", "rollback.archiveRestore", "state.transition", "loop.iteration.completed", "archive.completed", "archive.partial", "budget.summary", ] as const; /** * Array of event types that represent agentic backbone events. */ const AGENTIC_BACKBONE_EVENT_TYPES_ARRAY = [ "assistant.action", "tool.result", "file.updated", "filetree.updated", "rig.setup.completed", "rig.setup.failed", "rig.output", ] as const; /** * Array of event types that represent sentinel events. */ const SENTINEL_EVENT_TYPES_ARRAY = [ "sentinel.loaded", "sentinel.unloaded", "sentinel.error", "sentinel.output", "sentinel.triggered", ] as const; /** * Array of event types that represent connection state changes. */ const CONNECTION_STATE_EVENT_TYPES_ARRAY = [ "server.ready", "pong", "history.batch", "incomplete.codon", ] as const; ``` | Category | Types | Journaled | Broadcast | Sentinel-routed | | ------------------ | ----: | --------- | --------- | ---------------------- | | `server-state` | 20 | Yes | Yes | Yes | | `agentic-backbone` | 7 | Yes | Yes | Yes | | `sentinel` | 5 | Yes | Yes | No | | `connection-state` | 4 | No | No | No; sent to its target | `server-state` and `agentic-backbone` events are journaled, broadcast, and sentinel-routed. `sentinel` events are journaled and broadcast but are not sentinel-routed, preventing self-observation loops. Journaled events with no target go to every connected client whose handshake is complete. Connection-state events require an explicit target and are unicast to that client. The current catalog includes `budget.summary`, `archive.completed`, and `archive.partial`; the event reference is the source of truth for all 36 types. The old count of 27 is stale. ### Complete the connection-state exchange The connection-state events are: * `server.ready`, sent after the handshake. * `pong`, returned for `ping` and `ping.broadcast`. * `history.batch`, returned for `history.sync`. * `incomplete.codon`, present in the schema but with zero construction sites in version `0.10.0`; clients should not expect it from this release. `checkpoint.list` has two wire identities. It is a client command, and the server emits a journaled, broadcast `checkpoint.list` event in response. The TUI also constructs synthetic local copies that never leave the client. ### Use the error references Error severity levels and protocol error codes are owned by [errors and exit codes](/reference/errors-and-exit-codes). The current `failureReason` enum for `codon.completed` is also documented there rather than repeated here; the generated errors table is provider error classification, not this protocol's severity or code enum. ## Synchronize the event journal Broadcast events reach only clients connected at the time. For anything earlier, the journal is the record. `sendPreviousEvents: true` supplies recent context during the handshake, while `history.sync` is the complete-history command. It streams the event journal from the beginning of the run as `history.batch` events. Each batch carries `events: ServerEvent[]` and `hasMore: boolean`; the stream continues while `hasMore` is `true`. The journal is `.hankweave/events/events.jsonl`, with one JSON object per line. Its storage, query API, backends, and rotation belong to [event-journal integration](/integrate/event-journal). ### Buffer events before waiting An event can arrive before a client attaches its wait handler. A client therefore buffers every incoming event and checks that buffer before waiting for a new event. > **Pitfall:** If a client waits for `codon.completed` only after attaching its handler, the event may already have arrived. Buffer all incoming events; before waiting, check the buffer first. This prevents a wait from missing an event received while other client work was in progress. The pattern in pseudocode: ```text // Pseudocode: model the buffer-first wait pattern. onEvent(event): buffer.append(event) waitFor(type): if buffer contains an event of type: return that event wait for the next matching event ``` ## Building a client The protocol serves custom dashboards, CI/CD integrations, monitoring tools, alternative interfaces, and programmatic control. A client that runs or authors hanks uses the CLI instead. A dashboard can observe in `readonly` mode, but must protect the unauthenticated control channel separately; readonly still permits shutdown commands. ### Retry the connection A production client uses a connection loop with a maximum retry count and a delay. A server can be slow to start, especially when launched through `npx` or `bunx`, and network connections can be unreliable. The retry loop surrounds the handshake sequence without changing the protocol shapes. ### Monitor a run The versioned `websocket-quickstart` fixture is a complete monitoring client. It discovers the port from `runtime.lock`, connects in `readonly` mode, requests previous events, logs each event type the first time it appears, reports the first `codon.completed` event's cost, and disconnects. Its typecheck and live run are captured in the fixture; setup and compile commands belong to [WebSocket quickstart](/integrate/websocket-quickstart). The full client, which puts together the port discovery, handshake, event buffering, and clean disconnect described on this page: ```ts import { WebSocket, type RawData } from "ws"; import { readFileSync } from "node:fs"; // runtime.lock is a JSON object: { pid, runId, startTime, lastHeartbeat, port? }. // `port` is optional for backward compatibility and may be 0 (dynamic port) // until the server binds — so readers must poll until port !== 0. interface LockFile { pid: number; runId: string; startTime: string; lastHeartbeat: string; port?: number; } interface HandshakeResponseData { clientId: string; mode: string; eventHistory: unknown[]; totalEvents: number; } interface ServerEvent { id: string; timestamp: string; type: string; data?: Record; } const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); function readPort(lockPath: string): number { const lock = JSON.parse(readFileSync(lockPath, "utf-8")) as LockFile; return typeof lock.port === "number" ? lock.port : 0; } async function discoverPort(lockPath: string, maxAttempts = 50): Promise { for (let i = 0; i < maxAttempts; i++) { const port = readPort(lockPath); if (port !== 0) return port; await sleep(200); } throw new Error(`port stayed 0 in ${lockPath} after ${maxAttempts} polls`); } async function connect(port: number, maxAttempts = 10): Promise { for (let i = 0; i < maxAttempts; i++) { const ws = new WebSocket(`ws://localhost:${port}`); const opened = await new Promise((resolve) => { const onOpen = (): void => { cleanup(); resolve(true); }; const onError = (): void => { cleanup(); resolve(false); }; const cleanup = (): void => { ws.off("open", onOpen); ws.off("error", onError); }; ws.once("open", onOpen); ws.once("error", onError); }); if (opened) return ws; await sleep(500); } throw new Error(`could not connect to ws://localhost:${port}`); } async function main(): Promise { const lockPath = process.argv[2]; if (!lockPath) { console.error("usage: bun monitor.ts "); process.exit(2); } const port = await discoverPort(lockPath); console.log(`lock: ${lockPath} -> port ${port}`); const ws = await connect(port); const seenTypes = new Set(); let completedData: { codonId?: string; success?: boolean; cost?: number } | null = null; let resolveCompletion: (() => void) | null = null; const waitForCompletion = new Promise((resolve) => { resolveCompletion = resolve; }); ws.on("message", (raw: RawData) => { let event: ServerEvent; try { event = JSON.parse(raw.toString()) as ServerEvent; } catch { return; } if (!seenTypes.has(event.type)) { seenTypes.add(event.type); console.log(`event: ${event.type}`); } if (event.type === "handshake.response") { const data = event.data as unknown as HandshakeResponseData; console.log( `handshake.response: clientId=${data.clientId} mode=${data.mode} totalEvents=${data.totalEvents}`, ); } if (event.type === "codon.completed" && completedData === null) { completedData = (event.data ?? {}) as { codonId?: string; success?: boolean; cost?: number; }; resolveCompletion?.(); } }); ws.on("error", (error: Error) => { console.error(`websocket error: ${error.message}`); }); // connect() only resolves once the socket is open, so it is safe to send // the handshake immediately — no second "open" listener is needed. console.log("connected, sending handshake"); ws.send( JSON.stringify({ type: "handshake", data: { mode: "readonly", sendPreviousEvents: true }, }), ); await waitForCompletion; const data: { codonId?: string; success?: boolean; cost?: number } = completedData ?? {}; console.log( `codon.completed: codonId=${data.codonId ?? "?"} success=${String(data.success)} cost=$${ typeof data.cost === "number" ? data.cost.toFixed(4) : "?" }`, ); ws.close(); await new Promise((resolve) => ws.once("close", () => resolve())); console.log("disconnected"); process.exit(0); } void main(); ```