Claude Code hooks error handling DevOps automation debugging 2026

Claude Code Hooks: Why Your Pre-Edit Hook Is Stopping Everything (And How to Fix It)

The Prompt Shelf ·

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 codePreToolUsePostToolUseNotificationStop
0Allow the tool callContinueAcknowledgedContinue
1Block the tool callMark as errorError notedStop acknowledged
2Block + show stderr to ClaudeMark as errorError notedStop 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.


The hook system has more depth than most people explore:

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.

Related Articles

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules