Hooks: Event-Driven Automation in Claude Code
Claude Code is a powerful tool on its own, but its true strength emerges when you extend it with an event-driven automation system: Hooks. Hooks are shell commands that are automatically executed in response to specific events during the lifecycle of a Claude Code session, transforming a reactive AI assistant into a proactive and customized development system.
Unlike MCP servers, which require creating separate processes and communication through structured protocols, hooks operate as simple shell scripts executed directly by the Claude Code process. This makes them extremely lightweight, quick to implement, and perfect for automations that do not require the complexity of a dedicated server. In this article we will explore every aspect of hooks in depth: from configuration to creating complex automations, from real-world use cases to best practices for effective usage.
What You Will Learn
- Understand the event-driven hook system of Claude Code
- Configure hooks in JSON settings files
- Master all 6 available hook events
- Create custom hooks for complex automations
- Use community hooks like Britfix, CC Notify, and cchooks
- Manage errors, performance, and security in hooks
- Implement advanced inter-agent communication patterns
- Apply best practices for a robust hooks ecosystem
Series Overview
| # | Article | Focus |
|---|---|---|
| 1 | Claude as Technical Partner | Setup and first steps |
| 2 | Context and Project Setup | CLAUDE.md and configuration |
| 3 | Ideation and Requirements | MVP and user personas |
| 4 | Backend and Frontend Architecture | Spring Boot and Angular |
| 5 | Code Structure | Organization and conventions |
| 6 | Advanced Prompt Engineering | Advanced techniques |
| 7 | Testing and Quality | Strategies and test generation |
| 8 | Professional Documentation | README, API, ADR |
| 9 | Deploy and DevOps | Docker, CI/CD, monitoring |
| 10 | Evolution and Maintenance | Refactoring and scalability |
| 11 | Real Project Integration | Claude Code in production |
| 12 | Claude Code CLI | Command line mastery |
| 13 | Agent Skills | Extending Claude Code |
| 14 | Specialized Subagents | Orchestrating specialized agents |
| 15 | Hooks and Automation (you are here) | Event-driven automation |
| 16 | Ralph Wiggum Method | Autonomous iterative development |
| 17 | BMAD Method | AI-driven agile methodology |
| 18 | Multi-Agent Orchestration | Coordinating agent teams |
| 19 | Claude Cowork | Working together with Claude |
| 20 | Security and Privacy | Protecting the workflow |
| 21 | Monitoring and Productivity | Metrics and performance |
1. What Are Claude Code Hooks
Claude Code hooks are user-defined shell commands that are automatically executed in correspondence with specific events during a session. They work similarly to git hooks or development framework lifecycle hooks: they attach to key moments in the workflow and allow injecting custom logic without modifying the agent's core behavior.
The philosophy behind hooks is simple but powerful: instead of asking Claude to execute actions through prompts (which the agent might interpret differently than expected), hooks guarantee deterministic and reliable execution. The command is executed exactly as defined, every time, without variations or creative interpretations.
Hooks vs MCP Servers: When to Use What
| Feature | Hooks | MCP Servers |
|---|---|---|
| Complexity | Low - shell scripts | High - separate process |
| Latency | Minimal - direct execution | Higher - IPC communication |
| Model control | No - the LLM does not decide | Yes - the LLM chooses when to use them |
| Determinism | Total - always executed | Partial - depends on context |
| Configuration | JSON in settings.json | Dedicated server with SDK |
| Use cases | Automations, notifications, validations | Complex tools, API integrations |
| Security | Controlled local execution | Granular permissions per tool |
Hook System Architecture
The hook system fits into Claude Code's execution cycle as a transparent interception layer. Each time a supported event occurs, Claude Code checks whether hooks are configured for that event, prepares the context as JSON input via stdin, executes the specified shell command, and processes the output to determine any flow modifications.
Hooks receive structured input in JSON format via stdin and can
return JSON output via stdout to influence the agent's behavior.
This stream-based approach enables the composition of complex processing
pipelines while maintaining a simple and universal interface.
┌──────────────────────┐
│ Trigger Event │
│ (e.g. PreToolUse) │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Input Preparation │
│ (JSON via stdin) │
│ - tool_name │
│ - tool_input │
│ - session_id │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Command Execution │ ← User-defined shell script
│ (shell command) │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Output Processing │
│ (JSON via stdout) │
│ - decision: allow │
│ - decision: block │
│ - decision: modify │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Flow Continuation │
│ Claude Code │
└──────────────────────┘
2. Hook Configuration
Hooks are configured in the .claude/settings.json file within
the project directory or in the user's global configuration
(~/.claude/settings.json). The configuration structure is a JSON object
that maps events to lists of hooks, where each hook specifies the command to execute
and optionally a matcher to filter which tools or conditions trigger the hook.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"command": "/path/to/script.sh"
}
],
"PostToolUse": [
{
"matcher": "Bash",
"command": "python3 /path/to/logger.py"
}
],
"Stop": [
{
"command": "notify-send 'Claude Code' 'Task completed!'"
}
]
}
}
Hook Properties
Configuration Fields
| Field | Type | Required | Description |
|---|---|---|---|
matcher | string (regex) | No | Regex pattern to filter activation. Supports pipe for logical OR. |
command | string | Yes | Shell command to execute. Can be an absolute or relative path. |
timeout | number | No | Timeout in milliseconds. Default: 60000 (60 seconds). |
The matcher field is particularly powerful because it supports complete
regular expressions. This means you can create hooks that respond only to specific
tools or tool combinations, keeping the system reactive and performant.
// Single tool
"matcher": "Write"
// Multiple tools with OR
"matcher": "Write|Edit|MultiEdit"
// Complex regex pattern
"matcher": "^(Bash|Terminal)$"
// All tools (no matcher = wildcard)
// Omit the matcher field
// Tools starting with "File"
"matcher": "^File.*"
// Exclude specific tools (regex negation)
"matcher": "^(?!Read).*$"
Configuration Levels
Claude Code supports three hierarchical configuration levels for hooks, allowing you to define automations at different levels of granularity:
- Project (
.claude/settings.json): Hooks specific to the current repository. Ideal for project-related automations like linting, formatting, or team notifications. - User (
~/.claude/settings.json): Global hooks that apply to all user sessions. Perfect for personal preferences like desktop notifications or logging. - Enterprise (
/etc/claude/settings.json): Organization-level hooks. Used for corporate policies like audit logging or compliance checks.
When hooks exist at multiple levels for the same event, they are all executed in order of precedence: enterprise first, then user, finally project. This ensures that corporate policies are always respected, regardless of local configurations.
3. The 6 Hook Events in Detail
Claude Code exposes six distinct events to which hooks can be attached. Each event corresponds to a precise moment in the session lifecycle and provides a specific context through JSON input. Understanding each event in depth is fundamental for designing effective and robust automations.
3.1 PreToolUse: Intercept Before Execution
The PreToolUse event fires before Claude Code executes any tool. It is the most powerful interception point because it allows you to inspect, modify, or block the execution of a tool before it produces effects in the system.
JSON Input for PreToolUse
| Field | Type | Description |
|---|---|---|
tool_name | string | Tool name (e.g. "Write", "Bash", "Edit") |
tool_input | object | Parameters passed to the tool |
session_id | string | Unique session identifier |
The hook can return a JSON output with three possible decisions:
"decision": "allow"- Allows the tool to execute without modifications"decision": "block"- Completely blocks execution. Requires areasonfield with the justification."decision": "modify"- Modifies the tool's parameters before execution. Requires atool_inputfield with updated parameters.
#!/bin/bash
# PreToolUse hook: block operations on protected files
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input')
# Block writing to critical configuration files
if [ "$TOOL_NAME" = "Write" ] || [ "$TOOL_NAME" = "Edit" ]; then
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .path // ""')
# Protected files list
PROTECTED_FILES=(".env" ".env.production" "secrets.json" "credentials.yaml")
for PROTECTED in "${PROTECTED_FILES[@]}"; do
if [[ "$FILE_PATH" == *"$PROTECTED"* ]]; then
echo "{\"decision\": \"block\", \"reason\": \"Protected file: $PROTECTED. Manual modification required.\"}"
exit 0
fi
done
fi
# Block destructive Bash commands
if [ "$TOOL_NAME" = "Bash" ]; then
COMMAND=$(echo "$TOOL_INPUT" | jq -r '.command // ""')
# Dangerous patterns
if echo "$COMMAND" | grep -qE "rm\s+-rf\s+/|DROP\s+TABLE|DELETE\s+FROM.*WHERE\s+1=1|format\s+c:"; then
echo "{\"decision\": \"block\", \"reason\": \"Potentially destructive command blocked.\"}"
exit 0
fi
fi
# Allow everything else
echo '{"decision": "allow"}'
#!/bin/bash
# PreToolUse hook: automatically adds headers to new files
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
if [ "$TOOL_NAME" = "Write" ]; then
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path')
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content')
# Add copyright header to TypeScript files
if [[ "$FILE_PATH" == *.ts ]]; then
HEADER="// Copyright $(date +%Y) - MyCompany\n// Auto-generated header by Claude Code hook\n\n"
MODIFIED_CONTENT="${HEADER}${CONTENT}"
echo "$INPUT" | jq --arg content "$MODIFIED_CONTENT" \
'{decision: "modify", tool_input: (.tool_input + {content: $content})}'
exit 0
fi
fi
echo '{"decision": "allow"}'
3.2 PostToolUse: React After Execution
The PostToolUse event fires after a tool has completed its execution. It is the ideal point for logging, result validation, output transformation, and consequent actions based on the operation's result.
Unlike PreToolUse, PostToolUse cannot block or modify the operation (which has already occurred), but it can influence how Claude interprets the result and can execute side actions such as updating logs, sending notifications, or triggering external pipelines.
JSON Input for PostToolUse
| Field | Type | Description |
|---|---|---|
tool_name | string | Name of the tool executed |
tool_input | object | Parameters used for execution |
tool_output | string | Tool result |
session_id | string | Session identifier |
#!/usr/bin/env python3
"""PostToolUse hook: logs all file write operations."""
import json
import sys
from datetime import datetime
from pathlib import Path
def main():
input_data = json.loads(sys.stdin.read())
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
session_id = input_data.get("session_id", "unknown")
# Log only file modification operations
if tool_name in ("Write", "Edit", "MultiEdit"):
log_entry = {
"timestamp": datetime.now().isoformat(),
"session_id": session_id,
"tool": tool_name,
"file": tool_input.get("file_path", "N/A"),
"operation": "write" if tool_name == "Write" else "edit"
}
log_file = Path(".claude/hooks/activity.log")
log_file.parent.mkdir(parents=True, exist_ok=True)
with open(log_file, "a") as f:
f.write(json.dumps(log_entry) + "\n")
# Log executed Bash commands
if tool_name == "Bash":
command = tool_input.get("command", "")
log_entry = {
"timestamp": datetime.now().isoformat(),
"session_id": session_id,
"tool": "Bash",
"command": command[:200], # Truncate for safety
"exit_code": "success"
}
log_file = Path(".claude/hooks/bash-history.log")
log_file.parent.mkdir(parents=True, exist_ok=True)
with open(log_file, "a") as f:
f.write(json.dumps(log_entry) + "\n")
if __name__ == "__main__":
main()
3.3 UserPromptSubmit: Intercept User Prompts
The UserPromptSubmit event fires when the user sends a prompt to Claude Code. This hook allows you to pre-process, enrich, or filter user messages before they reach the model. It is particularly useful for adding automatic context, applying prompt templates, or implementing security filters on incoming messages.
#!/bin/bash
# UserPromptSubmit hook: adds automatic Git context
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt')
# Add current branch information
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "N/A")
LAST_COMMIT=$(git log --oneline -1 2>/dev/null || echo "N/A")
DIRTY_FILES=$(git status --porcelain 2>/dev/null | wc -l)
# Create enriched context
CONTEXT="[Git Context: branch=$BRANCH, last_commit=$LAST_COMMIT, dirty_files=$DIRTY_FILES]"
# Modify the prompt by adding context
ENRICHED_PROMPT="$CONTEXT\n\n$PROMPT"
echo "$INPUT" | jq --arg prompt "$ENRICHED_PROMPT" \
'{decision: "modify", prompt: $prompt}'
3.4 Stop: Actions at the End of Generation
The Stop event fires when Claude Code finishes generating a response or completes a task. It is the ideal point for notifications, cleanup, report generation, and any action that should happen at the end of an interaction. This hook is particularly valuable for long tasks where the user might not be in front of the terminal.
#!/bin/bash
# Stop hook: sends desktop notification when Claude completes
INPUT=$(cat)
STOP_REASON=$(echo "$INPUT" | jq -r '.stop_reason // "completed"')
# Notification on Linux (notify-send)
if command -v notify-send &> /dev/null; then
notify-send "Claude Code" "Task completed: $STOP_REASON" \
--icon=dialog-information \
--urgency=normal
fi
# Notification on macOS (osascript)
if command -v osascript &> /dev/null; then
osascript -e "display notification \"Task completed: $STOP_REASON\" with title \"Claude Code\""
fi
# Completion sound
if command -v aplay &> /dev/null; then
aplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null &
elif command -v afplay &> /dev/null; then
afplay /System/Library/Sounds/Glass.aiff &
fi
3.5 SessionStart: Session Initialization
The SessionStart event fires at the beginning of every new Claude Code session. It is the ideal moment for environment setup, loading additional context, validating prerequisites, and preparing the resources needed for the work session.
#!/bin/bash
# SessionStart hook: prepares the development environment
# Verify prerequisites
echo "Verifying development environment..." >&2
# Check that Node.js is available
if ! command -v node &> /dev/null; then
echo '{"warning": "Node.js not found. Some features may not work."}' >&2
fi
# Check that the development database is reachable
if command -v pg_isready &> /dev/null; then
if ! pg_isready -q 2>/dev/null; then
echo '{"warning": "PostgreSQL not reachable. Start the database."}' >&2
fi
fi
# Load environment variables from .env
if [ -f ".env" ]; then
export $(grep -v '^#' .env | xargs) 2>/dev/null
fi
# Record session start
echo "{\"session_start\": \"$(date -Iseconds)\", \"cwd\": \"$(pwd)\"}" \
>> .claude/hooks/sessions.log 2>/dev/null
# Clean up temporary files from previous sessions
find /tmp -name "claude-hook-*" -mtime +1 -delete 2>/dev/null
3.6 SessionEnd: Cleanup and Final Report
The SessionEnd event fires when a Claude Code session is closed. It is the perfect point for saving state, generating session reports, performing cleanup, and archiving useful information for future sessions.
#!/usr/bin/env python3
"""SessionEnd hook: generates a work session report."""
import json
import sys
from datetime import datetime
from pathlib import Path
def main():
input_data = json.loads(sys.stdin.read())
session_id = input_data.get("session_id", "unknown")
# Read the activity log
log_file = Path(".claude/hooks/activity.log")
activities = []
if log_file.exists():
with open(log_file) as f:
for line in f:
try:
entry = json.loads(line.strip())
if entry.get("session_id") == session_id:
activities.append(entry)
except json.JSONDecodeError:
continue
# Generate report
report = {
"session_id": session_id,
"end_time": datetime.now().isoformat(),
"total_operations": len(activities),
"files_modified": list(set(
a.get("file", "") for a in activities
if a.get("tool") in ("Write", "Edit", "MultiEdit")
)),
"bash_commands": sum(
1 for a in activities if a.get("tool") == "Bash"
)
}
# Save report
reports_dir = Path(".claude/hooks/reports")
reports_dir.mkdir(parents=True, exist_ok=True)
report_file = reports_dir / f"session-{session_id[:8]}.json"
with open(report_file, "w") as f:
json.dump(report, f, indent=2)
print(f"Session report saved: {report_file}", file=sys.stderr)
if __name__ == "__main__":
main()
Hook Events Summary
| Event | When It Fires | Can Block? | Can Modify? |
|---|---|---|---|
| PreToolUse | Before a tool executes | Yes | Yes (tool parameters) |
| PostToolUse | After a tool executes | No | No (side effects only) |
| UserPromptSubmit | When the user sends a prompt | Yes | Yes (prompt text) |
| Stop | When Claude finishes generating | No | No |
| SessionStart | At the beginning of a new session | No | No |
| SessionEnd | When the session closes | No | No |
4. Community Hooks: Ready-to-Use Solutions
The Claude Code community has developed a series of sophisticated hooks that solve common developer needs. These open-source projects demonstrate the versatility of the hook system and can be used directly or as a base for customizations.
4.1 Britfix: Automatic American-to-British English Conversion
Britfix is a PostToolUse hook that automatically converts American English spelling to British English spelling in files written by Claude Code. It was created for teams that work with British standards but use an AI model trained predominantly on American texts.
The hook intercepts every file write operation, analyzes the content for words with American spelling (such as "color", "optimize", "center") and replaces them with the British variants ("colour", "optimise", "centre"). The process is transparent and requires no manual intervention.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"command": "npx britfix --auto-fix"
}
]
}
}
4.2 CC Notify: Intelligent Desktop Notifications
CC Notify is an advanced desktop notification system that goes beyond simple completion notifications. It supports differentiated notifications by event type, notification history, integration with messaging systems (Slack, Discord, Telegram), and granular configuration by activity type.
A particularly useful feature of CC Notify is its ability to estimate the remaining time for long tasks based on the history of previous sessions, allowing the user to plan their time more effectively while Claude Code works in the background.
{
"hooks": {
"Stop": [
{
"command": "cc-notify --channel slack --sound chime --message 'Task completed'"
}
],
"SessionStart": [
{
"command": "cc-notify --channel desktop --message 'Claude session started'"
}
],
"PreToolUse": [
{
"matcher": "Bash",
"command": "cc-notify --log-activity bash"
}
]
}
}
4.3 cchooks: Python SDK for Professional Hooks
cchooks is a Python SDK that drastically simplifies the creation of complex hooks. Instead of writing shell scripts with manual JSON parsing, cchooks provides typed Python decorators, automatic input/output handling, and a utility library for common operations.
#!/usr/bin/env python3
"""Hook created with cchooks SDK."""
from cchooks import hook, PreToolUseInput, HookDecision
@hook("PreToolUse")
def validate_file_size(event: PreToolUseInput) -> HookDecision:
"""Block writing files that are too large."""
if event.tool_name == "Write":
content = event.tool_input.get("content", "")
max_lines = 500
if content.count("\n") > max_lines:
return HookDecision.block(
reason=f"File too large: {content.count(chr(10))} lines "
f"(max {max_lines}). Split into smaller files."
)
return HookDecision.allow()
@hook("PostToolUse")
def auto_lint(event):
"""Runs automatic linting after every file modification."""
import subprocess
if event.tool_name in ("Write", "Edit"):
file_path = event.tool_input.get("file_path", "")
if file_path.endswith(".ts") or file_path.endswith(".js"):
subprocess.run(
["npx", "eslint", "--fix", file_path],
capture_output=True, timeout=30
)
elif file_path.endswith(".py"):
subprocess.run(
["python3", "-m", "black", file_path],
capture_output=True, timeout=30
)
4.4 Claude Code Hook Comms: Real-Time Inter-Agent Communication
Claude Code Hook Comms enables real-time communication between multiple Claude Code instances working on the same project or related projects. It uses a messaging system based on shared files or sockets to coordinate the activities of multiple agents, avoiding conflicts and work duplication.
This hook is particularly useful in multi-agent development scenarios where different Claude Code instances work on different aspects of a project (for example frontend and backend) and need to synchronize on changes to shared interfaces.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"command": "claude-hook-comms publish --topic file-changes --data-from-stdin"
}
],
"SessionStart": [
{
"command": "claude-hook-comms subscribe --topic file-changes --handler sync-handler.sh"
}
]
}
}
// sync-handler.sh - Handles file change notifications
#!/bin/bash
INPUT=$(cat)
CHANGED_FILE=$(echo "$INPUT" | jq -r '.file_path')
AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id')
echo "[SYNC] Agent $AGENT_ID modified: $CHANGED_FILE" >> .claude/hooks/sync.log
5. Creating Custom Hooks: Step-by-Step Guide
Creating custom hooks requires understanding the input/output format, choosing the appropriate scripting language, and paying attention to robustness and efficiency. This section provides a complete path from creation to production deployment of professional hooks.
Step 1: Define the Objective
Before writing code, it is essential to clearly define what the hook should do:
- Which event? Identify the exact moment when the hook should intervene
- Which action? Define what it should do (block, modify, log, notify)
- Which conditions? Specify when the hook should activate (matcher)
- What impact? Evaluate the consequences of the hook on the workflow
Step 2: Write the Script
#!/bin/bash
set -euo pipefail
# Read JSON input from stdin
INPUT=$(cat)
# Extract common fields
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""')
# ================================
# HOOK LOGIC
# ================================
# Example: log the operation
echo "[$(date -Iseconds)] Hook triggered: tool=$TOOL_NAME session=$SESSION_ID" >&2
# ================================
# OUTPUT (only for PreToolUse/UserPromptSubmit)
# ================================
# Option 1: Allow
echo '{"decision": "allow"}'
# Option 2: Block
# echo '{"decision": "block", "reason": "Reason for blocking"}'
# Option 3: Modify
# echo '{"decision": "modify", "tool_input": {...}}'
Step 3: Test the Hook
Testing hooks is fundamental because errors in the script can block or slow down the entire Claude Code session. Here is a structured approach to testing:
#!/bin/bash
# Test a hook by simulating JSON input
HOOK_SCRIPT="./my-hook.sh"
# Simulate PreToolUse input for Write
echo '{
"tool_name": "Write",
"tool_input": {
"file_path": "/tmp/test.ts",
"content": "console.log(\"hello\");"
},
"session_id": "test-session-001"
}' | bash "$HOOK_SCRIPT"
echo "---"
# Simulate PreToolUse input for Bash with dangerous command
echo '{
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /"
},
"session_id": "test-session-001"
}' | bash "$HOOK_SCRIPT"
echo "---"
# Verify exit code
echo "Exit code: $?"
Step 4: Register and Activate
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"command": "bash .claude/hooks/pre-tool-guard.sh"
},
{
"matcher": "Bash",
"command": "bash .claude/hooks/bash-validator.sh"
}
],
"PostToolUse": [
{
"command": "python3 .claude/hooks/post-tool-logger.py"
}
],
"Stop": [
{
"command": "bash .claude/hooks/notify-complete.sh"
}
],
"SessionStart": [
{
"command": "bash .claude/hooks/session-init.sh"
}
],
"SessionEnd": [
{
"command": "python3 .claude/hooks/session-report.py"
}
]
}
}
6. Advanced Hook Patterns
Beyond basic usage, hooks can be combined into sophisticated patterns that solve complex automation needs. This section presents some of the most useful patterns that have emerged from community experience.
Pattern: Auto-Commit on Checkpoint
This pattern automatically creates Git commits at regular intervals during a development session, ensuring that work is never lost and providing granular rollback points.
#!/bin/bash
# PostToolUse hook: creates automatic checkpoints
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
# Only for file modification operations
if [ "$TOOL_NAME" = "Write" ] || [ "$TOOL_NAME" = "Edit" ]; then
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
# Check if there are enough changes for a checkpoint
CHANGES=$(git diff --stat 2>/dev/null | tail -1)
NUM_FILES=$(git diff --name-only 2>/dev/null | wc -l)
if [ "$NUM_FILES" -ge 3 ]; then
git add -A 2>/dev/null
git commit -m "checkpoint: auto-save after $NUM_FILES files modified" \
--no-verify 2>/dev/null
echo "[CHECKPOINT] Automatic commit: $NUM_FILES files" >&2
fi
fi
Pattern: Quality Gate Pre-Commit
This pattern automatically runs quality checks (linting, type checking, unit tests) before allowing write operations, ensuring that every modification meets the project's standards.
#!/bin/bash
# PreToolUse hook: quality gate for TypeScript files
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
if [ "$TOOL_NAME" = "Write" ]; then
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path')
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content')
if [[ "$FILE_PATH" == *.ts ]]; then
# Write the content to a temporary file for analysis
TEMP_FILE=$(mktemp /tmp/claude-hook-XXXXXX.ts)
echo "$CONTENT" > "$TEMP_FILE"
# Run type checking
ERRORS=$(npx tsc --noEmit --strict "$TEMP_FILE" 2>&1 || true)
rm -f "$TEMP_FILE"
if echo "$ERRORS" | grep -q "error TS"; then
ERROR_COUNT=$(echo "$ERRORS" | grep -c "error TS")
echo "{\"decision\": \"block\", \"reason\": \"Type check failed: $ERROR_COUNT TypeScript errors found. Fix before writing.\"}"
exit 0
fi
fi
fi
echo '{"decision": "allow"}'
Pattern: Context Enrichment Pipeline
This pattern automatically enriches every user prompt with relevant contextual information, such as repository state, recently modified files, failed tests, and project metrics.
#!/bin/bash
# UserPromptSubmit hook: context enrichment pipeline
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt')
# Gather context
CONTEXT_PARTS=()
# 1. Git state
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
if [ -n "$BRANCH" ]; then
DIRTY=$(git status --porcelain 2>/dev/null | wc -l)
CONTEXT_PARTS+=("[Branch: $BRANCH, Modified files: $DIRTY]")
fi
# 2. Recent failed tests
if [ -f "test-results.json" ]; then
FAILED=$(jq '.numFailedTests // 0' test-results.json 2>/dev/null)
if [ "$FAILED" -gt 0 ]; then
CONTEXT_PARTS+=("[WARNING: $FAILED tests failed]")
fi
fi
# 3. Pending TODOs
TODO_COUNT=$(grep -r "TODO\|FIXME\|HACK" src/ --include="*.ts" 2>/dev/null | wc -l)
if [ "$TODO_COUNT" -gt 0 ]; then
CONTEXT_PARTS+=("[Open TODOs in code: $TODO_COUNT]")
fi
# Build aggregated context
if [ ${#CONTEXT_PARTS[@]} -gt 0 ]; then
CONTEXT=$(printf "%s " "${CONTEXT_PARTS[@]}")
ENRICHED="$CONTEXT\n\n$PROMPT"
echo "$INPUT" | jq --arg p "$ENRICHED" '{decision: "modify", prompt: $p}'
else
echo '{"decision": "allow"}'
fi
7. Performance, Security, and Best Practices
Hooks are executed synchronously within Claude Code's lifecycle, which means slow or malfunctioning hooks can significantly degrade the user experience. Following performance and security best practices is essential for maintaining a smooth and protected workflow.
Performance Optimization
Golden Rules for Performant Hooks
| Rule | Motivation | Implementation |
|---|---|---|
| Aggressive timeouts | Avoid session blocking | Set timeout to 5-10 seconds for critical hooks |
| Specific matchers | Reduce unnecessary executions | Use precise regex instead of wildcards |
| Async operations | Do not block on slow I/O | Delegate heavy operations to background processes |
| Local cache | Avoid expensive recalculations | Save intermediate results in temporary files |
| Early exit | Minimize execution time | Check exit conditions at the beginning of the script |
| Controlled logging | Avoid excessive disk I/O | Use log rotation and severity levels |
Hook Security
Hooks execute shell commands with the current user's permissions, which represents both an advantage (full system access) and a risk (uncontrolled execution). It is fundamental to apply rigorous security principles:
- Principle of least privilege: Each hook should have access only to the resources strictly necessary for its operation
- Input validation: All data received via stdin must be validated and sanitized before use
- No hardcoded secrets: API keys, passwords, and tokens must never be included directly in hook scripts
- Audit trail: Maintain a log of all actions performed by hooks for traceability
- Periodic review: Regularly review configured hooks to identify emerging risks
Warning: Security Risks
Project hooks (defined in .claude/settings.json) are executed
automatically when a Claude Code session is opened in the repository. This means
a malicious repository could include harmful hooks. Always verify
configuration files before opening sessions in untrusted repositories.
Error Handling
A hook that fails silently is worse than a hook that does not exist. Implementing robust error handling is fundamental for the system's maintainability:
#!/bin/bash
set -euo pipefail
# Trap to handle errors and cleanup
cleanup() {
local exit_code=$?
if [ $exit_code -ne 0 ]; then
echo "[HOOK ERROR] Exit code: $exit_code" >&2
echo "{\"error\": \"Hook failed with code $exit_code\"}" >&2
fi
# Cleanup temporary resources
rm -f /tmp/claude-hook-$$.tmp 2>/dev/null
}
trap cleanup EXIT
# Timeout for the entire script
TIMEOUT=10
(
sleep $TIMEOUT
echo "[HOOK TIMEOUT] Script exceeded $TIMEOUT seconds" >&2
kill $$ 2>/dev/null
) &
TIMEOUT_PID=$!
# Main logic with error handling
INPUT=$(cat) || {
echo '{"decision": "allow"}' # Safe fallback
exit 0
}
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null) || TOOL_NAME=""
# ... hook logic ...
# Cancel the timeout timer
kill $TIMEOUT_PID 2>/dev/null || true
echo '{"decision": "allow"}'
8. Complete Example: Hook Ecosystem for an Angular Project
To conclude, we present a complete hook ecosystem configured for an Angular project with SSR, covering all six events and demonstrating how hooks collaborate to create a robust and automated development environment.
{
"hooks": {
"SessionStart": [
{
"command": "bash .claude/hooks/check-dependencies.sh",
"timeout": 15000
}
],
"PreToolUse": [
{
"matcher": "Write|Edit",
"command": "bash .claude/hooks/validate-angular-conventions.sh",
"timeout": 5000
},
{
"matcher": "Bash",
"command": "bash .claude/hooks/block-dangerous-commands.sh",
"timeout": 3000
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"command": "bash .claude/hooks/auto-lint-format.sh",
"timeout": 10000
},
{
"command": "python3 .claude/hooks/activity-tracker.py",
"timeout": 5000
}
],
"UserPromptSubmit": [
{
"command": "bash .claude/hooks/enrich-with-project-context.sh",
"timeout": 5000
}
],
"Stop": [
{
"command": "bash .claude/hooks/desktop-notification.sh"
}
],
"SessionEnd": [
{
"command": "python3 .claude/hooks/generate-session-report.py",
"timeout": 10000
}
]
}
}
#!/bin/bash
# PreToolUse: validate Angular conventions for new files
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
# Verify naming convention for Angular components
if [[ "$FILE_PATH" == *.component.ts ]]; then
# Check that the name follows kebab-case
FILENAME=$(basename "$FILE_PATH" .component.ts)
if ! echo "$FILENAME" | grep -qE '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'; then
echo "{\"decision\": \"block\", \"reason\": \"Naming convention violated: Angular components must use kebab-case. File: $FILENAME\"}"
exit 0
fi
fi
# Verify that service files are in the services/ directory
if [[ "$FILE_PATH" == *.service.ts ]] && [[ "$FILE_PATH" != */services/* ]]; then
echo "{\"decision\": \"block\", \"reason\": \"Angular services must be in the services/ directory\"}"
exit 0
fi
echo '{"decision": "allow"}'
9. Debug and Troubleshooting Hooks
When a hook does not work as expected, diagnosis can be complicated by the asynchronous nature and implicit execution of the system. Here are the most effective strategies for identifying and resolving problems.
Debug Techniques
- Logging to stderr: Everything written to
stderris displayed in the Claude Code terminal. Useecho "debug info" >&2for debug messages. - Dedicated log files: Write detailed output to log files in the
.claude/hooks/logs/directory for post-hoc analysis. - Isolated testing: Test each hook independently by simulating JSON input through pipes.
- Dry run mode: Implement a dry-run mode in hooks that logs actions without executing them.
#!/bin/bash
# Wrapper that adds debugging to any hook
HOOK_SCRIPT="$1"
LOG_DIR=".claude/hooks/logs"
mkdir -p "$LOG_DIR"
# Capture input
INPUT=$(cat)
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# Log input
echo "=== $TIMESTAMP ===" >> "$LOG_DIR/debug.log"
echo "Hook: $HOOK_SCRIPT" >> "$LOG_DIR/debug.log"
echo "Input: $INPUT" >> "$LOG_DIR/debug.log"
# Execute hook and capture output
OUTPUT=$(echo "$INPUT" | bash "$HOOK_SCRIPT" 2>> "$LOG_DIR/debug.log")
EXIT_CODE=$?
# Log output
echo "Output: $OUTPUT" >> "$LOG_DIR/debug.log"
echo "Exit: $EXIT_CODE" >> "$LOG_DIR/debug.log"
echo "" >> "$LOG_DIR/debug.log"
# Return output
echo "$OUTPUT"
exit $EXIT_CODE
Common Problems and Solutions
Quick Troubleshooting
| Problem | Probable Cause | Solution |
|---|---|---|
| Hook does not fire | Matcher does not match | Verify the exact tool name and regex pattern |
| Hook slows down Claude | Timeout too high or slow operation | Reduce timeout, delegate to background process |
| Output not processed | Malformed JSON on stdout | Validate output with jq and verify encoding |
| Permission denied | Script not executable | Run chmod +x script.sh |
| Missing environment variables | Shell does not inherit environment | Explicitly load .env in the script |
| Hook works manually but not from Claude | Different PATH in the hook environment | Use absolute paths for all commands |
Conclusion
Claude Code hooks represent the bridge between a reactive AI assistant and a proactive and customized development system. Through six strategic interception points, it is possible to automate validations, enrich context, protect the project from dangerous operations, and coordinate complex workflows.
The true power of hooks emerges from their composition: individually they are simple scripts, but combined they create an automation ecosystem that radically transforms how you interact with the AI agent. From auto-linting to completion notification, from quality gates to inter-agent communication, hooks make every Claude Code session more efficient, secure, and productive.
Key Takeaways
- 6 events cover the entire cycle: From SessionStart to SessionEnd, every moment is interceptable
- Guaranteed determinism: Unlike prompts, hooks always execute exactly what you define
- Active community: Britfix, CC Notify, cchooks, and Hook Comms solve common needs
- Powerful composition: Simple hooks combined create sophisticated automations
- Security first: Validate input, use timeouts, and verify hooks from external repositories
- Critical performance: Synchronous hooks require attention to timeouts and early exit
In the next article we will explore the Ralph Wiggum Method: how to configure Claude Code to execute completely autonomous development loops, iterating on a project without human intervention until the defined objectives are achieved.
Commenti
Caricamento commenti...
Sign in to comment