Hooks System
Build event subscribers for PreToolUse, PostToolUse, and other lifecycle events.
What You’ll Learn
Claude Code has built-in permissions, but organizations need more. A security team might want to block all writes to production config files. A compliance team might need an audit log of every command executed. A platform team might want to auto-tag files modified by AI. The hooks system makes all of this possible.
By the end, you’ll know:
- What hooks are and when they fire
- The four hook event types and their purposes
- How to configure hooks in
settings.json - How matcher patterns filter which tools trigger a hook
- The input/output contract (JSON stdin, exit codes)
- How to build security gates and audit systems
The Problem
The built-in permission system is binary: allow or deny. But real-world policies are nuanced:
- “Allow Bash, but block any command containing
rm -rf” - “Allow file edits, but not to files in
config/production/” - “Allow everything, but log every action to an audit file”
- “Allow git commits, but require a ticket number in the message”
You can’t express these rules through the standard permission UI. You need programmable policy enforcement — code that runs at specific points in the tool execution lifecycle and can inspect, modify, or block actions.
Without hooks:
AI calls tool → Permission check (allow/deny) → Execute
With hooks:
AI calls tool → Permission check → PreToolUse hook
│
inspect, validate,
block if needed
│
▼
Execute tool
│
▼
PostToolUse hook
│
log, audit,
side effects
How It Works
Hook Types
Claude Code fires hooks at four lifecycle events:
┌──────────────────────────────────────────────────┐
│ Hook Lifecycle │
│ │
│ ┌─────────────┐ │
│ │ Notification │ Fires when AI produces a │
│ │ Hook │ notification (status messages) │
│ └─────────────┘ │
│ │
│ ┌─────────────┐ │
│ │ PreToolUse │ Fires BEFORE a tool executes │
│ │ Hook │ Can BLOCK the tool call │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Tool │ The actual tool execution │
│ │ Execution │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ PostToolUse │ Fires AFTER a tool executes │
│ │ Hook │ For logging and side effects │
│ └─────────────┘ │
│ │
│ ┌─────────────┐ │
│ │ Stop │ Fires when the agent loop │
│ │ Hook │ is about to end a turn │
│ └─────────────┘ │
│ │
└──────────────────────────────────────────────────┘
| Hook | When | Can Block? | Common Use |
|---|---|---|---|
| PreToolUse | Before tool runs | Yes | Security gates, validation |
| PostToolUse | After tool runs | No | Logging, auditing, side effects |
| Notification | On status messages | No | Custom notifications |
| Stop | Before turn ends | Yes | Final validation, cleanup |
Configuration
Hooks are defined in .claude/settings.json (project-level) or ~/.claude/settings.json (user-level):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "/path/to/validate-command.sh"
},
{
"matcher": "Edit",
"command": "/path/to/check-file-path.sh"
}
],
"PostToolUse": [
{
"matcher": "*",
"command": "/path/to/audit-log.sh"
}
]
}
}
Each hook entry has:
- matcher — Which tool triggers this hook. Use a tool name like
"Bash"or"*"for all tools. - command — The shell command to run. This is your hook script.
Matcher Patterns
The matcher determines which tool calls trigger the hook:
Matcher Matches
──────── ────────────────────────────
"Bash" Only Bash tool calls
"Edit" Only Edit tool calls
"Read" Only Read tool calls
"mcp__*" All MCP tool calls
"*" Every tool call
Multiple hooks can match the same tool call. They execute in order. If any PreToolUse hook blocks, the tool call is stopped.
The Input/Output Contract
When a hook fires, Claude Code passes context as JSON on stdin and reads the decision from the exit code:
Input (stdin):
{
"hook_type": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/build-cache"
},
"session_id": "abc-123",
"message_id": "msg-456"
}
Output (exit code):
| Exit Code | Meaning | Effect |
|---|---|---|
| 0 | Allow | Tool call proceeds normally |
| 2 | Block | Tool call is blocked, AI is told why |
| Other | Error | Treated as allow (fail-open) |
Stdout (optional, for exit code 2):
When blocking, the hook can write a reason to stdout. This reason is shown to the AI so it can adjust:
BLOCKED: Cannot delete directories outside of project root.
The AI receives this as a tool error and can try a different approach.
Execution Flow (Detailed)
Here’s the complete flow when a PreToolUse hook is configured:
AI wants to run: Bash("npm install express")
│
▼
┌──────────────────┐
│ Permission Check │ Standard allow/deny
└────────┬─────────┘
│ (allowed)
▼
┌──────────────────┐
│ Matcher Check │ Does "Bash" match any hooks?
└────────┬─────────┘
│ (yes, matched)
▼
┌──────────────────┐
│ Run Hook Script │ Spawn process, pipe JSON to stdin
│ │
│ stdin: { │
│ "tool_name": │
│ "Bash", │
│ "tool_input": { │
│ "command": │
│ "npm install" │
│ } │
│ } │
└────────┬─────────┘
│
┌─────┴─────┐
│ │
exit 0 exit 2
(allow) (block)
│ │
▼ ▼
Execute Return error
tool to AI:
"BLOCKED: ..."
Key Insight
Hooks are the extension point for organizational policies. They let you enforce rules that the AI must follow without modifying the AI’s instructions.
This distinction is important. You could write “never delete production files” in CLAUDE.md, and the AI would usually comply. But instructions can be overridden, forgotten after compaction, or ignored in edge cases. A PreToolUse hook that checks file paths is deterministic — it cannot be bypassed, no matter what the AI decides.
Think of it this way:
| Layer | Mechanism | Guarantee |
|---|---|---|
| CLAUDE.md instructions | AI reads and follows | Best-effort (AI judgment) |
| Permission system | Allow/deny per tool | Binary, no nuance |
| Hooks | Custom code per call | Deterministic, programmable |
Hooks fill the gap between “ask the AI nicely” and “hard-code it into Claude Code.” They are the policy enforcement layer.
Hands-On Example
Example 1: Block Dangerous Commands
A PreToolUse hook that prevents destructive shell commands:
#!/bin/bash
# hooks/block-dangerous.sh
# PreToolUse hook for Bash tool
# Read JSON input from stdin
INPUT=$(cat)
# Extract the command
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Define blocked patterns
BLOCKED_PATTERNS=(
"rm -rf /"
"rm -rf ~"
"DROP DATABASE"
"DROP TABLE"
"mkfs"
"> /dev/sda"
"chmod -R 777 /"
)
# Check each pattern
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qi "$pattern"; then
echo "BLOCKED: Command contains dangerous pattern: $pattern"
exit 2
fi
done
# Allow the command
exit 0
Example 2: Audit Log
A PostToolUse hook that logs every tool execution:
#!/bin/bash
# hooks/audit-log.sh
# PostToolUse hook for all tools
INPUT=$(cat)
# Extract fields
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
SESSION=$(echo "$INPUT" | jq -r '.session_id')
# Build log entry
LOG_ENTRY=$(jq -n \
--arg ts "$TIMESTAMP" \
--arg tool "$TOOL" \
--arg session "$SESSION" \
--argjson input "$(echo "$INPUT" | jq '.tool_input')" \
'{timestamp: $ts, tool: $tool, session: $session, input: $input}')
# Append to audit log
echo "$LOG_ENTRY" >> ~/.claude/audit.jsonl
# PostToolUse hooks always exit 0 (can't block after execution)
exit 0
This produces a JSONL audit trail:
{"timestamp":"2026-03-14T10:00:01Z","tool":"Bash","session":"abc","input":{"command":"npm test"}}
{"timestamp":"2026-03-14T10:00:03Z","tool":"Edit","session":"abc","input":{"file_path":"src/auth.ts",...}}
{"timestamp":"2026-03-14T10:00:05Z","tool":"Read","session":"abc","input":{"file_path":"package.json"}}
Example 3: Protect Production Files
A PreToolUse hook that prevents edits to production configuration:
#!/bin/bash
# hooks/protect-prod-config.sh
# PreToolUse hook for Edit and Write tools
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Define protected paths
PROTECTED=(
"config/production"
".env.production"
"docker-compose.prod"
"k8s/production"
)
for protected in "${PROTECTED[@]}"; do
if echo "$FILE_PATH" | grep -q "$protected"; then
echo "BLOCKED: Cannot modify production file: $FILE_PATH. Use a staging config instead."
exit 2
fi
done
exit 0
Putting It All Together
Here’s a complete settings.json with all three hooks:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "/project/.claude/hooks/block-dangerous.sh"
},
{
"matcher": "Edit",
"command": "/project/.claude/hooks/protect-prod-config.sh"
},
{
"matcher": "Write",
"command": "/project/.claude/hooks/protect-prod-config.sh"
}
],
"PostToolUse": [
{
"matcher": "*",
"command": "/project/.claude/hooks/audit-log.sh"
}
]
}
}
With this configuration:
- Every Bash command is checked against a blocklist before execution
- Every file edit is checked against protected paths before execution
- Every tool execution (all tools) is logged to an audit file after execution
The AI is unaware of the hooks. It calls tools normally. If a hook blocks, the AI receives an error message and adapts — typically by choosing a safer alternative.
What Changed
| Without Hooks | With Hooks |
|---|---|
| Policies are instructions (best-effort) | Policies are code (deterministic) |
| No audit trail | Every action logged |
| Protection relies on AI compliance | Protection enforced at runtime |
| Same rules for all projects | Per-project hook configuration |
| No way to integrate with external systems | Hooks can call any external API |
| Security is trust-based | Security is verify-then-trust |
Next Session
This completes Module 3: Real Architecture. You now understand the three pillars that make Claude Code extensible: the control protocol (how CLI and SDK communicate), MCP (how external tools plug in), and hooks (how policies are enforced).
Session 16 begins Module 4: Configuration & Customization with Session Storage — how Claude Code persists state across sessions, enabling continuity and memory.