Claude Code hook exit code 2 vs exit 1: why your PreToolUse hook is not blocking

If your hook runs, prints an error, and the tool call still happens, the exit code is almost always the reason. Exit 1 is not "fail". Here is what each code does and how to prove your hook denies.

Written by Kyle, who made the free send gate mentioned at the end. Independent project, not affiliated with Anthropic.

Exit 0Success. Claude Code reads any JSON you printed. On PreToolUse the call still goes through the normal permission flow unless the JSON denies it.
Exit 2Blocking error. On PreToolUse the call is stopped and your stderr is the message.
Anything elseNon-blocking error, including 1. The call goes ahead.

The short answer

Anthropic's hooks reference defines three outcomes. Exit 0 is success. Exit 2 is a blocking error. Any other code, or a hook that fails some other way such as not starting or printing invalid JSON, is a non-blocking error: the action goes ahead and, on PreToolUse, you see a hook error notice in the transcript. If you want your hook to stop a call, exit 2 or return a JSON deny; exit 1 will not do it.

Shell habit is the trap. In most scripts, exit 1 means "this failed, stop". In a hook, exit 1 means "the hook itself had a problem, carry on".

Two ways to block

1. Exit 2 with a reason on stderr

#!/bin/bash
cmd=$(jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -q 'drop table'; then
  echo "Blocked: dropping tables is not allowed" >&2
  exit 2
fi
exit 0

This follows the pattern in Anthropic's hooks guide. The stderr text comes back to Claude as the reason for the block, so it can change course instead of retrying the same call.

2. Exit 0 with a JSON deny

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Database writes are not allowed"
  }
}

Print this to stdout and exit 0. The docs list allow, deny, ask and defer as the PreToolUse values; defer is for non-interactive mode (claude -p). hookSpecificOutput needs the hookEventName field. Stdout counts as JSON only if it is one JSON object with nothing around it but whitespace.

Which one

Pick one approach per hook; the docs say to choose one. Exit 2 is shorter. JSON gives you structured control, including ask. If you exit 2 and also print JSON, Claude Code still reads the JSON, but it cannot override the block. Even an allow does not let the action through.

What Claude sees

Write the stderr for the model. "Blocked: send requires approval, show Kyle the exact text first" gives Claude something to act on. "Error" does not.

Test that your hook denies, on purpose

A hook that never fires and a hook that fires but exits 1 look the same from the outside: the tool just runs. Separate the two.

  1. Pipe sample input into the script. The hooks guide suggests this:
    echo '{"tool_name":"Bash","tool_input":{"command":"drop table users"}}' | ./my-hook.sh
    echo $?
    You want 2 (or 0 with the deny JSON on stdout). If you get 1, that is your bug.
  2. Confirm it is registered. Run /hooks in Claude Code and check the hook is under PreToolUse.
  3. Check the matcher. Matchers are tested against the tool name and are case-sensitive. A value with only letters, digits, _, -, spaces, commas and | is an exact name or list, so Bash works and bash does not. Anything else is read as a regular expression.
  4. Trigger it for real with a harmless call that should be denied, then read the debug log. Start with claude --debug-file /tmp/claude.log and run tail -f /tmp/claude.log in another terminal, or run /debug in a session.

If your JSON deny does nothing

The docs name two causes. First, extra output before the JSON, usually an unconditional echo in a shell profile, so stdout no longer starts with {; Claude Code reads it as plain text and the hook exits 0 with no error shown. Search the debug log for Hook output does not start with {. Second, a field at the wrong level: permissionDecision goes inside hookSpecificOutput, not at the top level.

Other reasons a hook lets things through

Common questions

Does exit 1 block a Claude Code hook?

No. Exit 1 without valid JSON is a non-blocking error. The action goes ahead.

Does Claude see my stderr?

On exit 2 for a blockable event, yes, as the block message. On exit 0, never.

Can JSON on exit 2 allow the call?

No. The fields are read but cannot override the block.

Free tool: the send gate

If what you want to block is outward actions (email, chat, payments, publishes), I wrote a free MIT-licensed hook for it: claude-code-send-gate. It denies with the JSON form above and exit 0, until you approve the exact action by typing a code in your own terminal. Read the source to see a working example. Setup and limits are in the full guide. Related: bypass permissions safety and stopping Claude Code merging PRs.

I also sell The Operator Kit for Claude Code for USD 19. The gate does not need it.

Source on GitHub Home