Claude Code hook exit codes: what 0, 2 and everything else do
Exit 0 succeeds, exit 2 blocks and feeds stderr back to Claude, anything else is a non-blocking warning. What blocking means per event, and when to use JSON output instead.
Contents
A hook's exit code is the signal that tells Claude Code whether to continue, stop, or merely warn. Get it wrong and a failing lint goes unnoticed, or a note you meant as a warning halts the turn.
The short version: 0 succeeds, 2 blocks and hands your stderr to Claude, and anything else is a non-blocking error shown to you.
KEY POINT
What you will learn
- How exit 0, 2 and everything else differ
- What "blocking" means for each event, and which events can't be blocked
- When to print JSON instead of relying on an exit code
The three outcomes
| Exit code | Treated as | stdout | stderr |
|---|---|---|---|
0 | Success | Shown to you; for a few events it is added to Claude's context as plain text | Not shown |
2 | Blocking error | Ignored | Fed to Claude as feedback on events that support it |
| Anything else | Non-blocking error | Parsed as JSON when it looks like JSON; otherwise ignored | First line shown with Failed with non-blocking status code: |
Use 2 when you want to tell Claude something. Use 1 when you want to tell yourself something.
On exit 0, the events that add stdout to Claude's context are UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch. For PreToolUse, exit 0 does not approve the tool call — the normal permission flow still applies.
What exit 2 does per event
The effect depends on which event returned it.
| Event | Can block? | What exit 2 does |
|---|---|---|
PreToolUse | Yes | Blocks the tool call |
UserPromptSubmit | Yes | Blocks prompt processing and erases the prompt |
UserPromptExpansion | Yes | Blocks the expansion |
Stop | Yes | Prevents Claude from stopping and continues the conversation |
PermissionRequest | No | Not honored; the permission flow proceeds unchanged. Deny through the decision object instead |
Some events can't be blocked at all: for SessionStart and others, exit 2 shows stderr to the user and execution continues. A few, such as ConfigChange and Elicitation, surface no message. Check the reference's per-event table before relying on a block.
用語解説
Blocking on PostToolUse doesn't undo anything: PostToolUse runs after the tool has executed, so the file is already written. The point of exiting 2 there is not to revert, but to tell Claude "lint failed, fix it".
Three worked examples
Exit 2 on PreToolUse to stop a dangerous command
#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -Eq 'rm -rf /|git push --force'; then
echo "This command is not allowed here: $cmd" >&2
exit 2
fi
exit 0
For a fuller treatment of this pattern and its limits, see Block rm -rf with a PreToolUse hook.
Exit 2 on PostToolUse to make Claude fix a lint failure
#!/usr/bin/env bash
file=$(jq -r '.tool_input.file_path // empty')
[[ "$file" == *.ts ]] || exit 0
if ! npx eslint "$file"; then
echo "ESLint reported errors. Please fix: $file" >&2
exit 2
fi
exit 0
Exit 1 to tell only the human
#!/usr/bin/env bash
file=$(jq -r '.tool_input.file_path // empty')
if [[ "$file" == */migrations/* ]]; then
echo "Note: a migration file was changed: $file" >&2
exit 1
fi
exit 0
Claude keeps working; only you see the warning.
JSON output for finer control
Exit 0 and print a JSON object to stdout when an exit code isn't expressive enough. The field differs by event, which is the part people get wrong.
PreToolUse uses hookSpecificOutput.permissionDecision:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook"
}
}
PostToolUse and Stop use a top-level decision: "block" field with a reason, and PermissionRequest uses hookSpecificOutput.decision.behavior. Check the reference's decision-control table for the event you are writing.
Nothing else may reach stdout
Claude Code parses stdout as JSON only when it starts with { and ends with }. A stray echo — including an unconditional one in your shell profile, which a non-interactive sh -c can still source — breaks the parse silently. Send debug output to stderr.
Summary
- 0 succeeds, 2 blocks and feeds stderr to Claude, anything else is a non-blocking error shown to you
- Exit 2 on
PreToolUsecancels the call; onPostToolUseit asks Claude to fix an already-applied change PermissionRequestignores exit 2, and some events can't be blocked at all- Use 1 to inform yourself, 2 to inform Claude
- For JSON control,
PreToolUseusespermissionDecisionwhilePostToolUseandStopuse top-leveldecision
For the hook configuration itself, see Run lint and format automatically after every edit.
FAQ
- What happens if my hook exits with code 1?
- Any code other than 0 and 2 is a non-blocking error. The action proceeds, the transcript shows a hook error notice, and the first line of stderr appears prefixed with "Failed with non-blocking status code:".
- Can exit 2 on PostToolUse undo the tool call?
- No. PostToolUse runs after the tool has executed, so the change is already on disk. Exit 2 there feeds your stderr to Claude so it can fix the result.
- Does exit 2 block every event?
- No. PermissionRequest doesn't honor it at all, and for SessionStart and some others it just shows stderr to the user while execution continues.
Primary sources
This article was drafted by AI from official documentation and reviewed by the site operator before publishing. Found a mistake? Let us know via the contact page.