Claude Code Hooks: Automate Your Development Workflow
By Dorian Laurenceau
š Last reviewed: April 24, 2026. Updated with April 2026 findings and community feedback.
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.
<!-- manual-insight -->
What hooks are actually used for (outside of tutorials)
Tutorial examples make hooks look abstract: "intercept actions," "inject logic." In practice, teams on r/ClaudeAI keep using them for a short, boring list of things ā and that's a good sign, because boring tooling is tooling that works.
The real-world hook use cases that keep showing up:
- āPreToolUse guardrails on destructive commands. A 10-line bash hook that greps for
rm -rf,DROP TABLE,git push --forceand exits non-zero has saved more production incidents than any amount of careful prompting. This is what Anthropic's own hooks documentation leans into, and it's the right starting point. - āPostToolUse auto-formatting. Run
prettier,ruff, orgofmtafter any Write/Edit. No more nagging Claude about style ā the hook enforces it silently. A one-time 20-minute setup. - āSessionStart context injection. Dynamically pull the latest
git log --oneline -10or current ticket metadata into the session so Claude starts with fresh situational awareness. This is the cheapest way to keep an agent grounded in a fast-moving repo.
What hooks are not for, despite what clever blog posts suggest: complex routing logic, multi-step pipelines, or anything stateful. Those belong in scripts the agent calls explicitly. Hooks should be deterministic, fast (under 500ms), and idempotent. When you catch yourself writing a hook with retry logic, you've outgrown hooks.
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 |
|---|---|---|
PreToolUse | Before any tool executes | Validate, block, or modify |
PostToolUse | After a tool completes | Log, notify, transform output |
Notification | On specific events | Alert on errors, completions |
SessionStart | When a session begins | Initialize environment |
SessionStop | 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 |
|---|---|
matcher | Tool pattern to match (Bash, Write, * for all) |
command | Script to execute |
timeout | Max execution time (ms) |
onError | Action on failure: proceed or block |
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 |
|---|---|
proceed | Allow the tool to run |
block | Deny execution |
modify | 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 |
|---|---|
task_complete | Claude finished a task |
error | An error occurred |
context_limit | Approaching context limit |
cost_threshold | 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 |
|---|---|
Bash | All Bash commands |
Bash(npm:*) | npm commands only |
Bash(git:*) | git commands only |
Write | All file writes |
Write(src/**) | Writes to src folder |
Edit | All file edits |
* | All tools |
Read,Write,Edit | 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
timeoutin config - ā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:
Bash(git:*)is faster than* - āBatch logging: Write logs in batches, not per-event
Quick Summary
- ā
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
Learn AI, from prompts to agents
Module 6 ā AI Agents & ReAct
Create autonomous agents that reason and take actions.
Dorian Laurenceau
Full-Stack Developer & Learning DesignerFull-stack web developer and learning designer. I spent 4 years as a freelance full-stack developer and 4 years teaching React, JavaScript, HTML/CSS and WordPress to adult learners. Today I design learning paths in web development and AI, grounded in learning science. I founded learn-prompting.fr to make AI practical and accessible, and built the Bluff app to gamify political transparency.
One practical AI workflow in your inbox
Methods, resources and new exercises. Free, unsubscribe at any time.
āRelated Articles
FAQ
What are Claude Code hooks?+
Hooks are scripts that run at specific points in Claude Code's workflow-before actions (PreToolUse), after actions (PostToolUse), or on events (Notification). They add custom logic like validation or logging.
How do I create a hook in Claude Code?+
Define hooks in .claude/settings.json under the 'hooks' key. Specify the hook type, matcher pattern, and command to execute. Hooks can be shell scripts or any executable.
Can hooks block Claude's actions?+
Yes. PreToolUse hooks can return a non-zero exit code to block an action, or output JSON to modify Claude's behavior. This enables validation gates before destructive operations.
What's the difference between hooks and permissions?+
Permissions control what Claude CAN do (allow/deny). Hooks run custom code WHEN Claude does something. Use permissions for access control, hooks for custom logic and automation.