# 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](/content-assets/cf45dff5691c48c0/fixtures/0.10.0/hankweave-fixtures-0.10.0.tar.gz). The bundle's individual checked files are [monitor.ts](/content-assets/cf45dff5691c48c0/fixtures/0.10.0/files/websocket-quickstart/monitor.ts) and [refusal-probe.ts](/content-assets/cf45dff5691c48c0/fixtures/0.10.0/files/websocket-quickstart/refusal-probe.ts). From the directory containing the archive, extract its existing top-level directory and enter it: ```sh 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: ```sh 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: ```sh 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: ```sh 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: ```text ══════════════════════════════════════════════════ Hankweave Server Started WebSocket: ws://localhost: ══════════════════════════════════════════════════ Running in headless mode on port ➜ Listening on: http://localhost:/ (all interfaces) ``` That banner tells you three things that matter for the client: the server is up, the WebSocket endpoint is `ws://localhost:`, 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 `/agentRoot` as the agent workspace and exposes the input directory at its fixed `/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`: ```text /.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: ```sh 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. > **Pitfall:** Do not cache a first read of `port: 0`. Use a fixed `--port` during integration development, or read the lock again after the server binds. **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:`. 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: ```text connected, sending handshake event: handshake.response handshake.response: clientId= mode=readonly totalEvents=1 … event: codon.completed codon.completed: codonId=summarize-notes success=true 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](/integrate/protocol) 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: ```sh 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: ```sh 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: ```sh bun refusal-probe.ts ../probe-exec/.hankweave/runtime.lock ``` ```text [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](/reference/events) 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: ```text 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: | Command | Observe | | ----------------------------- | ------------------------------------------------------------------------------- | | `codon.next` or `codon.start` | `codon.started`, then work events, then `codon.completed`; match `data.codonId` | | `ping` | `pong` on this connection; its payload has `message: "pong"` and `timestamp` | | `history.sync` | `history.batch` events until `hasMore` is `false` | | `server.shutdown` | shutdown completion, WebSocket close, and lock-file removal | The complete command catalog is maintained by [the protocol reference](/integrate/protocol). 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](/integrate/event-journal). > **DeepDive:** In v0.10.0, under the execution directory, `events.jsonl` at `.hankweave/events/events.jsonl` is the canonical append-only history for journaled events. The server does not write a per-traffic WebSocket wire log; runtime log text goes to `.hankweave/logs/server.log`. Connection-state events and targeted errors are not part of the journal. **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](/reference/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 `/.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`: ```sh 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=$`, `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; } 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(); ``` The `hankweave` package exports `.`, `./schemas`, and `./types`; use [client and exported types](/integrate/client-and-exported-types) for those APIs. Use [the event catalog](/reference/events) 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.