Deploying Hankweave as a process

Most integrations with an LLM tool start from a library import or a long-running service. Hankweave offers neither. You spawn it, it runs a batch of work, it writes its state to disk, and it exits. That shape is deliberate, and it changes what "deploying" means: there is no daemon to supervise, no registry to join, and no client SDK to link. The integration surface is the process boundary plus the filesystem.

This page walks through that model in the order an integrator meets it: how a run starts and ends, what it leaves on disk, how to find the server it spawns, how to talk to it, how to tell how the run ended, and why shelling out beats embedding. Each section builds on the previous one, so by the end you can assemble the full spawn-discover-drive-collect loop yourself.

Why a process, not a service?#

When we integrate Hankweave, we coordinate a process rather than deploy a daemon or embed a library. At 0.10.0, Hankweave is a batch process whose integration surface is the process boundary plus the filesystem. Anything that can spawn a process and read files – a scheduler, a CI job, a Kubernetes Job, a script – can use that boundary.

The hankweave bin has no subcommands: running is the default operation. Give it one positional path: a value ending in .json is the hank path, and any other value is the data path. Or give it two: hank.json data/ supplies the hank path and then the data path. A hank is the file that defines the work; the runtime loads it from that explicit path.

Do not let the optional positional argument become an automation trap. A bare hankweave invocation opens the welcome wizard and exits; it never runs your hank. Pass explicit hank and data arguments instead.

A codon is one sealed unit of work in a hank. Each codon runs as part of the batch, and the run self-terminates when all codons complete. The runtime shuts the server down with reason all codons completed; there is no daemon to stop and no persistent control plane. The process exit code is the coarse verdict: 0 means completed or graceful, and 1 means failure. The full classification belongs to errors and exit codes.

We coordinate through processes and files instead of service discovery. For example, in the runner that builds these docs, we validate each hank and drive concurrent Hankweave instances as shell-out jobs rather than importing a runtime library. The rest of this page unpacks each step of that pattern.

What a run puts on disk#

Because the filesystem is half of the integration surface, the first thing to understand is where a run leaves its evidence. When a new execution is needed and you have not passed -e or --execution, Hankweave creates an execution directory under ~/.hankweave-executions/. Since 0.8.0, HANKWEAVE_RUNTIME_EXECUTION_BASE_DIR can move that managed root. The executionBaseDir field in hankweave.json is informational only; nothing consumes it. See the execution-directory reference for the full directory tree.

The optional hankweave.json is the runtime settings file. It can contain settings such as port and autostart, among others. The integrator-relevant paths live inside the execution directory:

  • .hankweave/runtime.lock – liveness and port.
  • .hankweave/state.json – run state.
  • .hankweave/events/events.jsonl – the event journal.
  • .hankweave/logs/server.log – the server log.
  • agentRoot/ – the agent workspace. Outputs stay here unless -o is given; there is no default output directory.

The copy stage is separate from the workspace. The hank's outputFiles[].copy pattern selects files from agentRoot/ for the CLI -o output directory, resolved as an absolute path from the current working directory. beforeCopy commands run in the agent workspace – the agentRoot/ directory the copy globs read from – before each copy group. This stage runs only when an output directory is configured; without -o, outputs remain in agentRoot/.

Run from the directory that holds hank.json and data. Here, hank.json is the hank path; the optional hankweave.json is a separate runtime settings file. The startup block prints the source-to-data mapping and the execution path, so automation can capture the directory Hankweave created. The capture below shows what that block looks like for a real run. The absolute path in it belongs to that capture; use the Exec line from your own startup output:

Captured with the minimal single-provider fixture at 0.10.0.

Output
Created new execution directory: ~/.hankweave-executions/<exec-id>
New execution: <exec-id>
  Source → data
  Exec   → ~/.hankweave-executions/<exec-id>
  SDKs   → Claude node_modules ✓
…
══════════════════════════════════════════════════
  Hankweave Server Started
  WebSocket: ws://localhost:<port>
══════════════════════════════════════════════════
…
Running in headless mode on port <port>
➜ Listening on: http://localhost:<port>/ (all interfaces)

Two lines matter for automation: New execution: <exec-id> and the Exec → mapping, which together tell you where this run's files will appear. The operator-facing run sequence is in the runbook.

Finding the server you spawned#

A batch process still needs a control channel while it runs, and Hankweave's is a WebSocket server inside the process. A service registry is not part of this model, so the question becomes: which port did it bind? When no port is configured, the WebSocket (a two-way connection) server defaults to port 0; the operating system assigns a free port when the server binds. That avoids collisions when several instances share a host. Help describes this as selecting a free port automatically.

