← Back to Guides
GuideFor: AI Engineers, ML Engineers, Platform Engineers, AI Systems Architects

How to Build a Claude Code Agent Loop That Cannot Run Away

Build a loop that reruns every failing test before it touches code, fixes only the failures that reproduce, and is stopped by a hook its own instructions cannot override.

#tutorial#intermediate#claude-code#loop-engineering#agent-loops#hooks#pytest

What you'll build

A Claude Code agent loop that watches a Python repo, sorts real test failures from flaky ones, fixes only the ones that reproduce, and stops itself on two hook-enforced ceilings it cannot talk its way past.

Most loops skip the next part. Three tests are red, and the loop reruns the suite five times before it edits a single line:

code
  run 1: 3 failed  run 2: 3 failed  run 3: 2 failed  run 4: 3 failed  run 5: 3 failedVerdicts:  REAL   5/5 failed  tests/test_pricing.py::test_discount_is_a_percentage  REAL   5/5 failed  tests/test_pricing.py::test_line_total_uses_discount  FLAKY  4/5 failed  tests/test_retry.py::test_retry_eventually_succeedsquarantined 1 test(s)

Two failures reproduce every time. One does not. The loop fixes the two and quarantines the third, instead of spending your money rewriting code that was never broken.

The ceiling looks like this when it refuses a tool call:

code
No progress for 2 iterations. Stop and ask for help.exit=2

That message did not come from the agent deciding to be careful. It came from a hook that runs before the tool call and returns exit code 2, which Claude Code treats as a block.

By the end you will have a loop that triages before it fixes, remembers what it tried across sessions, and halts on a ceiling enforced outside the prompt. Budget 45 minutes. The tutorial assumes you use Claude Code already and know git and pytest. It does not assume you have ever written a hook.

ℹ️

Verified against Python 3.13.9, pytest 9.1.1, git 2.51.0.windows.1 and Claude Code 2.1.269 on 2026-09-12. Everything outside the Claude Code session itself was run for real, including both hook ceilings. Only the session commands were not: starting claude, and running /hooks, /goal and /loop, all need an authenticated Claude Code session.

The PreToolUse contract this build rests on is stable across the 2.1 line. /goal and /loop are newer command surfaces and more likely to move, so if one of those errors on a first try, check the current command reference before assuming the bug is yours.

Why a Claude Code loop needs a ceiling outside the prompt

Claude Code gives you /goal: you write a completion condition and the agent keeps working across turns until that condition holds. The obvious way to bound it is to write the bound into the goal, and the official documentation suggests exactly that - add a clause such as or stop after 20 turns.

/goal is a wrapper around a session-scoped Stop hook - one that fires when Claude finishes a turn and can send it straight back to work. After each turn, Claude Code sends your condition and the conversation so far to a small fast model, which returns one of three verdicts: not yet met, met, or impossible. That model reads the transcript. It does not run your tests, and it does not count your turns independently. The turn clause is a sentence one model writes and another model judges.

The hook system guarantees something the evaluator cannot. Claude Code's hooks guide states that PreToolUse hooks fire before any permission-mode check, in every permission mode, and that a hook denying a call blocks it even under bypassPermissions or --dangerously-skip-permissions. The hooks reference supplies the mechanism: exit code 2 blocks the call whether or not the hook also prints JSON.

One of those is a request. The other is a gate. That split - advisory versus enforced - is the design variable behind everything else in this tutorial.

The cost of confusing them is on the record. In claude-code issue #64744, a /loop worker survived Ctrl+C and a closed terminal, ran roughly 864 iterations over 72 hours, and spent about 300 dollars nobody had approved. Both CronList and TaskList - the tools Claude Code uses to enumerate its own scheduled work - showed empty, so no command could see the loop, let alone stop it.

ℹ️

Claude Code does ship a native cap, and it is worth knowing before you build your own. After a Stop hook blocks eight times in a row without progress, Claude Code overrides it; you can raise that limit with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP. Read the qualifier closely. A loop that keeps running pytest and keeps editing files is making progress by that definition, so it never trips. The runaway shape you are guarding against here is a busy loop, not an idle one.

Prerequisites

Declared level: intermediate. You should be comfortable with git, a Python virtual environment, and pytest. Every version below is pinned to what this tutorial was verified against.

ToolVersionWhy it is here
Python3.13.9Runs the test suite and all four loop scripts
pytest9.1.1The test runner whose exit code becomes the success condition
git2.51.0The working-tree diff is half of the no-progress signal
Claude Code2.1.269Supplies /goal, /loop and the hook system

No API key, paid service or cloud account is needed for steps 1 to 8. Only the last three steps run Claude Code itself, and those bill against the Claude Code subscription or API credit you already have.

Every command block in this tutorial is POSIX shell. On Windows, run all of them in Git Bash, which ships with Git for Windows - not PowerShell. The for loops, printf, $PAYLOAD and the inline LOOP_BASH_CAP=2 assignments are bash syntax, and PowerShell will not run them.

Create the project and install pytest. The activation line differs by platform, because a virtual environment puts its scripts in bin on macOS and Linux and in Scripts on Windows. Run both lines: whichever one is not yours reports No such file or directory, which is safe to ignore.

code
mkdir loop-demo && cd loop-demopython -m venv .venvsource .venv/bin/activate      # macOS and Linuxsource .venv/Scripts/activate  # Windows, inside Git Bashpip install "pytest==9.1.1"

Now verify the environment before you write anything:

code
python --version && pytest --version && git --version && claude --version

Expected output - four lines, one per tool:

code
Python 3.13.9pytest 9.1.1git version 2.51.0.windows.12.1.269 (Claude Code)

Your git line will name your own platform. If pytest --version reports anything other than 9.1.1, your virtual environment is not active. If claude --version is not found, install Claude Code before continuing, because the last three steps need it.

