Claude Code Hooks: Exit 1 vs 2 and Hooks That Do Not Block
Go deeper on this topic
SaaS integrations
Give AI the context to continue the work: source conversations, open tasks, and unresolved details, from adoption decisions to integration code.
Article 4 of 9 in this series.
Next reads
Dub conversion tracking: lead and sale APIs with Stripe customer mapping
Connect Dub clicks to signups and sales. Check Business requirements, lead and sale fields, USD/JPY amounts, invoiceId, and Stripe metadata with official sources and a credential-free JSON planner.
Dub webhook signature verification: raw bodies and replays in Node.js
Verify Dub-Signature with raw-body HMAC-SHA256 and extract a minimal lead.created work record. An offline Node.js/Bun example checks tampering, JSON reserialization, and replay limits without credentials.
Record Slack huddles and feed AI at 22:00: six Circleback settings
A Circleback workflow for giving AI usable work context: Slack huddles, a 22:00 sync, source statements, open tasks, and adoption criteria. Separates workflow experience, documented features, and local test evidence.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
A Claude Code hook returns exit 1, yet the tool call continues. First check whether the failing check's status actually becomes the hook's final exit status. Then distinguish PreToolUse, which runs before the call, from PostToolUse, which runs after it.
The official hooks reference was checked on October 25, 2026. The table and downloadable model cover synchronous command hooks with empty stdout. They are not tests of Claude Code itself.
Why exit 1 and exit 2 differ
Exit 2 from PreToolUse blocks the pending tool call; stderr supplies the reason. Exit 1 or 127 can instead return to the normal permission flow, depending on onFailure. Returning to that flow does not grant permission: the usual permission decisions still apply.
| Exit status | PreToolUse / continue | PreToolUse / block | PostToolUse / continue or block |
|---|---|---|---|
| 0 | Normal permission flow | Normal permission flow | Already ran |
| 1 | Normal permission flow | Block call | Already ran |
| 2 | Block call | Block call | Already ran |
| 127 | Normal permission flow | Block call | Already ran |
PostToolUse exit 2 sends stderr to Claude, but the tool has already run. Reporting a check result and preventing an operation belong at different points in that sequence. This table compares whether the tool can still be stopped; it does not reproduce differences in error presentation.
Make onFailure explicit in hooks settings
onFailure: "block" requires Claude Code v2.1.295 or later. On Windows without Git Bash, a failing shell-form hook with onFailure unset can instead block the call. The downloadable model rejects an unset value and requires continue or block, excluding that platform-dependent default. Check the failure setting and Windows conditions against your installed version.
This illustrative .claude/settings.json shows the nesting. It blocks every matching Bash call, so it is not a configuration to paste into everyday work. The experiment does not create or apply any hook settings.
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "node",
"args": ["-e", "process.exit(2)"],
"onFailure": "block"
}]
}]
}
}Check the matcher, event, and final hook exit status in that order to distinguish a hook that never ran, a hook that ran without blocking, and a check that happened after the operation.
A later success can swallow the failing status
A check can exit 2 while its wrapper returns 0 from a later successful command. These POSIX shell examples use only fixed Node code: the check always exits 2 and the later command always exits 0.
# Swallows the check status. The whole script exits 0.
node -e 'process.exit(2)'
node -e 'process.exit(0)'Exiting at the failed check preserves 2 and skips the later command. || exit $? returns that preceding failure in a POSIX shell; it is not a PowerShell recipe.
node -e 'process.exit(2)' || exit $?
node -e 'process.exit(0)'Compare 16 cases offline
Save hook-exit.mjs and hook-exit-demo.mjs in the same directory. Node.js is required. Starting the demo with Bun still uses Node.js for its child processes.
node hook-exit-demo.mjs
# Or, with both Bun and Node.js installed:
bun hook-exit-demo.mjsThe demo observes exit 0, 1, 2, and 127 from fixed internal code. The 127 is an explicitly returned synthetic value, not a reproduction of a missing executable. It feeds two events, four statuses, and two explicit settings into the model, then compares wrappers that preserve or swallow the status. It never executes arguments as commands, contacts a service, reads or writes settings, or calls an AI.
The output has observedExits: [0, 1, 2, 127] and wrappers: {"preserved": 2, "swallowed": 0}. The wrappers reproduce the sequence using Node child processes without depending on a POSIX shell. The labels normal-permission-flow, block, and already-ran belong to this explanation model, not Claude Code's log messages.
Keep JSON and timeout handling outside this table
Review hooks that print valid JSON separately. Current official documentation reads valid JSON on nonzero exits too. Exit 2 retains its event-specific blocking effect; on other nonzero exits, JSON can determine the decision, except for hooks configured to block on failure. For structured decisions, consult the documented approach of exit 0 and one JSON object.
This model is an interpretation of the documentation. It does not test JSON parsing, timeouts, launch failures, async hooks, or other events. Its output cannot establish Claude Code's actual behavior or a protection guarantee. Record the event, explicit onFailure, final exit status, and untested conditions in your context handoff. If the selected connection settings also look wrong, check MCP configuration scopes.
FAQ
- Q. Why does exit 1 not block a Claude Code hook?
- For synchronous PreToolUse with empty stdout and onFailure continue, exit 1 returns to the normal permission flow. That is not permission to execute. Exit 2 blocks the call; with onFailure block, exit 1 also blocks.
- Q. Can PostToolUse exit 2 undo a tool call?
- No. The tool has already run. Exit 2 sends stderr to Claude. Use PreToolUse for checks intended to block a pending call.
- Q. Can I leave onFailure unset?
- On Windows without Git Bash, a failing shell-form hook with onFailure unset can block the call. These examples explicitly select continue or block. The block setting requires v2.1.295 or later.
- Q. Is JSON ignored on every nonzero exit?
- Current documentation reads valid JSON on nonzero exits too. Exit 2 retains its event-specific blocking effect; other nonzero exits also depend on failure settings. The downloadable model covers empty stdout only and does not test JSON.
- Q. Why does the hook return 0 when its check exits 2?
- A wrapper that returns a later success loses the failing status. A POSIX shell example puts || exit $? immediately after the check. The demo uses fixed Node code to compare preserved 2 with swallowed 0.