Claude Code Hooks Patterns

Tags: claude-code hooks patterns best-practices design-patterns code-organization advanced

Common patterns and best practices for implementing Claude Code hooks effectively.

Core Patterns

1. File Type Conditional Pattern

Execute different commands based on file extensions:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "case \"$CLAUDE_FILE_PATHS\" in *.py) black \"$CLAUDE_FILE_PATHS\";; *.js) prettier --write \"$CLAUDE_FILE_PATHS\";; *.go) gofmt -w \"$CLAUDE_FILE_PATHS\";; esac"
          }
        ]
      }
    ]
  }
}

2. Validation Pattern

Validate operations before allowing them to proceed:

#!/usr/bin/env python3
import json
import sys
import os
 
data = json.load(sys.stdin)
file_path = data.get('tool_input', {}).get('file_path', '')
 
# Define validation rules
forbidden_paths = ['.env', '.git/', 'secrets/', 'production.conf']
forbidden_operations = ['rm', 'chmod 777', 'sudo']
 
# Check path
if any(forbidden in file_path for forbidden in forbidden_paths):
    print(json.dumps({
        "continue": False,
        "stopReason": f"Access to {file_path} is restricted"
    }))
    sys.exit(2)
 
# Check commands
command = data.get('tool_input', {}).get('command', '')
if any(op in command for op in forbidden_operations):
    print(json.dumps({
        "continue": False,
        "stopReason": "Dangerous operation detected"
    }))
    sys.exit(2)
 
sys.exit(0)

3. Logging Pattern

Comprehensive logging with structured data:

#!/bin/bash
 
# Create log directory if it doesn't exist
LOG_DIR="$HOME/.claude/logs"
mkdir -p "$LOG_DIR"
 
# Generate timestamp
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
 
# Read JSON input
JSON_INPUT=$(cat)
 
# Log to JSON file
echo "$JSON_INPUT" | jq --arg ts "$TIMESTAMP" '. + {timestamp: $ts}' >> "$LOG_DIR/hooks.jsonl"
 
# Log to human-readable format
echo "[$TIMESTAMP] Event: $(echo "$JSON_INPUT" | jq -r '.hook_event_name') Tool: $(echo "$JSON_INPUT" | jq -r '.tool_name // "N/A"')" >> "$LOG_DIR/hooks.log"
 
exit 0

4. Conditional Execution Pattern

Execute hooks based on environment or configuration:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "[ -f .claude-hooks-enabled ] && /path/to/hook.sh || exit 0"
          }
        ]
      }
    ]
  }
}

5. Error Recovery Pattern

Handle errors gracefully and provide fallbacks:

#!/bin/bash
 
# Try primary formatter
if command -v prettier &> /dev/null; then
    prettier --write "$CLAUDE_FILE_PATHS" 2>/dev/null
elif command -v beautify &> /dev/null; then
    # Fall back to alternative
    beautify "$CLAUDE_FILE_PATHS" 2>/dev/null
else
    # Log warning but don't block
    echo "Warning: No formatter available" >&2
fi
 
# Always exit successfully to avoid blocking
exit 0

Advanced Patterns

6. Pipeline Pattern

Chain multiple operations together:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "prettier --write \"$CLAUDE_FILE_PATHS\" && eslint --fix \"$CLAUDE_FILE_PATHS\" && npm test -- \"$CLAUDE_FILE_PATHS\""
          }
        ]
      }
    ]
  }
}

7. Async Processing Pattern

Handle long-running tasks without blocking:

#!/usr/bin/env python3
import json
import sys
import subprocess
import threading
 
def async_task(data):
    # Perform long-running operation
    subprocess.run([
        "python3", 
        "/path/to/long_task.py", 
        json.dumps(data)
    ])
 
# Read input
data = json.load(sys.stdin)
 
# Start async task
thread = threading.Thread(target=async_task, args=(data,))
thread.daemon = True
thread.start()
 
# Return immediately
print(json.dumps({"continue": True}))
sys.exit(0)

