Claude Code Stop Hook: Block Early Exit Without Loops
A Claude Code Stop hook is a command that Claude Code runs when the main agent finishes responding. If the command prints {"decision": "block", "reason": "..."}, Claude keeps working and treats the reason as its next instruction. To make an unattended run continue until a real check passes, have the hook run your tests or look for the output file, block with a specific reason while the check fails, and stop blocking after a fixed number of retries so the run can't loop forever.
Which option is right for you?
The best choice is the lightest Claude Code tool that can verify your "done" condition. Start from the row that matches your situation:
| Your situation | What to pick first | First page to open | Why |
|---|---|---|---|
| Beginner, interactive session, one task | /goal with a condition such as "npm test exits 0" | Keep Claude working toward a goal | Needs no config file. It lasts for the current session only |
| Daily unattended runs (cron, CI, VPS) with a test command | A command Stop hook plus your own retry counter | Hooks reference: Stop | The check is deterministic and the same in every session |
| Tight budget | Command hook, a retry cap of 2–3, and --max-budget-usd on the run | Manage costs effectively | A shell check uses no model tokens. Only the retries do |
| The "done" condition needs judgment (prose quality, completeness) | A prompt-based Stop hook ("type": "prompt") | Prompt-based hooks | A model judges it, so it costs a model call each time it runs |
| Subagents should check their own work | A SubagentStop hook | Hooks reference: SubagentStop | It fires when a subagent finishes. Stop doesn't fire then |
If you're unsure, pick the command hook. You can test it from a terminal, and it gives the same answer every time.
What is a Claude Code Stop hook and when does it run?
A Claude Code Stop hook is a handler registered under the Stop event. Claude Code runs it every time the main agent ends a turn. The hooks reference says Stop "Runs when the main Claude Code agent has finished responding". It doesn't run when you interrupt, and API errors fire a separate StopFailure event instead (hooks reference).
This matters for unattended work because the model decides when it is finished, and it can be wrong. A Stop hook lets a script that you control decide whether the work is actually done.
The hook receives JSON on stdin. These are the fields that matter for a "keep working" hook (hooks reference):
| Field | What it tells you | Use it for |
|---|---|---|
session_id | A unique ID for the session | Naming a retry-counter file |
stop_hook_active | true when Claude is already continuing because a Stop hook blocked | Spotting a repeat pass |
last_assistant_message | The text of Claude's final reply | Logging and notifications. The docs recommend it over reading transcript_path |
background_tasks, session_crons | Work still in flight or scheduled | Telling "done" apart from "waiting on a background job" |
cwd, transcript_path | Working directory and transcript file | Finding project files |
Some points to know before you write one:
- Stop has no matcher support. It fires on every occurrence (hooks guide). In an interactive session, a hook that runs tests also runs after a one-line answer to a quick question. For example, you could have your cron wrapper export a variable such as
UNATTENDED=1and make the scriptexit 0when that variable isn't set. - Stop hooks fire "whenever Claude finishes responding, not only at task completion" (hooks guide). Your check has to work at any point in the task.
- A command hook has a 600-second default timeout (hooks reference). A slow test suite needs a larger
timeoutvalue.
For where Stop sits among the other events, see Claude Code Hooks Lifecycle: Every Event, Explained.
Claude Code Stop hook example: how do you keep working until tests pass?
Run your real check and block with a specific reason until it passes. When it passes, exit 0 without a decision. The decision fields for Stop are decision ("block" prevents stopping; leave it out to allow the stop) and reason, which the docs call "Required when decision is "block"".
Step 1: Register the hook
Add this to .claude/settings.json in your project. Use .claude/settings.local.json if you don't want to commit it.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify-before-stop.sh",
"timeout": 900
}
]
}
]
}
}
Notes:
- There is no
"matcher", because Stop doesn't support one. $CLAUDE_PROJECT_DIRavoids "command not found" errors when the working directory changes (hooks guide).timeoutis in seconds. 900 is an example value for a slow suite.
Step 2: Write the script
Save the following as .claude/hooks/verify-before-stop.sh and run chmod +x .claude/hooks/verify-before-stop.sh. In this example, "done" means npm test exits 0 and output/report.md exists and isn't empty. Swap in your own checks.
#!/bin/bash
# Example Stop hook: block until tests pass and the report exists, max N retries.
INPUT=$(cat)
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id')
ACTIVE=$(printf '%s' "$INPUT" | jq -r '.stop_hook_active')
MAX_RETRIES="${STOP_HOOK_MAX_RETRIES:-3}"
STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.claude/state"
mkdir -p "$STATE_DIR"
COUNT_FILE="$STATE_DIR/stop-$SESSION_ID.count"
LOG="$STATE_DIR/stop-hook.log"
# A fresh stop (not a continuation) starts a new retry budget.
[ "$ACTIVE" != "true" ] && echo 0 > "$COUNT_FILE"
COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
# The real check. Its output goes to a file, never to stdout.
if npm test --silent >"$STATE_DIR/last-test.txt" 2>&1 && [ -s output/report.md ]; then
echo "$(date -u +%FT%TZ) $SESSION_ID PASS retries=$COUNT" >>"$LOG"
rm -f "$COUNT_FILE"
exit 0 # no decision = Claude may stop
fi
# Retry budget spent: allow the stop, record the failure, warn the user.
if [ "$COUNT" -ge "$MAX_RETRIES" ]; then
echo "$(date -u +%FT%TZ) $SESSION_ID GAVE_UP retries=$COUNT" >>"$LOG"
rm -f "$COUNT_FILE"
jq -n --arg n "$COUNT" \
'{systemMessage: ("Stop hook gave up after " + $n + " retries; checks still failing.")}'
exit 0
fi
# Block and say exactly what is still broken.
echo $((COUNT + 1)) > "$COUNT_FILE"
FAILS=$(tail -n 30 "$STATE_DIR/last-test.txt")
jq -n --arg fails "$FAILS" '{
decision: "block",
reason: ("Not done. npm test must exit 0 and output/report.md must exist and be non-empty. Last test output:\n" + $fails)
}'
exit 0
systemMessage is a general JSON output field that shows a message to the user. The guide lists it as something a successful hook's JSON can surface (hooks guide).
Step 3: Test it before you run it unattended
Pipe sample input into the script yourself:
echo '{"session_id":"test1","stop_hook_active":false}' | .claude/hooks/verify-before-stop.sh
echo $?
You should see one JSON object with "decision": "block" while the tests fail, nothing once they pass, and an exit code of 0 in both cases.
The exit-code-2 alternative
There's a shorter version. Write the reason to stderr and exit 2:
echo "npm test is failing; fix it before finishing" >&2
exit 2
For Stop, exit 2 "Prevents Claude from stopping, continues the conversation", and stderr reaches Claude the same way reason does. The docs say to use one approach per hook (hooks guide). The JSON form is the better fit for unattended runs, because the same script can also return systemMessage when it gives up.
How do you use stop_hook_active so a Stop hook never loops forever?
In Claude Code, stop_hook_active is true when Claude is already continuing because a Stop hook blocked. Read it to tell a first stop from a repeat, and pair it with a counter to set a real cap.
The guide's minimal pattern exits early when the flag is true (hooks guide):
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # Allow Claude to stop
fi
That pattern prevents loops, but it allows exactly one retry. The first stop blocks. Every later stop in that continuation arrives with stop_hook_active: true, so the hook lets it through. If tests are still red after one round of fixes, the run ends red. The counter in the script above allows up to MAX_RETRIES blocks per task turn instead.
Pitfalls:
- Don't key the counter on the project directory only. Parallel sessions would use up each other's retries. Keying it by
session_idkeeps two cron jobs in the same repo from sharing a budget. - Put state under the project and outside git. Add
.claude/state/to.gitignore, or Claude may commit your counter files. - Make "give up" visible. Exiting 0 silently on the last retry looks the same as success in a cron log. Write a marker line your wrapper can grep for. AI Agent Monitoring in Claude Code: Catch Silent Failures shows how to alert on it.
- Choose a small cap. Each retry is a full extra turn with your whole context. A cap of 2–3 (an example value, not an official recommendation) is enough for tests to go green after a real fix. If the suite still fails after that, the cause usually needs a human.
What is CLAUDE_CODE_STOP_HOOK_BLOCK_CAP and how do you raise the limit?
CLAUDE_CODE_STOP_HOOK_BLOCK_CAP is the Claude Code environment variable that raises the built-in limit on consecutive Stop hook continuations. The default is 8. The hooks reference says that after stop hooks have continued the turn eight times in a row, "Claude Code overrides the next block and ends the turn" (hooks reference, as of November 2026).
When the cap is reached, the guide describes the symptom: Claude "ends the turn with a warning that the Stop hook blocked too many consecutive times" (hooks guide). That is where the "set claude_code_stop_hook_block_cap to raise this limit" text comes from.
One detail matters here: "The count of consecutive continuations resets each time Claude calls a tool." The built-in cap catches a hook that blocks while Claude only replies with text. It does not catch the common unattended case, where Claude edits files and reruns tests on each pass. Each tool call resets the count, so going by the docs' wording, a test-gated hook can keep blocking well past 8. That's why your own counter is the cap that protects your token budget.
How to raise it (only if you need to)
- First confirm the hook isn't blocking on a condition that can never pass, such as a missing file path or a test that needs network access.
- Set the variable for the run. In a shell or cron wrapper:
``bash CLAUDE_CODE_STOP_HOOK_BLOCK_CAP=12 claude -p "..." ` Or in a settings file, using the env key, which Claude Code reads "no matter how claude was launched" (environment variables): `json { "env": { "CLAUDE_CODE_STOP_HOOK_BLOCK_CAP": "12" } } ``
- Keep your script's
MAX_RETRIESat or below what you're willing to pay for.
The guide's condition for raising it is when your hook "legitimately needs more than eight iterations to converge". Keep the built-in cap in place on unattended runs.
The additionalContext output, which sends non-error feedback, goes through "the same loop protections" as decision: "block" (hooks reference). Switching to it doesn't get around the cap.
How do you fix a Claude Code Stop hook "JSON validation failed" error?
In Claude Code, this error means the hook's stdout parsed as JSON but didn't match the schema for the event. The guide explains that a JSON validation message means stdout "parsed as JSON but failed schema validation", and a parse message means it "looked like a JSON object but wasn't valid JSON". Both can happen "even on exit 0" (hooks guide).
Common causes in Stop hooks:
| What your hook printed | Problem | Fix |
|---|---|---|
{"decision": "allow"} or "approve" | Stop only documents "block" | Leave decision out, or print nothing, to allow the stop |
{"decision": "block"} with no reason | reason is required with block | Add a specific reason string |
{"hookSpecificOutput": {"permissionDecision": "deny"}} | That shape belongs to PreToolUse | Use the top-level decision/reason |
{"additionalContext": "..."} at the top level | It belongs inside hookSpecificOutput with "hookEventName": "Stop" | Nest it. Fields at the top level are silently ignored (hooks guide) |
{"reason": "Line 1\nfailed "quote""} built with echo | Unescaped quotes or newlines cause a parse error | Build it with jq -n --arg. The guide recommends a JSON encoder "instead of string concatenation" (hooks guide) |
A related failure produces no error at all: the decision is simply ignored. The rule is "Print nothing but the JSON object to stdout." Output that doesn't start with { is read as plain text, and the usual cause is an echo in ~/.zshrc or ~/.bashrc. The guide's fix is to wrap profile echoes in if [[ $- == i ]]; then ... fi so they run only in interactive shells (hooks guide).
Steps to fix:
- Start Claude Code with
claude --debug-file /tmp/claude.log, then trigger a stop. - In the log, search for
Hook output does not start with {(extra output) orHook JSON output had unrecognized keys(fields at the wrong level). Both strings come from the hooks guide. - Send all debug prints to stderr (
>&2) or a log file, and redirect test output to a file as the example script does. - Run the script by hand and pipe its output through
jq .. If jq can't parse it, Claude Code can't either.
Claude Code Stop hook not working: what should you check?
When a Claude Code Stop hook doesn't fire or doesn't block, the usual causes are registration, the path, permissions, or stdout. Work through this table from top to bottom:
| Check | How | Typical fix |
|---|---|---|
| 1. Is it loaded? | Type /hooks. It opens "a read-only browser for your configured hooks" | Fix the JSON (no trailing commas or comments) and the file location |
| 2. Correct key? | It must be under "Stop" (case-sensitive), inside a "hooks" array | Copy the registration block above |
| 3. Executable? | ls -l .claude/hooks/ | chmod +x the script |
| 4. Right path? | Look for "command not found" in the transcript | Use "$CLAUDE_PROJECT_DIR"/... or an absolute path |
| 5. Clean stdout? | Pipe sample JSON in by hand | Move logs to stderr and silence profile echoes |
| 6. Dependencies? | Look for jq: command not found | Install jq or rewrite the script in Python |
| 7. Managed policy? | /hooks shows Only hooks from managed settings run here | Your organization set allowManagedHooksOnly. Ask an admin (hooks guide) |
Two Stop-specific traps:
- Exit 1 doesn't block. Exit codes other than 0 and 2 without valid JSON are treated as non-blocking errors on most events (hooks guide), so
exit 1on failing tests lets Claude stop. - A broken exit-2 hook can trap Claude. On Stop, exit 2 "sends Claude back to keep working, and Claude can't repair a hook that won't run" (hooks reference). The reference treats one case as non-blocking: an exit 2 with empty stdout and a missing-file message such as
No such file or directoryon stderr. Other crashes still block. This is another reason to prefer the JSON form, where a crashed script doesn't print"decision": "block".
To see what the hook did, press Ctrl+O to open the transcript view. A block shows the reason, and a non-blocking error shows a <hook name> hook error notice (hooks guide). If Claude Code stops mid-run for reasons unrelated to hooks, see Why Claude Code Stops Mid-Automation (and How to Fix It).
Stop vs SubagentStop: which one covers subagents?
In Claude Code, Stop covers the main agent and SubagentStop covers subagents. The hooks guide lists SubagentStop as firing "When a subagent finishes" (hooks guide). A Stop hook alone won't check subagent output.
| Stop | SubagentStop | |
|---|---|---|
| Fires when | The main agent finishes a turn | A subagent finishes |
| Matcher | None, always fires | Agent type, e.g. Explore or a custom agent name |
| Extra input | stop_hook_active, last_assistant_message, background_tasks, session_crons | stop_hook_active, agent_id, agent_type, agent_transcript_path, last_assistant_message |
| Block effect | Claude keeps working | "keeps the subagent running and delivers the reason to it as its next instruction" (hooks reference) |
SubagentStop uses the same decision format as Stop. The counter script works with one change: key the counter on agent_id instead of session_id, because one session can run many subagents.
{
"hooks": {
"SubagentStop": [
{
"matcher": "test-writer",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify-subagent.sh" }
]
}
]
}
}
Pitfall: internal agents, such as prompt suggestions and /btw side questions, also trigger SubagentStop, sometimes with an empty agent_type (hooks reference). Always set a matcher with your agent's name. To choose the agent's tools, see Claude Code Subagent allowed-tools: Syntax, Examples, Fixes.
How do you disable a Claude Code hook quickly?
To turn off all Claude Code hooks without deleting them, set "disableAllHooks": true in a settings file. To turn off a single hook, remove its entry from the file that defines it.
- Find which file defines it.
/hookslists configured hooks by event. Hooks can live in~/.claude/settings.json(all projects),.claude/settings.json(project, committed),.claude/settings.local.json(project, not shared), managed policy, plugins, skills, or subagent frontmatter (hooks guide). - Turn off one hook. Delete its object from the
"Stop"array. JSON settings don't allow comments, so you can't comment it out. - Turn everything off. Add
"disableAllHooks": true. Precedence rules apply, so "a project's settings file can override yours", and managed hooks keep running unless the setting is also set in managed settings (hooks guide). This also turns off/goal, which runs on the hooks system (goal docs). - Stop a run that's already looping. Interrupt the session. Stop hooks don't run when you interrupt (hooks reference). Fix the script, then restart.
Settings edits are normally picked up while Claude Code is running. If a change doesn't appear after a few seconds, restart the session (hooks guide).
How do you watch token usage from Stop hook retries?
Use Claude Code's /usage command in a session, cap spend on headless runs, and count blocks in your hook's log. Every block starts another turn, and that turn re-sends your whole context.
- Inside a session, run
/usage. Its Session block shows total cost and usage by model. The docs call the dollar figure "an estimate" and point API users to the Console Usage page for real billing (costs). On Pro, Max, Ultra, Team, and Enterprise plans, the same screen shows plan usage bars and a usage breakdown. - For headless runs, add
--max-budget-usdas a hard ceiling. The costs page says session cost counts toward it (costs; flags in the CLI reference). - From your hook log, count retries per run:
``bash grep -c GAVE_UP .claude/state/stop-hook.log grep PASS .claude/state/stop-hook.log | tail -n 20 `` If many runs pass only after 2–3 retries, your prompt or CLAUDE.md probably isn't telling Claude to run the tests before it finishes.
For a sense of scale, Anthropic reports an enterprise average of "around $13 per developer per active day" (costs, as of November 2026). A hook that loops all night can push a single run well past that.
To get alerted, have the hook notify you when it gives up. For example, on macOS add osascript -e 'display notification "Stop hook gave up" with title "Claude Code"' to the give-up branch, or use notify-send on a Linux desktop. On a headless server, append to a file that your cron wrapper checks. The Notification event also exists for cases where Claude Code is waiting on you, such as permission_prompt or idle_prompt (hooks guide). More on cutting usage: Claude Code: How to See Token Usage (and Reduce It).
Next steps
Things you can do today:
- Copy the registration block and script above. Change the check to your real "done" condition, set
STOP_HOOK_MAX_RETRIES=3, and pipe sample JSON into it until the output is a single clean JSON object. - Add
.claude/state/to.gitignore, and add agrep GAVE_UPline to the end of your cron wrapper so a failed run doesn't look like a success. - Add
--max-budget-usdto yourclaude -pcommand as a second limit on spend.
If you'd rather not write a script: /goal is built in and costs nothing extra to set up. Its evaluator runs on the small fast model, and the docs say evaluation tokens are "typically negligible compared to main-turn spend" (goal docs). It suits interactive sessions and one-off claude -p "/goal ..." runs. It is a poor fit if you need a deterministic check, because the evaluator "doesn't run commands or read files independently" and only judges what Claude has shown in the conversation. It also lasts for one session only.
For running Claude Code itself, Anthropic's subscription plans (Pro, Max, Ultra, Team, Enterprise) are listed at claude.com/pricing (as of November 2026; check the official page). Pay-per-token API billing through the Claude Console lets you set workspace spend limits (costs). Neither one replaces a retry cap in your hook.
Related guides on this site:
- Claude Code Hooks Examples: 7 Copy-Paste Scripts
- Claude Code Cron Job Setup: Run Automations Overnight
- AI Agent Monitoring in Claude Code: Catch Silent Failures
FAQ
Where can I find Claude Code Stop hook examples on GitHub?
Anthropic's own repository has a complete hook reference implementation, the bash command validator example. It's a PreToolUse hook, but its input parsing and exit-code handling carry over to Stop. Community discussions also exist, such as this thread in ianymu/claude-verify-before-stop, but they aren't official sources. Check any community example for a retry cap and for clean stdout before you rely on it.
Why does Claude Code show "running stop hook" and how long should it take?
That status means your Stop hook command is running. It lasts as long as your check takes, such as the full test suite. Command hooks time out after 600 seconds by default (hooks reference). If it seems stuck, run the script by hand to time it, and raise timeout or run a faster test subset.
Can a Stop hook send me a notification when Claude finishes or gives up?
Yes. A Stop hook can run any command, so add a desktop notification or a webhook call to the branch that allows the stop, and use last_assistant_message for the summary text. The docs recommend that field over reading the transcript at Stop time (hooks reference). On servers, a log line that your cron wrapper checks is more reliable than a desktop notification.
Sources
- Hooks reference – Claude Code Docs
- Automate actions with hooks (hooks guide) – Claude Code Docs
- Environment variables – Claude Code Docs
- Keep Claude working toward a goal (/goal) – Claude Code Docs
- Manage costs effectively – Claude Code Docs
- CLI reference – Claude Code Docs
- Claude pricing
- anthropics/claude-code: bash_command_validator_example.py
- ianymu/claude-verify-before-stop discussion
Spotted something out of date or wrong? Tell us at shigoto.ai.note@gmail.com. Guides are re-checked against official sources on a rolling schedule.