Deploy Hankweave without source-tree assumptions

This page takes you from the downloaded fixture bundle to a verified, containerized Hankweave deployment: a consumer image built from the published npm package, a contributor image built from source, and the wiring for inputs, state, ports, CI and health checks that makes either one safe to run. Each recipe ends with a check you can run before moving on.

Two assumptions trip people up most often, and the page is organized around avoiding them. First, a deployed image is not a source checkout: the installed hankweave bin is the entrypoint, and source-tree commands do not apply. Second, a container that starts is not a container that ran fresh work: exit codes, lock files and resume behavior all need explicit handling, which the later sections cover. For Kubernetes Jobs, start with the batch-process model in Deployment model and return here for the image and flag details.

Choose the image that matches your job#

A consumer image installs the unscoped npm package hankweave; a contributor image builds SouthBridgeAI/hankweave-runtime from source. Pick the consumer image unless you are developing Hankweave itself. Do not use bun run server/index.ts in a consumer image: that entrypoint presumes the source tree and cannot work against an installed package. Invoke the installed hankweave bin with an explicit hank and data path instead; the grammar is hankweave [options] [config-or-data-path], and there is no run subcommand.

The recipes below use the fixture bundle, so download it before following them. The archive already contains one top-level hankweave-fixtures-0.11.0/ directory: extract it in its parent, then enter the extracted directory, and do not create another directory with that name first. The recipes use minimal-single-provider/hank.json and minimal-single-provider/data/, plus container-smoke/Dockerfile. Export ANTHROPIC_API_KEY before a run; never put provider keys in the image.

⌁ Terminal
# Run in the directory containing the downloaded archive.
tar -xzf hankweave-fixtures-0.11.0.tar.gz
cd hankweave-fixtures-0.11.0
export ANTHROPIC_API_KEY=your-key

Build the consumer image from npm#

The package requires Node >=22.19.0; Bun is an equally valid runner (the docs pipeline itself runs bunx hankweave@0.11.0). Install git as well: checkpoints are git-based recovery points, implemented with simple-git, which shells out to the git binary. The CLI runs git --version before it creates the execution directory and exits 1 when git is absent, so a gitless image fails at startup rather than at the first checkpoint.

Pin the package version in the image so its behavior and model spellings match this documentation:

⌁ Terminal
npm install -g hankweave@0.11.0

Pin the version so the image installs the documented release. With Bun, pin the version on each invocation instead:

⌁ Terminal
bunx hankweave@0.11.0

[since 0.9.0] The package is published with npm trusted publishing and provenance, so an image build can verify it with npm audit signatures.

Rather than write a Dockerfile from scratch, use the smoke image shipped in the fixture bundle. It installs the pinned package, creates the working and output directories, switches to the non-root node user, and sets the installed bin as the entrypoint:

DOCKERFILE
FROM node:22-bookworm-slim
ARG HANKWEAVE_VERSION=0.10.0
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* && npm install -g hankweave@${HANKWEAVE_VERSION}
RUN mkdir -p /work /output /executions && chown node:node /work /output /executions
USER node
ENV HANKWEAVE_RUNTIME_EXECUTION_BASE_DIR=/executions
WORKDIR /work
ENTRYPOINT ["hankweave"]

The Dockerfile's ARG HANKWEAVE_VERSION default is stale: the fixture was carried from the previous release, and its capture rewrote only the README's tag. Build with the version argument so the image installs the documented release:

⌁ Terminal
docker build --build-arg HANKWEAVE_VERSION=0.11.0 -t hankweave-fixture:0.11.0 container-smoke

The fixture README's build line omits this argument; following it verbatim installs the stale default under this image's tag:

⌁ Terminal
docker build -t hankweave-fixture:0.11.0 container-smoke

Check it. Run the image as its non-root user and require the command to print the documented release and exit 0:

⌁ Terminal
docker run --rm --user node hankweave-fixture:0.11.0 --version

The matching capture records the documented release followed by exit=0. If you override the image user to root, the Claude SDK refuses to start and the run exits 1 with --dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons; use the image's node user rather than working around that check.