Expect your Claude Code line to read the same or higher than 2.1.269. It ships daily and updates itself, so the pin records what this tutorial was verified against rather than a minimum you have to match.

The four pieces of a Claude Code agent loop, and the order they run in

A loop is one repeating cycle: it acts, observes what happened, adjusts, and goes around again until something stops it. Four separate pieces decide when that cycle ends. They are not interchangeable, and you will build them one at a time.

The condition is the definition of done. It belongs in the /goal text, and it has to be something the agent's own output can demonstrate, because the thing judging it is a model reading the transcript. "The tests pass" is a judgement. "pytest exited 0, and it collected more than zero tests" is a fact that shows up in the transcript.

The gate decides what is worth acting on at all. A test that fails once and passes twice is not a bug report. It is noise. The gate reruns the suite several times and sorts each failure into one of two lanes: reproduces every time, or does not. Only the first lane earns a fix.

The memory is a file. Anything the loop compares across iterations - what was failing last time, what the diff looked like last time - has to survive the session, because a scheduled run is a brand-new session every time. A transcript does not survive /clear, a compaction, or a closed terminal. A file does.

The ceiling is the hook. It runs before each tool call, reads the memory, and returns exit code 2 to block the call when a limit is crossed. Of the four, it is the only one the agent cannot reason its way around, because it is not in the conversation at all.

The order matters, and it is not the order you would guess. The gate has to come before the memory, because a flaky test makes the memory useless. Step 6 shows exactly how.

Step 1: Create the repo with a deliberate bug

Goal. Set up a Python package with one pricing function that is wrong.

Why this step. The loop needs a bug it can find without judgement. Pick something subjective and you will never be able to tell whether the loop worked or only sounded like it did. A wrong arithmetic operator fails a test the same way on every machine and on every run, which is what makes the rest of this exercise checkable.

Create the layout:

code
mkdir -p src/loopdemo teststouch src/loopdemo/__init__.py

File: src/loopdemo/pricing.py

code
"""Order pricing. One deliberate bug lives here."""def apply_discount(price: float, pct: float) -> float:    """Return price with pct percent taken off."""    return price - pctdef line_total(price: float, qty: int, pct: float) -> float:    """Return the discounted total for qty units."""    return apply_discount(price, pct) * qty

The bug is in apply_discount. It subtracts pct as a flat amount instead of treating it as a percentage, so a 10 percent discount on 200 takes off 10 rather than 20. line_total calls the same function, which is why one broken line will fail two tests.

File: tests/test_pricing.py

code
from loopdemo.pricing import apply_discount, line_totaldef test_discount_is_a_percentage():    assert apply_discount(200.0, 10.0) == 180.0def test_line_total_uses_discount():    assert line_total(200.0, 3, 10.0) == 540.0

File: pyproject.toml

code
[project]name = "loopdemo"version = "0.1.0"requires-python = ">=3.11"[tool.pytest.ini_options]pythonpath = ["src"]testpaths = ["tests"]

Run it. From the project root:

code
pytest -q --tb=no

Expected output.

code
FF                                                                       [100%]=========================== short test summary info ===========================FAILED tests/test_pricing.py::test_discount_is_a_percentage - assert 190.0 ==...FAILED tests/test_pricing.py::test_line_total_uses_discount - assert 570.0 ==...2 failed in 0.04s

What just happened. You have a repo with two failing tests and one root cause. The pythonpath setting lets pytest import loopdemo with no editable install. From here on, pytest run from the project root is the loop's source of truth.

Step 2: Add a flaky test that fails only sometimes

Goal. Add a third test that is genuinely unreliable, not deterministically broken.

Why this step. Real suites carry both kinds of red, and in a single run the two are indistinguishable. If every failing test were a real bug you could skip triage entirely and go straight to the fix. Treating them the same is how a loop spends all night editing correct code.

File: src/loopdemo/retry.py

code
"""A retry helper. Nothing here is buggy."""import randomdef call_with_retries(attempts: int = 3, success_rate: float = 0.5) -> bool:    """Simulate a flaky downstream call. Return True if any attempt wins."""    for _ in range(attempts):        if random.random() < success_rate:            return True    return False

File: tests/test_retry.py

code
from loopdemo.retry import call_with_retriesdef test_retry_eventually_succeeds():    assert call_with_retries(attempts=1, success_rate=0.5) is True

Run it. From the project root, five times in a row, so you see the behaviour rather than one sample:

code
for i in 1 2 3 4 5; do pytest -q --tb=no tests/test_retry.py | tail -1; done

Expected output. A mix. Yours will not match these exact lines, and that is the point:

code
1 passed in 0.02s1 passed in 0.01s1 failed in 0.24s1 failed in 0.21s1 failed in 0.17s

What just happened. Three tests can now come back red, from two different causes. test_discount_is_a_percentage and test_line_total_uses_discount fail every run. test_retry_eventually_succeeds fails about half of them. A single pytest run cannot tell you which is which, and neither can an agent looking at that single run.

💡

Real flaky tests usually come from a race, a timeout, or a shared fixture, and they fail far less often than half the time. A rare flake is honest but useless for teaching: you cannot demonstrate a gate against an event you have to wait an hour to see. This test uses random so the flakiness is frequent and obvious. The triage logic you build against it is identical either way.

Step 3: See why one pytest run is not actionable

Goal. Run the full suite once and read what it does and does not tell you.

Why this step. Before you build a gate, watch the thing the gate exists to filter. An unguarded loop sees exactly this output, once, and starts editing.

Run it. From the project root, twice, changing nothing in between:

code
pytest -q --tb=nopytest -q --tb=no

Expected output. Each run prints one of these two, and which one you get is a coin flip:

