33 KiB
Complete Guide to Claude Code Hooks
This guide teaches you how to create and configure custom hooks for Claude Code to automate workflows, validate operations, and enhance your development experience.
What are Claude Code Hooks?
Hooks are automated scripts that execute at specific points during Claude Code sessions. Each hook:
- Responds to specific events like tool usage, prompt submission, or session start/end
- Can validate, modify, or block operations based on your criteria
- Executes shell commands automatically with access to session data
- Integrates seamlessly with your development workflow
- Provides fine-grained control over Claude's behavior
Key Benefits
🔄 Automated Workflows
Execute custom scripts automatically when specific events occur during Claude Code sessions.
🛡️ Enhanced Security
Validate and block potentially dangerous operations before they execute.
📊 Session Monitoring
Track usage patterns, log operations, and gather analytics from your Claude Code sessions.
🔧 Custom Validation
Implement project-specific rules and requirements that Claude must follow.
⚡ Real-time Feedback
Provide immediate feedback to Claude based on the results of operations.
Hook Configuration
Configuration Files
Hooks are configured in your Claude Code settings files with the following priority order:
| Type | Location | Scope | Usage |
|---|---|---|---|
| User Settings | ~/.claude/settings.json |
All projects | Personal hooks across all projects |
| Project Settings | .claude/settings.json |
Current project | Shared team hooks (committed to repo) |
| Local Project Settings | .claude/settings.local.json |
Current project | Personal project hooks (not committed) |
| Enterprise Settings | Managed by policy | Organization | Company-wide hooks and policies |
When there are conflicts, settings files lower in the list take precedence.
Basic Hook Structure
Each hook is defined using event-based configuration:
{
"hooks": {
"EventName": [
{
"matcher": "ToolPattern",
"hooks": [
{
"type": "command",
"command": "your-script-here",
"timeout": 60
}
]
}
]
}
}
Configuration Elements
Event Names
- PreToolUse: Before Claude executes a tool
- PostToolUse: After Claude executes a tool successfully
- UserPromptSubmit: When user submits a prompt
- Notification: When Claude Code sends notifications
- Stop: When Claude finishes responding
- SubagentStop: When a subagent completes
- SessionStart: When a session begins or resumes
- SessionEnd: When a session terminates
- PreCompact: Before compacting conversation history
Matchers
For tool-based events (PreToolUse, PostToolUse):
"matcher": "Write" // Exact match for Write tool
"matcher": "Edit|Write" // Regex pattern for multiple tools
"matcher": "Bash.*" // Pattern matching with wildcards
"matcher": "*" // Match all tools
"matcher": "" // Also matches all tools
Hook Commands
{
"type": "command", // Currently only "command" supported
"command": "/path/to/script.sh", // Shell command to execute
"timeout": 30 // Optional timeout in seconds (default: 60)
}
Hook Events Reference
PreToolUse
Purpose: Validate or modify tool usage before execution Common Use Cases: Permission control, input validation, security checks
Input Data:
{
"session_id": "abc123",
"transcript_path": "/path/to/conversation.jsonl",
"cwd": "/current/working/directory",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/path/to/file.txt",
"content": "file content"
}
}
Common Matchers:
Bash- Shell commandsWrite- File creationEdit,MultiEdit- File modificationsRead- File readingTask- Subagent tasksWebFetch,WebSearch- Web operations
PostToolUse
Purpose: React to completed tool operations, provide feedback, or trigger follow-up actions Common Use Cases: Logging, validation, cleanup, triggering builds
Input Data:
{
"session_id": "abc123",
"transcript_path": "/path/to/conversation.jsonl",
"cwd": "/current/working/directory",
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/path/to/file.txt",
"content": "file content"
},
"tool_response": {
"filePath": "/path/to/file.txt",
"success": true
}
}
UserPromptSubmit
Purpose: Process or validate user prompts before Claude sees them Common Use Cases: Adding context, blocking sensitive prompts, prompt transformation
Input Data:
{
"session_id": "abc123",
"transcript_path": "/path/to/conversation.jsonl",
"cwd": "/current/working/directory",
"hook_event_name": "UserPromptSubmit",
"prompt": "Write a function to calculate factorial"
}
SessionStart
Purpose: Initialize session context and load project-specific information Common Use Cases: Loading project state, setting up environment, adding context
Input Data:
{
"session_id": "abc123",
"transcript_path": "/path/to/conversation.jsonl",
"hook_event_name": "SessionStart",
"source": "startup" // "startup", "resume", or "clear"
}
Matchers:
startup- Normal Claude Code startupresume- Resumed from--resume,--continue, or/resumeclear- After/clearcommand
Stop and SubagentStop
Purpose: Control whether Claude can finish responding or trigger additional actions Common Use Cases: Automated testing, continuous workflows, quality checks
Input Data:
{
"session_id": "abc123",
"transcript_path": "/path/to/conversation.jsonl",
"hook_event_name": "Stop",
"stop_hook_active": false
}
SessionEnd
Purpose: Cleanup tasks when a session terminates Common Use Cases: Saving session data, cleanup, logging statistics
Input Data:
{
"session_id": "abc123",
"transcript_path": "/path/to/conversation.jsonl",
"cwd": "/current/working/directory",
"hook_event_name": "SessionEnd",
"reason": "exit" // "clear", "logout", "prompt_input_exit", "other"
}
Hook Output and Control
Simple Exit Code Control
Exit Code 0: Success
- For most hooks: stdout shown to user in transcript mode (Ctrl+R)
- For UserPromptSubmit and SessionStart: stdout added as context for Claude
Exit Code 2: Blocking error
- Blocks the operation and shows stderr to Claude for automated handling
- Behavior varies by hook type (see reference table below)
Other Exit Codes: Non-blocking error
- Shows stderr to user and continues execution
Exit Code 2 Behavior by Hook Type
| Hook Event | Behavior |
|---|---|
PreToolUse |
Blocks tool execution, shows stderr to Claude |
PostToolUse |
Shows stderr to Claude (tool already executed) |
UserPromptSubmit |
Blocks prompt, erases it, shows stderr to user only |
Stop/SubagentStop |
Blocks stopping, shows stderr to Claude |
Notification |
Shows stderr to user only |
SessionStart/SessionEnd/PreCompact |
Shows stderr to user only |
Advanced JSON Output
For sophisticated control, hooks can return structured JSON:
Common JSON Fields
{
"continue": true, // Whether to continue execution (default: true)
"stopReason": "string", // Message when continue is false
"suppressOutput": true, // Hide stdout from transcript (default: false)
"systemMessage": "string" // Warning message for user
}
PreToolUse Permission Control
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow", // "allow", "deny", or "ask"
"permissionDecisionReason": "Auto-approved safe operation"
}
}
PostToolUse Feedback Control
{
"decision": "block", // "block" or undefined
"reason": "Code style violations detected",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Additional context for Claude"
}
}
UserPromptSubmit Processing
{
"decision": "block", // "block" or undefined
"reason": "Sensitive information detected",
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Current project status: Ready for deployment"
}
}
SessionStart Context Loading
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Recent issues: #123 (bug fix needed), #124 (feature ready)"
}
}
Complete Hook Examples
1. Code Style Enforcement
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-code.sh",
"timeout": 30
}
]
}
]
}
}
format-code.sh:
#!/bin/bash
# Code formatting hook
set -e
# Read hook input
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
# Only process code files
if [[ ! "$file_path" =~ \.(js|ts|jsx|tsx|py|go|rs)$ ]]; then
exit 0
fi
echo "🔧 Formatting $file_path..."
# Format based on file type
case "$file_path" in
*.js|*.jsx|*.ts|*.tsx)
if command -v prettier >/dev/null 2>&1; then
prettier --write "$file_path" 2>/dev/null || echo "❌ Prettier formatting failed"
fi
;;
*.py)
if command -v black >/dev/null 2>&1; then
black "$file_path" 2>/dev/null || echo "❌ Black formatting failed"
fi
;;
*.go)
if command -v gofmt >/dev/null 2>&1; then
gofmt -w "$file_path" 2>/dev/null || echo "❌ Go formatting failed"
fi
;;
*.rs)
if command -v rustfmt >/dev/null 2>&1; then
rustfmt "$file_path" 2>/dev/null || echo "❌ Rust formatting failed"
fi
;;
esac
echo "✅ Code formatting complete"
2. Security Validation
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-bash.py"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/check-sensitive.py"
}
]
}
]
}
}
validate-bash.py:
#!/usr/bin/env python3
import json
import sys
import re
# Dangerous command patterns
DANGEROUS_PATTERNS = [
(r'\brm\s+-rf\s+/', 'Dangerous: rm -rf with absolute path'),
(r'\bdd\s+if=', 'Dangerous: dd command detected'),
(r'\bchmod\s+777', 'Security risk: chmod 777 detected'),
(r'>\s*/dev/(sd[a-z]|hd[a-z])', 'Dangerous: writing to disk device'),
(r'\bcurl.*\|\s*bash', 'Security risk: piping curl to bash'),
(r'\bwget.*\|\s*bash', 'Security risk: piping wget to bash'),
]
try:
input_data = json.load(sys.stdin)
command = input_data.get('tool_input', {}).get('command', '')
if not command:
sys.exit(0)
# Check for dangerous patterns
for pattern, message in DANGEROUS_PATTERNS:
if re.search(pattern, command, re.IGNORECASE):
print(f"🚨 {message}", file=sys.stderr)
print(f"Command: {command}", file=sys.stderr)
print("Please review and confirm this command is safe.", file=sys.stderr)
sys.exit(2) # Block the command
# Auto-approve safe commands
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Command passed security validation"
}
}
print(json.dumps(output))
except Exception as e:
print(f"Hook error: {e}", file=sys.stderr)
sys.exit(1)
check-sensitive.py:
#!/usr/bin/env python3
import json
import sys
import re
# Sensitive information patterns
SENSITIVE_PATTERNS = [
(r'(?i)(password|pwd)\s*[:=]\s*[\'"]?[^\s\'"]+', 'Password detected'),
(r'(?i)(api[_-]?key|apikey)\s*[:=]\s*[\'"]?[a-zA-Z0-9]+', 'API key detected'),
(r'(?i)(secret|token)\s*[:=]\s*[\'"]?[^\s\'"]+', 'Secret/token detected'),
(r'\b[A-Za-z0-9+/]{40,}={0,2}\b', 'Base64-encoded secret detected'),
(r'-----BEGIN [A-Z ]+-----', 'Private key detected'),
]
try:
input_data = json.load(sys.stdin)
prompt = input_data.get('prompt', '')
# Check for sensitive information
for pattern, message in SENSITIVE_PATTERNS:
if re.search(pattern, prompt):
output = {
"decision": "block",
"reason": f"🚨 Security violation: {message}. Please remove sensitive information and try again."
}
print(json.dumps(output))
sys.exit(0)
# Add security reminder
security_context = """
🔒 Security reminder: This session is being monitored for sensitive information.
Current security status: All prompts are scanned for passwords, API keys, and secrets.
"""
output = {
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": security_context
}
}
print(json.dumps(output))
except Exception as e:
print(f"Hook error: {e}", file=sys.stderr)
sys.exit(1)
3. Project Context Loading
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/load-context.sh"
}
]
}
]
}
}
load-context.sh:
#!/bin/bash
# Project context loading hook
set -e
# Read hook input
input=$(cat)
session_id=$(echo "$input" | jq -r '.session_id')
source=$(echo "$input" | jq -r '.source // "startup"')
echo "🚀 Loading project context for $source..."
# Load git status
echo "## Git Repository Status"
echo "**Current branch:** $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'not a git repo')"
echo "**Last commit:** $(git log -1 --pretty=format:'%h - %s (%cr)' 2>/dev/null || echo 'no commits')"
# Check for uncommitted changes
if git diff-index --quiet HEAD -- 2>/dev/null; then
echo "**Working tree:** clean"
else
echo "**Working tree:** has uncommitted changes"
echo "**Modified files:**"
git diff --name-only HEAD 2>/dev/null | sed 's/^/ - /' || echo " - (unable to detect)"
fi
# Load recent issues/todos
echo -e "\n## Recent Issues & TODOs"
if [[ -f "TODO.md" ]]; then
echo "**From TODO.md:**"
head -10 "TODO.md" | sed 's/^/ /'
elif command -v rg >/dev/null 2>&1; then
echo "**TODOs in codebase:**"
rg -i "todo|fixme|hack|bug" --type-add 'code:*.{js,ts,py,go,rs,java,c,cpp,h}' -t code -n | head -5 | sed 's/^/ /'
fi
# Load package info
echo -e "\n## Project Configuration"
if [[ -f "package.json" ]]; then
name=$(jq -r '.name // "unknown"' package.json)
version=$(jq -r '.version // "unknown"' package.json)
echo "**Project:** $name v$version"
echo "**Scripts available:** $(jq -r '.scripts | keys | join(", ")' package.json 2>/dev/null || echo 'none')"
elif [[ -f "Cargo.toml" ]]; then
name=$(grep '^name' Cargo.toml | cut -d'"' -f2 2>/dev/null || echo 'unknown')
version=$(grep '^version' Cargo.toml | cut -d'"' -f2 2>/dev/null || echo 'unknown')
echo "**Rust project:** $name v$version"
elif [[ -f "go.mod" ]]; then
module=$(head -1 go.mod | cut -d' ' -f2 2>/dev/null || echo 'unknown')
echo "**Go module:** $module"
elif [[ -f "pyproject.toml" ]] || [[ -f "setup.py" ]]; then
echo "**Python project detected**"
fi
# Load recent changes
echo -e "\n## Recent Activity"
echo "**Last 3 commits:**"
git log -3 --pretty=format:' - %h %s (%cr)' 2>/dev/null || echo " - No git history available"
echo -e "\n✅ Project context loaded successfully"
4. Automated Testing
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/auto-test.sh",
"timeout": 120
}
]
}
]
}
}
auto-test.sh:
#!/bin/bash
# Automated testing hook
set -e
# Read hook input
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
# Only run tests for source files
if [[ ! "$file_path" =~ \.(js|ts|jsx|tsx|py|go|rs)$ ]]; then
exit 0
fi
echo "🧪 Running automated tests for $file_path..."
# Determine test command based on project type
run_tests() {
if [[ -f "package.json" ]] && jq -e '.scripts.test' package.json >/dev/null 2>&1; then
echo "📦 Running npm tests..."
npm test 2>&1 | tail -10
elif [[ -f "Cargo.toml" ]]; then
echo "🦀 Running Rust tests..."
cargo test 2>&1 | tail -10
elif [[ -f "go.mod" ]]; then
echo "🐹 Running Go tests..."
go test ./... 2>&1 | tail -10
elif [[ -f "pytest.ini" ]] || [[ -f "pyproject.toml" ]]; then
echo "🐍 Running Python tests..."
python -m pytest -x --tb=short 2>&1 | tail -10
else
echo "ℹ️ No test configuration found, skipping tests"
return 0
fi
}
# Run tests and capture result
if test_output=$(run_tests); then
echo "✅ Tests passed"
echo "$test_output"
else
echo "❌ Tests failed - informing Claude"
echo "Test failures detected in modified code:" >&2
echo "$test_output" >&2
exit 2 # This will show the error to Claude
fi
5. File Protection
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.py"
}
]
}
]
}
}
protect-files.py:
#!/usr/bin/env python3
import json
import sys
import os
from pathlib import Path
# Protected file patterns
PROTECTED_PATTERNS = [
'.env*',
'*.key',
'*.pem',
'id_rsa*',
'id_ed25519*',
'.aws/credentials',
'.ssh/config',
'secrets.*',
'private.*'
]
# Critical system files
CRITICAL_FILES = [
'/etc/passwd',
'/etc/shadow',
'/etc/hosts',
'/etc/fstab',
'~/.bashrc',
'~/.zshrc',
'~/.profile'
]
def is_protected_file(file_path):
"""Check if file matches protection patterns"""
path = Path(file_path)
# Check against patterns
for pattern in PROTECTED_PATTERNS:
if path.match(pattern) or any(part.match(pattern) for part in path.parts):
return True, f"matches protected pattern: {pattern}"
# Check critical system files
resolved_path = str(path.resolve())
for critical in CRITICAL_FILES:
critical_resolved = str(Path(critical).expanduser().resolve())
if resolved_path == critical_resolved:
return True, f"is a critical system file"
return False, None
try:
input_data = json.load(sys.stdin)
tool_input = input_data.get('tool_input', {})
file_path = tool_input.get('file_path', '')
if not file_path:
sys.exit(0)
# Check if file is protected
is_protected, reason = is_protected_file(file_path)
if is_protected:
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": f"⚠️ Protected file detected: {file_path} {reason}. Confirm this operation."
}
}
print(json.dumps(output))
else:
# Auto-approve safe files
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "File passed protection checks"
}
}
print(json.dumps(output))
except Exception as e:
print(f"Hook error: {e}", file=sys.stderr)
sys.exit(1)
MCP Integration
Understanding MCP Tools in Hooks
Claude Code hooks work seamlessly with Model Context Protocol (MCP) tools. MCP tools follow the naming pattern mcp__<server>__<tool>:
mcp__github__create_issue- GitHub MCP server's issue creation toolmcp__database__execute_query- Database MCP server's query toolmcp__filesystem__write_file- Filesystem MCP server's write tool
MCP Hook Configuration
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__github__.*",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/log-github-operations.sh"
}
]
},
{
"matcher": "mcp__.*__write.*|mcp__.*__delete.*",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-destructive-mcp.py"
}
]
}
]
}
}
Advanced Hook Patterns
1. Conditional Hook Execution
#!/usr/bin/env python3
# Conditional hook based on project type and environment
import json
import sys
import os
try:
input_data = json.load(sys.stdin)
# Only run in production-like environments
env = os.environ.get('NODE_ENV', 'development')
if env not in ['staging', 'production']:
sys.exit(0)
# Only for specific project types
if not os.path.exists('package.json'):
sys.exit(0)
# Your hook logic here
print("🚀 Production hook executed")
except Exception as e:
print(f"Hook error: {e}", file=sys.stderr)
sys.exit(1)
2. Multi-Tool Coordination
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/coordinate-tools.sh"
}
]
}
]
}
}
coordinate-tools.sh:
#!/bin/bash
# Multi-tool coordination example
set -e
# Read hook input
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path')
tool_name=$(echo "$input" | jq -r '.tool_name')
# Create a coordination log
mkdir -p .claude/logs
echo "$(date): $tool_name - $file_path" >> .claude/logs/tool-coordination.log
# Trigger related actions based on file type
case "$file_path" in
*.md)
echo "📝 Documentation updated, checking for broken links..."
# Link check could go here
;;
*.js|*.ts)
echo "📄 JavaScript updated, checking dependencies..."
if [[ -f package.json ]]; then
npm audit --audit-level=moderate || echo "⚠️ npm audit found issues"
fi
;;
Dockerfile)
echo "🐳 Dockerfile updated, validating..."
docker build --dry-run . || echo "⚠️ Docker build validation failed"
;;
*.yml|*.yaml)
echo "📋 YAML updated, validating syntax..."
python -c "import yaml; yaml.safe_load(open('$file_path'))" 2>/dev/null || echo "⚠️ YAML syntax error"
;;
esac
echo "✅ Tool coordination complete"
3. Session Analytics
#!/usr/bin/env python3
# Session analytics and usage tracking
import json
import sys
import sqlite3
from datetime import datetime
import os
DATABASE = os.path.expanduser('~/.claude/analytics.db')
def init_db():
"""Initialize analytics database"""
conn = sqlite3.connect(DATABASE)
conn.execute('''
CREATE TABLE IF NOT EXISTS tool_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
tool_name TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
file_path TEXT,
success BOOLEAN
)
''')
conn.commit()
return conn
try:
input_data = json.load(sys.stdin)
# Initialize database
conn = init_db()
# Log tool usage
conn.execute('''
INSERT INTO tool_usage (session_id, tool_name, file_path, success)
VALUES (?, ?, ?, ?)
''', (
input_data.get('session_id'),
input_data.get('tool_name'),
input_data.get('tool_input', {}).get('file_path', ''),
input_data.get('tool_response', {}).get('success', True)
))
conn.commit()
# Generate usage summary periodically
cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) FROM tool_usage WHERE date(timestamp) = date("now")')
daily_count = cursor.fetchone()[0]
if daily_count % 10 == 0: # Every 10th operation
print(f"📊 Daily tool usage: {daily_count} operations")
# Show top tools today
cursor.execute('''
SELECT tool_name, COUNT(*) as count
FROM tool_usage
WHERE date(timestamp) = date("now")
GROUP BY tool_name
ORDER BY count DESC
LIMIT 3
''')
top_tools = cursor.fetchall()
print("🏆 Most used tools today:")
for tool, count in top_tools:
print(f" {tool}: {count} times")
conn.close()
except Exception as e:
print(f"Analytics error: {e}", file=sys.stderr)
sys.exit(0) # Don't fail the operation for analytics errors
Best Practices
1. Hook Design Principles
- Single Responsibility: Each hook should have one clear purpose
- Fail Gracefully: Use exit code 0 for non-critical failures to avoid blocking operations
- Be Fast: Keep hook execution time minimal to avoid slowing down Claude
- Idempotent: Hooks should be safe to run multiple times with the same input
2. Error Handling
#!/usr/bin/env python3
import json
import sys
import logging
# Set up logging
logging.basicConfig(
filename=os.path.expanduser('~/.claude/hooks.log'),
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
try:
input_data = json.load(sys.stdin)
# Your hook logic here
except json.JSONDecodeError as e:
logging.error(f"JSON decode error: {e}")
print(f"Invalid JSON input: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
logging.error(f"Unexpected error: {e}")
print(f"Hook error: {e}", file=sys.stderr)
sys.exit(0) # Don't block on unexpected errors
3. Input Validation
def validate_input(input_data):
"""Validate hook input data"""
required_fields = ['session_id', 'hook_event_name']
for field in required_fields:
if field not in input_data:
raise ValueError(f"Missing required field: {field}")
# Validate file paths for path traversal
file_path = input_data.get('tool_input', {}).get('file_path')
if file_path and '..' in file_path:
raise ValueError(f"Invalid file path: {file_path}")
return True
4. Configuration Management
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/configurable-hook.py --config=$CLAUDE_PROJECT_DIR/.claude/hook-config.json"
}
]
}
]
}
}
Security and Safety
Security Best Practices
- Input Sanitization: Always validate and sanitize inputs from Claude Code
- Path Security: Check for directory traversal attacks (
../patterns) - Command Injection: Never pass user input directly to shell commands
- File Permissions: Use minimal required permissions for hook scripts
- Secrets Management: Never log or expose sensitive information
Security Example
#!/usr/bin/env python3
import json
import sys
import os
import re
from pathlib import Path
def sanitize_path(file_path):
"""Sanitize file path to prevent directory traversal"""
if not file_path:
return None
# Check for directory traversal
if '..' in file_path or file_path.startswith('/'):
raise ValueError(f"Unsafe path detected: {file_path}")
# Resolve to absolute path within project
project_dir = os.environ.get('CLAUDE_PROJECT_DIR', '.')
safe_path = Path(project_dir) / file_path
# Ensure path stays within project directory
try:
safe_path.resolve().relative_to(Path(project_dir).resolve())
return str(safe_path)
except ValueError:
raise ValueError(f"Path outside project directory: {file_path}")
try:
input_data = json.load(sys.stdin)
file_path = input_data.get('tool_input', {}).get('file_path')
if file_path:
safe_path = sanitize_path(file_path)
print(f"✅ Safe path validated: {safe_path}")
except Exception as e:
print(f"Security validation failed: {e}", file=sys.stderr)
sys.exit(2) # Block unsafe operations
Hook Execution Safety
- Timeout Limits: Always set reasonable timeouts to prevent hanging
- Resource Limits: Be mindful of CPU and memory usage
- Concurrent Execution: Hooks run in parallel - design for thread safety
- Environment Isolation: Use containers or sandboxing for untrusted hooks
Troubleshooting and Debugging
Common Issues
-
Hook Not Executing
- Check hook registration with
/hookscommand - Verify JSON syntax in settings files
- Ensure script is executable (
chmod +x script.sh) - Check matcher patterns are correct (case-sensitive)
- Check hook registration with
-
Permission Errors
- Verify script file permissions
- Check
$CLAUDE_PROJECT_DIRenvironment variable - Ensure scripts use absolute paths
-
Timeout Issues
- Increase timeout values for long-running operations
- Optimize hook performance
- Run expensive operations asynchronously
Debug Mode
Use claude --debug to see detailed hook execution:
claude --debug
Debug output includes:
- Hook discovery and matching
- Command execution details
- Execution time and exit codes
- stdout/stderr from hooks
Testing Hooks
Test hooks independently:
# Test hook script directly
echo '{"session_id":"test","hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"test.txt"}}' | ./.claude/hooks/your-hook.py
# Test JSON output
echo '{"session_id":"test"}' | python3 -c "
import json, sys
data = json.load(sys.stdin)
print(json.dumps({'continue': True, 'systemMessage': 'Test successful'}))
"
Hook Development Workflow
- Start Simple: Begin with basic exit code hooks
- Add Logging: Include debug output for troubleshooting
- Test Thoroughly: Test all code paths and edge cases
- Add Error Handling: Graceful degradation on failures
- Optimize Performance: Minimize execution time
- Document Behavior: Clear comments and documentation
Hook Ecosystem
Community Hooks
Share and discover hooks with the community:
- Claude Code Hooks Repository (example)
- Project-specific hook collections
- Team-shared hook libraries
Hook Templates
Use these templates as starting points:
Basic Hook Template:
#!/usr/bin/env python3
import json
import sys
import os
def main():
try:
# Read input
input_data = json.load(sys.stdin)
# Your hook logic here
# Success
print("Hook executed successfully")
sys.exit(0)
except Exception as e:
print(f"Hook error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
JSON Output Template:
#!/usr/bin/env python3
import json
import sys
def main():
try:
input_data = json.load(sys.stdin)
# Your logic here
output = {
"continue": True,
"hookSpecificOutput": {
"hookEventName": input_data.get('hook_event_name'),
# Event-specific output
}
}
print(json.dumps(output))
sys.exit(0)
except Exception as e:
print(f"Hook error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
Conclusion
Claude Code hooks provide powerful automation capabilities that can significantly enhance your development workflow. With hooks, you can:
- Automate repetitive tasks like code formatting and testing
- Enhance security through validation and monitoring
- Customize Claude's behavior to match your team's requirements
- Integrate with external tools and services
- Monitor and analyze your development patterns
Start with simple hooks and gradually build more sophisticated automation as you become comfortable with the system. Remember to prioritize security, test thoroughly, and document your hooks for team collaboration.
For more examples and community resources, see the Claude Code documentation and join the community discussions.