The Rabbit Hole Generator gave me NEEDLE this week, and it lands right on a question I keep running into: once you let a coding agent run unattended, who decides whether it actually did the job?
Most of the excitement around coding agents is about making them smarter. NEEDLE is interested in something much less glamorous. It assumes the agent is unpredictable, wraps it in a loop where every step around the agent is fixed and logged, and then refuses to take the agent’s word that it finished. I didn’t want to point a real AI model with its permission prompts switched off at anything, so I ran the whole thing against a fake agent: a 20-line shell script that succeeds, fails, hangs, and lies on cue. The lying one turned out to be the most interesting part.
What is NEEDLE used for in AI agent automation?
NEEDLE is an open-source Rust tool that runs headless coding agents (command-line AI coding tools such as Claude Code, Codex, OpenCode, or Aider, or any program you describe in a small YAML file) as workers against a shared queue of tasks. Each task is called a bead: a ticket with a title, a description, acceptance criteria, and a status. Beads live in a local SQLite database managed by a companion command-line tool, bead (from the bead-rs project, by the same author). A NEEDLE worker picks the next bead, claims it so no other worker can take it, hands it to the agent, and then decides what happened from a fixed list of outcomes (success, failure, timeout, crash) rather than from whatever the agent says. Several workers can share one queue with no central server.
The Architecture: a state machine around a black box
The core design is a finite-state machine: a program that can only ever be in one of a fixed set of states, with a defined rule for every move between them. NEEDLE’s README puts the rule bluntly: “If an outcome can happen, it has a handler. If it doesn’t have a handler, it cannot happen.” A worker loops through select, claim, build prompt, dispatch, and classify, and every transition is spelled out ahead of time. The agent is the only part allowed to be fuzzy, and NEEDLE treats it like a sealed box: it gets a prompt on standard input, it runs as a child process, and it produces an exit code plus whatever it changed in the repo.
Here’s that loop, with every exit path a bead took in the lab below:
That positions NEEDLE between two existing families of tools. Conversational agent frameworks like LangGraph or CrewAI assume an agent chatting in a loop, often with a person nearby. Workflow engines like Temporal assume each step is ordinary code that does the same thing every time. NEEDLE’s bet is that unattended agent work needs the second family’s discipline (explicit states, retries, timeouts, audit trails) applied to the first family’s unpredictable steps.
Two mechanisms carry most of the weight, and both showed up in the lab:
- Atomic claims with fencing tokens. When a worker claims a bead, the store bumps a counter called the claim epoch and hands it out as a fencing token: a number that proves “I’m the current holder of this lock.” Any later change to the bead (closing it, releasing it) has to present that token. If a slow, stale worker wakes up after its claim was taken over, its token is out of date and its write is rejected. Martin Kleppmann’s essay on distributed locking is the standard explanation of why locks alone aren’t enough and fencing tokens are. The everyday version is a valet ticket: having the car keys isn’t enough; you need the ticket that matches the current parking.
- Verification it runs itself. An agent “finishing” a bead means running
bead closewith a reason that ends in averified:block listing the commands it ran and their exit codes. NEEDLE doesn’t trust the list. It takes the committed code, extracts it into a clean temporary directory, and re-runs the workspace’s definition of done (a script or test command the repo itself declares as “this is what finished means”) before it accepts the close.
How the pieces fit
The diagram above is the logic. Here’s the plumbing: which files NEEDLE reads and writes, and the path one bead takes through them. Everything below comes from the v0.6.46 source or from files the lab actually produced.
The files, and what each is for:
| File | Lives in | What it’s for |
|---|---|---|
config.yaml |
~/.config/needle/ |
Global settings: default agent, model routing, worker limits, validation timeouts |
adapters/<name>.yaml |
~/.config/needle/ |
One file per custom agent: how to launch it and feed it the prompt |
.needle.yaml |
the repo | This repo’s overrides, including its declared gates. needle init creates it |
AGENTS.md |
the repo | Written by needle init: instructions for agents on using the bead CLI |
.beads/beads.db |
the repo | The queue itself, a SQLite database managed by bead-rs. Never committed |
.beads/checkpoint/ |
the repo | The committed, portable copy of the queue (see Reality Check 4) |
<adapter>-<worker>-<session>-<date>.jsonl |
~/.needle/logs/ |
The event log: one JSON object per decision |
heartbeats/<worker>.json |
~/.needle/state/ |
A per-worker “still alive” file |
Settings stack in a fixed order, later wins: built-in defaults, then ~/.config/needle/config.yaml, then the repo’s .needle.yaml, then NEEDLE_* environment variables, then command-line flags. That order is printed at the top of the generated config.yaml.
1. The queue. A bead is a row in .beads/beads.db. bead create adds one. Claiming picks the next ready bead by priority, then age, then ID (bead-rs’s fifo-v1 order, per bead claim --help) and assigns it in one SQLite transaction.
2. The worker claims it. needle run starts a worker in its own tmux session. Its claim returns a claim epoch, which is the fencing token from the Architecture section; the agent must present it to close the bead. While it runs, the worker keeps ~/.needle/state/heartbeats/<worker>.json fresh (every 30 seconds by default, health.heartbeat_interval_secs; considered stale after 300, health.heartbeat_ttl_secs) so other workers and NEEDLE’s cleanup pass can tell a dead worker from a slow one. Here’s the one the lab wrote (trimmed):
{
"worker_id": "alpha",
"qualified_id": "fake-agent-alpha",
"state": "BOOTING",
"workspace": "/work/needle-lab",
"last_heartbeat": "2026-10-18T14:18:26.344919087Z",
"beads_processed": 0,
"model": "fake-agent"
}
3. The adapter says how to run the agent. The worker loads ~/.config/needle/adapters/<name>.yaml, a small file describing one agent. Here’s the lab’s, field by field (field names checked against NEEDLE’s AgentAdapter struct and the loader):
name: fake-agent
agent_cli: fake-agent.sh
input_method:
method: stdin
invoke_template: "cd {workspace} && /work/bin/fake-agent.sh < {prompt_file}"
timeout_secs: 8
| Field | What it controls |
|---|---|
name |
What you pass to needle run --agent and what shows up in logs and needle status |
agent_cli |
The agent’s program name. needle test-agent checks it’s on your PATH; it’s for checks and docs, not what actually runs |
input_method |
How the prompt reaches the agent: method: stdin (piped in), method: file (written to a path from path_template), or method: args (passed after a flag) |
invoke_template |
The shell command that actually runs, via bash -c. Placeholders: {workspace}, {prompt_file}, {bead_id}, {model} |
timeout_secs |
Kill the agent after this many seconds (0 means use the global agent.timeout). Newer idle_timeout_secs (no output for N seconds) and hard_timeout_secs (absolute cap) split this in two |
Optional fields you’d reach for with a real agent include model (fills {model}), environment (extra environment variables), version_command, and output_transform (a helper that normalizes the agent’s output so NEEDLE can count tokens). For Claude Code or Codex you don’t write a file at all; NEEDLE has built-in adapters you select with --agent claude or --agent codex. From the source, they differ from mine mostly in invoke_template:
claude: cd {workspace} && claude -p --model claude-sonnet-4-6 --max-turns 30 --output-format stream-json --dangerously-skip-permissions --verbose < {prompt_file}
codex: cd {workspace} && codex exec --model {model} --sandbox workspace-write --json "$(cat {prompt_file})"
Both use timeout_secs: 3600 and an output_transform helper (needle-transform-claude, needle-transform-codex). Note the difference in trust: the Claude adapter turns permission prompts off entirely (Reality Check 6), while the Codex one runs inside Codex’s workspace-write sandbox.
4. The prompt. NEEDLE writes a prompt file for each dispatch and the template feeds it to the agent. The lab’s fake agent saved one: 6,814 bytes for a one-line bead. Trimmed to its skeleton:
[needle:fake-agent-alpha:lab-260074e1:pluck]
## Task
Lab bead NORMAL_SUCCESS
## Description
Marker: NORMAL_SUCCESS. Do not split.
## Workspace
/work/needle-lab
...
## Mandatory pre-close verification checklist
...
2. Verify the committed state, not the working tree. Extract `HEAD` into a named
temporary directory, run the repository's own definition of done there ...
Run `scripts/definition-of-done.sh --fast` when the script exists; otherwise use the
language default (`go build ./... && go vet ./... && go test -short ./...`,
`cargo build --all-targets && cargo test`, `npm test`, or `pytest -q`). It must pass.
...
4. Push the commits with `git push` ...
5. Only after steps 1–4 pass, close the bead:
`bead close lab-260074e1 --reason "..."`
The close reason MUST end with a fenced `verified:` block ...
Only these command prefixes are re-run; anything else in the block is
ignored, never executed: go test, go vet, go build, cargo test, cargo build,
npm test, pytest, make test, scripts/definition-of-done.sh
...
Bead ID: lab-260074e1
Two things worth noticing. The prompt tells the agent exactly how NEEDLE will check its work, so a well-behaved agent runs the same checks first. And only an allowlist of commands from the agent’s verified: block is ever re-run, so an agent can’t smuggle an arbitrary command into the gate.
5. The agent runs. The worker renders invoke_template and runs it with bash -c as a child process, with the claim’s fencing token in NEEDLE_BEAD_FENCING_TOKEN. The agent edits files, commits, pushes, and calls bead close. NEEDLE classifies the run from its exit code and the timer alone, then checks the work itself.
6. The gate. NEEDLE extracts the pushed commit into a clean temporary directory and runs the repo’s definition of done there, the subject of the next section.
7. The outcome. Pass: the bead is closed. Fail: it’s released for a retry, and quarantined for two hours after five failures. Timeout: deferred. Every step lands in ~/.needle/logs/ as an event, which is what the lab timeline below is built from.
The definition of done: the check the whole gate trusts
The definition of done is the repo’s own pass/fail check: a command or script that answers “is this work actually finished?” NEEDLE runs it after the agent claims it’s done, against a clean copy of the committed code, and believes it over anything the agent says. It’s the trust anchor for the whole system, so it’s worth knowing exactly how NEEDLE finds it.
Is it required? No, but without one you get much weaker checking. From the v0.6.46 source (src/config/mod.rs, src/validation/fallback.rs), NEEDLE decides what to run in this order:
-
Gates the repo declares in its
.needle.yaml. If present, NEEDLE runs exactly those and nothing else. NEEDLE’s own repo declares two:gates: - type: command run_in: clean commands: - scripts/definition-of-done.sh --fast - type: command run_in: clean commands: - cargo test --librun_in: clean(the default) means “in a fresh extraction of the exact commit”;run_in: workspaceruns in the shared checkout and is meant only for checks that must see uncommitted state. -
Otherwise,
scripts/definition-of-done.shif the file exists. This is what the lab used. -
Otherwise, a language default picked from a marker file:
go.modrunsgo build ./... && go vet ./... && go test -short ./...;Cargo.tomlrunscargo build --all-targets && cargo test;pyproject.tomlorpytest.inirunspytest -q. Apackage.jsongets a gate only if the host configures one (validation.default_gates.node), because a clean extraction has nonode_modulesand a barenpm testwould fail no matter what the agent did. -
Otherwise, nothing real. NEEDLE only checks that everything was committed (
git status --porcelainis empty) and passes with ano verifier for this workspacewarning. A repo can also opt out of the fallback entirely withvalidation.fallback_gate: false, which NEEDLE logs on every dispatch.
The contract. Exit 0 means done. Any non-zero exit is a verdict of “not done”: the bead is released with a verification-failed label, as in the lab’s LIE_SUCCESS case. A check that can’t run at all (the extraction fails, the command can’t start) is treated as a gate error, not a verdict, and five of those in a row trip the circuit breaker the lab hit. Each gate run is capped by validation.outcome_timeout_seconds (default 50 seconds), and only the first validation.stderr_cap_bytes (default 4,096 bytes) of error output are kept, which is the “capped at 4096” text in the lab’s Fix build titles.
Writing a good one:
- Check what “done” means for this repo, typically tests plus lint plus a build or compile step, and nothing that depends on the agent’s word.
- Make it deterministic. It runs in a fresh copy with no caches, no
node_modules, and no uncommitted files, so it can’t rely on anything not in the commit. Avoid network calls and flaky tests; a flaky gate turns into false “not done” verdicts and, eventually, a tripped circuit breaker. - Keep it fast, under the 50-second default, or raise
validation.outcome_timeout_secondsdeliberately. Puttimeouton slow steps so a hang fails cleanly. - Support
--fastif you like. The prompt tells agents to runscripts/definition-of-done.sh --fast, while NEEDLE’s fallback gate runs it with no arguments, so the no-argument run should be the full check. - Print what failed. That stderr becomes the title of the Fix build bead, truncated to 4 KB.
The lab’s version only had to catch one lie:
#!/bin/sh
# Lab definition of done: no result file may report FAIL.
ls result-*.txt >/dev/null 2>&1 || exit 0
! grep -l FAIL result-*.txt
For a real Python repo, something like this (I ran it in a python:3.12-slim container against a tiny test repo):
#!/usr/bin/env bash
# Definition of done for this repo. NEEDLE runs it in a clean copy of the
# committed code; exit 0 means "done", anything else means "not done".
set -euo pipefail
FAST=0
[ "${1:-}" = "--fast" ] && FAST=1 # NEEDLE's prompt asks agents to run --fast
step() { echo "==> $*"; }
step "lint"
timeout 60 ruff check src tests
step "unit tests"
timeout 120 pytest -q tests
if [ "$FAST" -eq 0 ]; then
step "byte-compile everything"
timeout 60 python -m compileall -q src
fi
echo "definition of done: PASS"
On working code it printed each step and definition of done: PASS with exit 0. After I broke the function under test, --fast failed with exit 1 and pytest’s own output naming the failing assertion (FAILED tests/test_add.py::test_add - assert 0 == 4), which is exactly what a Fix build bead would carry.
Protect it. Because the gate trusts this script and nothing else, an agent that edits it can make “verified” mean whatever it likes. Treat changes to it like changes to CI: a CODEOWNERS entry plus branch protection, so a human has to approve them (Reality Check 5 has the rest).
The Reality Check: where this breaks
That’s the design on paper. Every week, the Rabbit Hole Generator also writes a critique of its pick: where the tool is likely to fail at scale, which assumptions its design quietly depends on, and what security hole it might open. This section goes through that critique point by point, because the places a tool breaks are what tell you where you can lean on it.
The Rabbit Hole Generator raised six points about NEEDLE, in three groups:
- Points 1 to 3 are about scale. I tested all three at small scale: point 1 with a single deliberately awkward folder name, 2 and 3 by reading the event logs from two workers.
- Points 4 and 5 are assumptions the design leans on. Point 5 was the most testable claim of the week, and it’s where the Rabbit Hole Generator got NEEDLE wrong. Point 4 is about many machines sharing one queue, so I simulated two hosts with Docker to test it.
- Point 6 is the vulnerability NEEDLE introduces. I checked the mechanism in the source and README but did not try to exploit it, because a real test means running a real AI model with its permission prompts disabled against content designed to trick it, which is exactly what this lab was set up to avoid.
Having read the source and run the lab, I’d grade these differently than the Rabbit Hole Generator did. Each tested point links to the lab step that tested it.
1. Every dispatch goes through bash -c (tested: it breaks on spaces)
This one is true, and it’s not hidden. The dispatcher spawns bash -c with the adapter’s invoke_template, a shell command with placeholders like {workspace} and {prompt_file}. Those placeholders are filled with a plain find-and-replace (render_template in src/dispatch/mod.rs); nothing is quoted. NEEDLE’s own example templates read cd {workspace} && ..., so I put a workspace at /work/space lab/repo and gave it a bead that succeeds everywhere else. NEEDLE’s log recorded only exit_code: 1 after 0 ms, so the agent never started. Rendering the template by hand, exactly as render_template does, shows why:
rendered: cd /work/space lab/repo && /work/bin/fake-agent.sh < /tmp/p.md
bash: line 1: cd: too many arguments
exit=1
So what: on one tidy machine you’d never hit this; across a fleet with different folder layouts, it’s a silent class of “the agent failed” that has nothing to do with the agent, and the shell error isn’t kept in NEEDLE’s event log. How to avoid it: quote the placeholders in your own adapter templates (cd '{workspace}'), the way NEEDLE’s bundled zcode-headless plugin already does, and keep workspace paths free of spaces and shell characters.
2. Every worker reaches for the same bead first (tested)
Confirmed, at small scale. Selection is deterministic: NEEDLE’s README says “given the same queue state, every worker computes the same ordering.” In the lab, two workers started together and both went for the same top-priority bead. One won; the other logged bead.claim.race_lost 30 milliseconds later and moved on. It happened again on the next free bead.
So what: with two workers that costs nothing. With fifty, every one of them stampedes the same row, and the store spends its time saying no. How to watch for it: the ratio is one jq line over the event logs, so you can track it as you add workers:
cat ~/.needle/logs/*.jsonl | jq -r .event_type | grep -cE 'bead.claim.(succeeded|race_lost)'
If races start to rival successful claims, split the fleet: needle run --help documents strands.explore.workspaces, a fixed list of repos per worker, so groups of workers stop competing for the same queue.
3. Polling instead of events (tested: polling yes, poor observability no)
Partly true. Workers do poll: my two workers emitted 281 heartbeat.emitted events in about two minutes. But the Rabbit Hole Generator’s claim that this gives “poor observability” didn’t hold up. NEEDLE writes a structured event in JSON Lines format (one JSON object per line) for every claim, dispatch, classification, gate result, and release, and needle status rolls those up into useful counters (lab step 5 shows both).
So what: the real cost of polling is wasted cycles and slower reaction, not blindness. At laptop scale it’s noise; on a large fleet it’s worth measuring CPU and store load before assuming it’s free.
4. It assumes the queue’s atomic claims hold up across machines (tested: they don’t, and the collision surfaces in Git)
On one machine, a claim is a single SQLite transaction, which is why two workers there can never both win. The question is what happens with NEEDLE workers on several machines. bead-rs’s own design docs answer half of it. The live database (.beads/beads.db) is local and never committed; what travels between machines is the checkpoint, a set of JSON Lines files under .beads/checkpoint/ that you commit and push like any other file. So each machine has its own database, and the only thing they share is a Git repo:
To see what that means in practice, I simulated two hosts with Docker: a shared bare Git repo on a Docker volume as the “server”, and two containers (host-a, host-b), each with its own clone and its own bead store. Host-a created three beads and pushed; host-b cloned and built its store from the committed checkpoint with bead restore (the bead-rs recovery command, which verifies a named checkpoint generation before loading it). Same bead 0.2.6, same NEEDLE v0.6.46 install as the rest of the lab. I drove bead directly rather than full NEEDLE workers, since workers claim through exactly this command.
Experiment 1: both hosts claim at the same moment. I fired bead claim --assignee <host> --json on both containers at the same clock second:
host-a {"bead_id":"fleet-523ce4e7","assignee":"host-a","claim_epoch":1,"lease":null}
host-b {"bead_id":"fleet-523ce4e7","assignee":"host-b","claim_epoch":1,"lease":null}
Same bead, both successful, both at claim epoch 1. Each claim was perfectly atomic inside its own database; neither database knew the other existed. In a fleet, that’s two agents doing the same work.
Experiment 2: both push. Host-a pushed first and succeeded. Host-b’s push was rejected (! [rejected] main -> main (fetch first)), the normal Git outcome, so it ran git pull as the hint suggests:
Auto-merging .beads/checkpoint/current.json
CONFLICT (content): Merge conflict in .beads/checkpoint/current.json
Auto-merging .beads/checkpoint/forensic.jsonl
CONFLICT (content): Merge conflict in .beads/checkpoint/forensic.jsonl
Auto-merging .beads/checkpoint/previous.json
CONFLICT (content): Merge conflict in .beads/checkpoint/previous.json
Automatic merge failed; fix conflicts and then commit the result.
So the collision does surface, but as a Git text conflict, not as anything bead understands. The conflicting lines in forensic.jsonl (trimmed) are the same bead with two owners:
<<<<<<< HEAD
{"issue":{"assignee":"host-b","base_status":"in_progress","claim_epoch":1,...,"id":"fleet-523ce4e7",...
=======
{"issue":{"assignee":"host-a","base_status":"in_progress","claim_epoch":1,...,"id":"fleet-523ce4e7",...
>>>>>>> 73b95d7
Meanwhile each live store is sure it owns the work: bead show fleet-523ce4e7 says Assignee: host-a on host-a and Assignee: host-b on host-b. bead doctor on host-b did flag an error, but only because the conflict markers made current.json unparseable; its uuid_divergence check reported “No UUID divergence detected”, and nothing named the duplicate claim.
Experiment 3: resolving the conflict, the documented hazard. bead-rs’s ADR-009 describes the risk plainly: “flushing a local store over a checkpoint that has newer content pulled from elsewhere can discard the remote work,” and the rule against it “exists purely as operator discipline, enforced by nothing.” bead-rs deliberately never runs Git; instead its R027 check looks at the state of the store versus the checkpoint. I tried both ways an operator might resolve that merge.
Taking the remote checkpoint (git checkout --theirs) and then running the documented follow-up, bead sync reconcile, was refused, correctly:
bead: Integrity error: sync reconcile refused: covered-ahead integrity failure - event-stream prefix: checkpoint event identity (4b9fbd9c-..., 4) is absent or has different content in the live store
From then on, host-b’s store refused every mutation and even an explicit bead sync flush-only with the same checkpoint_integrity_failure. That’s R027 doing its job: bead 0.2.6 will not let host-b flush its database over newer pulled work. But host-b’s own database still says host-b owns the bead, so whatever agent is working it keeps going.
Keeping the local checkpoint (git checkout --ours) is where work disappears, and bead can’t see it happen. From bead’s point of view on host-b, the checkpoint matches its database perfectly:
Live sequence: 5
Covered sequence: 5
Relationship: aligned
The merge commit pushed as a fast-forward (73b95d7..a819a14 main -> main), and the shared repo now named host-b as the owner and contained zero events from host-a. Host-a’s claim was gone from the shared history, with no error anywhere on host-b; bead doctor there came back clean.
Detection only happened on the losing host, and only after its next pull. Host-a’s git pull was a clean fast-forward, and then:
ERROR checkpoint_freshness: Checkpoint freshness error: Integrity error: covered-ahead integrity failure: event-stream superset: live event identity (4b9fbd9c-..., 4) has different content than the pulled checkpoint
Host-a could no longer close its own bead (checkpoint_integrity_failure), and sync reconcile was refused. The only way forward was an explicit bead restore of the pulled checkpoint, which first refuses (“Target database is not empty … rerun with –allow-non-empty only when replacing that native state is intended”) and, with --allow-non-empty, replaces host-a’s store. Afterwards host-a agrees with everyone else: Assignee: host-b. Host-a’s claim, and any record of the work its agent did, is gone.
Two related details from the docs: bead-rs’s ADR-019 plans a bead sync commit command to make checkpoint commits safer, but it isn’t in bead 0.2.6 (unrecognized subcommand 'commit'). And bead 0.2.6 does ship bead sync fork (R028), which gives each clone its own store identity so independently advancing histories can merge instead of colliding; I didn’t test that path.
So what: on one machine, none of this applies; that’s all the single-host lab above proves. Across machines, “atomic claim” means atomic per machine. Two hosts can claim and work the same bead, and when the checkpoints collide, the outcome depends on how a human (or a script) resolves a Git conflict in JSON Lines files. One resolution wedges a host safely; the other silently erases the other host’s record, and the host that erased it sees nothing wrong. How to run it safely, based on what the lab showed:
- Don’t share one queue across machines through Git unless you accept duplicate work. A single coordinating host per queue (several workers on one machine, one database) keeps claims genuinely atomic.
- If you must sync, pull and
bead sync reconcilebefore any work, and never resolve a checkpoint conflict by picking a side in Git (--ours/--theirs). Treat a conflict in.beads/checkpoint/as an incident: stop workers on both hosts first. - Alert on
checkpoint_freshnesserrors frombead doctorand oncovered-ahead-integrity-failureinbead sync statuson every host after each pull. That’s where the damage becomes visible, on the losing host. - Watch the telemetry seam too: every
attempt.resolvedevent in the NEEDLE logs reported{"backend":"bead-rs","atomic_claim":true,"atomic_resolution":false,"attempt_resolution":true}, and every release was followed bybead.resolution.failedwith reasonownership_lost. On one machine that’s harmless bookkeeping; across machines, alert on any other reason.
5. It assumes exit codes tell you whether the work succeeded (tested: it doesn’t trust them)
This is where the Rabbit Hole Generator got NEEDLE wrong, and it’s the most useful thing I learned this week. NEEDLE explicitly does not trust exit codes. In lab step 5, an agent that exited 0, pushed a commit, closed its bead, and claimed verification passed was caught when NEEDLE re-ran the check itself. In a separate run, an agent that exited 0 and pushed work but never closed its bead was marked bead.orphaned and deferred, not closed.
The real assumption is narrower: NEEDLE trusts the repository’s own definition of done. If that script checks the wrong thing, or the agent edits it, “verified” means only that the agent’s code agreed with the agent’s script. And when a workspace declares no verifier at all, NEEDLE’s source (src/outcome/fallback_verification.rs) shows the only remaining check is that everything the agent did was committed; a clean pass is logged with a no verifier for this workspace warning.
So what: the gate is only as honest as the script behind it. How to keep it honest: make every agent-run repo declare a real definition of done; protect that script so agents can’t quietly weaken it, for example with a CODEOWNERS entry plus branch protection so changes to it need a human review; and treat any no verifier warning as a finding, not a pass.
6. Introduced Vulnerability (checked in source, not exploited)
The Rabbit Hole Generator flagged command injection through template rendering into bash -c. The mechanism is real (point 1 shows how unquoted values behave), but the obvious attack doesn’t line up. The bead’s text (the part an outsider is most likely to influence) never goes into the shell template; it’s written to a prompt file and fed on standard input. What gets substituted into the shell string are values the operator controls: the workspace path, the model name, the bead ID.
The bigger introduced risk is the one the README flags as a heads-up: the built-in Claude adapter runs claude -p ... --dangerously-skip-permissions. That flag tells Claude Code to skip its usual permission prompts, the “may I run this command?” confirmations, which is what unattended operation requires. So the bead text doesn’t need to inject shell syntax; it only needs to persuade an agent that is already allowed to run any command in your checkout, with whatever credentials that machine holds. That’s prompt injection with the safety prompts turned off. It’s the same blast-radius question I dug into with agent-bom: what can this agent actually reach if something convinces it to misbehave?
The lab turned up one more thing that widens that reach. --workspace is not a boundary; needle run --help says so directly:
-w, --workspace <WORKSPACE>
Home workspace for this worker.
NOT an exclusive scope. The Explore strand still auto-discovers every directory containing `.beads/` under `strands.explore.workspace_root`, so the worker can claim beads in other repos.
I watched it happen: a worker I started in a second repo went and claimed beads from the first one. So what: NEEDLE makes the orchestration deterministic. It doesn’t make the agent safe. How to contain it: run workers in disposable containers or VMs with only the credentials that one queue needs, pin each worker to an explicit strands.explore.workspaces list, and treat bead text from anyone you don’t fully trust as untrusted input to a program that can run anything.
The 30-Minute Lab: a fake agent that lies on purpose
The Rabbit Hole Generator’s challenge: show deterministic dispatch around a worker that looks unpredictable, without a real AI model. Two workers, one shared queue, one winner per claim, and distinct outcome paths for success, failure, and timeout. I ran everything in a throwaway Ubuntu container, per the usual rule for this series, and added a fourth case the challenge didn’t ask for: an agent that lies.
1. Installation
docker run -d --name needle-lab ubuntu:24.04 sleep infinity
docker exec -it needle-lab bash
apt-get update && apt-get install -y git tmux curl ca-certificates jq
curl -fsSL https://github.com/jedarden/NEEDLE/releases/download/v0.6.46/install.sh | bash
export PATH=$HOME/.local/bin:$PATH
needle --version # needle 0.6.46 (b619b6a 2026-10-17T11:12:04Z)
bead --version # bead 0.2.6 (e0bd976 2026-09-27T07:31:48Z)
The installer verified SHA-256 checksums and dropped native ARM64 binaries, so it ran without emulation on a Mac. NEEDLE launches each worker inside tmux, a terminal multiplexer that keeps a program running in a detachable session, which is why it’s in the install line.
A quick note on drift, since it shapes a few steps below. This series works through a backlog of Rabbit Hole Generator picks, and this one was generated in June. NEEDLE moves fast (it published five releases the day before I ran this lab), so some of the generator’s suggested steps no longer matched: it said to build from source with Rust’s cargo and use a queue command called br, where today you install prebuilt binaries, the queue command is bead, and the worker flag is --identifier, not --identity. I pinned v0.6.46 so the commands here stay reproducible, and I’ll only call out drift again where it changes what you type.
2. A workspace NEEDLE will accept
git init --bare /work/origin.git && git clone /work/origin.git /work/needle-lab
cd /work/needle-lab
bead init --prefix lab
needle init --backend bead-rs
git add -A && git commit -m init && git push -u origin main
The local bare repository (origin.git, a repository with no working files, the kind a server hosts) isn’t decoration. NEEDLE’s shipped-work gate is on by default: before it accepts that a bead is done, it checks that the agent’s commit was pushed to the branch’s upstream. A plain git init with no upstream can’t pass, so a bare repo on the same disk gives it something to check against without touching GitHub.
I also committed the tiny definition-of-done script shown in the definition of done section. With no gates declared in .needle.yaml, scripts/definition-of-done.sh is the first thing NEEDLE looks for:
#!/bin/sh
# Lab definition of done: no result file may report FAIL.
ls result-*.txt >/dev/null 2>&1 || exit 0
! grep -l FAIL result-*.txt
3. The fake agent and its adapter
This is the stand-in for an AI model. It reads the prompt NEEDLE builds, looks for a marker word in the bead’s description, and behaves accordingly. No model, no API key, no credentials:
#!/usr/bin/env bash
set -uo pipefail
input="$(cat)"
id="$(echo "$input" | grep -oE "^Bead ID: lab-[a-z0-9]+" | cut -d" " -f3)"
[ -n "$id" ] || exit 3 # not a work prompt (see "mitosis" below)
close() {
echo "$1" > "result-${id}.txt"
git add "result-${id}.txt" && git commit -qm "fake-agent: ${id}" && git push -q
bead close "$id" --fencing-token "$NEEDLE_BEAD_FENCING_TOKEN" --reason "wrote result-${id}.txt
\`\`\`verified:
scripts/definition-of-done.sh exit=0
\`\`\`"
}
if echo "$input" | grep -q TRIGGER_TIMEOUT; then sleep 30
elif echo "$input" | grep -q TRIGGER_FAIL; then exit 2
elif echo "$input" | grep -q LIE_SUCCESS; then close "FAIL: did not actually do it"
else close "done by fake agent"
fi
LIE_SUCCESS is the case I added: the agent writes a result that its own definition of done will reject, then closes the bead claiming the check passed. That’s a fair model of an AI agent confidently reporting “all tests pass” when they don’t.
The adapter is the one explained field by field in How the pieces fit, saved as ~/.config/needle/adapters/fake-agent.yaml. (Its unquoted cd {workspace} is the pattern Reality Check 1 is about; it’s fine here because the lab paths have no spaces.)
Getting the adapter right took three tries, and each failure taught me something. input_method: stdin fails to parse; it’s a tagged value that needs the nested method: key. The custom-adapter example in docs/configuration.md uses a different shape again (adapter: / cli: / args:) from the structure the loader actually reads (name / agent_cli / invoke_template), so I read the source to get it right. NEEDLE has a built-in check for exactly this, needle test-agent, which validates an adapter before any worker touches it. With /work/bin on the PATH (output trimmed):
Adapter: fake-agent
CLI: fake-agent (found at /work/bin/fake-agent.sh)
Input: stdin
Timeout: legacy: 8s (single timeout)
Probe: exit 3 (2ms)
Status: READY
The probe sends a non-work prompt, so my agent’s exit 3 is the expected answer.
4. Three walls before the first bead ran
Here’s the “My Battles With Technology” part. With the adapter ready and beads queued, needle run printed “Started worker”, and then nothing happened. The worker had died silently.
-
A fresh install can’t start a worker. I kept the tmux pane open after exit (
tmux set -g remain-on-exit on) and found the actual error:bash: line 1: /root/.needle/logs/needle-fake-agent-alpha.stderr.log: No such file or directory Pane is dead (status 1, Sun Oct 18 13:01:38 2026)The command that launches the worker redirects its error output into
~/.needle/logs/before anything has created that directory. On a brand-new install, every worker dies on its first line and leaves no log.mkdir -p ~/.needle/logsfixed it. -
--agent fake-agentwasn’t enough. Model routing still fell back to the defaultclaudeadapter, which didn’t exist in the container. The generated~/.config/needle/config.yamlships withdefault_adapter: claudeunderrouting:; changing it tofake-agentcleared it. -
The agent couldn’t close its own bead. NEEDLE’s prompt template tells the agent to run
bead close {bead_id} --reason "..."and never mentions a token. An agent that does exactly that is rejected every time, and you can watch the claim epoch climb as NEEDLE retries and re-claims:bead: Lease conflict: Claim-epoch credential required: issue nt-7ea95683 is claimed at claim epoch 1; pass --fencing-token 1 (...) bead: Lease conflict: Claim-epoch credential required: issue nt-7ea95683 is claimed at claim epoch 2; pass --fencing-token 2 (...) bead: Lease conflict: Claim-epoch credential required: issue nt-7ea95683 is claimed at claim epoch 3; pass --fencing-token 3 (...)That’s the fencing token from the Architecture section doing its job, a little too well. NEEDLE does pass the token to the agent, as the environment variable
NEEDLE_BEAD_FENCING_TOKEN, butbead0.2.6 doesn’t read it. NEEDLE’s own example adapter (examples/adapters/noop.yaml) passes--fencing-token $NEEDLE_BEAD_FENCING_TOKENexplicitly, so that’s what my agent does. This isn’t a bug I discovered: bead-rs’s own tracker has a priority-1 item for it, which says 5,216 agent mutations were rejected with this exact error.
None of those three problems is about agents. They’re ordinary first-run friction in a project that moves fast. But they’re also a reminder that “deterministic orchestration” still runs on a lot of configuration, and getting it wrong fails quietly.
5. Running two workers against one queue
for m in NORMAL_SUCCESS TRIGGER_FAIL TRIGGER_TIMEOUT LIE_SUCCESS; do
bead create --title "Lab bead $m" --priority 2 --issue-type task \
--description "Marker: $m. Do not create sub-issues, split this work, or decompose it."
done
needle run --agent fake-agent --identifier alpha
needle run --agent fake-agent --identifier beta
Both workers start in their own tmux session and share the queue. Every decision lands as a JSON line in ~/.needle/logs/. Here’s the trimmed timeline (minutes:seconds, worker, event, key fields), with each bead’s marker added in parentheses so it’s readable:
01:48.077 beta bead.claim.succeeded lab-3cd7b7e3 (NORMAL_SUCCESS)
01:48.107 alpha bead.claim.race_lost lab-3cd7b7e3
01:48.125 beta agent.completed lab-3cd7b7e3 exit_code=0
01:48.131 beta verification.passed lab-3cd7b7e3 gates_run=1
01:48.148 beta outcome.handled lab-3cd7b7e3 action=closed
01:48.282 beta agent.completed lab-e55fd5fd (TRIGGER_FAIL) exit_code=2
01:48.316 beta outcome.handled lab-e55fd5fd action=released:dispatch_failed
01:48.381 beta bead.mitosis.evaluated lab-e55fd5fd splittable=false
01:48.427 alpha bead.claim.succeeded lab-5747ee6c (TRIGGER_TIMEOUT)
01:48.460 beta bead.claim.race_lost lab-5747ee6c
01:48.781 beta agent.completed lab-6f94a7cb (LIE_SUCCESS) exit_code=0
01:48.785 beta outcome.classified lab-6f94a7cb outcome=failure
01:48.799 beta verification.failed lab-6f94a7cb fallback verifier 'scripts/definition-of-done.sh' exited Some(1)
01:48.851 beta bead.false_close_detected lab-6f94a7cb class=never_compiled
01:48.851 beta outcome.handled lab-6f94a7cb action=released:gate_failed
01:48.958 beta bead.claim.succeeded lab-30d07194 priority=0 ("Fix build: e8b413b...")
... three more Fix build attempts, each re-verified and rejected ...
01:49.639 beta workspace.gate_degraded consecutive_errors=5
01:56.460 alpha agent.timeout lab-5747ee6c (TRIGGER_TIMEOUT) elapsed_ms=8005
01:56.527 alpha outcome.handled lab-5747ee6c action=deferred
Here’s how to read it, line by line (the diagram shows the same paths):
The races (claim.succeeded, then race_lost 30 ms later). Both workers picked the same bead because the selection order is deterministic, and exactly one claim won. It happened twice. That’s the challenge’s “one atomic claim winner per item,” and Reality Check 2 in miniature.
NORMAL_SUCCESS closed only after verification.passed. The agent’s exit 0 is the least important line here. What closed the bead was NEEDLE extracting the committed code and running the definition of done itself (gates_run=1). For comparison, in a separate run an agent that exited 0 and pushed its work but never called bead close got this instead (events trimmed to the relevant fields):
{"e":"outcome.classified","d":{"exit_code":0,"outcome":"success"}}
{"e":"bead.orphaned"}
{"e":"outcome.handled","d":{"action":"deferred","outcome":"success"}}
NEEDLE’s source describes that case as “shipped work detected but acceptance remains open.” An exit code alone isn’t enough.
TRIGGER_FAIL went down the failure path, and NEEDLE kept trying. Exit code 2 was classified as a failure and the bead was released for another attempt. Between retries, NEEDLE dispatched a different kind of prompt, a “mitosis” template, asking the agent whether the bead should be split into smaller pieces. My fake agent declined (that’s the exit 3 line in the script), and the log recorded splittable: false. Each retry prompt also carried a summary of the previous attempts, so the prompt grew from 6,855 to 7,291 to 7,384 bytes. In this run the circuit breaker (below) paused the workspace before TRIGGER_FAIL ran out of retries, so a later worker picked it back up and finished the story:
{"event_type":"bead.quarantined","bead_id":"lab-e55fd5fd","data":{"failure_count":5,"round":1,"until":"2026-10-18T15:04:07.511384053+00:00"}}
After five failures the bead was benched for two hours instead of burning money in a loop.
LIE_SUCCESS is the line worth stopping on. The agent exited 0, pushed a commit, and closed the bead with a verified: block claiming scripts/definition-of-done.sh exit=0. NEEDLE classified the outcome as failure four milliseconds later anyway. It re-ran the definition of done against a clean extraction of the commit (not the shared working copy the agent could have tampered with), saw exit 1, logged bead.false_close_detected, and reopened the bead. Then it filed a new priority 0 bead titled “Fix build: e8b413b…” pointing at the exact commit that broke the gate. Priority 0 jumps the queue, so the very next claim went to the fix.
That’s also where the lab got a little funny. My fake agent “fixed” the build by writing another passing result file, which did nothing about the FAIL file it left behind. So each fix was rejected too and generated another Fix build bead. After five consecutive gate errors, NEEDLE stopped making fix beads, marked the workspace’s gate as degraded, and filed a single “Gate broken” bead for a human. The queue afterwards, from bead list:
lab-30d07194 Fix build: e8b413b7... fallback verifier 'scripts/definition-of-done.sh' exited Some(1) ... Open P0
lab-e5085e50 Fix build: db9ee0ea... fallback verifier 'scripts/definition-of-done.sh' exited Some(1) ... Open P0
lab-3cd7b7e3 Lab bead NORMAL_SUCCESS Closed P2
lab-e55fd5fd Lab bead TRIGGER_FAIL Open P2
lab-5747ee6c Lab bead TRIGGER_TIMEOUT Open P2
lab-6f94a7cb Lab bead LIE_SUCCESS Open P2
lab-a3f8967f Gate broken: fallback_definition_of_done ... Open P2
(Condensed to one line per bead.) The amplification loop had a circuit breaker.
TRIGGER_TIMEOUT was killed on schedule. NEEDLE killed the hung agent at 8,005 ms against an 8-second limit, recorded exit code 124 (the conventional “killed by a timeout” code, borrowed from the GNU timeout command), classified it as timeout, and deferred the bead rather than failing it. A timeout and a failure take different paths, which is the challenge’s third outcome class.
needle status sums the run up in two lines worth more than the whole table:
Total beads completed / claims: 1 / 8
False Closes by Adapter:
ADAPTER MODEL COUNT
--------------------------------------------------------------------
fake-agent - 5
Eight claims, one real completion, and five times the agent said it was done when it wasn’t. In a real fleet, that per-adapter false-close count is the number I’d watch. It’s a direct measure of how often a given model claims success it didn’t earn.
My findings file, as the challenge asked:
Worker beta claimed: NORMAL_SUCCESS (won race vs alpha), TRIGGER_FAIL, LIE_SUCCESS, two Fix build beads (twice each)
Worker alpha claimed: TRIGGER_TIMEOUT (won race vs beta)
NORMAL_SUCCESS => success, verified by re-run definition of done, closed
TRIGGER_FAIL => failure, released, retried, split declined, quarantined after 5 (follow-up run)
TRIGGER_TIMEOUT => timeout at 8.0s, deferred
LIE_SUCCESS => exit 0 but verification failed, false close detected, Fix build filed
Deterministic ordering observed: yes (both workers chose the same bead, twice)
Where this fits in a real environment
NEEDLE makes sense when you’ve already decided agents will do unattended work (backlog cleanup, dependency bumps, test fixes across many repos) and your actual worry is accountability, not capability. The property the lab showed most clearly: it measures what the agent did against your own definition of done and keeps a record of when the agent’s claims didn’t match. If your repos have a meaningful definition-of-done script or test suite, NEEDLE turns it into a gate that agents can’t talk their way past.
It’s only as good as that gate, though (Reality Check 5). A repo with weak or missing tests gets a verifier that passes almost anything, or only a “was it committed” check with a warning. And the safety of the whole thing still hinges on point 6: workers run agents with permission prompts disabled, and a worker isn’t confined to the repo you started it in, so the deterministic wrapper sits around something that can execute anything the machine can. I’d run it in disposable environments with scoped credentials and pinned workspace lists, not on a workstation holding the keys to production. And keep each queue on one machine: point 4 showed that two hosts syncing through Git can claim the same bead, and a routine merge resolution can erase one host’s work without either host’s tooling noticing at the time. Even on one machine, point 2 (every worker racing for the same row) is worth measuring as the fleet grows.
Actionable Takeaways
- Judge agents by re-run verification, not exit codes. The most valuable thing NEEDLE does is re-run your checks on the committed code after the agent says it’s done, and count the times it lied.
- Track false closes per model.
needle statusreports them per adapter. A climbing number is the earliest signal that a model or prompt change made your agents overconfident. - Keep each queue on one machine. Claims are atomic per database; two hosts syncing through Git can both claim a bead, and a Git merge can quietly erase one of them.
- Protect the definition of done. It’s the one thing the whole gate trusts, so changes to it should need a human review.
- Treat unattended agents as code execution with your credentials.
--dangerously-skip-permissionsmeans the bead text is effectively input to a program that can run anything, in any repo the worker can discover. Contain it accordingly.
The agent can stay unpredictable. Everything around it has to be boring, written down, and checked twice.