You set up a pre-edit hook to run Prettier before Claude modifies a file. It works perfectly on your machine. Then you pull it onto a colleague’s laptop, they run Claude Code, and every single file edit fails with a cryptic exit code error. Claude stops. Nothing gets done. Your colleague files a ticket.
This is the hook failure mode that trips up most teams: hooks are synchronous gates. A non-zero exit code doesn’t just flag a warning — it blocks the operation Claude was about to perform. For PreToolUse hooks, that means Claude’s tool call never happens. For some hook types, it means the entire session stops responding and requires manual intervention.
Understanding why this happens — and how to design around it — is the difference between hooks that help and hooks that become landmines.
How Hook Failure Actually Works
Claude Code’s hook system uses exit codes as a control signal. Here’s the semantics by hook type:
| Exit code | PreToolUse | PostToolUse | Notification | Stop |
|---|---|---|---|---|
0 | Allow the tool call | Continue | Acknowledged | Continue |
1 | Block the tool call | Mark as error | Error noted | Stop acknowledged |
2 | Block + show stderr to Claude | Mark as error | Error noted | Stop acknowledged |
Exit code 2 is the one most people don’t know about. When a PreToolUse hook exits with 2, Claude receives your hook’s stderr output as feedback and can adjust its behavior accordingly. Exit code 1 just blocks silently. Exit code 2 is the “block with explanation” signal.
The practical implication: if your hook exits non-zero for any reason — a missing binary, a network timeout, a syntax error in the hook script itself — Claude’s operation is blocked. Not retried. Blocked.
The Four Hook Types and When Each One Blocks
PreToolUse
Runs before Claude executes any tool: file edits, Bash commands, MCP calls, everything.
If this hook exits non-zero, the tool call is cancelled. Claude never writes the file, never runs the command. If Claude was in the middle of a multi-step task, the task usually stalls — Claude will try to figure out what happened and may retry, but it’s dependent on the model’s judgment.
Use this hook for: security gates (blocking writes to sensitive paths), validation (checking file syntax before edit), rate limiting.
Do not use this hook for: anything that might legitimately fail due to environment differences. A pre-edit linter that fails because the linter isn’t installed will block every edit on every machine where the linter is missing.
PostToolUse
Runs after Claude executes a tool. By this point, the edit or command has already happened.
If this hook exits non-zero, Claude receives an error signal, but the tool result has already been applied. A failed PostToolUse hook doesn’t roll back anything. It tells Claude something went wrong after the fact, and Claude may or may not act on that.
Use this hook for: logging, notifications, triggering downstream processes, running formatters after edits.
Notification
Runs when Claude emits a notification event (typically end-of-session or milestone events).
Exit codes here are mostly irrelevant for workflow control — Claude’s behavior isn’t gated on notification hooks. Use these freely for alerting without worrying about blocking anything.
Stop
Runs when Claude is about to end a session.
Exit code 0 allows the stop. Exit code non-zero signals Claude to continue rather than stop. This is occasionally useful (force Claude to complete a specific action before stopping), but it’s easy to accidentally create infinite loops where Claude can never terminate.
The Most Common Hook Failure Patterns
Pattern 1: Binary not found
Your hook calls prettier, eslint, black, rustfmt, or any formatter. Works on your machine. Fails everywhere that tool isn’t installed.
#!/bin/bash
# This will fail if prettier isn't installed
prettier --write "$CLAUDE_TOOL_INPUT_FILE_PATH"
Exit code when prettier isn’t found: 127 (command not found). Result: every file edit is blocked on machines without Prettier.
Fix: Check for the binary first
#!/bin/bash
FILE_PATH="$CLAUDE_TOOL_INPUT_FILE_PATH"
# Check if prettier is available
if ! command -v prettier &> /dev/null; then
# Binary missing — log and exit 0 to allow the operation
echo "[hook] prettier not found, skipping format" >&2
exit 0
fi
# Binary exists — run it
if ! prettier --write "$FILE_PATH" 2>&1; then
echo "[hook] prettier failed on $FILE_PATH" >&2
# Exit 0 anyway — don't block Claude for a formatter issue
exit 0
fi
exit 0
The key pattern: on hook setup errors (missing tools, wrong environment), exit 0 and log to stderr. Reserve non-zero exits for intentional blocking — security violations, deliberate gates.
Pattern 2: Hook script has a syntax error
Your hook is a Python script. You update it, introduce a syntax error, and now every operation Claude attempts is blocked because the hook crashes immediately.
# Syntax error: missing colon
def check_file(path)
return True
Python exits 1 on syntax errors. Result: all PreToolUse hooks silently block every operation.
Fix: Wrap the entire hook in error handling
#!/usr/bin/env python3
import sys
import os
import json
def main():
try:
# Read hook input from stdin
input_data = json.loads(sys.stdin.read())
file_path = input_data.get("tool_input", {}).get("file_path", "")
# Your actual logic here
result = do_check(file_path)
if result.should_block:
# Exit 2 to block with explanation to Claude
print(result.reason, file=sys.stderr)
sys.exit(2)
sys.exit(0)
except Exception as e:
# Any unexpected error: log it, but don't block Claude
print(f"[hook error] {e}", file=sys.stderr)
sys.exit(0) # Exit 0 — let Claude proceed
def do_check(file_path):
# Your logic here
pass
if __name__ == "__main__":
main()
The outermost try-except catches everything — import errors, attribute errors, JSON parse failures — and converts them to a graceful exit 0. Your hook failing shouldn’t stop Claude from doing work.
Pattern 3: Timeout
Your hook makes a network call — pinging a security API, checking a registry, posting to Slack. The network is slow. The hook hangs for 30 seconds and eventually Claude Code’s operation times out.
Claude Code has a default hook timeout of 60 seconds. Hooks that take longer than this are killed with an error. But 60 seconds of blocked operation per file edit is already unacceptable in practice.
Fix: Set explicit timeouts in your hook
#!/bin/bash
# Using timeout command to limit network calls
RESPONSE=$(timeout 5 curl -s "https://security-api.internal/check?path=$CLAUDE_TOOL_INPUT_FILE_PATH")
CURL_EXIT=$?
if [ $CURL_EXIT -eq 124 ]; then
# timeout command returned 124 = timed out
echo "[hook] security API timeout, allowing operation" >&2
exit 0
fi
if [ $CURL_EXIT -ne 0 ]; then
echo "[hook] security API unreachable, allowing operation" >&2
exit 0
fi
# Process response
# ...
exit 0
import requests
try:
response = requests.get(
"https://security-api.internal/check",
params={"path": file_path},
timeout=5 # 5 second timeout
)
except requests.exceptions.Timeout:
print("[hook] API timeout, skipping check", file=sys.stderr)
sys.exit(0)
except requests.exceptions.ConnectionError:
print("[hook] API unreachable, skipping check", file=sys.stderr)
sys.exit(0)
Network calls in hooks should always have aggressive timeouts. If the API is down, Claude should still be able to do its job.
Pattern 4: Hook reads from wrong input source
Claude Code passes tool information to hooks via stdin as JSON. Hooks that try to read from environment variables, arguments, or files instead of stdin will fail to get the data they need.
# Wrong: this won't have the tool data
FILE_PATH=$1 # No arguments are passed to hooks
# Wrong: this env var doesn't exist
FILE_PATH=$TOOL_FILE_PATH
Fix: Always read stdin
#!/bin/bash
# Read the JSON input from stdin
INPUT=$(cat)
# Extract fields using jq
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
if [ -z "$FILE_PATH" ]; then
# Not an edit operation, or field not present
exit 0
fi
# Continue with your logic
import json
import sys
input_data = json.loads(sys.stdin.read())
tool_name = input_data.get("tool_name")
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path")
The full stdin JSON structure for a file edit hook looks like:
{
"session_id": "abc123",
"tool_name": "Edit",
"tool_input": {
"file_path": "/path/to/file.ts",
"old_string": "const x = 1",
"new_string": "const x = 2"
}
}
Debugging Hooks: Finding What’s Failing
When a hook is failing silently, you need to see what it’s actually doing. Claude Code doesn’t surface hook output prominently — you have to go looking for it.
Method 1: Run the hook directly
Hooks are just scripts. You can run them manually with synthetic input:
# Create a test input
cat > /tmp/hook-test.json << 'EOF'
{
"session_id": "test",
"tool_name": "Edit",
"tool_input": {
"file_path": "/tmp/test.ts",
"old_string": "const x = 1",
"new_string": "const x = 2"
}
}
EOF
# Run the hook and see what happens
cat /tmp/hook-test.json | bash ~/.claude/hooks/pre-edit.sh
echo "Exit code: $?"
This is the fastest way to reproduce failures. Run the hook directly, check the exit code, look at stdout/stderr.
Method 2: Add logging to the hook
Add explicit logging to a file you can inspect:
#!/bin/bash
LOG_FILE="/tmp/claude-hooks.log"
echo "[$(date '+%H:%M:%S')] pre-edit hook called" >> "$LOG_FILE"
echo "Input: $(cat)" | tee /tmp/hook-last-input.json >> "$LOG_FILE"
# Your hook logic here
RESULT=$(do_check)
EXIT_CODE=$?
echo "[$(date '+%H:%M:%S')] exit code: $EXIT_CODE" >> "$LOG_FILE"
exit $EXIT_CODE
Then tail the log in a separate terminal while Claude runs:
tail -f /tmp/claude-hooks.log
Method 3: Check Claude’s session transcripts
Claude Code stores session transcripts that include tool call results. After a hook failure, look at the transcript to see what Claude received:
# Find recent session transcripts
ls -lt ~/.claude/projects/*/transcripts/ | head -10
# Read the most recent one
cat ~/.claude/projects/$(ls -t ~/.claude/projects/ | head -1)/transcripts/$(ls -t ~/.claude/projects/$(ls -t ~/.claude/projects/ | head -1)/transcripts/ | head -1)
The transcript shows Claude’s view of what happened — what the hook returned, how Claude interpreted it.
Method 4: Isolate with a minimal hook
When you can’t tell which part of a complex hook is failing, replace it temporarily with a minimal version:
#!/bin/bash
# Minimal debug hook — logs everything, always exits 0
echo "=== DEBUG HOOK ===" >&2
echo "Input received:" >&2
cat | tee /tmp/hook-debug-input.json >&2
echo "=== END INPUT ===" >&2
exit 0
This tells you: is the hook being called at all? Is the input structured as expected? Once you know that, add back your actual logic incrementally.
Practical Hook Implementations
Here are three production-ready hooks that demonstrate graceful error handling:
Formatter hook (PostToolUse)
Formats files after Claude edits them. Never blocks on formatting failures.
#!/bin/bash
# Post-edit formatter hook
# Runs after Claude edits a file. Never blocks.
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Not a file edit, skip
[ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ] && exit 0
# Determine formatter by extension
EXT="${FILE_PATH##*.}"
case "$EXT" in
ts|tsx|js|jsx|json|css|html)
if command -v prettier &> /dev/null; then
prettier --write "$FILE_PATH" 2>/dev/null || true
fi
;;
py)
if command -v black &> /dev/null; then
black --quiet "$FILE_PATH" 2>/dev/null || true
fi
;;
go)
if command -v gofmt &> /dev/null; then
gofmt -w "$FILE_PATH" 2>/dev/null || true
fi
;;
rs)
if command -v rustfmt &> /dev/null; then
rustfmt "$FILE_PATH" 2>/dev/null || true
fi
;;
esac
# Always exit 0 — formatting failure is not a reason to block
exit 0
Security gate hook (PreToolUse)
Blocks writes to sensitive paths. Uses exit code 2 to explain the block to Claude.
#!/bin/bash
# Pre-edit security gate
# Blocks writes to sensitive paths. Explains why to Claude via stderr.
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Only check edit/write operations
case "$TOOL_NAME" in
Edit|Write|MultiEdit) ;;
*) exit 0 ;;
esac
[ -z "$FILE_PATH" ] && exit 0
# Define sensitive path patterns
SENSITIVE_PATTERNS=(
"^/etc/"
"^/usr/"
"\.env$"
"\.env\."
"credentials"
"secrets\."
"\.pem$"
"\.key$"
"id_rsa"
"id_ed25519"
)
for pattern in "${SENSITIVE_PATTERNS[@]}"; do
if echo "$FILE_PATH" | grep -qE "$pattern"; then
echo "BLOCKED: Write to sensitive path '$FILE_PATH' matches pattern '$pattern'. Use a .env.example or config template instead." >&2
exit 2
fi
done
exit 0
Audit log hook (PostToolUse)
Records every tool call for compliance or debugging. Never fails.
#!/usr/bin/env python3
# Post-tool-use audit logger
# Records all Claude operations to a log file. Never blocks anything.
import json
import sys
import os
from datetime import datetime
LOG_FILE = os.path.expanduser("~/.claude/audit.log")
def main():
try:
input_data = json.loads(sys.stdin.read())
entry = {
"timestamp": datetime.now().isoformat(),
"session_id": input_data.get("session_id", "unknown"),
"tool_name": input_data.get("tool_name", "unknown"),
"tool_input": input_data.get("tool_input", {}),
}
# Truncate large inputs in the log
if isinstance(entry["tool_input"], dict):
for key, value in entry["tool_input"].items():
if isinstance(value, str) and len(value) > 500:
entry["tool_input"][key] = value[:500] + "...[truncated]"
with open(LOG_FILE, "a") as f:
f.write(json.dumps(entry) + "\n")
except Exception:
# Never fail — audit logging should be transparent
pass
sys.exit(0)
if __name__ == "__main__":
main()
Hook Configuration: Settings and Timeouts
Hooks are configured in .claude/settings.json. The relevant structure:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/security-gate.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/formatter.sh"
}
]
}
],
"Notification": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/audit-log.py"
}
]
}
]
}
}
The matcher field accepts:
- Specific tool names:
"Edit","Bash","Write","MultiEdit" - Pipe-separated alternatives:
"Edit|Write|MultiEdit" - Wildcard:
"*"(matches all tools)
Hooks run in the order they appear in the array. If the first hook exits non-zero on a PreToolUse, subsequent hooks in that list do not run.
There’s no built-in per-hook timeout setting in the JSON config as of mid-2026 — the global timeout applies to all hooks. Set timeouts inside your hook scripts themselves (as shown in the network call example above) rather than relying on the framework to cut them off.
The Design Principle: Hooks Should Enhance, Not Gate
The failure mode that makes hooks feel unreliable is treating them as hard gates when they should be soft advisors. The security gate pattern above is a deliberate exception — it’s supposed to block. Formatters, linters, loggers, and notifiers are not supposed to block. They’re supposed to do their thing and get out of the way.
The design principle: if the hook can’t complete its job, it should fail open, not fail closed. Failing open means exiting 0 and logging the problem. Failing closed means exiting non-zero and stopping Claude’s operation.
Reserve non-zero exits for things you truly want to stop. If you wouldn’t accept “Claude couldn’t edit any files because Prettier wasn’t installed,” then your formatter hook shouldn’t exit non-zero when Prettier is missing.
Related Reading
The hook system has more depth than most people explore:
- Claude Code Hooks: The Complete 2026 Production Reference (32+ Events, 5 Handler Types, Exit Code Semantics) — the full event list and JSON schema for every hook type, useful when building hooks that need to handle multiple tool types differently
- Claude Code Hooks: 12 Real-World Automation Patterns 2026 — production patterns for Slack alerts, security scanning, and test triggering, all built with the graceful failure patterns described in this guide
Once you have robust error handling in place, hooks become one of the most powerful parts of the Claude Code workflow — they let you enforce team standards, maintain audit trails, and integrate with your existing toolchain without asking Claude to remember to do things manually.