windlassa pipeline runner with no model inside it

Finding

Three things this pipeline runner refuses to do, and the test that proves each.

A pipeline is a graph: script nodes, agent nodes and human gates, joined by edges that only advance when a verify command exits 0. The runner starts processes, reads exit codes, enforces caps and appends to a log. That is all it does.

Open the recorded run Run it yourself

The demo graph, as declared
  1. seedscriptexit 0
  2. edge verify
  3. bumpscriptretried
  4. edge verify
  5. gate1gateexit 3, waits
  6. a human answered
  7. finishscriptexit 0
  • edge verified
  • retry on fail
  • human gate
  1. It will not run a model inside the runner.

    There is no model call in src/runner.mjs and nothing to make one with: package.json declares 0 runtime dependencies. An agent node is a spawned claude -p child process, checked the way a script is checked: by its exit code, by whether the files it promised now exist, and against a USD cap the runner passes down. The selftest runs a whole graph with zero model calls.

    Tests
    the runner has no model inside it: zero dependencies, no HTTP client, no model SDK and an agent node is a spawned claude process, checked like a script in tests/no-model.test.mjs
    Command
    npm test
  2. It will not proceed past a human gate.

    A gate node writes its question to disk and the process exits with code 3. Nothing times out into an assumed answer; only answer followed by resume moves the run forward. A declared cap that trips (stall, budget-while-working, cap:attempts, cap:wall, cap:usd) halts with the same code and a named reason. Exit 3 is never an error. It is the runner refusing to answer for you.

    Tests
    exit 3: the run stops at the gate and waits for a human in tests/redaction.test.mjs; selftest checks run pauses at gate (exit 3) and paused at gate1 in src/runner.mjs
    Command
    npm test, or the five lines under Run it yourself
  3. It will not leak a planted secret.

    The event log records a hash of everything a node produces and never the contents, so a replay of a client engagement carries no client material. Gate answers are the one value logged in full, because the decision a human made is the fact an attended replay exists to show. So every string inside an answer is redacted before it is written, and that promise is tested against the real runner, not a mock:

    1. A GitHub-token-shaped string was planted inside a gate answer. The test asserts it never reaches events.jsonl, and that the decision itself, {"ok": true}, still does.
    2. Then the redaction was removed and the same scenario was run again. The token came through, which is the test failing for the right reason. That control is now a permanent test: a copy of the runner with the redaction line deleted must let the token survive, or the suite fails.

    The limit, stated: the redaction knows 11 token shapes, listed under Receipts, and it only sees what answer_schema admits. A schema that accepts free text can accept anything.

    Tests
    a secret planted in a gate answer never reaches events.jsonl, the human decision itself still survives, or the replay shows nothing and negative control: with the redaction removed, the planted token survives, so the first test must fail without it in tests/redaction.test.mjs
    Command
    npm test

Replay

The run, as it was recorded

Below is part of the demo replay: the rendered output of a real run of examples/demo/pipeline.graph.json through run, answer, resume, replay and view. Nothing on it was typed by hand. bin/build-site.mjs re-renders it from the committed run data with the same windlass view command and copies it here, and tests/site.test.mjs fails if the copy and examples/demo-replay.html differ by one byte.

What to look for: bump needed 2 attempts, because its outgoing edge's verify command failed the first time and the declared on_fail retry sent the run back to seed. Then gate1 paused the run with exit 3, a human answered, and only then did it resume. 34 events were recorded in all.

Run header, from state.json and events.jsonl
graph
windlass-echo-demo (v1)
run id
demo
status
done
halt reason
none
started at
2026-09-05T11:31:14.256Z
last event recorded at
2026-09-05T11:31:22.649Z
active wall time
1.06 s (1056 ms) (recorded; excludes time paused at a human gate)
total cost
$0
current node
finish
nodes / edges declared
4 / 3
events recorded
34

caps declared in graph: attended=true, max_wall_minutes=5, max_attempts_per_node=2, max_usd_total=1

Nodes, final status
idkindfinal statusattemptslast exitdurationcost
seedscriptdone20 (pass)142 ms$0
bumpscriptdone20 (pass)135 ms$0
gate1gatedone10 (pass)0 ms$0
finishscriptdone10 (pass)90 ms$0
The gate: paused, answered, resumed
gate1 gate attempt 1 paused — waiting on human2026-09-05T11:31:15.130Z
gate question

state.json has count>=2 — confirm to proceed to finish?

