A complete breakdown of Claude Code Hooks — five event types, exit code control, and four real-world configurations to make AI actions auditable, interceptable, and automated.
Every time Claude Code reads a file, writes code, or runs a command, an event system is running in the background. Hooks are the interface to plug into that system — you can inject your own logic at any point: automatically run linters, log operations, intercept dangerous actions, or trigger any shell command.
This article covers the complete Hooks mechanism, configuration, and real-world usage.
Hooks are shell commands configured in settings.json that Claude Code runs automatically when specific events occur.
The closest analogy: git hooks. Git can trigger scripts before and after commit, push, and other operations. Claude Code Hooks work exactly the same way — the difference is that the trigger points are the AI's tool calls.
Why does this matter?
The more capable Claude Code becomes, the more you need a deterministic control layer. Hooks provide:
- Execution guarantees that don't depend on prompts (Claude might ignore instructions, but hooks always run)
- Auditable operation logs
- Automated quality checks
| Type | Trigger | Typical Use |
|---|---|---|
PreToolUse |
Before a tool call | Block dangerous operations, log intent |
PostToolUse |
After a tool call | Auto-lint, run tests |
PreCompact |
Before context compaction | Save current state snapshot |
Notification |
When Claude sends a notification | Desktop alerts, Slack messages |
Stop |
When Claude finishes responding | Summarize logs, trigger follow-up workflows |
PreToolUse and PostToolUse are the most commonly used — they let you intercept and post-process tool calls.
Hooks go in ~/.claude/settings.json (global) or .claude/settings.json at the project root (project-level):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npm run lint --silent"
}
]
}
]
}
}
Three core fields:
matcher: A regex matching tool names — determines which tool calls trigger this hook. "Write|Edit" fires after writing or editing files. Leave empty or use ".*" to match all tools.type: Currently only "command".command: Any shell command.When writing matchers, you need to know the tool names:
| Tool Name | Action |
|---|---|
Write |
Write a new file |
Edit |
Edit a file |
Bash |
Execute a shell command |
Read |
Read a file |
Glob |
File search |
Grep |
Content search |
TodoWrite |
Update task list |
During execution, data is passed as JSON via stdin:
{
"tool_name": "Write",
"tool_input": {
"file_path": "/path/to/file.rb",
"content": "..."
},
"tool_response": "..."
}
PreToolUse receives tool_input (the arguments before the call). PostToolUse also receives tool_response (the tool's return value).
Use this data to write hooks with conditional logic:
#!/bin/bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path')
# Only run rubocop on .rb files
if [[ "$file" == *.rb ]]; then
rubocop "$file" --autocorrect-all --no-color
fi
A hook's exit code controls Claude Code's subsequent behavior:
| Exit Code | Meaning |
|---|---|
0 |
Success, continue execution |
2 |
Block: cancel the current tool call, feed stderr back to Claude |
| Other non-zero | Log the error, but continue execution |
Exit code 2 is the most powerful — it lets you write interception logic in PreToolUse to stop Claude from performing an operation and explain why.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'file=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r .file_path 2>/dev/null); [[ \"$file\" == *.rb ]] && rubocop -A \"$file\" --no-color -q || true'"
}
]
}
]
}
}
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash -c 'cmd=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r .command); if echo \"$cmd\" | grep -qE \"rm.*/(migrations|seeds)\"; then echo \"Deleting migrations or seeds directories is not allowed\" >&2; exit 2; fi'"
}
]
}
]
}
}
When Claude tries to run rm -rf db/migrations/, the hook returns exit code 2, Claude receives the error message, and the operation is cancelled.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|Bash",
"hooks": [
{
"type": "command",
"command": "echo \"$(date '+%Y-%m-%d %H:%M:%S') $CLAUDE_TOOL_NAME: $(echo $CLAUDE_TOOL_INPUT | jq -c .)\" >> ~/.claude/audit.log"
}
]
}
]
}
}
Every file write or command execution is logged to ~/.claude/audit.log for easy tracing.
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude has finished\" with title \"Claude Code\"'"
}
]
}
]
}
}
Get notified automatically when a long task finishes — no need to stare at the terminal. (macOS uses osascript, Linux uses notify-send)
Hooks can go in two places:
Global (~/.claude/settings.json): Rules that apply across all projects — logging, notifications.
Project-level (.claude/settings.json): Project-specific checks — rubocop for one project, eslint for another. Project-level and global hooks merge and both run — they don't override each other.
For team projects, commit the project-level settings.json to git so everyone runs the same hooks.
Steps to troubleshoot when a hook isn't firing:
write is not the same as Writeecho "hook triggered" >> /tmp/hook.log to confirm the trigger fires, then layer in complex logicThe core value of Hooks is connecting unpredictable AI behavior with deterministic engineering standards. Claude might forget to run tests, but a PostToolUse hook won't. Claude might accidentally delete files, but a PreToolUse interceptor won't let it through.
Start with one hook: auto-lint after writing files. Once that's running smoothly, add more.