You're reading the 0.10.0 archive.

Connect a client over WebSocket

Hankweave exposes a WebSocket control channel on every server it launches. Over that channel you can watch a run's events as they happen, send commands such as codon.next or server.shutdown, and catch up on history after joining mid-run. This page builds a small TypeScript client that does all three: it starts a server, discovers the port from the lock file, completes the handshake, and observes a codon through to completion.

A few terms carry the whole page. A hank is the configuration file passed to the server; a codon is one agent task; and the execution directory passed with -e stores the run's state. The two positional inputs to every launch command below are the hank and a data directory.

To run the checked example, download the 0.10.0 fixture bundle. The bundle's individual checked files are monitor.ts and refusal-probe.ts. From the directory containing the archive, extract its existing top-level directory and enter it:

⌁ Terminal
tar -xzf hankweave-fixtures-0.10.0.tar.gz
cd hankweave-fixtures-0.10.0

The steps below use minimal-single-provider/hank.json and minimal-single-provider/data from that bundle. Export ANTHROPIC_API_KEY before the live run. First, check the hank schema (the configuration shape) and paths with the fixture's validation command; this does not check provider health:

⌁ Terminal
bunx hankweave@0.10.0 minimal-single-provider/hank.json minimal-single-provider/data --validate

Launch the server where your client can reach it#

The client needs a running server on a known port. For an existing hank and data directory, launch in headless mode (without the interactive terminal interface, or TUI) and choose an execution directory:

⌁ Terminal
bunx hankweave@0.10.0 --headless --port 7777 -e ./my-exec ./hank.json ./data/

For the downloaded fixture, run this from hankweave-fixtures-0.10.0/. With the explicit -e ./my-exec path, use --start-new --force for a fresh run when that directory already contains .hankweave/; --start-new alone does not overwrite existing execution state:

⌁ Terminal
bunx hankweave@0.10.0 minimal-single-provider/hank.json minimal-single-provider/data --headless --start-new --force --port 7777 -e ./my-exec -o ./my-output

The pinned fixture run records the server-start box and its listening address:

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

Running in headless mode on port <port>
➜ Listening on: http://localhost:<port>/ (all interfaces)

That banner tells you three things that matter for the client: the server is up, the WebSocket endpoint is ws://localhost:<port>, and headless mode is active. The server prints its Hankweave v0.10.0 banner, writes .hankweave/runtime.lock inside ./my-exec, and requests the first codon in --headless mode because autostart defaults to true. Without --start-new, an existing explicit execution with state is resumed; with --start-new, use --force as above to replace existing state (or choose a new -e directory). Hankweave creates <exec-dir>/agentRoot as the agent workspace and exposes the input directory at its fixed <exec-dir>/agentRoot/read_only_data_source path as a symlink (a filesystem link).

The WebSocket is the control protocol. An HTTP request to any path receives 400 Bad Request JSON that directs the caller to the WebSocket endpoint and includes Access-Control-Allow-Origin: *.

The control channel has no authentication and the server binds without an explicit hostname. Do not publish port 7777 to an untrusted network. For Docker, bind the published host port to 127.0.0.1 where appropriate; put authenticated access controls in front of remote use. The TUI's guarded keys do not add authentication. The ordinary LLM proxy is separate: --proxy enables it, while this WebSocket remains the control channel.

Check-it: Confirm the Hankweave banner and then inspect the lock file in the next step. If you publish the port, verify that the host-side binding is limited to a trusted local or private network.

Read the lock file to find the port#

The server writes a lock file so clients can find it. The lock path is relative to the execution directory passed to -e:

Output
<exec-dir>/.hankweave/runtime.lock

For the fixture command above, read ./my-exec/.hankweave/runtime.lock. It is a JSON object containing pid, runId, startTime, lastHeartbeat, and port; the server refreshes lastHeartbeat every 30 seconds. A second launch against the same running execution directory fails with Server already running (PID: …).

