Free playbooks in your inbox
tutorial · Claude Code Loops

How to Build a Multi-Step Loop With Claude

Run two Claude Code loops back to back every night, where the second only starts if the first one's work passed its check. It takes four checks in shell, and the schedule has to live somewhere other than /loop, whose recurring tasks expire 7 days after creation.

From the youcanbuildthings catalog ▸ Build-tested

Hello builders,

Once one Claude Code loop works, the next win is a multi-step loop: two of them running every weekday as a pipeline, where the second picks up the first one’s verified output and you never touch a keyboard. The usual version is two scripts in a row, which lets the second loop build happily on top of the first loop’s failure, and if you scheduled it with /loop it quietly stops a week later. So here is the short answer to graph engineering vs loop engineering: you already have the loops, and you add one edge.

Somebody asked it plainly: “Are we still in loop engineering or we shifted to graph engineering?” And somebody else, more honestly: “Am I gonna have to learn what graphs are?” No. The line worth keeping, with 1,325 bookmarks behind it, is “Loops are for exploring. Graphs are for scaling.” We use a loop when we do not know how many iterations the work takes. A graph earns its place when you know the steps and the interesting part is the dependency between them.

The edge is a refusal

Two loops that merely run in order are two scripts. What makes them a graph is that loop B is unable to start when loop A’s output fails its gate.

Graph engineering vs loop engineering: loop A, a dependency upgrade, passes has it run, did it exit clean and is there a verdict, or loop B did not start.

The edge reads a verdict, so each loop needs a judge that did not do the work. We give the repo a verifier subagent at .claude/agents/verifier.md:

---
name: verifier
description: Judges whether a phase may advance. Never edits code.
model: sonnet
tools: Read, Grep, Glob, Bash
---
Run the test suite yourself, read its raw output, and check git diff --stat against the task.
Advance only if the suite shows zero failures and the diff stays in scope. Never edit files.
Write only .loop/verdict.json as {"advance": true|false, "reason": "<one sentence>"}.

Then a Stop hook that refuses to let a session end until that verdict says advance, saved as .claude/hooks/phase-gate.sh and registered on Stop in .claude/settings.json. Every claude -p run in the repo now finishes with a verdict on disk or runs out of turns trying, so install it only once your loops have the verifier:

#!/usr/bin/env bash
V=.loop/verdict.json
[ -f "$V" ] || { echo "GATE: no verdict on disk. Run the verifier subagent, then stop." >&2; exit 2; }
[ "$(jq -r '.advance' "$V")" = "true" ] || { echo "GATE: verifier refused: $(jq -r '.reason' "$V")" >&2; exit 2; }
{ "hooks": { "Stop": [ { "hooks": [
  { "type": "command", "command": ".claude/hooks/phase-gate.sh" }
] } ] } }

Now we add three functions to .loop/lib.sh. The first bounds a run on dollars, turns and wall-clock, and timeout is GNU coreutils, so on a Mac run brew install coreutils first:

run_bounded() {                    # $1 = dollars, $2 = seconds, $3.. = prompt
  local budget="$1" secs="$2"; shift 2; mkdir -p .loop/runs
  timeout --signal=INT "$secs" claude -p --max-budget-usd "$budget" --max-turns 20 \
    --output-format json "$@" > ".loop/runs/$(date -u +%Y%m%dT%H%M%SZ).json"
}

run_node() {                       # $1 = node name, $2.. = the loop invocation
  local node="$1"; shift
  mkdir -p ".graph/$node"; rm -f .loop/verdict.json ".graph/$node/verdict.json"   # no stale verdicts
  "$@"; local rc=$?
  cp .loop/verdict.json ".graph/$node/verdict.json" 2>/dev/null || true
  printf '%s' "$rc" > ".graph/$node/rc"
  return $rc
}

edge_ready() {                     # $1 = upstream node
  local v=".graph/$1/verdict.json" r=".graph/$1/rc"
  [ -f "$r" ] || { echo "edge blocked: $1 has not run" >&2; return 1; }
  [ "$(cat "$r")" = "0" ] || { echo "edge blocked: $1 exited $(cat "$r")" >&2; return 1; }
  [ -f "$v" ] || { echo "edge blocked: $1 produced no verdict" >&2; return 1; }
  [ "$(jq -r '.advance' "$v")" = "true" ] || { echo "edge blocked: $1 verdict says $(jq -r '.reason' "$v")" >&2; return 1; }
}

Then the pipeline itself. We save it as .loop/pipeline.sh and run it from the repo root with bash .loop/pipeline.sh, with loop A’s dependency-upgrade prompt and loop B’s test prompt set at the top:

source .loop/lib.sh
UPGRADE_TASK="Upgrade the outdated dependencies. Change only the lockfile and version strings."
TEST_TASK="Run the full test suite and fix any test the upgrade broke. Do not edit the tests."
run_node upgrade run_bounded 5.00 1800 "$UPGRADE_TASK"
if edge_ready upgrade; then
  run_node tests run_bounded 5.00 1800 "$TEST_TASK"
else
  echo "GRAPH: downstream node 'tests' not started." >&2
fi

The picture asks three questions of loop A: has it run, did it exit clean, is there a verdict. The code asks a fourth, because the verdict also has to say advance. Why check all of them? Because a run can exit zero having done nothing useful. And if you ever enforce this edge with a hook, the hook must exit 2, because an exit 1 between two loops fires, logs, and lets the downstream node start anyway.

Now fail loop A’s gate on purpose and watch loop B not start. Save the output that shows it refusing.

A schedule that outlasts a week

Here’s the fact that decides where the pipeline lives. From Anthropic’s scheduled tasks docs:

“Recurring tasks automatically expire 7 days after creation. The task fires one final time, then deletes itself. This bounds how long a forgotten loop can run.”

It’s a sensible guard for a forgotten loop, and it quietly ends a pipeline on day eight. The same page points durable schedules at Routines or Desktop scheduled tasks, which run a Claude prompt on a timer and suit a single loop well. The Desktop page spells out the trade:

  • persistent across restarts, with no open session required
  • runs only while the app is running and the computer is awake, and “Closing the laptop lid still puts it to sleep”
  • after a missed run, exactly one catch-up run for the most recently missed time in the last seven days

If your laptop cannot stay awake, Routines run in the cloud instead, with a one-hour minimum interval and a fresh clone that can’t see your local files. Our pipeline is a shell file, though, and work that’s already shaped like a repository has a third home: a scheduled GitHub Actions job that runs bash .loop/pipeline.sh, on a runner where Claude Code is installed and signed in.

How big should the graph get? I stop adding nodes when I can no longer say, without looking, what happens downstream if node two fails, and for me that arrives around four. If you’re choosing between a fourth node and a better gate on your first one, we’d take the gate every time, since it improves everything downstream.

Now go build something this weekend!

John Cook

Why trust this? Every youcanbuildthings guide is pulled from a build-tested book: code that ran in production before it was written down.