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 04. 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 0Advanced 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 010. 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 $RESULTIntegration 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
- Always handle errors gracefully - Don’t let hooks break Claude’s workflow
- Use appropriate exit codes - 0 for success, 2 for blocking, others for warnings
- Keep hooks fast - Use async patterns for long operations
- Log appropriately - Balance between debugging needs and performance
- Validate all inputs - Never trust external data
- Use environment variables - For configuration and secrets
- Test thoroughly - Hooks can significantly impact Claude’s behavior
- Document your hooks - Future you will thank you
Related Resources
- Concrete Examples
- Debugging Guide
- Security Considerations
- Overview & Architecture
- Integration Patterns
See Also
- Pattern Implementation Examples
- Advanced Pattern Techniques
- Workshop Debugging Patterns
- Subagent Design Patterns - Related pattern system
- TypeScript SDK Guide - Implementing hooks programmatically
- Optimization Patterns - Performance considerations
Related Pattern Libraries
Explore more patterns in the dedicated patterns section:
- All Patterns - Complete pattern library
- Testing Patterns - Advanced testing patterns
- Security Scanning - Security patterns
- Performance Optimization - Speed patterns
- Observability Patterns - Monitoring