code
FFF                                                                      [100%]=========================== short test summary info ===========================FAILED tests/test_pricing.py::test_discount_is_a_percentage - assert 190.0 ==...FAILED tests/test_pricing.py::test_line_total_uses_discount - assert 570.0 ==...FAILED tests/test_retry.py::test_retry_eventually_succeeds - assert False is ...3 failed in 0.04s
code
FF.                                                                      [100%]=========================== short test summary info ===========================FAILED tests/test_pricing.py::test_discount_is_a_percentage - assert 190.0 ==...FAILED tests/test_pricing.py::test_line_total_uses_discount - assert 570.0 ==...2 failed, 1 passed in 0.03s

If both your runs came back the same, run it a few more times. Across twenty runs the split lands near even.

That variation is the finding. Same code, same command, a different number of failures, and nothing in either output marks which line is the unreliable one. The two pricing failures are identical in both. The third comes and goes.

What just happened. You have seen the loop's raw input, and seen that it is not stable. Hand either of those runs to an agent with "fix the failing tests" and it works on whatever happened to be red, because it has no evidence that one of them does not need fixing. On the runs where test_retry_eventually_succeeds is red, it will edit retry.py, which is correct code, and either break it or spend a turn confirming it cannot be improved.

⚠️

Check the exit code, not just the text. pytest exits 1 when tests fail, but it exits 5 when it collects no tests at all - a typo in a path does that. A success condition written as "no failures" is satisfied by collecting nothing. This is why the condition in step 10 asserts exit 0 and a non-zero collected count.

Step 4: Build a flaky-test triage gate

Goal. Write a script that runs the suite five times, tallies which tests fail, and sorts each failure into real or flaky.

Why this step. Repetition is the only thing that separates the two kinds of red. A test that fails 5 times out of 5 reproduces; a test that fails 3 out of 5 does not. Run that measurement before any fix, and every later part of the loop can trust its input.

Create the loop's own directory and keep it out of git:

code
mkdir -p .loopprintf '.loop/\n__pycache__/\n.pytest_cache/\n.venv/\n' > .gitignoregit init -q && git add -A && git commit -qm "loop demo fixture"

On Windows, git add prints one warning: in the working copy of ..., LF will be replaced by CRLF the next time Git touches it line per file. That is git normalising line endings, and nothing is wrong.

.loop/ is a run artifact, not source. It also has to stay untracked for a reason you will meet in step 6: the loop hashes its own working-tree diff, and a tracked state file would change that hash every single iteration.

File: .loop/triage.py

code
"""Run the suite N times and sort each failure into real or flaky."""import subprocessimport sysfrom collections import Counterfrom pathlib import PathRUNS = 5QUARANTINE = Path(__file__).parent / "quarantine.txt"def failures_in_one_run() -> list[str]:    """Return the node id of every test that failed in a single run."""    result = subprocess.run(        [sys.executable, "-m", "pytest", "-q", "--tb=no"],        capture_output=True,        text=True,    )    return [        line.split()[1]        for line in result.stdout.splitlines()        if line.startswith("FAILED ")    ]def main() -> None:    tally: Counter[str] = Counter()    for run in range(1, RUNS + 1):        failed = failures_in_one_run()        tally.update(failed)        print(f"  run {run}: {len(failed)} failed")    flaky = [node for node, count in sorted(tally.items()) if count != RUNS]    QUARANTINE.write_text("\n".join(flaky) + "\n", encoding="utf-8")    print("\nVerdicts:")    for node, count in sorted(tally.items()):        verdict = "REAL " if count == RUNS else "FLAKY"        print(f"  {verdict}  {count}/{RUNS} failed  {node}")    print(f"\nquarantined {len(flaky)} test(s)")if __name__ == "__main__":    main()

sys.executable rather than a bare pytest keeps the script inside the active virtual environment. Parsing the FAILED lines from -q output gives node ids directly, and a node id is the same identifier you hand back to pytest to run one test on its own.

Run it. From the project root:

code
python .loop/triage.py

Expected output. Your per-run counts and the flaky test's own N/5 tally will both differ from these, because one test is genuinely unreliable. What should match is the two pricing tests at REAL 5/5, the retry test labelled FLAKY, and quarantined 1 test(s):

code
  run 1: 3 failed  run 2: 3 failed  run 3: 2 failed  run 4: 3 failed  run 5: 3 failedVerdicts:  REAL   5/5 failed  tests/test_pricing.py::test_discount_is_a_percentage  REAL   5/5 failed  tests/test_pricing.py::test_line_total_uses_discount  FLAKY  4/5 failed  tests/test_retry.py::test_retry_eventually_succeedsquarantined 1 test(s)

Five coin flips can land the same way. If your flaky test fails 5 times out of 5, it is labelled REAL. If it passes 5 times out of 5, it never enters the tally at all and quarantine.txt comes back empty. Either happens about 3 percent of the time. That is the gate being honest rather than clairvoyant: with five samples it cannot tell a consistently unlucky test from a broken one. Raise RUNS to trade time for confidence, and rerun triage whenever the verdicts look wrong.

What just happened. One file exists that did not before: .loop/quarantine.txt, holding one node id. Every later step reads it. The loop now has a written record of which failures it is allowed to care about.

code
cat .loop/quarantine.txt
code
tests/test_retry.py::test_retry_eventually_succeeds

Step 5: Give the loop a memory that survives the session

Goal. Create a state file split into two halves, and a script that resets exactly one of them.

Why this step. A scheduled loop starts a brand-new session on every run, so anything it needs to remember has to be on disk. What is less obvious is that two kinds of memory live in that file and they have opposite lifetimes. Get the split backwards and the loop fails in one of two specific ways.

This is state, not context: what the loop carries between runs, as distinct from what gets fed into each iteration. Run-scoped state belongs to one session and must reset when a new one starts: which session is running, and how many tool calls it has made. Work-scoped state belongs to the job and must survive: what was failing last iteration, what the diff looked like, and how many iterations have gone by without either changing.

File: .loop/reset.py