Build the contributor image from source#

When you need an image built from source, build the public repository SouthBridgeAI/hankweave-runtime at the release tag (commit 8216cf3fb2984223cd7175c6d8ae7ead0dec12c0). Use the repository's bun.lock lockfile. The runtime does not use bun.lockb, so a Dockerfile that copies bun.lockb copies a file the build never reads.

Wire inputs, state, and outputs into the container#

Whichever image you built, a run needs three things from outside the container: the hank config, the data source, and provider keys. Mount the first two and pass the keys through environment variables; never bake secrets into the image or its build layers.

⌁ Terminal
# Keep provider keys outside the image.
docker run --env-file .env <image> hank.json data/

Mount the data volume read-only in the container. That read-only is your mount's convention, not something the runtime enforces: inside the execution, the runtime exposes the data at agentRoot/read_only_data_source as a symlink to the source directory (or a copy when you pass --copy), so a write through that path would land in the original data directory. The data-mount procedure belongs to the runbook.

FIG. 1 Container mount and environment wiring for Hankweave execution
Read the diagram as text
Output
+-- host ----------------------------+          +-- container -----------------+
|                                    |          |                              |
|  ANTHROPIC_API_KEY  . . . . . . . . . env . . . . .>  hankweave                |
|                                    |          |      |  -o /output           |
|  minimal-single-provider/ ---------|-- mount :ro -->  /work (ro)             |
|                                    |          |      |                       |
|  container-executions/  ----------|-- mount :rw -->  /executions            |
|                                    |          |      v                       |
|  container-output/  <--------------|-- mount :rw ---  /output                |
+------------------------------------+          +------------------------------+

State needs the same treatment as input. Persist the execution directory with a bind mount or named volume: it carries .hankweave/ state, checkpoints, and logs, and -e <path> on that volume is what lets a later container resume after a restart (the directory is created when missing and resumed when it has state).