8. State Management Pattern

Track state across hook invocations:

#!/usr/bin/env python3
import json
import sys
import sqlite3
from datetime import datetime
 
# Connect to state database
conn = sqlite3.connect('/tmp/claude_hooks_state.db')
cursor = conn.cursor()
 
# Create table if not exists
cursor.execute('''
    CREATE TABLE IF NOT EXISTS hook_state (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        session_id TEXT,
        tool_name TEXT,
        timestamp DATETIME,
        data TEXT
    )
''')
 
# Read input
data = json.load(sys.stdin)
 
# Store state
cursor.execute('''
    INSERT INTO hook_state (session_id, tool_name, timestamp, data)
    VALUES (?, ?, ?, ?)
''', (
    data.get('session_id'),
    data.get('tool_name'),
    datetime.now(),
    json.dumps(data)
))
 
conn.commit()
conn.close()
 
sys.exit(0)

9. Template Expansion Pattern

Use templates for complex operations:

#!/bin/bash
 
# Read template based on file type
FILE_PATH="$CLAUDE_FILE_PATHS"
EXTENSION="${FILE_PATH##*.}"
 
TEMPLATE_DIR="$HOME/.claude/templates"
TEMPLATE_FILE="$TEMPLATE_DIR/header.$EXTENSION"
 
if [ -f "$TEMPLATE_FILE" ]; then
    # Prepend template to file if not already present
    if ! grep -q "$(head -1 "$TEMPLATE_FILE")" "$FILE_PATH"; then
        cat "$TEMPLATE_FILE" "$FILE_PATH" > "$FILE_PATH.tmp"
        mv "$FILE_PATH.tmp" "$FILE_PATH"
    fi
fi
 
exit 0

10. Notification Aggregation Pattern

Batch notifications to avoid spam:

#!/usr/bin/env python3
import json
import sys
import time
import os
from collections import defaultdict
 
CACHE_FILE = '/tmp/claude_notifications.json'
BATCH_WINDOW = 60  # seconds
 
def load_cache():
    if os.path.exists(CACHE_FILE):
        with open(CACHE_FILE, 'r') as f:
            return json.load(f)
    return {'notifications': [], 'last_sent': 0}
 
def save_cache(cache):
    with open(CACHE_FILE, 'w') as f:
        json.dump(cache, f)
 
def send_batch(notifications):
    # Send aggregated notification
    summary = f"Claude Code: {len(notifications)} events"
    # Your notification logic here
    print(f"Sending: {summary}")
 
data = json.load(sys.stdin)
cache = load_cache()
 
# Add notification
cache['notifications'].append({
    'time': time.time(),
    'event': data
})
 
# Check if we should send
if time.time() - cache['last_sent'] > BATCH_WINDOW:
    send_batch(cache['notifications'])
    cache['notifications'] = []
    cache['last_sent'] = time.time()
 
save_cache(cache)
sys.exit(0)

Security Patterns

11. Input Sanitization Pattern

#!/usr/bin/env python3
import json
import sys
import re
import shlex
 
def sanitize_path(path):
    # Remove dangerous characters
    path = re.sub(r'[;&|`$()]', '', path)
    # Resolve to absolute path
    path = os.path.abspath(path)
    # Ensure within project
    if not path.startswith(os.getcwd()):
        raise ValueError("Path outside project")
    return path
 
def sanitize_command(cmd):
    # Parse safely
    try:
        args = shlex.split(cmd)
        # Check against whitelist
        allowed_commands = ['npm', 'yarn', 'pnpm', 'pytest', 'rspec']
        if args[0] not in allowed_commands:
            raise ValueError(f"Command {args[0]} not allowed")
        return shlex.join(args)
    except:
        raise ValueError("Invalid command format")
 
data = json.load(sys.stdin)
try:
    # Sanitize inputs
    if 'file_path' in data.get('tool_input', {}):
        data['tool_input']['file_path'] = sanitize_path(
            data['tool_input']['file_path']
        )
    
    if 'command' in data.get('tool_input', {}):
        data['tool_input']['command'] = sanitize_command(
            data['tool_input']['command']
        )
    
    sys.exit(0)