code
"""Reset run-scoped state. Work-scoped state survives on purpose."""import jsonfrom pathlib import PathSTATE = Path(__file__).parent / "state.json"FRESH_RUN = {"session_id": None, "bash_calls": 0}FRESH_WORK = {"failing_signature": None, "diff_hash": None, "unchanged": 0}def main() -> None:    work = dict(FRESH_WORK)    if STATE.exists():        work = json.loads(STATE.read_text(encoding="utf-8"))["work"]    state = {"run": dict(FRESH_RUN), "work": work}    STATE.write_text(json.dumps(state, indent=2), encoding="utf-8")    print(f"run reset. work kept: unchanged={work['unchanged']}")if __name__ == "__main__":    main()

Run it. From the project root:

code
python .loop/reset.py

Expected output.

code
run reset. work kept: unchanged=0

What just happened. .loop/state.json now exists with both halves. Inspect it:

code
cat .loop/state.json
code
{  "run": {    "session_id": null,    "bash_calls": 0  },  "work": {    "failing_signature": null,    "diff_hash": null,    "unchanged": 0  }}

Those are the two failures the split protects against. Never reset the run half, and the call counter climbs across every session until the cap blocks every tool call you make, forever. Reset the work half on every session, and the no-progress counter is always at attempt one, so it never trips and the loop retries the same failed fix indefinitely.

💡

Ask one question of every field you add here: what should this be when a new session starts? If the answer is "zero", it is run-scoped. If the answer is "whatever it was", it is work-scoped. There is no third answer, and fields that seem to need one are usually two fields.

Step 6: Define "no progress" mechanically

Goal. Write a script that decides whether an iteration changed anything, and records the answer.

Why this step. "The loop is stuck" is a thing you notice hours later. To stop on it, the loop needs a definition it can evaluate every iteration with no judgement: the set of real failing tests is identical to last time, and the working-tree diff is identical to last time. Same symptoms plus same code means the last iteration accomplished nothing.

Either half alone gives a false reading. Same failures with a changed diff means the agent is trying things that have not worked yet, which is normal. An unchanged diff with different failures means something non-deterministic is moving, which is the flaky test.

File: .loop/progress.py

code
"""Decide whether this iteration changed anything, and write it down."""import hashlibimport jsonimport subprocessimport sysfrom pathlib import PathSTATE = Path(__file__).parent / "state.json"QUARANTINE = Path(__file__).parent / "quarantine.txt"def quarantined() -> set[str]:    """Return the flaky tests triage told us not to trust."""    if not QUARANTINE.exists():        return set()    text = QUARANTINE.read_text(encoding="utf-8")    return {line.strip() for line in text.splitlines() if line.strip()}def failing_signature() -> list[str]:    """Return every real failing test, sorted. Flaky ones are excluded."""    result = subprocess.run(        [sys.executable, "-m", "pytest", "-q", "--tb=no"],        capture_output=True,        text=True,    )    skip = quarantined()    return sorted(        node        for line in result.stdout.splitlines()        if line.startswith("FAILED ")        for node in [line.split()[1]]        if node not in skip    )def diff_hash() -> str:    """Return a short hash of the working-tree diff."""    result = subprocess.run(["git", "diff"], capture_output=True, text=True)    return hashlib.sha256(result.stdout.encode()).hexdigest()[:12]def main() -> None:    state = json.loads(STATE.read_text(encoding="utf-8"))    work = state["work"]    failing, digest = failing_signature(), diff_hash()    signature = "|".join(failing)    same = signature == work["failing_signature"] and digest == work["diff_hash"]    work["unchanged"] = work["unchanged"] + 1 if same else 0    work["failing_signature"] = signature    work["diff_hash"] = digest    STATE.write_text(json.dumps(state, indent=2), encoding="utf-8")    print("failing (real only):")    for node in failing or ["(none)"]:        print(f"  {node}")    print(f"diff {digest}   unchanged iterations: {work['unchanged']}")if __name__ == "__main__":    main()

quarantined() is the dependency that forces triage to come first. Without that filter, the flaky test drifts in and out of the failing set from one run to the next, so the signature never repeats, unchanged never climbs past zero, and the stall detector never fires at all. One unreliable test silently disables the entire no-progress mechanism. Quarantine is not hygiene here. It is what makes the measurement possible.

One thing about diff_hash() is easy to miss. Both subprocess.run calls inherit the directory you launched from, even though the script finds state.json relative to its own __file__. Run progress.py from inside src/ and git diff reports on a different subtree, so the hash means something else. Run it from the project root, always.

Run it. From the project root, three times, changing nothing in between:

code
python .loop/reset.pypython .loop/progress.pypython .loop/progress.pypython .loop/progress.py

Expected output. The last line of each run, in order:

code
diff e3b0c44298fc   unchanged iterations: 0diff e3b0c44298fc   unchanged iterations: 1diff e3b0c44298fc   unchanged iterations: 2

If you committed everything in step 4, your hash is e3b0c44298fc too, on every machine. That is the digest of an empty diff, because a clean working tree makes git diff print nothing. If you have uncommitted changes, yours differs and that is fine. What must match either way is the counter climbing 0, 1, 2 while the hash holds still.

If your counter stays at 0, the flaky test is still in the signature. That is step 4's roughly 3 percent case: rerun python .loop/triage.py, check .loop/quarantine.txt is not empty, then start this step again.

The full output of one run looks like this:

code
failing (real only):  tests/test_pricing.py::test_discount_is_a_percentage  tests/test_pricing.py::test_line_total_uses_discountdiff e3b0c44298fc   unchanged iterations: 2

What just happened. The flaky test is gone from the signature, so the signature is stable, so unchanged is a real measurement. The loop now has a number that means "the last two iterations accomplished nothing" and a file that carries it across sessions. Nothing enforces it yet.

Step 7: Write the PreToolUse hook that enforces both ceilings

