Claude Code Hooks: Complete Tutorial for Automating Your Dev Workflow
Learn how to use Claude Code hooks to auto-lint files, enforce policies, run git workflows, and build deterministic guardrails — with working examples.
Claude Code Hooks: Automate Your Dev Workflow with Deterministic Guardrails
Most Claude Code users discover hooks by accident — or never at all. That's a mistake. Hooks are the feature that separates casual Claude Code users from power users who have it deeply wired into their development process.
If you've ever wished Claude would automatically run your linter after editing a file, refuse to touch certain directories, or log every tool call for audit purposes — hooks are exactly what you need.
This tutorial walks you through Claude Code hooks from zero to a working multi-hook setup, with real configuration examples you can drop into your project today.
What Are Claude Code Hooks?
Hooks are user-defined commands, scripts, or HTTP endpoints that Claude Code calls automatically at specific points in its execution lifecycle.
Think of hooks like Git hooks (pre-commit, post-merge) — except instead of reacting to Git events, you're reacting to Claude's actions: before it runs a tool, after it edits a file, when a session starts, when a task completes.
Here's why this matters: prompt instructions have a non-zero failure rate. You can write "never delete files without asking" in your CLAUDE.md, and Claude will mostly respect it — but "mostly" isn't good enough when you're working with production code, financial data, or anything where one wrong action has real consequences.
Hooks give you 100% enforcement. The shell script either blocks the action or it doesn't. No hallucination, no misinterpretation, no exceptions.
How Hooks Work: The Core Model
Every hook has three parts:
PreToolUse, PostToolUse, SessionStart)"Bash", "Write", "*")Claude Code passes a JSON payload to your hook via stdin. Your hook reads it, does its thing, and exits. The exit code tells Claude what to do next:
| Exit Code | Behavior |
|---|---|
0 | Success — continue normally |
2 | Blocking — abort the action |
| Any other | Non-blocking warning — log but continue |
If your hook exits with 2, Claude stops the tool call completely. This is how you build hard guardrails.
Setting Up Your First Hook
Hooks live in settings.json. There are three locations depending on scope:
| File | Scope |
|---|---|
~/.claude/settings.json | Applies to all your Claude Code sessions |
.claude/settings.json | Project-wide (commit to git, shared with team) |
.claude/settings.local.json | Local overrides (gitignore this) |
Here's the base structure:
json{
"hooks": {
"EventName": [
{
"matcher": "ToolName",
"hooks": [
{
"type": "command",
"command": "path/to/your/script.sh"
}
]
}
]
}
}Let's build three practical hooks from scratch.
Hook #1: Auto-Lint After File Edits
This is the most common hook. Every time Claude edits a JavaScript or TypeScript file, run ESLint and Prettier automatically.
Create.claude/hooks/post-edit-lint.sh:
bash#!/bin/bash
# Read the JSON payload from stdin
INPUT=$(cat)
# Extract the file path that was edited
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ -z "$FILE" ]; then
exit 0
fi
# Only lint JS/TS files
if [[ "$FILE" =~ \.(js|jsx|ts|tsx)$ ]]; then
echo "Running ESLint on $FILE..."
npx eslint --fix "$FILE" 2>&1
npx prettier --write "$FILE" 2>&1
echo "Lint complete."
fi
exit 0Make it executable:
bashchmod +x .claude/hooks/post-edit-lint.sh.claude/settings.json:
json{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/post-edit-lint.sh"
}
]
},
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/post-edit-lint.sh"
}
]
}
]
}
}Now every time Claude writes or edits a file, it gets linted automatically. You stop thinking about it.
Hook #2: Block Destructive Bash Commands
This hook fires before Claude runs any Bash command and blocks ones that look dangerous.
Create.claude/hooks/block-dangerous-commands.sh:
bash#!/bin/bash
INPUT=$(cat)
# Extract the command Claude wants to run
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [ -z "$COMMAND" ]; then
exit 0
fi
# Block patterns
DANGEROUS_PATTERNS=(
"rm -rf /"
"rm -rf ~"
"DROP TABLE"
"DROP DATABASE"
"> /dev/sda"
"dd if=/dev/zero"
"chmod -R 777 /"
)
for PATTERN in "${DANGEROUS_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qi "$PATTERN"; then
echo "BLOCKED: Detected dangerous command pattern: '$PATTERN'" >&2
echo "Command was: $COMMAND" >&2
exit 2 # Exit code 2 = blocking
fi
done
exit 0json{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-dangerous-commands.sh"
}
]
}
]
}
}Exit code 2 causes Claude to abort the tool call entirely and tell you what happened. This is your last line of defense.
Hook #3: Git Auto-Commit Log After Session Ends
Track what Claude changed each session by automatically writing a summary to a log file.
Create.claude/hooks/session-end-log.sh:
bash#!/bin/bash
INPUT=$(cat)
LOG_FILE=".claude/session-log.md"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
# Get git diff summary
DIFF_SUMMARY=$(git diff --stat HEAD 2>/dev/null || echo "No git changes detected")
cat >> "$LOG_FILE" << EOF
## Session ended: $TIMESTAMP
### Git changes:
\`\`\`
$DIFF_SUMMARY
\`\`\`
---
EOF
echo "Session logged to $LOG_FILE"
exit 0json{
"hooks": {
"SessionEnd": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/session-end-log.sh"
}
]
}
]
}
}Now every time a Claude Code session ends, a structured diff summary is appended to .claude/session-log.md. Useful for auditing what changed across sessions.
Complete Multi-Hook Settings File
Here's a full .claude/settings.json combining all three hooks:
json{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-dangerous-commands.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/post-edit-lint.sh"
}
]
},
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/post-edit-lint.sh"
}
]
}
],
"SessionEnd": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/session-end-log.sh"
}
]
}
]
}
}Advanced: HTTP Hooks for Team Logging
Instead of writing to a local file, send events to a central endpoint. Useful when your team wants a shared audit trail.
json{
"hooks": {
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "http",
"url": "https://your-internal-api.com/claude-audit",
"method": "POST"
}
]
}
]
}
}Claude Code will POST the full event JSON (tool name, inputs, outputs, session ID) to your endpoint. Your API can store it, alert on anomalies, or feed a dashboard.
All Claude Code Hook Events
Here's a reference of the key events available:
| Event | When It Fires |
|---|---|
PreToolUse | Before any tool runs |
PostToolUse | After a tool succeeds |
PostToolUseFailure | After a tool fails |
SessionStart | When a Claude Code session begins |
SessionEnd | When a session ends |
UserPromptSubmit | Before user input is processed |
Stop | When Claude decides to stop |
TaskCompleted | When a task is marked done |
SubagentStart | When a subagent is spawned |
SubagentStop | When a subagent finishes |
WorktreeCreate | When a git worktree is created |
PreCompact | Before context compaction |
PostCompact | After context compaction |
Use "*" as the matcher to catch all tools for a given event.
Common Mistakes to Avoid
1. Forgetting to make scripts executable. Every hook script needschmod +x. If Claude reports a hook error, this is usually why.
2. Using exit code 1 instead of 2 for blocking. Exit code 1 is a non-blocking warning — Claude logs it but continues. Only exit code 2 blocks the action.
3. Heavy hooks on every tool call. If your PostToolUse hook runs a full test suite on every file write, sessions become sluggish. Be selective with your matchers.
4. Not testing hooks before relying on them. Run your hook scripts manually with a sample JSON payload before trusting them:
bashecho '{"tool_input": {"command": "rm -rf /"}}' | bash .claude/hooks/block-dangerous-commands.sh
echo "Exit: $?".claude/settings.json.
Key Takeaways
- Hooks fire at lifecycle events (PreToolUse, PostToolUse, SessionEnd, etc.) and run shell commands, HTTP calls, or subagents
- Exit code
2blocks the action; exit code0continues; anything else is a logged warning - Store project hooks in
.claude/settings.json(versioned) and personal hooks in~/.claude/settings.json - Use
PreToolUse+Bashmatcher for safety guardrails;PostToolUse+Write/Editfor post-processing - Hooks give you deterministic enforcement — unlike CLAUDE.md instructions, they cannot be ignored or misinterpreted
Next Steps
If you're using Claude Code seriously, hooks are non-negotiable once your projects grow past solo experiments. They're the difference between "Claude Code does what I hope" and "Claude Code does what I know."
Want to go deeper on Claude Code configuration? Read our guide on writing effective CLAUDE.md files — the companion to hooks for encoding project context and rules.
Preparing for the Claude Certified Architect (CCA) exam? Hooks, tool orchestration, and lifecycle management are core topics. Our CCA exam guide covers the full syllabus and our CCA practice test bank has 200+ questions with detailed explanations.
Ready to Start Practicing?
300+ scenario-based practice questions covering all 5 CCA domains. Detailed explanations for every answer.
Free CCA Study Kit
Get domain cheat sheets, anti-pattern flashcards, and weekly exam tips. No spam, unsubscribe anytime.