The JSON setting has a stricter shape: "port": 0 is not valid in hankweave.json, because runtime configuration requires a positive integer. Leave port unset there to keep automatic selection, or pin a port with -p or --port <port>, or with HANKWEAVE_RUNTIME_PORT. Otherwise, discover the assigned port through the lock file.

The runtime binds first. Bun assigns the free port, the runtime adopts server.port as the actual port, and then the lock file is rewritten with that port. When --proxy is enabled, the rewrite also carries proxyPort. With dynamic ports, we reread the lock until its recorded port is no longer 0, then connect using that port.

The lock is JSON with pid, runId, startTime, lastHeartbeat, and an optional port. The port may be 0 initially when dynamic allocation is in use. The runtime refreshes lastHeartbeat every 30 seconds. The interface definition and the diagram below show the lock's shape and how the spawn-bind-discover cycle fits together:

Output
    interface LockFile {
      pid: number;
      runId: string;
      startTime: string;
      lastHeartbeat: string;
      port?: number; // Optional for backward compatibility with old lock files
FIG. 1 integrator view: your process → hankweave headless → lock file/port → WS
Read the diagram as text
Output
integrator view: your process → hankweave headless → lock file/port → WS

+-----------------------------------+
| your process                      |
| CI job · forge.py · your service  |
+----------------+------------------+
                 |
                 | spawn: hankweave hank.json data/ --headless
                 | exit code returns: 0 done · 1 failed
                 v
+-----------------------------------+
| hankweave server process          |
| one per execution dir             |
| self-terminates when codons end   |
+----------+------------------------+
           | writes <exec>/.hankweave/runtime.lock
           | {pid, runId, port, lastHeartbeat↻30s}
           | + events.jsonl · state.json · agentRoot/
           v
+-----------------------------------+
| lock file and execution files     |
+-----------------------------------+

            your process → reread port until it is nonzero
            your process → ws://localhost:<port>
                             WebSocket only; plain HTTP gets 400

Port 0 default: the operating system picks the port; the lock file is how you learn it.

To connect, read <executionPath>/.hankweave/runtime.lock, take its port, and dial the WebSocket. --attach --execution <dir> uses this discovery order: an explicit --port, then the lock file, then fallback port 7777. If the lock file cannot be read, attach exits 1 and tells you to use --port directly. The complete flag contract is in the CLI reference.

The lock file also answers a second question: is the run still alive? Evaluate two signals: whether the recorded process ID is alive and whether the heartbeat is older than 120 seconds. On a later start, the runtime uses those signals to remove the lock and mark the run crashed when the process is dead or the heartbeat is stale. The container health-check recipe belongs to deployment operations.

Driving the run over WebSocket-or-nothing#

Once you have the port, there is exactly one way to talk to the server. The server speaks WebSocket only. The http:// address in the startup banner names where the server listens; it is not an HTTP API. A plain HTTP request from a browser, curl, or a health probe that does not upgrade receives status 400 with a JSON body saying This server only accepts WebSocket connections. The response includes CORS * and points to the WebSocket form; it does not disrupt the execution.

--headless (unattended mode) runs without the TUI (terminal interface) and autostarts by default. To start the server and leave the run waiting for WebSocket commands, use --headless --no-autostart; it prints Autostart disabled, waiting for WebSocket commands.... That parked mode gives an integration control over when work begins.

Handshakes, access modes, and command envelopes belong to the protocol reference. The first-client sequence belongs to the WebSocket quickstart; this page does not duplicate either contract. The protocol reference fixes the endpoint path and the quickstart connects to it; this page deliberately does not choose between path forms.

The bind scope deserves attention before you put this on a shared network. The startup banner says that the server listens on all interfaces. The control channel is therefore network-reachable unless the integrator constrains it. The channel is unauthenticated: the runtime checks no credential when a client connects, and the handshake does not restrict the requested mode. Treat that bind scope as part of the deployment boundary. If you publish the port, keep it on a trusted local or private network; remote access needs an external authenticated access control. Do not treat a read-only mode as a security boundary either: the server's READ_ONLY_COMMANDS set includes server.shutdown and server.force_shutdown, so a read-only client can still shut the server down.

Knowing how it ended#

A self-terminating process reports its outcome through channels you already have: the exit code for the coarse verdict, and the filesystem or WebSocket for detail. Exit 0 means the run completed or shut down gracefully after SIGINT or SIGTERM. Exit 1 covers codon failure, a crash, startup failure, or force shutdown. A graceful shutdown has a 30-second watchdog; if a shutdown step wedges, the watchdog force-exits with the code the shutdown would have produced – 0 for a graceful SIGINT/SIGTERM shutdown, 1 when the run was already failing or the shutdown was forced. Since 0.9.0, an outputFiles copy or beforeCopy failure also produces exit 1. See errors and exit codes for the complete contract.

Some failures happen before any codon runs, and those are visible at startup. The capture below shows the self-test diagnostic and the final startup error when no API key is set; the run exits 1:

Captured with the minimal single-provider fixture at 0.10.0.

Output
[<ts>] [ERROR] Self-test completed: FAILED
[<ts>] [ERROR] Self-test FAILED: Some checks failed
[<ts>] [ERROR]   - authentication: ✗ No authentication found (set ANTHROPIC_API_KEY)
[ERROR] Server startup failed!
Error message: Self-test failed for 1 model(s):
  - Claude Haiku 4.5 (latest) (anthropic/claude-haiku-4-5): Some checks failed
      • authentication: No authentication found (set ANTHROPIC_API_KEY)
…

The authentication line names the failing check and the missing variable, which is usually enough to fix the environment and retry.

That pitfall follows from how execution directories resume. An explicit -e or --execution <path> selects an execution directory. Hankweave creates it when it does not exist; without --start-new, an existing directory with .hankweave/execution-meta.json is resumed, subject to the data-hash check. A completed execution can therefore resume silently when you omit --start-new. If that explicit path already contains .hankweave/, --start-new alone exits 1; use --start-new --force (-n -f) for a fresh run in the same directory. That backs up .hankweave/ as .hankweave.backup-<timestamp> and wipes agentRoot/ unless --no-wipe; alternatively, use a new -e directory. Without an explicit -e, --start-new creates a fresh managed execution directory.

During codons, the headless console is quiet by design. Capture the execution path from startup output first, then observe completion through the exit code, .hankweave/events/events.jsonl, .hankweave/state.json, files under agentRoot/, or a WebSocket client. The watch cadence and diagnostic procedure belong to runbook observation and observe and debug.

Shell out, don't embed#

The objection to a process boundary is practical: a library call looks like it would offer tighter control. At 0.10.0 that option is not on the table. The published package is unscoped hankweave and publishes exactly three subpaths: . (the CLI entry, with no verified library surface), ./schemas, and ./types. The public import forms are hankweave/schemas and hankweave/types, for parsing state.json and events.jsonl and for building clients–not for driving runs. See client and exported types for the exports contract.

HankweaveRuntime exists as a class in the source, but it is not exported from any of those package paths. Deep hankweave/server/* imports are not public API at 0.10.0, so in-process embedding is unsupported. Contributor snippets that use those imports are source examples, not an integration contract. The flags below are the actual control surface an integrator gets:

Output
Execution Control:
  -e, --execution <path>    Use specific execution directory
                            Creates if doesn't exist, resumes if has state
  -n, --new, --start-new    Start new execution, never resume
                            Use -n -f to overwrite existing state
  -f, --force               Override safety checks (hash mismatch, existing state)
  --no-wipe                 With --start-new --force, preserve the existing
                            agentRoot/ workspace instead of wiping it
  -y                        Non-interactive mode, skip confirmation prompts
Output
Server:
  -p, --port <port>         WebSocket server port (default: auto-select free port)
  --headless                Run without TUI (for CI/CD and scripts)
  --no-autostart            Don't automatically start codons
  --proxy                   Enable the LLM proxy server (disabled by default)
  --anthropic-base-url <url> Custom Anthropic API base URL
  --idle-timeout <seconds>  Idle timeout for WebSocket and proxy servers (0-255, default: 0)
  --shim-idle-timeout <seconds>   Harness idle timeout in seconds (default: 120, per-codon)
Output
  --attach                  Connect TUI to an already-running server (read-only mode)

For one unattended run, pass explicit hank and data arguments with --headless; add --start-new when unchanged data must run again. Use --no-autostart only when a WebSocket client will start the run.

For an integration, we bring the pieces of this page together into one loop:

  1. Spawn the hankweave bin with explicit hank and data arguments; do not use the bare invocation.
  2. Discover the assigned port by reading runtime.lock.
  3. Drive the process over WebSocket, or park it with --headless --no-autostart for a client-controlled start.
  4. Read the process exit code as the run's verdict.
  5. Collect state, journal, logs, and workspace files from the execution directory.

In the docs-building runner, we use this shell-out pattern: validate each hank, then spawn bunx hankweave@0.10.0 shards with explicit hank and data arguments, --start-new, and -o results directories. The runner polls and collects artifacts; it does not import a runtime library.

Port 0 makes that pattern safe for concurrent instances. The forge ran three and then four executions concurrently on one host and one data snapshot without a port collision.