Goal. Write a PreToolUse hook that counts Bash calls per session and returns exit code 2 when either ceiling is crossed.

Why this step. Everything so far is advisory. The counters exist, but an agent that does not read them, or reads them and decides to continue, is unaffected. A hook is different: Claude Code runs it before the tool call, and exit code 2 blocks that call. Nothing in the conversation can override it.

Claude Code sends the hook a JSON payload on stdin. The fields that matter here are session_id and tool_name:

code
{  "session_id": "abc123",  "hook_event_name": "PreToolUse",  "tool_name": "Bash",  "tool_input": {    "command": "npm test",    "description": "Run test suite"  },  "tool_use_id": "toolu_01ABC123..."}

session_id is what makes the run-scoped reset automatic. If the id in the payload does not match the one in the state file, this is a new session and the call counter starts over, with no separate reset step to remember.

File: .loop/guard.py

code
"""PreToolUse hook. Exit 2 blocks the tool call the agent asked for."""import jsonimport osimport sysfrom pathlib import PathCAP = int(os.environ.get("LOOP_BASH_CAP", "40"))STALL = 2STATE = Path(__file__).parent / "state.json"def block(message: str) -> int:    print(message, file=sys.stderr)    return 2def main() -> int:    payload = json.load(sys.stdin)    state = json.loads(STATE.read_text(encoding="utf-8"))    run = state["run"]    if run["session_id"] != payload.get("session_id"):        run["session_id"] = payload.get("session_id")        run["bash_calls"] = 0    run["bash_calls"] += 1    STATE.write_text(json.dumps(state, indent=2), encoding="utf-8")    if state["work"]["unchanged"] >= STALL:        return block(            f"No progress for {STALL} iterations. Stop and ask for help."        )    if run["bash_calls"] > CAP:        return block(            f"Bash call {run['bash_calls']} exceeds the cap of {CAP}. "            f"Stop and report what is done."        )    return 0if __name__ == "__main__":    sys.exit(main())

The script is Python, not shell, for a documented reason. The official troubleshooting guide lists jq: command not found as a known hook failure, and its own advice is to install jq or use Python. On Windows a shell-form hook runs in Git Bash if Git for Windows is installed and PowerShell if not, and jq ships with neither. Python is already a prerequisite here, so using it removes a dependency and the executable-bit problem at the same time.

The message goes to stderr, not stdout. On exit 2, stderr is what Claude Code feeds back to the model, so this is the text the agent reads when it is blocked. Write it as an instruction, not a complaint.

There is no spend cap, and there cannot be. The PreToolUse payload carries no cost, token or budget field - that is a documented fact about the payload, not an omission here. Token spend does appear in /goal status, but hooks cannot see it. A Bash call count is a proxy for work done, and a hard ceiling on something measurable beats a soft one on something that is not.

Run it. From the project root, test the hook directly, exactly as the official docs recommend testing hooks, by piping a payload into it.

Clear one thing first, and it is step 5's split biting for real. Step 6 walked unchanged up to 2, which is already this hook's stall threshold, and reset.py preserves the work half on purpose. Delete the state file so both halves start fresh. Skip this and the hook blocks your very first call, so you never see the allow path:

code
PAYLOAD='{"session_id":"s1","tool_name":"Bash","tool_input":{"command":"pytest"}}'rm -f .loop/state.jsonpython .loop/reset.pyecho "$PAYLOAD" | python .loop/guard.pyecho "exit=$?"

Expected output.

code
run reset. work kept: unchanged=0exit=0

What just happened. The hook ran, incremented the counter, found neither ceiling crossed, and exited 0 to allow the call. You just tested a guardrail without starting Claude Code, spending a token, or trusting a description of what it would do.

Deleting that file did something reset.py cannot. reset.py will never clear unchanged, because work-scoped state surviving a reset is the entire point of it, so removing the file is the only way to clear the work half. That asymmetry is what keeps a scheduled run from forgetting what the previous run learned.

Check the counter moved:

code
python -c "import json;print(json.load(open('.loop/state.json'))['run'])"
code
{'session_id': 's1', 'bash_calls': 1}

Step 8: Prove both ceilings return exit code 2

Goal. Drive the hook past each limit and confirm it returns exit code 2.

Why this step. A guardrail nobody has watched fire is a guardrail you are guessing about. Both paths are testable in a few seconds, and testing them now is much cheaper than discovering at 3am that the counter was off by one.

Run it. From the project root, and first the call cap. LOOP_BASH_CAP lets you lower it for the test rather than sending 41 payloads:

code
PAYLOAD='{"session_id":"s1","tool_name":"Bash","tool_input":{"command":"pytest"}}'rm -f .loop/state.jsonpython .loop/reset.py > /dev/nullfor i in 1 2; do echo "$PAYLOAD" | LOOP_BASH_CAP=2 python .loop/guard.py; doneecho "$PAYLOAD" | LOOP_BASH_CAP=2 python .loop/guard.pyecho "exit=$?"

Expected output. The first two calls print nothing and pass. The third is refused:

code
Bash call 3 exceeds the cap of 2. Stop and report what is done.exit=2

Now the stall ceiling. Reset, then run the progress detector three times without changing any code, which walks unchanged up to 2:

code
PAYLOAD='{"session_id":"s1","tool_name":"Bash","tool_input":{"command":"pytest"}}'python .loop/reset.py > /dev/nullpython .loop/progress.py > /dev/nullpython .loop/progress.py > /dev/nullpython .loop/progress.py > /dev/nullecho "$PAYLOAD" | python .loop/guard.pyecho "exit=$?"

Expected output.

code
No progress for 2 iterations. Stop and ask for help.exit=2

What just happened. Both ceilings block, and both report why in text the agent will read. Order inside the hook matters: the stall check runs before the cap check, so a stuck loop is stopped for being stuck rather than for being long. Every piece of the mechanism is now on disk and tested. What is missing is the wiring.