Two timing details affect any client that reads this file. If you omit --port, the initial lock can contain port: 0 while the server binds. Re-read it after a short delay or poll until port !== 0. port is optional for compatibility with older lock files, so handle an absent value as well. With -e, the --attach TUI reads the same lock path: an absent port falls back to 7777, while an unreadable or invalid lock prints an error and exits 1. Without an execution path or an explicit --port, it uses 7777.

This command prints the bound port from the fixture's lock file:

⌁ Terminal
cat ./my-exec/.hankweave/runtime.lock | jq .port

In our client, we'll handle both startup waits: poll for a non-zero lock port, then retry a connection that arrives before the server is ready. The checked client below includes both.

Check-it: Run the jq command after launch and verify that it reports the bound port rather than 0.

Open a WebSocket and complete the handshake#

Connect to ws://localhost:<port>. Multiple clients can connect at once, and each receives a unique clientId.

Before the server accepts any command from your connection, it requires a handshake. Send this first:

JSON
{"type":"handshake","data":{"mode":"readonly","sendPreviousEvents":true}}

The two modes are readonly and readandwrite; sendPreviousEvents defaults to false. The server grants the requested mode without authentication and replies with handshake.response, whose data contains clientId, mode, eventHistory, and totalEvents. With sendPreviousEvents: true, eventHistory contains at most the 50 most recent events, while totalEvents remains the full count.

Do not send commands until the response arrives. A non-handshake message sent earlier receives an error event with Handshake required before sending commands, addressed only to that client.

We can compare our connection with the checked monitor's recorded handshake and completion sequence:

Output
connected, sending handshake
event: handshake.response
handshake.response: clientId=<id> mode=readonly totalEvents=1
…
event: codon.completed
codon.completed: codonId=summarize-notes success=true cost=$<cost>
disconnected

exit=0

The capture shows the shape of a healthy session: the handshake is answered with a clientId and the requested mode, events stream in, and the run ends with codon.completed before the client disconnects cleanly.

Check-it: Send the handshake and wait for handshake.response containing a clientId before sending a command.

Choose your access mode#

The handshake's mode field selects which commands the server will accept from your connection. Choose readonly for observation and the small read-only command set, but do not treat it as a security boundary. The server accepts the requested mode without authentication, and readonly still permits server.shutdown and server.force_shutdown. Restrict network access as described in the launch step.

The read-only commands are checkpoint.list (the saved checkpoint-history query), history.sync, ping, server.shutdown, and server.force_shutdown. The remaining commands, including codon.next, require readandwrite.

See the protocol reference for the complete 14-command catalog and payloads.

The command envelope is flat. A schema-valid mutation from a read-only client looks like this:

JSON
{"id":"readonly-next","type":"codon.next"}

To distinguish a permission refusal from a format error, we'll also try this malformed wrapper:

JSON
{"type":"command","command":{"type":"start"}}

The three failure boundaries remain distinct: a command before the handshake is rejected for the missing handshake; the malformed wrapper is rejected as Invalid command format; and the valid codon.next envelope reaches the permission gate and returns error.data.code: INSUFFICIENT_PERMISSIONS with severity operation.

The fixture includes a checked probe that exercises all three boundaries. From hankweave-fixtures-0.10.0/websocket-quickstart/, install the fixture dependencies in the client terminal if you have not done so already:

⌁ Terminal
bun install

Keep the launch server running, then launch a separate probe server from hankweave-fixtures-0.10.0/ on port 7778 with --no-autostart; this keeps the probe capture's server.idle event separate from the launch server's autostart run:

⌁ Terminal
bunx hankweave@0.10.0 minimal-single-provider/hank.json minimal-single-provider/data --headless --no-autostart --port 7778 -e ./probe-exec

From hankweave-fixtures-0.10.0/websocket-quickstart/, run the checked probe against that server:

⌁ Terminal
bun refusal-probe.ts ../probe-exec/.hankweave/runtime.lock
Output
[pre-handshake] event: error — no-code: Handshake required before sending commands
[handshaking] event: handshake.response
[malformed] event: server.ready
[malformed] event: server.idle
[malformed] event: error — no-code: Invalid command format
[readonly] event: error — INSUFFICIENT_PERMISSIONS: Cannot execute state-modifying commands in read-only mode
PASS: handshake requirement, malformed envelope, and readonly permission refusal are distinct

