development
Claude Code Hooks — The Complete Visual Reference
Claude Code is an engine. A very good engine. But it’s running a factory tune — safe, conservative, doesn’t know your track. Hooks are the ECU remap. Twenty-six interception points where you can observe what the agent is doing, modify its inputs before it acts, or block it entirely. This is the complete map.
I’ve been building Coherence — a framework for keeping AI-generated code aligned with project specs — and hooks turned out to be a foundational piece. They’re how you make Claude Code respect boundaries it doesn’t know about. Not through bigger prompts. Through actual enforcement.
This isn’t a rewrite of the docs. It’s a guide written by someone who’s been in the engine bay, with real examples from production code. If you’ve used Claude Code but haven’t wired up hooks yet, this is your map.
What Are Hooks?
Hooks are user-defined callbacks that fire at specific lifecycle points during a Claude Code session. Think of them as event listeners for everything the agent does — from starting a session to writing a file to spawning a subagent.
You configure them in one of three places:
.claude/settings.json— project-level, commit to git, shared with team~/.claude/settings.json— global, applies to all projects.claude/settings.local.json— project-level, gitignored, personal overrides
Four handler types:
command— runs a shell command. JSON on stdin, exit code + JSON on stdout.http— POSTs JSON to a URL. Response body = JSON output.prompt— sends a prompt to Claude for single-turn evaluation.agent— spawns a subagent with tools for deep verification.
The exit code protocol is simple and critical:
- Exit 0 — allow (tool proceeds normally)
- Exit 2 — block (
PreToolUseonly — prevents the tool from executing) - Exit 1 — non-blocking error (logged, but doesn’t stop anything)
Data flows in via stdin as JSON. Decisions flow out via stdout JSON or exit codes. That’s the contract.
The Lifecycle
This is the centrepiece. Every hook event in the order it can fire, grouped by context. Click any event to see what it does and what it matches on.
Hook Lifecycle
Click any event to see details. Colour = category.
The Complete Hook Reference
All 26 events. Filter by category, expand any row for the full detail — matcher, input fields, decision control, and a copy-paste example config.
1 SessionStart Session Observe Only Session begins or resumes
startup | resume | compact | clear {
"hooks": {
"SessionStart": [{
"matcher": "startup",
"hooks": [{
"type": "command",
"command": "echo \"Session started at $(date)\" >> ~/.claude/session.log"
}]
}]
}
} 2 InstructionsLoaded Session Observe Only CLAUDE.md or .claude/rules/*.md loaded into context
Filename of the loaded file {
"hooks": {
"InstructionsLoaded": [{
"matcher": "CLAUDE.md",
"hooks": [{
"type": "command",
"command": "echo \"Loaded: $0\" >> ~/.claude/instructions.log"
}]
}]
}
} 3 UserPromptSubmit Session Can Modify User submits a prompt, before Claude processes it
Not used {
"hooks": {
"UserPromptSubmit": [{
"hooks": [{
"type": "command",
"command": ".claude/hooks/prompt-logger.sh"
}]
}]
}
} 4 PreToolUse Tool Can Block Before a tool call executes
Tool name: Bash | Read | Write | Edit | MultiEdit | Grep | Glob | mcp__* {
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": ".claude/hooks/bash-firewall.sh"
}]
}]
}
} 5 PermissionRequest Tool Can Modify Permission dialog appears, user hasn't responded yet
Tool name (same as PreToolUse) {
"hooks": {
"PermissionRequest": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": ".claude/hooks/auto-approve-safe.sh"
}]
}]
}
} 6 PermissionDenied Tool Can Modify Auto-mode classifier denies a tool call
Tool name {
"hooks": {
"PermissionDenied": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "echo \"Denied: $(cat | jq -r .tool_name)\" >> .claude/denials.log"
}]
}]
}
} 7 PostToolUse Tool Can Modify After a tool call succeeds
Tool name (same as PreToolUse) {
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit|MultiEdit",
"hooks": [{
"type": "command",
"command": "npx prettier --write \"$CLAUDE_TOOL_INPUT_FILE_PATH\""
}]
}]
}
} 8 PostToolUseFailure Tool Observe Only After a tool call fails
Tool name (same as PreToolUse) {
"hooks": {
"PostToolUseFailure": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "echo \"Failed: $(cat | jq -r .tool_name)\" >> .claude/failures.log"
}]
}]
}
} 9 Notification Async Observe Only Claude Code sends a notification (e.g. task complete)
Notification type {
"hooks": {
"Notification": [{
"hooks": [{
"type": "command",
"command": "osascript -e 'display notification \"Claude done\" with title \"Claude Code\"'"
}]
}]
}
} 10 SubagentStart Agent Observe Only A subagent is spawned via Agent/Task tool
Agent details {
"hooks": {
"SubagentStart": [{
"hooks": [{
"type": "command",
"command": "echo \"Subagent spawned at $(date)\" >> .claude/agents.log"
}]
}]
}
} 11 SubagentStop Agent Can Modify A subagent finishes its work
Agent details {
"hooks": {
"SubagentStop": [{
"hooks": [{
"type": "command",
"command": ".claude/hooks/validate-agent-output.sh"
}]
}]
}
} 12 TaskCreated Agent Can Modify A task is created via TaskCreate tool
Task details {
"hooks": {
"TaskCreated": [{
"hooks": [{
"type": "command",
"command": "echo \"Task created: $(cat | jq -r .subject)\" >> .claude/tasks.log"
}]
}]
}
} 13 TaskCompleted Agent Can Modify A task is marked as completed
Task details {
"hooks": {
"TaskCompleted": [{
"hooks": [{
"type": "command",
"command": ".claude/hooks/validate-task-completion.sh"
}]
}]
}
} 14 Stop Session Can Modify Claude finishes responding
Fires for all stops (no matcher) {
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": ".claude/hooks/stop-gate.sh",
"statusMessage": "Checking stop conditions..."
}]
}]
}
} 15 StopFailure Session Observe Only Turn ends due to API error
Not used {
"hooks": {
"StopFailure": [{
"hooks": [{
"type": "command",
"command": "echo \"API error at $(date)\" >> .claude/errors.log"
}]
}]
}
} 16 TeammateIdle Agent Can Modify An agent team teammate is about to go idle
Teammate details {
"hooks": {
"TeammateIdle": [{
"hooks": [{
"type": "command",
"command": ".claude/hooks/rebalance-work.sh"
}]
}]
}
} 17 ConfigChange Environment Can Modify A configuration file changes during a session
Config details {
"hooks": {
"ConfigChange": [{
"hooks": [{
"type": "command",
"command": "echo \"Config changed: $(cat | jq -r .file_path)\" >> .claude/config.log"
}]
}]
}
} 18 CwdChanged Environment Can Modify Working directory changes (e.g. Claude runs cd)
Directory path {
"hooks": {
"CwdChanged": [{
"hooks": [{
"type": "command",
"command": "echo \"CWD changed to $(cat | jq -r .new_cwd)\" >> .claude/cwd.log"
}]
}]
}
} 19 FileChanged Environment Can Modify A watched file changes on disk
Matcher specifies which filenames to watch {
"hooks": {
"FileChanged": [{
"matcher": "package.json",
"hooks": [{
"type": "command",
"command": "npm install",
"statusMessage": "Installing dependencies..."
}]
}]
}
} 20 WorktreeCreate Environment Can Modify Worktree created via --worktree or isolation: "worktree"
Worktree details {
"hooks": {
"WorktreeCreate": [{
"hooks": [{
"type": "command",
"command": "npm install",
"statusMessage": "Setting up worktree dependencies..."
}]
}]
}
} 21 WorktreeRemove Environment Observe Only Worktree removed at session exit or subagent finish
Worktree details {
"hooks": {
"WorktreeRemove": [{
"hooks": [{
"type": "command",
"command": "echo \"Worktree removed: $(cat | jq -r .worktree_path)\" >> .claude/worktrees.log"
}]
}]
}
} 22 PreCompact Maintenance Observe Only Before context compaction
manual | auto {
"hooks": {
"PreCompact": [{
"matcher": "auto",
"hooks": [{
"type": "command",
"command": "echo \"Auto-compact at $(date)\" >> .claude/compactions.log"
}]
}]
}
} 23 PostCompact Maintenance Observe Only After context compaction completes
Compaction details {
"hooks": {
"PostCompact": [{
"hooks": [{
"type": "command",
"command": ".claude/hooks/log-compaction.sh"
}]
}]
}
} 24 Elicitation MCP Can Modify MCP server requests user input during tool call
MCP details {
"hooks": {
"Elicitation": [{
"hooks": [{
"type": "command",
"command": ".claude/hooks/handle-elicitation.sh"
}]
}]
}
} 25 ElicitationResult MCP Can Modify User responds to MCP elicitation, before response sent back
MCP details {
"hooks": {
"ElicitationResult": [{
"hooks": [{
"type": "command",
"command": ".claude/hooks/validate-elicitation-response.sh"
}]
}]
}
} 26 SessionEnd Session Observe Only Session terminates
Not used {
"hooks": {
"SessionEnd": [{
"hooks": [{
"type": "command",
"command": "echo \"Session ended at $(date)\" >> ~/.claude/session.log"
}]
}]
}
} Five Hooks That Change Everything
You don’t need all 26 to get value. These five are the ones I’d set up on any project immediately. Each gets a full walkthrough.
1. The Security Gate
PreToolUse on Bash. The bouncer at the door. Block rm -rf, git reset --hard, DROP TABLE, and network curls piped to shell.
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": ".claude/hooks/bash-firewall.sh"
}]
}]
}
}
#!/usr/bin/env bash
set -euo pipefail
cmd=$(cat | jq -r '.tool_input.command // empty')
[[ -z "$cmd" ]] && exit 0
if echo "$cmd" | grep -qE 'rm -rf|git reset --hard|DROP TABLE|curl.*\|.*sh'; then
echo "Blocked: destructive or unsafe command" >&2
exit 2
fi
exit 0
Exit code 2 is the key. It’s the only code that actually blocks execution. Everything else is advisory.
2. Auto-Format on Every Write
PostToolUse on Write|Edit|MultiEdit. Prettier runs automatically after every file Claude touches. No more “please format that.”
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit|MultiEdit",
"hooks": [{
"type": "command",
"command": "npx prettier --write \"$CLAUDE_TOOL_INPUT_FILE_PATH\""
}]
}]
}
}
The $CLAUDE_TOOL_INPUT_FILE_PATH environment variable is one of the convenience vars Claude Code sets for PostToolUse hooks on file-related tools. No jq parsing needed.
3. Protected Paths
PreToolUse on Edit. Prevent Claude from editing production configs, .env files, or migration scripts without asking.
#!/usr/bin/env bash
set -euo pipefail
json=$(cat)
file=$(echo "$json" | jq -r '.tool_input.file_path // empty')
[[ -z "$file" ]] && exit 0
case "$file" in
*.env*|**/migrations/**|**/production/**)
echo "Protected path: $file — manual edit required" >&2
exit 2
;;
esac
exit 0
4. Auto-Commit Breadcrumbs
PostToolUse on Write|Edit. Create micro-commits after every edit so you can always roll back. The safety net you didn’t know you wanted.
#!/usr/bin/env bash
set -euo pipefail
git add -A
if ! git diff --cached --quiet; then
git commit -m "chore(ai): claude edit at $(date -Is)" --no-verify
fi
exit 0
Yes, this creates a lot of commits. That’s the point. You can squash later. You can’t un-delete a file Claude removed three tool calls ago.
5. Tests Must Pass Before PR
PreToolUse on mcp__github__create_pull_request. Block PR creation if the test suite is failing. Works with MCP tool matchers.
#!/usr/bin/env bash
set -euo pipefail
if pnpm test --reporter=dot; then
exit 0
else
echo "Tests failing. Fix before creating PR." >&2
exit 2
fi
The matcher syntax extends to MCP tools via the mcp__ prefix. Any MCP server tool is hookable.
Hooks in the Wild: Coherence
Coherence is our framework for keeping AI-generated code aligned with project specifications. It uses hooks as one of four enforcement layers (alongside specs, agents, and skills). Here’s how the real hooks work.
The Drift Nudge
The spec-drift-nudge is a PostToolUse observer. It doesn’t block anything — it counts edits and nudges you to run a drift check when things have changed enough. Fifty edits or seven days, whichever comes first. Throttled to once per 30 minutes so it doesn’t become the boy who cried wolf.
// === CONFIGURATION ===
const EDITS_THRESHOLD = 50; // Nudge after this many edits
const DAYS_THRESHOLD = 7; // Nudge after this many days
const THROTTLE_MS = 30 * 60 * 1000; // 30 minutes between nudges
// === END CONFIGURATION ===
// ... reads stdin JSON, increments edit counter in coherence-state.json ...
if (shouldNudge) {
state.lastNudge = new Date().toISOString();
writeState(state);
process.stdout.write(JSON.stringify({
message: `Coherence nudge: ${reason}. Consider running /coherence to check for spec drift.`,
}));
}
The pattern worth noting: hooks can maintain state between invocations via local JSON files. The coherence-state.json file tracks edits, last drift check, and last nudge time. This turns a stateless callback into a stateful observer.
Boundary Guards
The boundary-guard is a PreToolUse blocker for Next.js projects. It enforces that Prisma database access stays in lib/db/ — no direct imports in components or API routes. It blocks the tool and tells Claude exactly why and what to do instead.
const GUARDED_PATHS = [
{
pathContains: '/app/',
forbiddenPatterns: [
{
pattern: /from ['"]@prisma\/client['"]/,
message: 'Direct Prisma import in app/ directory.',
fix: 'Import from lib/db/ instead. All database access goes through repository functions.',
},
],
principle: 'Prisma stays in lib/db/. Never import Prisma directly in components. (CLAUDE.md)',
},
];
// ...
if (violations.length > 0) {
process.stdout.write(JSON.stringify({
decision: 'block',
reason: `Boundary violation:\n${violations.map((v, i) => `${i + 1}. ${v}`).join('\n')}\n\nPrinciple: ${matchedPrinciple}`,
}));
}
This is architectural enforcement at the tool level. CLAUDE.md says “don’t import Prisma in components.” The boundary guard makes that instruction enforceable, not advisory. Claude learns quickly — after a few blocks it starts using the repository pattern without being told.
Forbidden Imports
The forbidden-imports hook is simpler but equally useful. For an Express backend, it blocks React imports, browser APIs (document, window, localStorage), and unsafe code (eval, new Function).
const FORBIDDEN_PATTERNS = [
{
pattern: /from ['"]react['"]/,
message: 'React imports are not allowed in server code.',
},
{
pattern: /document\.|window\.|localStorage\./,
message: 'Browser APIs are not available in Node.js server code.',
},
{
pattern: /eval\s*\(|new Function\s*\(/,
message: 'eval() and new Function() are forbidden for security reasons.',
},
];
These are the kinds of mistakes that pass linting (because the linter runs against different rules) and pass tests (because the test environment polyfills browser globals). The hook catches them at write time, before they ever reach a commit.
Handler Types
Four ways to implement a hook. Use the simplest one that works.
| Type | How it works | Best for |
|---|---|---|
command | Runs a shell command. JSON on stdin, exit code + JSON on stdout. | File ops, git, formatting, blocking |
http | POSTs JSON to a URL. Response body = JSON output. | Team dashboards, remote policy servers, logging |
prompt | Sends a prompt to Claude for single-turn evaluation. $ARGUMENTS = event JSON. | Semantic checks (“does this edit touch auth logic?”) |
agent | Spawns a subagent with tools (Read, Grep, Glob). Full codebase analysis. | Deep verification, multi-file consistency checks |
Most hooks should be command. It’s the fastest, most predictable handler type. Use prompt when you need semantic understanding. Use agent when you need multi-file analysis. Use http when you need to phone home.
Configuration Cheat Sheet
Where hooks live
| Location | Scope | Notes |
|---|---|---|
~/.claude/settings.json | Global (all projects) | Good for personal safety nets |
.claude/settings.json | Project (shared) | Commit to git, team-wide enforcement |
.claude/settings.local.json | Project (personal) | Gitignored. Personal overrides. |
Key environment variables
| Variable | Available in | Value |
|---|---|---|
$CLAUDE_PROJECT_DIR | All hooks | Project root |
$CLAUDE_SESSION_ID | All hooks | Current session ID |
$CLAUDE_TOOL_INPUT_FILE_PATH | PostToolUse (file tools) | File path for the tool that just ran |
$CLAUDE_CODE_REMOTE | All hooks | "true" in remote/headless environments |
$CLAUDE_ENV_FILE | SessionStart | Path to write env vars that persist |
Key decision control patterns
PreToolUse — the most powerful hook, uses hookSpecificOutput:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Safe command",
"updatedInput": { "command": "modified-command" },
"additionalContext": "Extra context for Claude"
}
}
PostToolUse, Stop, SubagentStop, ConfigChange — use top-level decision:
{
"decision": "block",
"reason": "Tests failing. Fix them first."
}
PermissionRequest — uses hookSpecificOutput with a decision object:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow",
"updatedInput": { "command": "npm run lint" }
}
}
}
Sources
This guide draws from the official documentation and several excellent community resources:
- Official Claude Code Hooks documentation — the canonical reference for all 26 events
- Official hooks quickstart guide
- Coherence — our open-source framework using hooks for architectural enforcement
- disler/claude-code-hooks-mastery — community project with lifecycle diagrams and validation patterns
- Steve Kinney’s hook examples — copy-paste cookbook
- Blake Crosley’s production hooks tutorial — good on exit code semantics and performance
- GitButler’s hooks integration — real-world PreToolUse + PostToolUse + Stop hooks for branch management
Steve Chambers is the founder of Viewyonder , a GenAI consultancy helping teams work effectively with AI. He builds Coherence and writes about what actually works when AI meets real codebases.