Step 9: Wire the hook into Claude Code's settings.json

Goal. Register guard.py as a PreToolUse hook scoped to this project.

Why this step. Claude Code has no idea guard.py exists. A hook only runs when a settings file registers it against an event and a tool matcher, and four separate mistakes in that registration all fail quietly rather than loudly.

Create the directory if it does not exist (mkdir -p .claude), then write the file.

File: .claude/settings.json

code
{  "hooks": {    "PreToolUse": [      {        "matcher": "Bash",        "hooks": [          {            "type": "command",            "command": "python \"${CLAUDE_PROJECT_DIR}/.loop/guard.py\"",            "timeout": 30,            "statusMessage": "Checking loop ceilings..."          }        ]      }    ]  }}

The nesting is hooks, then the event name, then an array of matcher groups, then a hooks array inside each group. The repeated key is not a typo.

"matcher": "Bash" is case-sensitive. bash matches nothing, silently.

${CLAUDE_PROJECT_DIR} makes the path absolute. A relative path fails when the agent has changed directory, and the documented symptom is command not found.

statusMessage is cosmetic - it is the line Claude Code shows while the hook runs.

It lives in the project's .claude/settings.json, not your user settings. A Bash cap in user settings applies to every repo you open, including your own interactive work, and it will eventually block a command you typed yourself.

Also note that guard.py imports only the standard library, so the bare python here is safe. It does not need the virtual environment that sys.executable protected in step 4.

Run it. Clear the state file first. Step 8 left unchanged at 2 to demonstrate the stall ceiling, and that ceiling is about to become real: wire the hook in with the file in that condition and Claude Code will refuse the first Bash call of your session, and every one after it. This is the same trap step 7 cleared, and it bites harder here because the next two steps have no expected output to compare against.

code
rm -f .loop/state.jsonpython .loop/reset.py

Now start a session:

code
claude

Then, inside the session:

code
/hooks

Expected output. /hooks opens an event picker - choose PreToolUse to see the matcher and command you just registered. If nothing is configured, the JSON is invalid: trailing commas and comments are both rejected.

That listing proves the file parsed, not that the hook runs. To check it actually fires, ask Claude to run any trivial Bash command, then open a second terminal at the project root and read the counter:

code
python -c "import json;print(json.load(open('.loop/state.json'))['run'])"

session_id should now be a real identifier and bash_calls at least 1. You cleared the file a moment ago, so if it still reads {'session_id': None, 'bash_calls': 0}, nothing has fired: the hook is registered but not running, and the callout below is where to look.

What just happened. Every Bash call in this project now passes through guard.py first. The counter in .loop/state.json moves on its own, and the run half resets by itself whenever the session id changes.

⚠️

If the hook is listed but never seems to run, check your shell profile. Claude Code runs a shell-form hook through Git Bash on Windows, or sh -c elsewhere, and some profiles print a banner on startup. That output is prepended to the hook's own output, and if the result no longer starts with {, Claude Code treats all of stdout as plain text. On exit 0 nothing appears in the transcript at all. Guard profile echoes with if [[ \$- == *i* ]]; then ... fi.

Step 10: Write a /goal condition that can actually be checked

Goal. Write the /goal condition the loop runs against.

Why this step. A stopping condition the agent can satisfy without doing the work is how most agent loops fail. The condition here is judged by a small model reading the transcript, not by a program running your tests. So it has to name evidence the agent's own output will contain. A condition that describes an internal state nobody prints cannot ever be confirmed.

Back in the Claude Code session from step 9, start with the version most people write, and see what is wrong with it:

code
/goal fix the failing tests until they pass

"Until they pass" has no observer. The agent reports that the tests pass, the evaluator reads that report, and the report is the only evidence in the transcript. Nothing independent has checked anything.

The same intent, made checkable:

code
/goal Run python .loop/triage.py first and fix only tests it marks REAL.Done when pytest exits 0 with a non-zero collected count, shown in thetranscript, and the diff touches only src/. Stop after 12 iterations. Stopand ask if .loop/progress.py reports unchanged >= 2.

Every clause points at something that shows up on screen. pytest exit 0 with a non-zero collected count is a line in the transcript, and asking for the collected count is what stops exit 5 - no tests collected, because a path was mistyped - from reading as success. .loop/triage.py marking a test REAL is printed output. unchanged >= 2 is printed by progress.py.

One honest caveat about that condition, because you will hit it. The quarantined test still runs and still reports red - quarantine only removes it from the no-progress signature, not from the suite. So pytest exit 0 is not reliably reachable yet, even after both real bugs are fixed. The first item under "Where to go next" closes that gap with a conftest.py that deselects the quarantine list; until then, expect the goal to keep going after the real work is done.

Run it. Type the condition as a single line. The four lines above are page wrapping, not line breaks you should type - in a Claude Code prompt, Enter submits, so a literal newline sends a half-finished goal and then three stray messages. Use Shift+Enter if you want the breaks.

Then send a bare /goal, with no argument, to read the condition back:

code
/goal

Expected output. /goal prints the active condition, how long it has been running, how many turns have been evaluated, current token spend, and the evaluator's most recent reason. Read the condition it echoes and confirm it ends with unchanged >= 2. If it stops earlier, the text submitted early and you have set a truncated goal - clear it with /goal clear and enter it again as one line.

What just happened. The loop has a definition of done that is checkable from the transcript. Note what the two "stop" clauses in that goal actually are: requests. The 12-iteration clause is judged by the same evaluator model that wants to finish the job. That is exactly why step 7 exists - the hook enforces the same two limits from outside the conversation, where no amount of reasoning reaches it.

Step 11: Trigger the loop with /loop

Goal. Run the whole thing on an interval with /loop.

Why this step. /goal starts when you start it. /loop takes an interval and starts each run itself, which is the difference between a task you supervise and a loop that runs without you.

