Issue 00 · Autumn 2026日本語 · RSS
AgentKoji

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.

TL;DR

  • 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:

FieldMeaning
stop_hook_activetrue when Claude is already continuing because of a stop hook
last_assistant_messageThe text of Claude’s final response
background_tasksTasks still running, such as shell commands or subagents
session_cronsScheduled 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

OutputEffect
JSON {"decision": "block", "reason": "..."}Claude doesn’t stop; reason (required) tells it why
hookSpecificOutput.additionalContextClaude continues; shown as “Stop hook feedback”, not as a hook error
exit 2 with stderrSame as reason: Claude gets stderr as the explanation
exit 0, nothing printedClaude 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:

Statestop_hook_activeOutputExit
src/ changed, a test failsfalsedecision: "block" with the reason0
Same, second timetrue(none)0
src/ changed, tests passfalse(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