answer written to
gate1-answer.json
reads (declared in graph, not read by the viewer)
state.json
answer_schema (declared in graph)
{
  "type": "object",
  "required": [
    "ok"
  ],
  "properties": {
    "ok": {
      "type": "boolean"
    }
  }
}
2026-09-05T11:31:15.132Z — graph paused, halt reason: gate
2026-09-05T11:31:22.127Z — a human answered gate gate1: {"ok":true}
2026-09-05T11:31:22.464Z — resumed at gate gate1
gate1 gate attempt 2 0 (pass)2026-09-05T11:31:22.466Z
gate answered

at 2026-09-05T11:31:22.467Z, written to gate1-answer.json

the answer the human gave
{
  "ok": true
}
exit code
0 (pass)
duration
0 ms
cost
$0
outgoing edge verify
edgeexitduration
gate1->finish0 (pass)90 ms

Open the full replay one file, 16 KB, no JavaScript, no network requests; it passes windlass view --check

Run it yourself

Reproduce exit code 3

Node 22 or newer, zero runtime dependencies. The last line exits with code 3.

git clone https://github.com/jamessuuu/windlass
cd windlass
npm install
mkdir try && cp examples/echo/pipeline.graph.json try/
node bin/windlass.mjs run try/pipeline.graph.json --run-id try --allow-nested

It prints GRAPH PAUSED, halt_reason: gate, current: gate1 and writes try/gate1-answer.json.request.json, the question a human is being asked. Read the exit code with echo $?, or $LASTEXITCODE in PowerShell. Nothing continues until you answer:

node bin/windlass.mjs answer try/pipeline.graph.json gate1 '{"ok":true}' --run-id try
node bin/windlass.mjs resume try/pipeline.graph.json --run-id try --allow-nested
node bin/windlass.mjs replay try/pipeline.graph.json --run-id try
node bin/windlass.mjs view try/.graph-runner/try --out try/replay.html

--allow-nested is there because the runner refuses to start inside a live Claude Code session by default: a nested claude -p has been seen to hang. A script-only graph has nothing to spawn, but the runner cannot know that in advance. npm test runs the whole suite, selftest included.

Receipts

Every number on this page, and where it came from

Each value is written by bin/build-site.mjs from the file named beside it, and tests/site.test.mjs derives every one again and fails if the page disagrees. None is typed.

WhatValueArtifactRegenerate with
Selftest checks passed25 / 25src/runner.mjs, the selftest() functionnpm run selftest
Tests in the suite41 (no-model 2, redaction 7, site 14, viewer 18)tests/*.test.mjs, one test() eachnpm test
Runtime dependencies0package.json, "dependencies"node -e "console.log(Object.keys(require('./package.json').dependencies||{}).length)"
Nodes and edges in the demo graph4 nodes, 3 edgesexamples/demo/pipeline.graph.jsonnode bin/windlass.mjs validate examples/demo/pipeline.graph.json
Human gates in the demo graph1examples/demo/pipeline.graph.json, nodes with kind "gate"same as above
Events recorded in the demo run34examples/demo/.graph-runner/demo/events.jsonl, one line eachnode bin/windlass.mjs replay examples/demo/pipeline.graph.json --run-id demo
Attempts the bump node needed2examples/demo/.graph-runner/demo/state.json, nodes.bump.attemptssame as above
Active runner time1056 ms, excluding time paused at the gatestate.json, active_mssame as above
Total cost$0, measured (script nodes cost nothing)state.json, usd_totalsame as above
The answer the human gave{"ok":true}events.jsonl, the gate-answer eventsame as above
Token shapes the redaction knows11src/runner.mjs, SECRET_PATTERNSlisted below
Replay page16 KB, one file, no script, no remote resourceexamples/demo-replay.htmlnode bin/windlass.mjs view examples/demo/.graph-runner/demo --out examples/demo-replay.html, then node src/viewer.mjs --check examples/demo-replay.html

Token shapes the redaction knows

From SECRET_PATTERNS in src/runner.mjs. Each pattern is built by string concatenation so the source never contains a token-shaped literal itself.

  1. github token
  2. github oauth
  3. github fine-grained PAT
  4. anthropic key
  5. openai-style key
  6. aws access key
  7. slack token
  8. google api key
  9. telegram bot token
  10. private key block
  11. stripe live key

Limits

What this page does not show

  • One fixture graph of 4 nodes, script-only. Nothing here claims the design holds at fifty nodes or with many agent nodes; the runner has not been run at that scale.
  • The demo's cost column reads $0 because script nodes cost nothing and the runner measured zero. A field the log never recorded renders as "not recorded", never as 0; that rule has its own tests in tests/viewer.test.mjs.
  • The redaction is a list of token shapes, not an understanding of secrets. Keep answer_schema narrow.
  • The replay viewer has been read on desktop and in print. It has not been tested with a screen reader.
  • Exercised on Windows with Git Bash and PowerShell. Not yet on Linux or macOS.