The interval can lead the prompt as a bare token, 30m, or trail it as a clause, every 2 hours. Either form works:

code
/loop 30m Run python .loop/triage.py, fix only tests it marks REAL, then runpython .loop/progress.py. Stop when pytest exits 0 with a non-zero collectedcount. Commit to a new branch, never to the default branch.

The branch clause says commit rather than push because the repo you built in step 4 has no remote. Add one and you can say push.

Step 10's caveat applies to that stop condition too, and it matters more here. Until the conftest.py deselect from "Where to go next" is in place, the quarantined test keeps the suite red, pytest exit 0 is never reached, and this schedule keeps firing until you cancel it. Which is the next section.

Run it. As one line again, for the same reason as step 10.

Expected output. Claude Code confirms the schedule it created, naming an interval and a task id. Two things to check: the interval reads 30m, or whatever value Claude rounded it to, and the prompt echoed back ends with never to the default branch. If it stops earlier, the text submitted early and you have scheduled a fragment - cancel it using the next section, then enter the whole prompt as one line. Intervals round up to a one-minute cron granularity, and one that does not map to a clean cron step gets rounded to one that does, with Claude saying what it picked.

What just happened. Something other than you can now start the loop. The guard still runs before every Bash call inside each of those runs, and .loop/state.json carries the work-scoped counters from one run into the next.

⚠️

/goal does not resolve inside a /loop prompt. /loop is a skill and /goal is a built-in command, and a scheduled fire only executes skills that Claude is allowed to invoke on its own. Built-in commands reach Claude as plain text, so /loop 30m /goal ... sends the literal characters and sets no goal. Write the stop condition directly into the loop's prompt, as above.

ℹ️

Cloud routines, created with /schedule (aliased as /routines), are a different tool and not the one for this loop. They run on a fresh clone with no access to your local files, their minimum interval is one hour, and they need a claude.ai subscription login rather than an API key. For a loop watching a repo on your own disk, /loop is the right choice.

Three bounds apply to /loop whether or not you ask for them: the minimum interval is one minute, a session is limited to 50 scheduled tasks, and a recurring task expires 7 days after creation - it fires one last time, then deletes itself. Those bounds cap how bad a forgotten loop gets. They are not a substitute for turning it off.

Turn off the /loop before you walk away

You have just created the object this tutorial opened with: a recurring loop that outlives your attention. Do not leave it running while you finish reading. Two things to do, in this order.

First, the reliable one. Set the kill switch in the shell you launch Claude Code from, and restart the session so it takes effect:

code
export CLAUDE_CODE_DISABLE_CRON=1

That makes /loop unavailable and stops every already-scheduled task from firing, whether or not any command can see it. It is the same kill-switch reflex any unattended agent needs: one control that does not depend on the agent cooperating.

Second, clear the goal and delete the schedule from inside the session:

code
/goal clear

Then ask Claude to list its scheduled tasks and delete the one you just made. Do this as well as the kill switch, not instead of it. Issue #64744 is precisely a case where the listing came back empty while the loop kept running, so a clean list is not proof of a stopped loop.

The last two sections have you editing pricing.py by hand. A 30-minute loop firing against the same working tree while you do that is how you end up debugging a race you created yourself.

When it breaks

Every error string below is either produced by the code in this tutorial or documented in Claude Code's own troubleshooting guide. Four of them account for most of the time lost, so they get their own sections first; the rest are in the table. If a run goes wrong in a way none of these cover, turning on session logging is the next place to look.

jq: command not found

Your hook is shell-form and parsing JSON with jq, which is not installed. On Windows this is the default state: a shell-form hook runs in Git Bash if Git for Windows is present and PowerShell if not, and jq ships with neither. Claude Code's own troubleshooting guide names this failure and offers two fixes - install jq, or parse the JSON in Python. This tutorial takes the second, which is why guard.py is Python and needs no extra dependency.

Failed with non-blocking status code:

Your hook exited with a non-zero code that was not 2. Only exit 2 blocks a tool call. Every other non-zero code is a non-blocking error: Claude Code shows this prefix and the first line of your stderr in the transcript, then runs the tool anyway. A guard that exits 1 when it means to refuse is not a guard. Check the return path in block().

Goal cleared after an unrecoverable error

/goal gave up rather than finishing. Four causes clear a goal this way: an authentication failure, an exhausted credit balance, a context overflow it cannot recover from, and an unavailable model. None are fixable from inside the loop. The message ends with Run /goal again to continue, so once the cause is fixed the condition can be re-set without rebuilding anything.

pytest exit code 5, and why it can fake success

pytest exits 5 when it collected no tests at all, which a mistyped path will do. This one is dangerous rather than merely annoying: a success condition written loosely as "no failures" is satisfied by collecting nothing, so a loop can report done having run zero tests. That is why the condition in step 10 asks for exit 0 and a non-zero collected count. The other exit codes: 0 all passed, 1 tests failed, 2 interrupted, 3 internal error, 4 usage error.

Everything else

