Claude Code hook exit codes: what 0, 2 and everything else do

Claude Code Published: Updated:

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.

Verified on Sep 25, 2026 These tools change quickly. Please also check the latest official documentation.
Contents
  1. The three outcomes
  2. What exit 2 does per event
  3. Three worked examples
    1. Exit 2 on PreToolUse to stop a dangerous command
    2. Exit 2 on PostToolUse to make Claude fix a lint failure
    3. Exit 1 to tell only the human
  4. JSON output for finer control
  5. Summary

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 codeTreated asstdoutstderr
0SuccessShown to you; for a few events it is added to Claude's context as plain textNot shown
2Blocking errorIgnoredFed to Claude as feedback on events that support it
Anything elseNon-blocking errorParsed as JSON when it looks like JSON; otherwise ignoredFirst 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.

EventCan block?What exit 2 does
PreToolUseYesBlocks the tool call
UserPromptSubmitYesBlocks prompt processing and erases the prompt
UserPromptExpansionYesBlocks the expansion
StopYesPrevents Claude from stopping and continues the conversation
PermissionRequestNoNot 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 PreToolUse cancels the call; on PostToolUse it asks Claude to fix an already-applied change
  • PermissionRequest ignores exit 2, and some events can't be blocked at all
  • Use 1 to inform yourself, 2 to inform Claude
  • For JSON control, PreToolUse uses permissionDecision while PostToolUse and Stop use top-level decision

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.