Stop Hooks in Claude Code: Verify Before “Done”
Add a small Stop Hook check to Claude Code, verify tests or files with an exit code, and keep secrets out of local logs.
Contents
Stop Hooks in Claude Code: Verify Before You Say “Done”
Claude Code can stop after producing a plausible answer. A Stop Hook gives the project one small, repeatable check before that stop: run a test, inspect a file, or validate the working tree. It does not replace CI or prove that every requirement is correct.
If Claude Code uses an API provider, open the current BetterToken setup guide, configure your own API key in the tool, and send a short test request. Confirm the expected model, status, and token usage in Dashboard. Keep the key out of the local hook and its logs.
Stop Hook, CLAUDE.md, and CI have different jobs
A Stop Hook runs the short command at the end of a turn. CLAUDE.md tells the agent which command and boundary to follow but does not execute anything. CI runs independently after push or pull request and remains the final gate.
Pick one claim and one signal
Write down the claim you want to verify, the command that proves it, and the exit code that means success. For a Node project, the contract might be: “the focused tests pass and dist/app.js exists.” Start with a read-only or local check. Do not put deploys, publishing, or external writes in a hook that runs at every stop.
Configure a minimal hook
Create .claude/settings.json with a Stop command hook and a short timeout:
{
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "./scripts/check-before-stop.sh",
"timeout": 30
}]
}]
}
}
Keep the script explicit:
#!/usr/bin/env sh
set -eu
if [ -t 0 ]; then
input='{"stop_hook_active":false}'
else
input=$(cat)
fi
stop_hook_active=$(printf '%s' "$input" | node -e 'let r="";process.stdin.on("data",c=>r+=c);process.stdin.on("end",()=>{try{process.stdout.write(String(Boolean(JSON.parse(r).stop_hook_active)))}catch{process.stdout.write("false")}})')
block_stop() {
if [ "$stop_hook_active" = "true" ]; then
printf '%s\n' "Stop check still fails: $block_reason. Run it manually; CI remains required." >&2
exit 0
fi
BLOCK_REASON="$block_reason" node -e 'process.stdout.write(JSON.stringify({decision:"block",reason:`Stop check failed: ${process.env.BLOCK_REASON}`}) + "\n")'
exit 0
}
npm test -- --runInBand >/dev/null 2>&1 || { block_reason='npm test'; block_stop; }
test -s dist/app.js || { block_reason='dist/app.js is missing'; block_stop; }
exit 0
Run the script manually first, make it executable, and replace the commands with the ones that exist in your repository. Then verify the workflow:
- Break the expected file name and run
printf '%s\n' '{"stop_hook_active":false}' | ./scripts/check-before-stop.sh; echo $?; expect JSON with"decision":"block", a concisereason, and code0. - Restore the path and repeat the command; expect code
0. - Ask Claude Code to complete a small task and confirm that the
decision: "block"response prevents Stop and sends the concise reason back to Claude. - With the check still broken, pipe
{"stop_hook_active":true}into the script; expect a short stderr warning and code0. Then restore the check and confirm normal exit0. This branch deliberately fails open after one remediation attempt while CI remains required. If you need repeated blocking, implement an explicit project-owned counter and boundary instead of assuming a fixed undocumented retry count.
Log only the check name and exit result. Never print API keys, .env contents, full prompts, or complete test logs.
Handle a failed stop
Separate two cases. If the hook returns JSON with decision: "block", read reason: the checked condition failed normally. Run that check directly, fix the code or test, confirm that any expected file came from the current command, and retry the Claude Code task.
If the hook command itself exits nonzero or times out, treat it as a hook execution error, not a confirmed reason block. Read the hook error and stderr, run the script manually, and fix its path, permissions, dependency, or time budget before retesting. A passing git diff --check does not prove business logic, and an existing build file may be stale.
Sources
- Claude Code hooks — checked August 22, 2026
- BetterToken Claude Code documentation