Lesson 4 · Agentic Loop Engineering

The Stop Hook: a Deterministic Gate

In Lesson 3 the gate was a CLAUDE.md rule — words the model follows. Now we replace words with a script: a check that physically blocks the turn from ending until it passes.

⏱ ~9 min🎯 Gate 3, wired for real🔗 Builds on Lessons 1–3

1 · Advisory vs. deterministic

Everything you've used so far asks the model to verify. A CLAUDE.md rule, a rubric, a delegation habit — the model reads them and almost always complies. But "almost always" is a probability, and the docs draw the line crisply: hooks are for "actions that must happen every time with zero exceptions" — deterministic, not advisory. (Hooks)

WORDS In-prompt instruction — followed this turn, usually.
WORDS CLAUDE.md rule — followed every session, usually.
WORDS Verification subagent — independent judgment, but still invoked by the model.
CODE Stop hook — a script the harness runs when Claude tries to end the turn. The model cannot skip it, forget it, or talk its way past it.

The Stop hook is where your verifier stops being a request and becomes a law of physics for the session.

2 · The mechanism, in one breath

When Claude tries to end its turn, the harness fires the Stop event and runs your script. The script gets a JSON payload on stdin (session id, transcript path, cwd…). Then a single contract:

exit 0 → the turn is allowed to end.
exit 2 → the stop is blocked, and whatever the script wrote to stderr is fed to Claude as the reason — Claude keeps working with that text as its feedback.
(Equivalent JSON form: print {"decision":"block","reason":"…"} and exit 0.)

Read that middle line again, because it's the loop: the stderr of your check is the agent's next clue. "Error messages are themselves feedback" (Writing tools for agents) — a Stop hook that prints "FAILED" gives the model nothing; one that prints the failing test output hands it the fix.

Runaway protection: a hook that always fails could trap the session in work-forever. Two safety rails: Claude Code overrides the gate after 8 consecutive blocks (best practices), and the stdin payload includes stop_hook_active — true when the turn is already being continued by a Stop hook — so your script can implement its own escape policy.

3 · The files

Two files, both versioned in the repo. Note what's absent: no CLAUDE.md rule about tests anywhere. That's the experiment.

.claude/settings.json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/scripts/check.sh",
            "timeout": 120 }
        ]
      }
    ]
  }
}

Stop hooks fire on every turn end — they take no matcher. Project-level .claude/settings.json is committed and shared; use .claude/settings.local.json for a personal-only gate.

scripts/check.sh
#!/usr/bin/env bash
# Stop hook: the turn may not end while the test suite is red.
input=$(cat)                      # consume the hook's JSON payload

cd "$(dirname "$0")/.." || exit 0

if npm test >/tmp/stop-hook-test.log 2>&1; then
  exit 0                          # green — the turn may end
fi

{
  echo "BLOCKED: npm test is failing. The turn may not end until the suite is green."
  # the failing-tests summary, minus stack-frame noise
  sed -n '/failing tests:/,$p' /tmp/stop-hook-test.log | grep -vE "^ +at " | head -25
  grep -E "^ℹ (tests|pass|fail)" /tmp/stop-hook-test.log
} >&2
exit 2                            # red — block, stderr becomes Claude's feedback

Note the filtering instead of a raw tail: a blind tail catches stack-trace noise and can cut off the expected-vs-actual values — the most diagnostic lines. This script went through two revisions during testing before its stderr satisfied the lesson's own "actionable on failure" rule. Engineering the verifier is the work.

4 · The dialogue — watch the gate refuse

The demo repo has a seeded bug (two failing tests) that nobody mentions. You ask for something unrelated:

You
Add a short Usage section to README.md showing parseDuration in action.
Claude — main agent
⏺ Read(README.md) · Read(src/duration.js)
⏺ Edit(README.md) — adds Usage section
Added a Usage section with two examples. — attempts to end the turn —
⛔ Stop hook — scripts/check.sh
EXIT 2 · TURN BLOCKED
BLOCKED: npm test is failing. The turn may not end until the suite is green.
✖ failing tests:
✖ parses a combination
  AssertionError: Expected values to be strictly equal:
  + actual - expected
  + 3780000
  - 5400000
✖ parses a full combination
  AssertionError: Expected values to be strictly equal:
  + 3795000
  - 5415000
ℹ tests 8   ℹ pass 6   ℹ fail 2
Claude — main agent (continuing, with the stderr as feedback)
The Stop hook blocked the turn: two duration tests are failing — combinations are off by exactly the minutes component. Checking the unit table.
⏺ Read(src/duration.js) — finds m: 6000 — a missing zero
⏺ Edit(src/duration.js) — m: 60000
⏺ Bash(npm test) → 8 passing
Fixed: the minutes constant was 6000 instead of 60000. Suite is green. README Usage section added as requested. — attempts to end the turn —
✓ Stop hook — scripts/check.sh
EXIT 0 · TURN ENDS
1
Nobody asked for the fix. The task was a README edit. The hook fired anyway — Stop hooks gate every turn end, with no judgment call about relevance. That's the meaning of deterministic.
2
The stderr did the teaching. Claude's first continuation line reasons directly from the test output the hook printed. A bare "BLOCKED" would have forced a blind search; the failing assertions pointed straight at the minutes constant.
3
The gate composes with everything else. Nothing stops you from keeping the Lesson 3 diff-grader too: the subagent judges requirements (model-based, rich), the Stop hook enforces green tests (deterministic, narrow). Different rungs of the taxonomy, same loop.

5 · Design rules for a good Stop-hook check

Fast. It runs on every turn end. A 90-second suite makes every turn cost 90 seconds — Ronacher's "speed is a feature" applies doubly here. Gate on the fast tier (unit tests, typecheck), not the slow one (e2e).
Deterministic. A flaky test in a Stop hook is a randomly locking door. Quarantine flakes before gating.
Actionable on failure. Print the failing output, not a verdict word.
Scoped. A Stop hook is wrong for exploratory or Q&A sessions — it'll demand green tests when you asked a question. Put aggressive gates in settings.local.json, or have the script exit 0 when nothing relevant changed (e.g. git diff --quiet src/ test/ && exit 0 — only gate when code actually moved).


6 · Check yourself

Loading…

💬 Demo 02 is ready on your machine. Run cd ~/projects/stop-hook-demo && claude, approve the hook when prompted, and ask only for the README Usage section — then watch the gate refuse to let the turn end. Debrief here afterward.

Where to go next