SymptomCauseFix
jq: command not foundA shell-form hook parsing JSON with jq, which is not installed - the default on WindowsUse Python, as guard.py does
command not found naming your hook scriptA relative path in settings.json, resolved from a directory the agent changed toUse ${CLAUDE_PROJECT_DIR}/.loop/guard.py
/hooks shows no hooks configuredInvalid JSON in .claude/settings.jsonRemove trailing commas and comments; both are rejected
Hook is listed but never firesMatcher case. Matchers are case-sensitive, so bash never matches BashUse the exact tool name
Failed with non-blocking status code:The hook exited 1, not 2. Only exit 2 blocks; every other non-zero code is a non-blocking error and the call proceedsReturn 2 from the block path
Hook output ignored, nothing in the transcriptA shell profile printed a banner, so stdout no longer starts with {Guard profile echoes with if [[ $- == *i* ]]; then ... fi
Every Bash call refused with No progress for 2 iterationsThe stall ceiling is tripped in the state file, usually inherited from step 8rm -f .loop/state.json && python .loop/reset.py. reset.py alone will not fix this - it preserves the work half by design
Every Bash call blocked with a cap message, in every sessionThe call counter never resetspython .loop/reset.py. The session-id check in guard.py should make this automatic - if it does not, the payload is not reaching the script
unchanged never climbs above 0A flaky test is still in the failing signatureRun python .loop/triage.py to regenerate .loop/quarantine.txt
The loop reports success with nothing fixedpytest exited 5 - no tests collected, usually a mistyped pathAssert exit 0 and a non-zero collected count
Goal cleared after an unrecoverable errorAuth failure, exhausted credit, context overflow, or an unavailable modelFix the cause, then /goal again to continue
Stop hook blocked too many consecutive timesClaude Code overrode a Stop hook after 8 consecutive blocks without progressRaise CLAUDE_CODE_STOP_HOOK_BLOCK_CAP, or accept the stop

The full traceback format differs by platform. On Windows the file reference in a pytest failure uses a backslash - tests\test_pricing.py:5 - where macOS and Linux print tests/test_pricing.py:5. The node ids in the short test summary info block use forward slashes everywhere, which is why every script in this tutorial parses those lines and not the traceback.

What you built, assembled

The four pieces described at the start connect like this once they are wired together, with the tool call as the thing that has to get past the gate.

mermaid
flowchart LR
    A["Agent turn<br/>wants to run Bash"] --> G{"guard.py<br/>PreToolUse hook"}
    G -->|"exit 2<br/>blocked"| S["Loop stops<br/>reason sent to agent"]
    G -->|"exit 0<br/>allowed"| B["Bash call runs"]
    B --> T["triage.py<br/>5 runs, sorts REAL vs FLAKY"]
    T --> Q[".loop/quarantine.txt"]
    B --> P["progress.py<br/>signature + diff hash"]
    Q --> P
    P --> J[".loop/state.json<br/>run-scoped + work-scoped"]
    J --> G
    B --> A

    style A fill:#4A90E2,color:#FFFFFF
    style G fill:#7B68EE,color:#FFFFFF
    style S fill:#E74C3C,color:#FFFFFF
    style B fill:#6BCF7F,color:#2C2C2A
    style T fill:#FFD93D,color:#2C2C2A
    style Q fill:#FFA07A,color:#2C2C2A
    style P fill:#98D8C8,color:#2C2C2A
    style J fill:#955D37,color:#FFFFFF

The arrow from state.json back into guard.py is the loop's only durable memory, and the one edge in that picture that survives a session ending. Watch the direction between quarantine.txt and progress.py as well: triage feeds the no-progress signal, never the reverse, which is the step 6 dependency drawn out.

One asymmetry in that diagram is worth naming, because it is the same distinction the tutorial opened with. Only guard.py sits on the path a tool call has to cross. Triage does not: nothing stops the agent skipping triage.py and editing retry.py anyway, because "run triage first" lives in the prompt, and the prompt is a request. The ceilings are gates. If you want triage enforced rather than asked for, it has to move onto that same path - a PreToolUse matcher on Edit|Write that refuses a write to any file the quarantine list touches would be the shape of it.

The complete artifact

code
loop-demo/├── .claude/│   └── settings.json├── .loop/│   ├── guard.py│   ├── progress.py│   ├── quarantine.txt│   ├── reset.py│   ├── state.json│   └── triage.py├── src/│   └── loopdemo/│       ├── __init__.py│       ├── pricing.py│       └── retry.py├── tests/│   ├── test_pricing.py│   └── test_retry.py├── .gitignore└── pyproject.toml

.loop/state.json and .loop/quarantine.txt are generated, so they are not listed as files you write. Everything else appears in full in the steps above.

To confirm the whole thing works end to end, apply the fix the loop is meant to find and watch the signal change. Edit src/loopdemo/pricing.py so apply_discount reads:

code
def apply_discount(price: float, pct: float) -> float:    """Return price with pct percent taken off."""    return price - (price * pct / 100)

Then, from the project root in the terminal that has .venv active - not the second terminal you opened in step 9:

code
pytest -q --tb=no tests/test_pricing.pypython .loop/progress.py

Expected output.

code
..                                                                       [100%]2 passed in 0.03sfailing (real only):  (none)diff df1019b86e8d   unchanged iterations: 0

Your hash differs here, because the working tree now genuinely has a change in it, the one-line fix you just made. Both real failures are gone, the signature is empty, and unchanged has reset to 0 because the diff moved. The stall ceiling stands down on its own. The flaky test is still in quarantine, exactly where it belongs.

Where to go next

Make pytest skip the quarantine. Right now the flaky test still runs and still reports red; it is only excluded from the signature. Read .loop/quarantine.txt in a conftest.py and pass each node id to --deselect, and the suite itself goes green when the real bugs are fixed. That turns "pytest exits 0" into a condition the loop can genuinely reach.

Run the progress detector automatically. progress.py only runs when something calls it. A PostToolUse hook matched on Edit|Write would update the signature after every file change with no prompt instruction needed, which closes the gap where an agent simply does not run it.

Re-triage on a schedule, separately. Quarantine decays. A test quarantined in March may have been fixed in April, and nothing currently checks. A second /loop on a weekly interval that reruns triage.py against the quarantine list and removes anything now passing 5 out of 5 keeps the list honest.

References


Agentic AI

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Get the next article by email

One email when a new piece goes up. No digest, no drip sequence.

One email per new article. Unsubscribe in one click.

Books by Ranjan Kumar

The 7 GenAI Architectures cover

The 7 GenAI Architectures

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The ChatML Handbook cover

The ChatML Handbook

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments