You're reading the 0.10.0 archive.

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>. 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 <execution>/.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://<host>/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:<port> 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:

Output
══════════════════════════════════════════════════
  Hankweave Server Started
  WebSocket: ws://localhost:<port>
══════════════════════════════════════════════════

Running in headless mode on port <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:<port>.
  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.

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; @hankweave/types and deep server imports are not public API.

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: <type>. 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.

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; 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;
Scroll to explore the table →
CategoryTypesJournaledBroadcastSentinel-routed
server-state20YesYesYes
agentic-backbone7YesYesYes
sentinel5YesYesNo
connection-state4NoNoNo; 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. 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.

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.

The pattern in pseudocode:

Output
// 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.

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<string, unknown>;
}

const sleep = (ms: number): Promise<void> => 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<number> {
  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<WebSocket> {
  for (let i = 0; i < maxAttempts; i++) {
    const ws = new WebSocket(`ws://localhost:${port}`);
    const opened = await new Promise<boolean>((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<void> {
  const lockPath = process.argv[2];
  if (!lockPath) {
    console.error("usage: bun monitor.ts <path-to-runtime.lock>");
    process.exit(2);
  }

  const port = await discoverPort(lockPath);
  console.log(`lock: ${lockPath} -> port ${port}`);

  const ws = await connect(port);
  const seenTypes = new Set<string>();
  let completedData: { codonId?: string; success?: boolean; cost?: number } | null = null;
  let resolveCompletion: (() => void) | null = null;
  const waitForCompletion = new Promise<void>((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<void>((resolve) => ws.once("close", () => resolve()));
  console.log("disconnected");
  process.exit(0);
}

void main();