exit=0

The probe output labels each boundary as it hits it: the pre-handshake rejection, the malformed-envelope rejection (after server.ready and server.idle arrive), and the read-only permission refusal, ending in PASS only when all three stay distinct.

A rollback is started by one of the rollback commands in the protocol reference. During rollback, state-modifying commands are blocked with ROLLBACK_IN_PROGRESS; only the five read-only commands remain accepted.

Check-it: Run the refusal probe against a live lock and confirm all three boundaries. In particular, use the flat codon.next envelope for the permission check; do not use the malformed wrapper as evidence of read-only enforcement.

Receive events and find what you need#

After the handshake, watch for server.ready on the connecting client. Its data includes serverVersion, executionPath, agentRootPath, dataPath, and port, with optional proxyPort when --proxy is enabled and optional outputDirectory when -o was supplied.

In this headless recipe, startup requests the first codon because autostart is enabled by default; the handshake also invokes the same autostart path, so the monitor does not need to send codon.next. With autostart: false (the --no-autostart probe), the server emits server.idle with reason startup and waits for codon.next or codon.start. Filter the stream by event type and payload fields such as codonId; a codon run can produce codon.started, assistant.action, tool.result, file.updated, and codon.completed.

Server-state events describe runtime state; agentic-backbone events are the assistant, tool, and file events produced while a codon works; sentinel events come from background observers. These categories are broadcast to all handshaked clients only when emitted without a target. Targeted errors–including handshake, malformed-envelope, and permission errors–are sent only to their target and are not journaled. Connection-state events such as server.ready, pong, and history.batch are also unicast and never journaled.

A sentinel is a background observer attached to runtime or codon events; it processes the event stream and is configured per codon. incomplete.codon is present in the event schema and catalog, but the v0.10.0 runtime does not construct it, so do not require it as a live signal. Use the event catalog for the complete event list and payload fields.

We can use the monitor capture to follow the event order, without assuming that every run has the same event set:

Output
event: server.ready
…
event: codon.started
…
event: codon.completed

The excerpt shows the bracket to look for: server.ready opens the session, and a codon's work is bracketed by codon.started and codon.completed.

Check-it: After the handshake, confirm server.ready; with autostart enabled, confirm that a codon.started event precedes the captured codon.completed event.

Send commands and correlate responses#

Every client command is a JSON object with a tracking id, a command type, and optional data. The server validates the envelope and emits an error event for an invalid format. Commands do not receive direct acknowledgements; correlate them with the events that follow.

Use these correlations for the operations shown here:

Scroll to explore the table →
CommandObserve
codon.next or codon.startcodon.started, then work events, then codon.completed; match data.codonId
pingpong on this connection; its payload has message: "pong" and timestamp
history.synchistory.batch events until hasMore is false
server.shutdownshutdown completion, WebSocket close, and lock-file removal

The complete command catalog is maintained by the protocol reference. During rollback, state-modifying commands return ROLLBACK_IN_PROGRESS. Both shutdown commands are in the read-only allowlist; a second server.shutdown while shutdown is in progress escalates to force shutdown.

A ping response is connection-state, unicast to the sender, and never journaled. The command id is required by the command schema and is useful in your own logs, but the response envelope gets a fresh event id rather than echoing it. For one outstanding ping, install the listener before sending and match the next pong on that connection by its message and timestamp, not by an echoed identifier. ping.broadcast requires readandwrite and additionally includes the sender's clientId in each recipient's payload.

For a liveness check, send:

JSON
{"type":"ping","id":"t1"}

Check-it: Confirm that a pong arrives on your client and that it contains message and timestamp, not an echoed t1.

Catch up on history mid-run#

A client joining during or after a run can request the 50-event handshake window with sendPreviousEvents: true. That window is not the complete journal. Send history.sync when you need the full history:

JSON
{"id":"history-1","type":"history.sync"}

