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.
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".
#!/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.
{
"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.
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.
Failed with non-blocking status code: followed by the first line of stderr. The tool runs.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.
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.
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./hooks in Claude Code and check the hook is under PreToolUse._, -, spaces, commas and | is an exact name or list, so Bash works and bash does not. Anything else is read as a regular expression.claude --debug-file /tmp/claude.log and run tail -f /tmp/claude.log in another terminal, or run /debug in a session.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.
command hook on PreToolUse does not block the call, unless it is configured with "onFailure": "block" (needs Claude Code v2.1.295 or later).deny blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions.No. Exit 1 without valid JSON is a non-blocking error. The action goes ahead.
On exit 2 for a blockable event, yes, as the block message. On exit 0, never.
No. The fields are read but cannot override the block.
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.