Claude Code Stop Hook: Verify Before It Stops
Claude Code overrides a Stop hook after eight blocks in a row. A Stop hook that keeps Claude working until the tests pass, with the loop guard it needs.
- A Stop hook runs when Claude finishes responding and can send it back to work with a reason, so it can verify "done" with something other than Claude's word.
- It fires after every response, not only at task end, so our example runs npm test only when src/ or test/ changed.
- Use last_assistant_message rather than transcript_path, and respect stop_hook_active; Claude Code caps forced continuations at eight.
A Stop hook runs when Claude finishes responding. It can send Claude back to work with a reason, which makes it the place to check “is this actually done?” with something other than Claude’s own word. Below is a Stop hook for a dummy todo app that blocks the stop while src/ or test/ has changes and npm test fails. We tested on macOS with Claude Code 2.1.304, jq 1.7.1 and Node’s built-in test runner, on 2026-10-20, by piping dummy JSON into the script. We did not watch it fire in a live session. Why a completion report alone can’t be trusted is covered in Don’t Trust an Agent’s “Done”.
When Stop fires
Stop runs when the main agent finishes responding. It doesn’t run when you interrupt, and API errors fire StopFailure instead (source: https://code.claude.com/docs/en/hooks, checked 2026-10-20). It fires after every response, not only at the end of a task (source: https://code.claude.com/docs/en/hooks-guide, checked 2026-10-20). A Stop hook that runs the full test suite therefore runs it after every answer, including answers to plain questions. The script below skips the suite when nothing under src/ or test/ changed.
The input includes:
| Field | Meaning |
|---|---|
stop_hook_active | true when Claude is already continuing because of a stop hook |
last_assistant_message | The text of Claude’s final response |
background_tasks | Tasks still running, such as shell commands or subagents |
session_crons | Scheduled wakeups for this session |
Use last_assistant_message instead of reading transcript_path; the docs say the transcript file isn’t guaranteed to contain the final message at Stop time (source: https://code.claude.com/docs/en/hooks, checked 2026-10-20).
Keeping Claude working
| Output | Effect |
|---|---|
JSON {"decision": "block", "reason": "..."} | Claude doesn’t stop; reason (required) tells it why |
hookSpecificOutput.additionalContext | Claude continues; shown as “Stop hook feedback”, not as a hook error |
exit 2 with stderr | Same as reason: Claude gets stderr as the explanation |
exit 0, nothing printed | Claude stops |
Claude Code caps this at eight. After stop hooks have continued the turn eight times in a row, Claude Code overrides the next block and ends the turn. The count resets each time Claude calls a tool. CLAUDE_CODE_STOP_HOOK_BLOCK_CAP raises the cap (same source).
One trap: if a Stop hook exits 2 with nothing on stdout and its stderr says a file is missing, such as No such file or directory, Claude Code treats it as a non-blocking error. A wrong script path doesn’t trap Claude in a loop, but it doesn’t block either (same source).
A Stop hook that requires passing tests
#!/usr/bin/env bash
# .claude/hooks/stop-require-tests.sh (Stop)
input=$(cat)
if [ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ]; then
exit 0 # already continued once because of this hook; let it stop
fi
cd "${CLAUDE_PROJECT_DIR:?}" || exit 0
if [ -z "$(git status --porcelain -- src test)" ]; then
exit 0 # nothing changed in src/ or test/
fi
if npm test --silent >/dev/null 2>&1; then
exit 0
fi
jq -n '{decision: "block",
reason: "src/ or test/ has uncommitted changes and npm test fails. Run npm test, fix the failures, then finish."}'
exit 0
We ran it in four states of the dummy repo:
| State | stop_hook_active | Output | Exit |
|---|---|---|---|
src/ changed, a test fails | false | decision: "block" with the reason | 0 |
| Same, second time | true | (none) | 0 |
src/ changed, tests pass | false | (none) | 0 |
No changes in src/ or test/ | false | (none) | 0 |
The stop_hook_active check means this hook sends Claude back at most once per stop. That’s the pattern the hooks guide shows to avoid hitting the cap (source: https://code.claude.com/docs/en/hooks-guide, checked 2026-10-20). If you want Claude to keep trying until the tests pass, drop the check and rely on the cap, but expect up to eight extra rounds of usage before the override.
Whether Claude actually reruns and fixes the tests after the block: (not tested in a live session).
The settings.json
Stop has no matcher support, so leave matcher out.
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-require-tests.sh", "timeout": 120 }
]
}
]
}
}
claude doctor reported no invalid settings. We raised the timeout to 120 seconds for the test run; the default for a command hook is 600 (source: https://code.claude.com/docs/en/hooks, checked 2026-10-20).
Judge with exit codes, not the model
This hook decides with git status and the test runner’s exit code. It never reads last_assistant_message to guess whether Claude “sounds done”. Facts from the machine don’t change when the model does. For faster per-edit checks, see Claude Code PostToolUse: Format and Test on Edit. For blocking a tool call before it runs, see Claude Code PreToolUse Hook Examples, and for the overall approach, Claude Code Hooks in Practice.
Summary
- Stop runs after every response, not just at the end of a task. Skip the expensive check when nothing changed.
- Block with
decision: "block"and a requiredreason, or exit 2 with stderr. - Check
stop_hook_activeso the hook sends Claude back at most once. - Claude Code overrides a Stop hook after eight consecutive blocks;
CLAUDE_CODE_STOP_HOOK_BLOCK_CAPchanges that. - Use
last_assistant_message, not the transcript file. - Decide with
git statusand exit codes, not by reading Claude’s message.