except ValueError as e:
    print(json.dumps({
        "continue": False,
        "stopReason": str(e)
    }))
    sys.exit(2)

12. Rate Limiting Pattern

#!/usr/bin/env python3
import json
import sys
import time
from collections import defaultdict
 
RATE_LIMIT_FILE = '/tmp/claude_rate_limits.json'
MAX_CALLS_PER_MINUTE = 10
 
def check_rate_limit(tool_name):
    # Load existing data
    try:
        with open(RATE_LIMIT_FILE, 'r') as f:
            limits = json.load(f)
    except:
        limits = defaultdict(list)
    
    # Clean old entries
    current_time = time.time()
    limits[tool_name] = [
        t for t in limits.get(tool_name, []) 
        if current_time - t < 60
    ]
    
    # Check limit
    if len(limits[tool_name]) >= MAX_CALLS_PER_MINUTE:
        return False
    
    # Add current call
    limits[tool_name].append(current_time)
    
    # Save
    with open(RATE_LIMIT_FILE, 'w') as f:
        json.dump(dict(limits), f)
    
    return True
 
data = json.load(sys.stdin)
tool_name = data.get('tool_name', '')
 
if not check_rate_limit(tool_name):
    print(json.dumps({
        "continue": False,
        "stopReason": f"Rate limit exceeded for {tool_name}"
    }))
    sys.exit(2)
 
sys.exit(0)

Testing Patterns

13. Mock Testing Pattern

#!/bin/bash
 
# Check if in test mode
if [ "$CLAUDE_TEST_MODE" = "true" ]; then
    # Use mock responses
    echo '{"continue": true, "test": true}'
    exit 0
fi
 
# Normal execution
# ... your hook logic here ...

14. Debug Logging Pattern

#!/bin/bash
 
# Enable debug mode
DEBUG="${CLAUDE_DEBUG:-false}"
 
debug_log() {
    if [ "$DEBUG" = "true" ]; then
        echo "[DEBUG] $1" >> "$HOME/.claude/debug.log"
    fi
}
 
# Read input
JSON_INPUT=$(cat)
debug_log "Received input: $JSON_INPUT"
 
# Process
TOOL_NAME=$(echo "$JSON_INPUT" | jq -r '.tool_name')
debug_log "Processing tool: $TOOL_NAME"
 
# Your logic here
RESULT=0
 
debug_log "Exiting with code: $RESULT"
exit $RESULT

Integration Patterns

15. Webhook Pattern

#!/usr/bin/env python3
import json
import sys
import requests
 
WEBHOOK_URL = os.environ.get('CLAUDE_WEBHOOK_URL')
 
def send_webhook(data):
    if not WEBHOOK_URL:
        return
    
    try:
        response = requests.post(
            WEBHOOK_URL,
            json={
                'event': data.get('hook_event_name'),
                'tool': data.get('tool_name'),
                'timestamp': data.get('timestamp'),
                'session_id': data.get('session_id')
            },
            timeout=5
        )
        response.raise_for_status()
    except Exception as e:
        # Log but don't block
        print(f"Webhook failed: {e}", file=sys.stderr)
 
data = json.load(sys.stdin)
send_webhook(data)
sys.exit(0)

Best Practices Summary

  1. Always handle errors gracefully - Don’t let hooks break Claude’s workflow
  2. Use appropriate exit codes - 0 for success, 2 for blocking, others for warnings
  3. Keep hooks fast - Use async patterns for long operations
  4. Log appropriately - Balance between debugging needs and performance
  5. Validate all inputs - Never trust external data
  6. Use environment variables - For configuration and secrets
  7. Test thoroughly - Hooks can significantly impact Claude’s behavior
  8. Document your hooks - Future you will thank you

See Also

Explore more patterns in the dedicated patterns section: