Claude Code Hooks: Automate Your Development Workflow
By Learnia Team
Claude Code Hooks: Automate Your Development Workflow
This article is written in English. Our training modules are available in French.
Hooks intercept Claude Code's actions before or after execution. They let you inject custom logic—validation, logging, notifications, transformations—into Claude's workflow. Think of hooks as middleware for your AI assistant.
What Are Hooks?
Hooks are scripts that run at specific points in Claude Code's lifecycle:
Hook Execution Flow:
- →User Request → You ask Claude to do something
- →PreToolUse Hook → Your script runs first (can block, modify, or proceed)
- →Tool Runs → Claude executes the action
- →PostToolUse Hook → Your script runs after (can log, notify, or transform)
- →Response to User → You see the result
Hook Types
| Hook | When It Runs | Purpose |
|---|---|---|
| Before any tool executes | Validate, block, or modify |
| After a tool completes | Log, notify, transform output |
| On specific events | Alert on errors, completions |
| When a session begins | Initialize environment |
| When a session ends | Cleanup, summarize |
Why Use Hooks?
Without Hooks
You manually check each action:
Claude wants to run: rm -rf ./temp
[y] Accept [n] Reject
Every. Single. Time.
With Hooks
Automation handles routine checks:
Claude wants to run: rm -rf ./temp
[PreToolUse] ✓ Validated: temp directory only
Proceeding automatically...
Claude wants to run: rm -rf /
[PreToolUse] ✗ Blocked: root directory access denied
Safe commands proceed; dangerous ones block automatically.
Hook Configuration
Hooks are configured in
~/.claude/settings.json or .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "python .claude/hooks/validate-bash.py"
}
],
"PostToolUse": [
{
"matcher": "*",
"command": "python .claude/hooks/log-actions.py"
}
]
}
}
Hook Object Properties
| Property | Description |
|---|---|
| Tool pattern to match (, , for all) |
| Script to execute |
| Max execution time (ms) |
| Action on failure: or |
PreToolUse Hooks
PreToolUse hooks run before Claude executes a tool. They can:
- →Proceed: Allow the action
- →Block: Deny the action
- →Modify: Change parameters
Input Format
Your hook receives JSON on stdin:
{
"hook_type": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm install lodash"
},
"session_id": "abc123",
"timestamp": "2026-01-12T10:30:00Z"
}
Output Format
Return JSON on stdout:
{
"decision": "proceed"
}
Decision options:
| Decision | Behavior |
|---|---|
| Allow the tool to run |
| Deny execution |
| Change tool input |
Block Example
# .claude/hooks/validate-bash.py
import json
import sys
input_data = json.loads(sys.stdin.read())
command = input_data.get("tool_input", {}).get("command", "")
# Block dangerous commands
dangerous_patterns = [
"rm -rf /",
"rm -rf ~",
"sudo rm",
"> /dev/sda",
"mkfs",
"dd if="
]
for pattern in dangerous_patterns:
if pattern in command:
result = {
"decision": "block",
"reason": f"Blocked dangerous command containing: {pattern}"
}
print(json.dumps(result))
sys.exit(0)
# Allow everything else
print(json.dumps({"decision": "proceed"}))
Modify Example
# .claude/hooks/add-dry-run.py
import json
import sys
input_data = json.loads(sys.stdin.read())
command = input_data.get("tool_input", {}).get("command", "")
# Add --dry-run to destructive commands
if command.startswith("kubectl delete"):
result = {
"decision": "modify",
"tool_input": {
"command": command + " --dry-run=client"
},
"reason": "Added --dry-run for safety"
}
else:
result = {"decision": "proceed"}
print(json.dumps(result))
PostToolUse Hooks
PostToolUse hooks run after a tool completes. They receive the tool's output and can:
- →Log actions
- →Send notifications
- →Transform output
- →Trigger follow-up actions
Input Format
{
"hook_type": "PostToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
},
"tool_output": {
"stdout": "All tests passed",
"stderr": "",
"exit_code": 0
},
"session_id": "abc123",
"timestamp": "2026-01-12T10:30:00Z"
}
Logging Example
# .claude/hooks/log-actions.py
import json
import sys
from datetime import datetime
input_data = json.loads(sys.stdin.read())
log_entry = {
"timestamp": datetime.now().isoformat(),
"tool": input_data.get("tool_name"),
"input": input_data.get("tool_input"),
"output_preview": str(input_data.get("tool_output", {}))[:200]
}
with open(".claude/logs/actions.jsonl", "a") as f:
f.write(json.dumps(log_entry) + "\n")
# PostToolUse hooks should return empty or minimal response
print(json.dumps({"status": "logged"}))
Notification Example
# .claude/hooks/notify-errors.py
import json
import sys
import requests
input_data = json.loads(sys.stdin.read())
tool_output = input_data.get("tool_output", {})
# Check for errors
exit_code = tool_output.get("exit_code", 0)
stderr = tool_output.get("stderr", "")
if exit_code != 0 or stderr:
# Send to Slack
webhook_url = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
requests.post(webhook_url, json={
"text": f"⚠️ Claude Code error:\n```{stderr}```"
})
print(json.dumps({"status": "processed"}))
Notification Hooks
Notification hooks respond to system events rather than tool usage:
{
"hooks": {
"Notification": [
{
"event": "task_complete",
"command": "python .claude/hooks/notify-complete.py"
},
{
"event": "error",
"command": "python .claude/hooks/notify-error.py"
}
]
}
}
Event Types
| Event | Description |
|---|---|
| Claude finished a task |
| An error occurred |
| Approaching context limit |
| Cost exceeded threshold |
Task Completion Notification
# .claude/hooks/notify-complete.py
import json
import sys
import os
input_data = json.loads(sys.stdin.read())
# macOS notification
os.system(f"""osascript -e 'display notification "Task complete" with title "Claude Code"'""")
# Or desktop-notifier for cross-platform
# from desktop_notifier import DesktopNotifier
# notifier = DesktopNotifier()
# await notifier.send(title="Claude Code", message="Task complete")
print(json.dumps({"status": "notified"}))
Session Hooks
SessionStart
Runs when Claude Code starts:
{
"hooks": {
"SessionStart": [
{
"command": "python .claude/hooks/session-start.py"
}
]
}
}
Use cases:
- →Set environment variables
- →Check prerequisites
- →Load session context
# .claude/hooks/session-start.py
import json
import sys
import os
# Check required tools
required = ["node", "npm", "git"]
missing = [cmd for cmd in required if os.system(f"which {cmd} > /dev/null") != 0]
if missing:
result = {
"warning": f"Missing tools: {', '.join(missing)}"
}
else:
result = {"status": "ready"}
print(json.dumps(result))
SessionStop
Runs when Claude Code exits:
# .claude/hooks/session-stop.py
import json
import sys
input_data = json.loads(sys.stdin.read())
# Generate session summary
summary = {
"session_id": input_data.get("session_id"),
"duration": input_data.get("duration_seconds"),
"tools_used": input_data.get("tool_count"),
"tokens_used": input_data.get("token_count")
}
with open(".claude/logs/sessions.jsonl", "a") as f:
f.write(json.dumps(summary) + "\n")
print(json.dumps({"status": "logged"}))
Matcher Patterns
Matchers determine which tools trigger a hook:
| Pattern | Matches |
|---|---|
| All Bash commands |
| npm commands only |
| git commands only |
| All file writes |
| Writes to src folder |
| All file edits |
| All tools |
| Multiple tools |
Multiple Matchers
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash(rm:*),Bash(git:push --force*)",
"command": "python .claude/hooks/confirm-dangerous.py"
}
]
}
}
Real-World Hook Recipes
Recipe 1: Command Allowlist
Only permit specific commands:
# .claude/hooks/allowlist.py
import json
import sys
input_data = json.loads(sys.stdin.read())
command = input_data.get("tool_input", {}).get("command", "")
ALLOWED_PREFIXES = [
"npm ",
"yarn ",
"git ",
"node ",
"python ",
"pytest ",
"echo ",
"cat ",
"ls ",
"pwd"
]
allowed = any(command.startswith(prefix) for prefix in ALLOWED_PREFIXES)
if allowed:
print(json.dumps({"decision": "proceed"}))
else:
print(json.dumps({
"decision": "block",
"reason": f"Command not in allowlist: {command.split()[0]}"
}))
Recipe 2: Audit Log
Create a detailed audit trail:
# .claude/hooks/audit-log.py
import json
import sys
import os
from datetime import datetime
input_data = json.loads(sys.stdin.read())
audit_entry = {
"timestamp": datetime.now().isoformat(),
"user": os.environ.get("USER"),
"session": input_data.get("session_id"),
"tool": input_data.get("tool_name"),
"action": input_data.get("tool_input"),
"hook_type": input_data.get("hook_type")
}
log_file = os.path.expanduser("~/.claude/logs/audit.jsonl")
os.makedirs(os.path.dirname(log_file), exist_ok=True)
with open(log_file, "a") as f:
f.write(json.dumps(audit_entry) + "\n")
print(json.dumps({"decision": "proceed"}))
Recipe 3: Slack Notifications
Alert team on specific events:
# .claude/hooks/slack-notify.py
import json
import sys
import os
import requests
input_data = json.loads(sys.stdin.read())
tool_name = input_data.get("tool_name")
tool_input = input_data.get("tool_input", {})
WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL")
# Notify on deployments
if tool_name == "Bash":
command = tool_input.get("command", "")
if "deploy" in command or "kubectl apply" in command:
requests.post(WEBHOOK_URL, json={
"text": f"🚀 Deployment initiated:\n```{command}```"
})
print(json.dumps({"decision": "proceed"}))
Recipe 4: Git Safety
Prevent dangerous git operations:
# .claude/hooks/git-safety.py
import json
import sys
input_data = json.loads(sys.stdin.read())
command = input_data.get("tool_input", {}).get("command", "")
if not command.startswith("git "):
print(json.dumps({"decision": "proceed"}))
sys.exit(0)
BLOCKED_PATTERNS = [
"git push --force",
"git push -f",
"git reset --hard",
"git clean -fd",
"git checkout -- .", # discard all changes
]
PROTECTED_BRANCHES = ["main", "master", "production"]
for pattern in BLOCKED_PATTERNS:
if pattern in command:
print(json.dumps({
"decision": "block",
"reason": f"Dangerous git operation: {pattern}"
}))
sys.exit(0)
# Check protected branches
for branch in PROTECTED_BRANCHES:
if f"push origin {branch}" in command or f"push -f origin {branch}" in command:
print(json.dumps({
"decision": "block",
"reason": f"Direct push to protected branch: {branch}"
}))
sys.exit(0)
print(json.dumps({"decision": "proceed"}))
Recipe 5: Test Verification
Run tests before allowing merges:
# .claude/hooks/require-tests.py
import json
import sys
import subprocess
input_data = json.loads(sys.stdin.read())
command = input_data.get("tool_input", {}).get("command", "")
# Check if this is a merge operation
if "git merge" in command or "git push" in command:
# Run tests first
result = subprocess.run(
["npm", "test"],
capture_output=True,
text=True
)
if result.returncode != 0:
print(json.dumps({
"decision": "block",
"reason": f"Tests must pass before merge/push:\n{result.stderr}"
}))
sys.exit(0)
print(json.dumps({"decision": "proceed"}))
Hook Debugging
Enable Verbose Mode
claude --verbose
Shows hook execution:
[HOOK] PreToolUse: Running validate-bash.py
[HOOK] Input: {"tool_name": "Bash", "tool_input": {"command": "npm test"}}
[HOOK] Output: {"decision": "proceed"}
[HOOK] Duration: 45ms
Test Hooks Manually
echo '{"tool_name": "Bash", "tool_input": {"command": "rm -rf /"}}' | python .claude/hooks/validate-bash.py
Common Issues
Hook not running:
- →Check file path in settings
- →Verify matcher pattern matches tool
- →Ensure script is executable
Hook timing out:
- →Increase
in configtimeout - →Optimize script performance
- →Use async for slow operations
JSON parse errors:
- →Validate JSON output from your script
- →Check for extra print statements
- →Ensure clean stdout/stderr separation
Hooks + Other Features
Hooks + Custom Commands
Commands can trigger hooks:
<!-- .claude/commands/safe-deploy.md -->
Deploy to production. Hooks will:
- Validate deployment criteria
- Log the deployment
- Notify the team
The command's Bash calls trigger your hooks automatically.
Hooks + Permissions
Hooks add another layer to permissions:
Request: git push --force origin main
1. Permission check: Is Bash(git:*) allowed? → YES
2. PreToolUse hook: Is this safe? → BLOCKED (protected branch)
See Claude Code Permissions: Deny, Allow & Ask Modes Explained.
Hooks + MCP
MCP tool calls trigger hooks too:
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__github__*",
"command": "python .claude/hooks/validate-github.py"
}
]
}
}
See Model Context Protocol (MCP) for Claude Code: Complete Guide.
Performance Considerations
- →Keep hooks fast: Target <100ms execution
- →Async for slow operations: Don't block on network calls in PreToolUse
- →Cache when possible: Store computed values between calls
- →Use specific matchers:
is faster thanBash(git:*)* - →Batch logging: Write logs in batches, not per-event
Key Takeaways
- →
PreToolUse for validation: Block or modify dangerous actions before they run.
- →
PostToolUse for observation: Log, notify, and analyze without blocking.
- →
Matchers for precision: Target specific tools to minimize overhead.
- →
JSON in, JSON out: Hooks communicate via simple JSON protocol.
- →
Layer with permissions: Hooks complement the permission system.
Build Autonomous AI Agents
Hooks are one piece of building reliable autonomous agents. Learn the broader principles of agent orchestration.
In our Module 6 — Autonomous Agents, you'll learn:
- →Agent architecture patterns
- →Orchestration and control loops
- →Error handling and recovery
- →Building reliable AI workflows
Module 6 — AI Agents & ReAct
Create autonomous agents that reason and take actions.