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.

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.

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' "{\"systemMessage\":\"Stop check still fails: $block_reason. Run it manually; CI remains required.\"}" exit 0 fi printf '%s\n' "Stop check blocked: $block_reason" >&2 exit 2 } 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:

  1. Break the expected file name and run printf '%s\n' '{"stop_hook_active":false}' | ./scripts/check-before-stop.sh; echo $?; expect stderr and code 2.
  2. Restore the path and repeat the command; expect code 0.
  3. Ask Claude Code to complete a small task and confirm that exit 2 prevents Stop and sends the short stderr reason back to Claude.
  4. With the check still broken, pipe {"stop_hook_active":true} into the script; expect a systemMessage JSON object and code 0. Then restore the check and confirm normal exit 0. If stop_hook_active is true, the sample fails open after one remediation attempt: it returns 0 with a systemMessage for the operator, preventing an endless loop while CI remains required.

Log only the check name and exit result. Never print API keys, .env contents, full prompts, or complete test logs.

Handle a failed stop

Run the named check directly, fix the code, and repeat the check in a fresh attempt. A passing git diff --check does not prove business logic; an existing build file may be stale. A hook can catch only the claims you wrote into it, so keep the list short and visible.

Keep the API workflow separate

For Claude Code API work, use your own API key and compare the current Anthropic-compatible settings with BetterToken’s Claude Code documentation. BetterToken is API access, not a Claude subscription. The local hook does not need access to the key or to full Dashboard logs.

Ready to optimize your LLM workflow?

Join thousands of developers building faster, smarter, and more cost-effective AI applications with BetterToken.