Home / Guides / Stop hook that runs tests
A Stop hook that runs tests before Claude Code finishes
CLAUDE.md can say “run the tests before you stop.” The model still forgets. A Stop hook is a shell command Claude Code runs when the main agent finishes a turn. If the suite is red, exit 2 blocks the stop and the stderr message becomes Claude’s next instruction.
This page is only that gate: one hook, test command, exit codes, and the loop guard. It is not a tour of every hook event. For format-on-edit, .env protection, rm -rf blocks, notifications and /compact context, use the separate Claude Code hooks examples page. Subagents and slash commands: Claude Code subagents + hooks.
Shapes below follow Anthropic’s hooks reference and hooks guide (checked October 21, 2026). The script uses jq.
What Stop actually receives
Stop runs when the main agent has finished responding. It does not run on a user interrupt. The hook reads JSON on stdin. Besides the common fields (session_id, cwd, transcript_path), Stop includes:
stop_hook_active—truewhen this turn is already a continuation caused by a Stop hook.last_assistant_message— the text Claude just produced (do not parse the transcript file for that).background_tasks/session_crons— in-flight work, so you can tell “done” from “paused waiting for a task”.
On exit 2, Claude Code prevents the stop and feeds stderr to Claude as the reason to continue. Exit 0 lets the turn end. You can instead print JSON with decision: "block" and a reason; exit 2 is the smaller script. Official docs also note an 8-consecutive-continuation cap: eight blocks in a row with no tool call, and Claude Code overrides the next one. The count resets when Claude calls a tool.
Register it (no matcher)
Stop does not use a matcher. Put the command in project settings so the whole team gets the gate. $CLAUDE_PROJECT_DIR is the project root for the session.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tests-before-stop.sh",
"timeout": 300
}
]
}
]
}
}Default command-hook timeout is 600 seconds; set timeout if your suite should fail faster than that. Run /hooks and confirm the script is listed under Stop. chmod +x .claude/hooks/tests-before-stop.sh. Invalid JSON in settings (a trailing comma) silently drops the hook.
The tests-before-stop script
Read stdin once. If this is already a continuation, let Claude stop. If the worktree is clean, skip the suite. Otherwise run the real project command — not a command the agent invented — and exit 2 on failure with the last lines of output.
#!/bin/bash # Stop hook. Exit 2 = keep working (stderr is the reason). set -u cd "$CLAUDE_PROJECT_DIR" || exit 0 INPUT=$(cat) if [ "$(echo "$INPUT" | jq -r '.stop_hook_active // false')" = "true" ]; then exit 0 fi # Nothing changed this turn: do not tax the suite. git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0 git diff --quiet && git diff --cached --quiet && \ [ -z "$(git ls-files --others --exclude-standard)" ] && exit 0 # Swap for: pytest -q / pnpm test --silent / go test ./... if ! npm test --silent >/tmp/claude-stop-tests.log 2>&1; then echo "Tests failed. Fix the code, not the tests." >&2 echo "Last lines:" >&2 tail -n 40 /tmp/claude-stop-tests.log >&2 exit 2 fi exit 0
Point the test line at what CI runs. A Python repo uses pytest -q; a pnpm monorepo uses the package script, not a raw npm test the agent guessed. Write the command in AGENTS.md so the hook and the model agree.
Why stop_hook_active matters
Without the guard, a red suite that Claude cannot fix becomes: stop → exit 2 → Claude retries → stop → exit 2, up to eight times. That is tokens and time, then an override. Checking stop_hook_active gives you one forced retry, then a stop so a human can look.
If you want Claude to keep iterating, omit the early exit and rely on the eight-block cap (or raise CLAUDE_CODE_STOP_HOOK_BLOCK_CAP). The safer default for a shared repo hook is: block once, then stand down. The input’s background_tasks array distinguishes “done” from “paused on a long task”.
What the hook must not do
- Do not delete or skip tests to go green. Put that in
CLAUDE.mdas well; the hook only re-runs the suite, it cannot stop the model editingtests/. Pair with aPreToolUsedeny on test deletions if you need a hard stop; that pattern lives on the hooks examples page, not here. - Do not run the full suite on an empty diff. Stop fires after every finished turn, including “I read two files and I am done.” The git check keeps the hook cheap.
- Do not block from a PostToolUse formatter. Formatters run after an edit and cannot undo it. Stop is the place that can refuse “done”.
- Do not treat exit 0 as a special approval. On Stop, exit 0 means “no objection, you may stop.” There is no extra permission flow.
Test the hook without waiting for a session
Pipe a fixture and check the exit code:
export CLAUDE_PROJECT_DIR="$PWD"
echo '{"stop_hook_active":true}' | .claude/hooks/tests-before-stop.sh; echo $?
# expect 0
# Dirty the tree, then:
echo '{"stop_hook_active":false}' | .claude/hooks/tests-before-stop.sh; echo $?
# expect 2 if the suite is red, 0 if green
If jq is missing, decide whether to fail open (exit 0) or fail closed (exit 2 with “install jq”). Fail open is less surprising on a new machine; fail closed is the right call once the hook is required in CI-equivalent local work.
Prompt-based Stop hooks (optional)
Claude Code also supports type: "prompt" Stop hooks that ask a model whether the turn may end. That judges the conversation; it does not run pytest. Keep the command hook for a real tests gate. Agent-type hooks that spawn a subagent to run the suite are marked experimental; prefer the shell script above.
AGENTS.md / CLAUDE.md lines
@AGENTS.md # Tests - Done means the project test command in AGENTS.md is green. - Never delete, skip, or # noqa a test to make the Stop hook pass. - If the Stop hook reports failures, fix the code and re-run that same command.
Keep the test command in AGENTS.md so Cursor and Codex get the same definition of done even though they do not run this hook. Thin CLAUDE.md plus shared rules: CLAUDE.md template.
FAQ
What does exit 2 do in a Claude Code Stop hook?
Exit 2 prevents Claude from stopping and sends stderr back as the reason to keep working. Exit 0 lets the turn end. This is the tests gate: failing tests exit 2.
What is stop_hook_active?
The Stop hook input sets stop_hook_active to true when Claude Code is already continuing because a Stop hook blocked. Exit 0 in that case so a red suite you cannot fix does not burn eight extra turns.
Does a Stop hook support a matcher?
No. Stop fires on every completed turn (not user interrupts). Register the command under hooks.Stop with no matcher. Use /hooks to confirm it loaded.
Will a Stop hook loop forever if tests stay red?
Claude Code overrides a Stop hook after eight consecutive blocks with no tool call in between. Check stop_hook_active and exit 0 when it is true if you want a single retry. Raise the cap with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP only if you need more.
Where do I put a tests-before-stop hook?
Commit the script at .claude/hooks/ and register it in .claude/settings.json under Stop. Use $CLAUDE_PROJECT_DIR so the path is stable. chmod +x the script.
Want the whole drawer?
One-File Agent Rules ($9) is one universal AGENTS.md with verify-before-done and a plan gate. The Claude Code Kit ($24) ships a fill-in CLAUDE.md, 6 least-privilege subagents, 10 slash commands, a settings.json with permissions and hook wiring, and 4 tested hooks including tests-before-stop.
Or get the free Starter AGENTS.md by email. One-time purchase, instant download. Packs are written against documented conventions; we have not benchmarked them against a reference project.
Launch week: 30% off any kit with code LAUNCH at Stripe checkout, through Oct 28, 2026. 14-day refund.