Without -e, new executions are created under the managed root ~/.hankweave-executions/. The effective way to move that root is HANKWEAVE_RUNTIME_EXECUTION_BASE_DIR; the hankweave.json executionBaseDir field is informational and moves nothing (that key's contract belongs to the hankweave.json reference). The shipped image sets HANKWEAVE_RUNTIME_EXECUTION_BASE_DIR=/executions, so auto-managed executions land on the persisted volume instead of an ephemeral home directory. Output-copy name collisions are renamed with an _N_timestamp suffix by default; --overwrite-output overwrites instead.

Set per-environment behavior with HANKWEAVE_RUNTIME_* variables (PORT, MODEL, AUTOSTART, SHOW_COSTS, and others) rather than editing hank.json per environment. Use model shortcuts from the model-resolution reference in HANKWEAVE_RUNTIME_MODEL; the retired model ID from the old container example is not a valid spelling. Sentinel provider keys use the HANKWEAVE_SENTINEL_<VAR> prefix; key and model setup belongs to authentication and models.

Hankweave excludes HANKWEAVE_RUNTIME_* and HANKWEAVE_SENTINEL_* from the environment of Claude Agent SDK codons, so server settings and sentinel keys do not reach those agent processes. Pi-harness codons are different: their bash tool spawns with the server's full environment, so a shell command such a codon runs sees those variables under their own names.

With inputs, state, and secrets settled, run the complete non-root recipe. Make the output and executions bind mounts writable by the image's node user (UID/GID 1000 on Linux), changing ownership only on the directories you create for them:

⌁ Terminal
mkdir -p container-output container-executions
# On Linux, only when the bind mounts need it:
sudo chown 1000:1000 container-output container-executions
docker run --rm --user node -e ANTHROPIC_API_KEY \
  -v "$PWD/minimal-single-provider:/work:ro" \
  -v "$PWD/container-output:/output" \
  -v "$PWD/container-executions:/executions" \
  hankweave-fixture:0.11.0 hank.json data/ --headless --start-new -o /output

The fixture's non-root capture exits 0 and leaves summary.md on the mounted output volume; running the same image as root exits 1 with the refusal quoted above.

Check it. A non-interactive (--headless) run with -o /output exits 0 and leaves summary.md on the mounted results volume.

Reach the server from outside the container#

A running container also starts Hankweave's WebSocket control server, and exposing it is a separate decision from running the workload. Leave the port at its default, 0: the operating system assigns a free port after launch, which is what lets concurrent instances share a host without colliding (the docs pipeline ran three, then four, concurrent executions on one data snapshot this way).

When a published port, reverse proxy, or --attach client must reach the server, pin it with -p/--port <port> and publish that same container port. Bind the published host port to 127.0.0.1 where local access suffices, or to a trusted private network, and require external authenticated access controls for remote use: the control channel is unauthenticated (the server grants every handshake its requested mode), and even its read-only command set includes server.shutdown and server.force_shutdown, so a published port is a control channel, not a monitoring surface.

The pinned-port recipe publishes on the loopback interface only:

⌁ Terminal
docker run --rm --user node \
  -p 127.0.0.1:8080:8080 \
  -v "$PWD/minimal-single-provider:/work:ro" \
  -v "$PWD/container-output:/output" \
  -v "$PWD/container-executions:/executions" \
  -e ANTHROPIC_API_KEY \
  hankweave-fixture:0.11.0 hank.json data/ --headless --start-new --port 8080 -o /output

After the server binds, it writes the chosen port to <executionPath>/.hankweave/runtime.lock; --attach --execution <dir> reads that file to discover the port (falling back to 7777 when the lock provides none). Pin a port only when outside reachability requires it; otherwise keep port 0.

Run hanks in CI and notice reused runs#

Use --headless for CI/CD and scripts. Keep the working tree clean, pass explicit hank.json and data/ arguments (an invocation that names none of the hank, data, or execution inputs opens the wizard), start fresh runs with --start-new, and pass -o with a results directory. The docs pipeline's own runner follows exactly this shape, naming each run <seq>-<scope>-<label>__<hash>; the headless contract belongs to the runbook.

Inside an image whose entrypoint is the installed bin, the fresh-run recipe is:

⌁ Terminal
hankweave hank.json data/ \
  --headless \
  --start-new \
  -o /output

Reuse is keyed on the data signature: without --start-new, the runtime resumes the newest managed execution whose recorded data hash matches. When that execution has already completed, the runtime announces it: under --headless it prints Resumed completed execution <id> — nothing to do. All codons already completed. Use --start-new to run fresh. on stdout, runs no codon, and exits 0. Exit 0 therefore does not mean new work ran; --start-new is how you run fresh.

A changed hank.json is compared only when you resume an explicit -e <dir> directory: there, a non-interactive run refuses with exit 1 unless you add -y or --force. A managed resume (no -e) never reads the hank hash, so it needs no -y after a hank edit; it keeps the old plan, and --start-new is what runs the edited hank. The fence shows a headless run with -y; treat -y as a confirmation-prompt skip, not as a changed-hank guard:

⌁ Terminal
hankweave hank.json data/ \
  --headless \
  -y \
  -o /output

Flag defaults and deprecations belong to the CLI reference. Gate the job on the process exit code: 0 means success and 1 means failure, including outputFiles copy and beforeCopy failures (see exit codes and errors).

A 30-second shutdown watchdog bounds a wedged graceful shutdown, but it is armed only once shutdown begins: a run that never starts shutting down has no watchdog, so give the CI job an external process deadline as well. --max-cost and --max-time set run-level caps suitable for CI; both are parsed, validated, and listed in the shipped --help.

Headless stderr can contain PostHog ENOTFOUND hw-telemetry.southbridge.ai stack traces when the telemetry host is unreachable. That is unsuppressed log noise from a known telemetry defect, not a run failure.

Check it. Require a failing hank to exit 1, and a completed job to exit 0 with results present. The captured completion observable, from the host side of the same run the container performs, is:

JSONL
{"id": "<id>", "timestamp": "<ts>", "type": "codon.completed", "data": {"codonId": "summarize-notes", "success": true, "cost":"<n>", "duration":"<n>", "exitStatus": {"type": "success"}}}
{"id": "<id>", "timestamp": "<ts>", "type": "state.transition", "data": {"transitionType": "RunCompleted", "runId": "<id>", "transition": {"type": "RunCompleted", "data": {"runId": "<id>"}}, "resultingState": {"currentRunId": null, "runCount": 1, "totalCost":"<n>", "currentRunCost":"<n>"}}}

The container capture also shows the reuse notice: a rerun without --start-new prints Resumed completed execution …, exits 0, and runs nothing new. Do not read that exit 0 as proof of fresh work.

Schedule batch runs on Kubernetes#

A Kubernetes Job with restartPolicy: Never and a bounded backoffLimit matches the batch-process deployment model; ports, locks, and exit-code behavior for that model belong to Deployment model. Adapt the volume and secret wiring to your cluster.

Pass flag values space-separated. The equals-sign form still parses but prints a deprecation warning everywhere, not only in Kubernetes, so do not carry --flag=value into new manifests:

⌁ Terminal
--port 8080
--execution /executions/job-001

The --proxy flag is a separate HTTP proxy for Claude API requests, disabled by default and enabled with --proxy; it is not an authentication layer for the WebSocket control channel.

Mirror the package for air-gapped installs#

Air-gapped environments need both halves of the deployment mirrored: the versioned npm tarball and the public repository tag. The npm tarball ships only dist/ and schemas/ (no CHANGELOG), so the tarball plus the matching repository tag carries everything an air-gapped install needs.

For an Anthropic endpoint behind a corporate proxy, pass the base-URL option:

⌁ Terminal
hankweave hank.json data/ --anthropic-base-url <url>

The equivalent hankweave.json key is anthropicBaseUrl. The old Ollama recipe does not work here: Ollama is not one of the built-in providers (the registry defines anthropic, openai, groq, google, deepseek, and amazon-bedrock), and nothing in the runtime reads OLLAMA_HOST.

Healthcheck a server with no /health endpoint#

Do not probe the main server at HTTP /health: it exposes no such route. The only /health route belongs to the LLM proxy, which is disabled by default and enabled with --proxy.

Instead, read the lock file. The runtime writes <executionPath>/.hankweave/runtime.lock as JSON with the fields {pid, lockId, runId, startTime, lastHeartbeat, port?}, where lockId is a token unique to each lock acquisition, and refreshes lastHeartbeat every 30 seconds:

TYPESCRIPT
    // Update lock file with runId and heartbeat
    interface LockFile {
      pid: number;
      /**
       * Unique per lock acquisition. Pid + "acquired once" is not identity:
       * after >120s of missed heartbeats a successor may legitimately
       * replace this lock, and teardown/heartbeat must then recognize the
       * file is no longer theirs instead of unlinking or refreshing it.
       */
      lockId: string;
      runId: string;
      startTime: string;
      lastHeartbeat: string;
      port?: number; // Optional for backward compatibility with old lock files
    }

    // The new token is committed to this.lockId only AFTER the write
    // succeeds: assigning first would leave the instance holding token B
    // while disk and registry still carry token A on an ENOSPC/EACCES
    // failure — teardown would then release B (a no-op), skip the
    // still-owned A lock, and leak A's registry entry.
    const newLockId = randomUUID();
    const lockData: LockFile = {
      pid: process.pid,
      lockId: newLockId,
      runId,
      startTime: new Date().toISOString(),
      lastHeartbeat: new Date().toISOString(),
      port: this.config.port, // NOTE: May be 0 initially if using dynamic port; updated after server binds

Use both signals in a container healthcheck: the PID must be alive, and lastHeartbeat must be no more than 120 seconds old. On its next start, Hankweave removes a lock whose PID is dead or whose heartbeat is older than 120 seconds and marks the run crashed. Do not replace that test with test -f .../runtime.lock: file existence passes for a wedged server whose heartbeat is stale.

Check it. During a run, verify that the mounted lock's PID is alive and its lastHeartbeat stays no older than about 30 seconds plus scheduling jitter.