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 (PreToolUse only — 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.

Session Tool Agent Async Environment Maintenance MCP
Session Lifecycle
↓
↓
Agentic Loop
↓
Claude processes...
↓
Tool Execution
↓
denied?
↘
approved
↓
[Tool executes]
success
↓
failure
↓
If MCP tool with input
→
Subagent Lifecycle
→
[subagent works]
→
Task Lifecycle
→
[task runs]
→
(async, any time)
Environment & Maintenance
↓

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
Matcher matches on startup | resume | compact | clear
Key input fields session_id, type (startup/resume/compact/clear)
Decision control No decision control — observe only.
Example config
{
  "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
Matcher matches on Filename of the loaded file
Key input fields file_path, content
Decision control No decision control — observe only.
Example config
{
  "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
Matcher matches on Not used
Key input fields prompt (the user's input text)
Decision control Can modify the prompt text via stdout JSON.
Example config
{
  "hooks": {
    "UserPromptSubmit": [{
      "hooks": [{
        "type": "command",
        "command": ".claude/hooks/prompt-logger.sh"
      }]
    }]
  }
}
4 PreToolUse Tool Can Block Before a tool call executes
Matcher matches on Tool name: Bash | Read | Write | Edit | MultiEdit | Grep | Glob | mcp__*
Key input fields tool_name, tool_input (tool-specific parameters)
Decision control Uses hookSpecificOutput: • permissionDecision: "allow" | "deny" | "ask" • permissionDecisionReason: "Why" • updatedInput: { modified parameters } • additionalContext: "Extra context for Claude" Exit code 2 = block the tool.
Example config
{
  "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
Matcher matches on Tool name (same as PreToolUse)
Key input fields tool_name, tool_input, permission_type
Decision control Uses hookSpecificOutput with decision object: • behavior: "allow" | "deny" | "ask" • updatedInput: { modified parameters }
Example config
{
  "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
Matcher matches on Tool name
Key input fields tool_name, tool_input, denial_reason
Decision control Can provide additional context to Claude about the denial.
Example config
{
  "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
Matcher matches on Tool name (same as PreToolUse)
Key input fields tool_name, tool_input, tool_output
Decision control Uses top-level decision: • { "decision": "block", "reason": "..." } Also supports message for informational feedback.
Example config
{
  "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
Matcher matches on Tool name (same as PreToolUse)
Key input fields tool_name, tool_input, error
Decision control No decision control — observe only.
Example config
{
  "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)
Matcher matches on Notification type
Key input fields type, message
Decision control No decision control — async, fire-and-forget.
Example config
{
  "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
Matcher matches on Agent details
Key input fields agent_type, prompt, subagent_id
Decision control No decision control — observe only.
Example config
{
  "hooks": {
    "SubagentStart": [{
      "hooks": [{
        "type": "command",
        "command": "echo \"Subagent spawned at $(date)\" >> .claude/agents.log"
      }]
    }]
  }
}
11 SubagentStop Agent Can Modify A subagent finishes its work
Matcher matches on Agent details
Key input fields subagent_id, result
Decision control Uses top-level decision: • { "decision": "block", "reason": "..." }
Example config
{
  "hooks": {
    "SubagentStop": [{
      "hooks": [{
        "type": "command",
        "command": ".claude/hooks/validate-agent-output.sh"
      }]
    }]
  }
}
12 TaskCreated Agent Can Modify A task is created via TaskCreate tool
Matcher matches on Task details
Key input fields task_id, subject, description
Decision control Can modify task details.
Example config
{
  "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
Matcher matches on Task details
Key input fields task_id, subject, result
Decision control Can modify or add context to completion.
Example config
{
  "hooks": {
    "TaskCompleted": [{
      "hooks": [{
        "type": "command",
        "command": ".claude/hooks/validate-task-completion.sh"
      }]
    }]
  }
}
14 Stop Session Can Modify Claude finishes responding
Matcher matches on Fires for all stops (no matcher)
Key input fields stop_reason
Decision control Uses top-level decision: • { "decision": "block", "reason": "..." } Block = Claude continues working instead of stopping.
Example config
{
  "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
Matcher matches on Not used
Key input fields error
Decision control No decision control — output and exit code ignored.
Example config
{
  "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
Matcher matches on Teammate details
Key input fields teammate_id, idle_reason
Decision control Can assign new work to prevent idle.
Example config
{
  "hooks": {
    "TeammateIdle": [{
      "hooks": [{
        "type": "command",
        "command": ".claude/hooks/rebalance-work.sh"
      }]
    }]
  }
}
17 ConfigChange Environment Can Modify A configuration file changes during a session
Matcher matches on Config details
Key input fields file_path, change_type
Decision control Uses top-level decision: • { "decision": "block", "reason": "..." }
Example config
{
  "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)
Matcher matches on Directory path
Key input fields old_cwd, new_cwd
Decision control Can modify or block directory change.
Example config
{
  "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 matches on Matcher specifies which filenames to watch
Key input fields file_path, change_type
Decision control Can provide context about the change to Claude.
Example config
{
  "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"
Matcher matches on Worktree details
Key input fields worktree_path, branch
Decision control Can modify worktree setup.
Example config
{
  "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
Matcher matches on Worktree details
Key input fields worktree_path
Decision control No decision control — observe only.
Example config
{
  "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
Matcher matches on manual | auto
Key input fields trigger (manual/auto), token_count
Decision control No decision control — observe only.
Example config
{
  "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
Matcher matches on Compaction details
Key input fields tokens_before, tokens_after
Decision control No decision control — observe only.
Example config
{
  "hooks": {
    "PostCompact": [{
      "hooks": [{
        "type": "command",
        "command": ".claude/hooks/log-compaction.sh"
      }]
    }]
  }
}
24 Elicitation MCP Can Modify MCP server requests user input during tool call
Matcher matches on MCP details
Key input fields mcp_server, prompt, options
Decision control Can modify the elicitation prompt or auto-respond.
Example config
{
  "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
Matcher matches on MCP details
Key input fields mcp_server, user_response
Decision control Can modify the user's response before sending to MCP server.
Example config
{
  "hooks": {
    "ElicitationResult": [{
      "hooks": [{
        "type": "command",
        "command": ".claude/hooks/validate-elicitation-response.sh"
      }]
    }]
  }
}
26 SessionEnd Session Observe Only Session terminates
Matcher matches on Not used
Key input fields session_id, duration
Decision control No decision control — observe only.
Example config
{
  "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.

TypeHow it worksBest for
commandRuns a shell command. JSON on stdin, exit code + JSON on stdout.File ops, git, formatting, blocking
httpPOSTs JSON to a URL. Response body = JSON output.Team dashboards, remote policy servers, logging
promptSends a prompt to Claude for single-turn evaluation. $ARGUMENTS = event JSON.Semantic checks (“does this edit touch auth logic?”)
agentSpawns 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

LocationScopeNotes
~/.claude/settings.jsonGlobal (all projects)Good for personal safety nets
.claude/settings.jsonProject (shared)Commit to git, team-wide enforcement
.claude/settings.local.jsonProject (personal)Gitignored. Personal overrides.

Key environment variables

VariableAvailable inValue
$CLAUDE_PROJECT_DIRAll hooksProject root
$CLAUDE_SESSION_IDAll hooksCurrent session ID
$CLAUDE_TOOL_INPUT_FILE_PATHPostToolUse (file tools)File path for the tool that just ran
$CLAUDE_CODE_REMOTEAll hooks"true" in remote/headless environments
$CLAUDE_ENV_FILESessionStartPath 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:

  1. Official Claude Code Hooks documentation — the canonical reference for all 26 events
  2. Official hooks quickstart guide
  3. Coherence — our open-source framework using hooks for architectural enforcement
  4. disler/claude-code-hooks-mastery — community project with lifecycle diagrams and validation patterns
  5. Steve Kinney’s hook examples — copy-paste cookbook
  6. Blake Crosley’s production hooks tutorial — good on exit code semantics and performance
  7. 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.

← All writing