The server streams the journal from its beginning in history.batch events. Read each batch's events array and continue until hasMore is false. For the journal location, query API, storage backends, and growth or rotation rules, see event-journal integration.

Check-it: Send history.sync and read history.batch events until one reports hasMore: false.

Shut down cleanly and handle non-zero exit#

Send server.shutdown for graceful shutdown. The server sends SIGTERM to the current codon runner, waits for sentinels, telemetry, and flushes, and uses a 30-second watchdog if teardown wedges.

Send server.force_shutdown to skip graceful teardown. It force-kills the runner, performs minimal cleanup, and schedules process exit 1; that is the direct force-shutdown path's intent, not a guarantee for every escalation. A second server.shutdown while shutdown is already in progress takes this escalation path, but it races the graceful shutdown already underway: the recorded double-signal capture ended with exit=0. Do not treat that capture as proof that force shutdown always wins.

For ordinary termination, process exit 0 means clean completion or graceful shutdown; exit 1 means failure or crash. A missing .hankweave/runtime.lock after shutdown indicates that the server has stopped. See errors and exit codes for the full process contract.

The checked minimal-single-provider fixture has one codon, so its autostart run schedules graceful shutdown after that codon completes. If the original run has already exited before this check, relaunch the explicit -e ./my-exec execution with --start-new --force (or choose a new -e directory), then send the shutdown command to the fresh execution.

If you inspect the Git-backed checkpoint history, keep three values separate: the first-line commit prefix, the stored checkpoint type or event status, and the process exit code. Include exit: checkpoint commits in the inspection. The checkpoint-created transition maps statuses other than rig-setup, completed, and error–including exit–to checkpoint type skipped; skipped is a checkpoint classification, not a process exit result.

JSON
{"id":"shutdown-1","type":"server.shutdown"}

Check-it: Send server.shutdown, confirm the WebSocket closes, and verify that ./my-exec/.hankweave/runtime.lock is removed. If the one-codon autostart run has already removed its lock, relaunch the explicit -e ./my-exec execution with --start-new --force (or use a new -e directory) before checking. If you inspect the resulting checkpoints, verify that an exit: commit is not confused with process exit code 0 or with a completed checkpoint type.

Put it together with the compilable client#

Now we can put the connection and observation steps together in one TypeScript file. The checked client imports WebSocket from ws and node:fs, reads <exec-dir>/.hankweave/runtime.lock, polls for a non-zero port, retries early ECONNREFUSED connections, sends a read-only handshake with sendPreviousEvents: true, waits for the first codon.completed, prints its cost, and disconnects. It uses no Hankweave package imports.

From hankweave-fixtures-0.10.0/websocket-quickstart/, use the fixture's package.json and tsconfig.json:

⌁ Terminal
bun install
bunx tsc --noEmit
bun monitor.ts ../my-exec/.hankweave/runtime.lock

The project supplies ws, TypeScript, @types/ws, and @types/node, with strict Node16 module settings.

bunx tsc --noEmit is the type check; bun monitor.ts ... is the run. The monitor's live output includes a handshake.response, event-type lines, codon.completed: codonId=summarize-notes success=true cost=$<cost>, disconnected, and exit=0 in the checked session. Start it while the autostarted codon is still running; if that codon has already completed, relaunch the explicit -e ./my-exec execution with --start-new --force (or choose a new -e directory) and start the monitor immediately. The cost is runtime data, so do not substitute a fixed value.

Here is the full client. The two startup waits from the lock-file step appear as discoverPort and connect; the handshake and the codon.completed wait from the earlier steps appear in main:

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();

The hankweave package exports ., ./schemas, and ./types; use client and exported types for those APIs. Use the event catalog for all event schemas. We use only ws here for portability.

Check-it: From the fixture directory, run bun install, then bunx tsc --noEmit and verify its exit status is 0. Against the launched server, start the monitor while its first autostarted codon is still running; if needed, relaunch the explicit -e ./my-exec execution with --start-new --force (or use a new -e directory) first. Run bun monitor.ts ../my-exec/.hankweave/runtime.lock and confirm the client prints a cost before disconnecting.