Skip to content

The Hooks System

22 hook events, 4 handler types, and full configuration reference

25 min read

Claude edits a file and you want to run a linter automatically. Claude runs a Bash command and you want to validate it against a blocklist. Claude starts a session and you want to log it for compliance. Hooks give you 22 lifecycle events to intercept, inspect, and control every action Claude takes.

Hooks are lifecycle callbacks that fire at specific points during Claude’s execution — before a tool runs, after a file is edited, when a session starts, and 19 other moments. With 22 event types and 4 handler types (command, HTTP, prompt, agent), hooks are the deepest extensibility point in the CLI. This chapter is the full configuration reference, including security-focused patterns like blocking destructive commands and audit logging.

Hook Execution Flow
Tool Call Pre ToolUse exit 0 Execute Post ToolUse exit 2 Blocked hooks fire even with --dangerously-skip-permissions
Hook event flow: event, matcher, handler, exit code with allow and block branchesEventMatcherHandlerExit CodeAllow (exit 0)Block (exit 1)

Event Types

Every hook targets one of 22 events. Each event fires at a specific moment in Claude’s lifecycle and optionally supports a matcher to narrow scope.

Session & Configuration Events

EventWhen It FiresMatcher
SessionStartSession begins or resumesSession source: startup, resume, clear, compact
SessionEndSession terminatesExit reason: clear, logout, prompt_input_exit
ConfigChangeA config file changesConfig source: user_settings, project_settings, etc.
InstructionsLoadedCLAUDE.md or rules loadedNo matcher support

User Interaction Events

EventWhen It FiresMatcher
UserPromptSubmitUser submits a promptNo matcher support
PermissionRequestPermission dialog appearsTool name
NotificationClaude sends a notificationNotification type: permission_prompt, idle_prompt

Tool Execution Events

EventWhen It FiresMatcher
PreToolUseBefore a tool executesTool name: Bash, Edit|Write, mcp__.*
PostToolUseAfter a tool succeedsTool name
PostToolUseFailureAfter a tool failsTool name

Agent Management Events

EventWhen It FiresMatcher
SubagentStartSubagent spawnedAgent type name
SubagentStopSubagent finishesAgent type name
StopMain Claude finishes respondingNo matcher support
TeammateIdleTeam agent going idleNone
TaskCompletedTask marked completeNone

Context & Isolation Events

EventWhen It FiresMatcher
PreCompactBefore context compactionTrigger: manual, auto
PostCompactAfter context compactionTrigger: manual, auto
WorktreeCreateWorktree being createdNo matcher support
WorktreeRemoveWorktree being removedNo matcher support

MCP Integration Events

EventWhen It FiresMatcher
ElicitationMCP server requests user inputNone
ElicitationResultUser responds to MCP elicitationNone
Try This

Create a simple audit hook. Add a PostToolUse hook for Edit that logs which files were modified:

Create .claude/hooks/log-edits.sh: #!/bin/bash
echo ”$(date): Edit on $(cat - | jq -r ‘.tool_input.file_path’)” >> /tmp/claude-edits.log

Run a coding task, then check /tmp/claude-edits.log. Every file edit is now tracked.

Handler Types

Each hook entry specifies one of four handler types that determine how the hook runs.

Handler Types

TypeBehaviorUse Case
commandExecutes a shell command. Hook input arrives on stdin as JSON. Supports timeout, async, and statusMessage options.Bash validators, linters, log scripts, any local automation
httpSends a POST request with JSON body to a URL. Supports headers with env var interpolation and allowedEnvVars whitelisting.Audit logging to external services, webhook-based policy enforcement
promptSends hook input to a Claude model for evaluation. Uses $ARGUMENTS placeholder in the prompt text, replaced with the JSON input.AI-powered command validation, semantic policy checks
agentSpawns a subagent with tool access to evaluate the hook input. Most powerful but slowest handler type.Complex verification tasks that require reading files or running commands

Command Handler

The most common handler type. The shell command receives hook context on stdin as JSON and communicates its decision via exit codes.

{
"type": "command",
"command": "./.claude/hooks/validator.sh",
"timeout": 600,
"async": false,
"statusMessage": "Validating..."
}
  • command (required): shell command to run
  • timeout (optional): seconds before cancel, default 600
  • async (optional): run in background without blocking Claude
  • statusMessage (optional): spinner text shown during execution

HTTP Handler

Posts the JSON input to an external endpoint. Headers support environment variable interpolation with $VAR_NAME syntax, but only for variables listed in allowedEnvVars.

{
"type": "http",
"url": "http://localhost:8080/hooks/validate",
"headers": {"Authorization": "Bearer $MY_TOKEN"},
"allowedEnvVars": ["MY_TOKEN"],
"timeout": 30
}

Prompt Handler

Delegates the decision to a Claude model. The $ARGUMENTS placeholder in your prompt text is replaced with the full JSON input from the hook event.

{
"type": "prompt",
"prompt": "Should this bash command run? $ARGUMENTS\n\nDeny commands with 'rm -rf'.",
"model": "claude-3-5-haiku-20241022",
"timeout": 30
}

Agent Handler

Spawns a full subagent with tool access. Use this when evaluation requires reading files, running commands, or multi-step reasoning.

{
"type": "agent",
"prompt": "Verify this operation is safe: $ARGUMENTS",
"timeout": 60
}

Configuration

Hooks are defined in settings.json under the hooks key. The structure nests event name, then an array of matcher+hooks pairs, then an array of handler objects within each pair.

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "./.claude/hooks/block-rm.sh"
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "./scripts/validate-edit.sh",
"timeout": 10,
"statusMessage": "Validating edit..."
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "./scripts/run-linter.sh",
"timeout": 30,
"statusMessage": "Running linter..."
}
]
}
]
}
}

Configuration Scopes

Hooks can be defined at multiple levels, from personal defaults to project-specific rules.

Where to Put Hooks

LocationScopeUse Case
~/.claude/settings.jsonAll projectsPersonal safety rules that follow you everywhere
.claude/settings.jsonThis projectTeam-shared hooks, committed to git
.claude/settings.local.jsonThis project, local onlyLocal-only hooks, gitignored
Plugin hooks/hooks.jsonWhere plugin is enabledDistributed hooks (only http and prompt types allowed)
Agent/Skill frontmatterWhile component is activeHooks scoped to a specific agent or skill lifecycle
Gotcha

Hooks do not walk up the directory tree. A parent directory’s .claude/settings.json hooks do not fire when running from a subfolder — even if the subfolder has no settings.json of its own. This is different from skills and MCP configs (which do inherit upward). Each subfolder that needs hook enforcement must define its own .claude/settings.json. For org-wide enforcement, use managed settings.

The /hooks Browser

Inside an interactive Claude session, type /hooks to open the built-in hook browser. It lists all registered hooks across every scope, shows which events they target, and lets you verify your configuration is loaded correctly. Pair it with --debug "hooks" when troubleshooting execution order or matcher issues.

Event Matchers

Matchers are regex patterns that filter which specific instances of an event trigger your hook. Without a matcher, the hook fires for every occurrence of that event type.

{"matcher": "Bash"} // Exact tool name
{"matcher": "Edit|Write"} // Multiple tools (regex OR)
{"matcher": "mcp__memory__.*"} // All MCP memory server tools
{"matcher": "mcp__.*__write.*"} // Any MCP write operation

Matchers are case-sensitive and use full regex syntax. This means Bash* does not mean “anything starting with Bash” — it means “Bas” followed by zero or more “h” characters. For exact tool matching, just use the tool name directly: Bash.

For Notification events, the matcher targets the notification type (permission_prompt, idle_prompt) rather than a tool name. For SessionStart, it matches the session source (startup, resume, clear, compact).

No Matcher = Fires for Everything

Omitting the matcher field means the hook runs on every event of that type. For PreToolUse, that means every single tool call — Bash, Edit, Write, Read, Glob, Grep, and all MCP tools. Be intentional about whether you want broad or narrow scope.

Exit Code Behavior

Command handlers communicate their decision through exit codes. The convention is specific to the hooks system and differs from typical Unix expectations.

Exit Codes

Exit CodeMeaningBehavior
0Success / AllowAction proceeds. If the hook printed JSON to stdout, it is processed for advanced control.
2BlockAction is prevented. Stderr output is fed back to Claude as the reason.
OtherNon-blocking errorHook failure is logged. Stderr shown in verbose mode. Action still proceeds.

What exit code 2 blocks depends on the event:

Exit Code 2 Blocking by Event

EventWhat Gets Blocked
PreToolUseTool call is prevented entirely
PermissionRequestPermission is denied
UserPromptSubmitPrompt is not processed
Stop, SubagentStopAgent is prevented from stopping
ConfigChangeConfig change is blocked
PostToolUseNothing — the tool already ran. Stderr is shown but the action cannot be undone.

Hooks in the same event array run sequentially in array order. The first hook to exit with code 2 wins — subsequent hooks in the array are not executed.

Exit Codes Are Reversed from Unix Convention

In standard Unix, exit code 0 means success and non-zero means failure. The hooks system reuses 0 for “allow” but specifically reserves exit code 2 for “block.” Exit code 1 (the typical Unix error) is treated as a non-blocking hook failure, not a block. If your script accidentally exits with 1, the action will still proceed — only exit code 2 actually prevents anything.

Hooks Receive Tool Input as Stdin JSON

Command handlers do not receive arguments on the command line. Instead, the full hook context — including tool_input, tool name, and event metadata — arrives as a JSON object on stdin. Your script must read from stdin (e.g., INPUT=$(cat)) and parse it with a tool like jq. Forgetting to read stdin is the most common cause of hooks that silently do nothing.

Environment Variables

Command hooks automatically receive several environment variables:

  • CLAUDE_PROJECT_DIR — project root directory
  • CLAUDE_PLUGIN_ROOT — plugin installation directory
  • CLAUDE_PLUGIN_DATA — plugin persistent data directory
  • CLAUDE_ENV_FILE — (SessionStart only) file path for persisting environment variables across the session

The CLAUDE_ENV_FILE variable deserves special attention. In a SessionStart hook, you can write export statements to this file to set environment variables that persist for all subsequent Bash commands in the session:

.claude/hooks/setup-env.sh
#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE"
echo 'export PATH="$PATH:./node_modules/.bin"' >> "$CLAUDE_ENV_FILE"
fi
exit 0
Tip

Use —debug “hooks” to see exactly which hooks fire, in what order, and what input they receive. This is the fastest way to diagnose hooks that are not matching or are receiving unexpected data.

Hooks Are Deterministic, CLAUDE.md Is Advisory

This is the most critical distinction in Claude Code’s enforcement model. Hooks are deterministic — a PreToolUse hook that exits with code 2 ALWAYS blocks the tool call. No prompt, no instruction, no CLAUDE.md directive can override it. CLAUDE.md instructions are advisory — Claude follows them as best-effort guidance, but a sufficiently persuasive prompt injection or complex task flow can cause Claude to ignore them. For any rule that must be enforced without exception (no writes to production, no curl to external URLs, no rm -rf), use a hook. For preferences and guidelines (coding style, response format, project conventions), CLAUDE.md is appropriate.

Build Your Own Hook

Use the interactive builder below to generate a valid settings.json hook configuration. Select an event, set a matcher, choose a handler type, fill in the details, and copy the output directly into your project.

Hook Config BuilderCONFIG GENERATOR
Before a tool executes
{
"hooks": {
"PreToolUse": [
{
"hooks": [
{
"type": "command"
}
]
}
]
}
}
Now Do This

Add a PostToolUse hook on Edit that logs the modified file path to /tmp/claude-edits.log. Run a coding task and check the log. You now have an audit trail for every file change Claude makes — the first step toward compliance-ready AI tooling.

Hooks as Security Guardrails

You pass --dangerously-skip-permissions in CI because there’s no human to answer prompts. Now nothing stops Claude from running rm -rf / or curling your environment variables to an external server. Nothing — except hooks. Hooks are the only safety layer that survives permission bypass.

Hooks are not just an extensibility mechanism — they are the only safety layer in Claude CLI that cannot be bypassed, even with --dangerously-skip-permissions. By wiring shell scripts or HTTP endpoints into the tool execution lifecycle, you can enforce hard security boundaries that apply to every session, every user, and every agent. This section focuses on the security side: blocking dangerous operations, logging for compliance, and centralizing policy enforcement.

PreToolUse Blocking

The PreToolUse event fires before every matched tool call, giving your hook a chance to inspect the input and block execution. A hook that exits with code 2 prevents the tool from running entirely. The error message you write to stderr is fed back to Claude as the reason the action was denied.

Add this to your .claude/settings.json:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "./.claude/hooks/block-dangerous.sh"
}
]
}
]
}
}

The hook script at .claude/hooks/block-dangerous.sh:

#!/usr/bin/env bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
BLOCKED=(
"rm -rf /"
"rm -rf ~"
"DROP TABLE"
"DROP DATABASE"
"> /dev/sda"
"mkfs."
":(){ :|:& };:"
)
for pattern in "${BLOCKED[@]}"; do
if echo "$COMMAND" | grep -qi "$pattern"; then
echo "BLOCKED: pattern '$pattern' matched" >&2
exit 2 # Exit code 2 = BLOCK the tool call
fi
done
exit 0 # Exit code 0 = allow

When Claude attempts to run any command containing a blocked pattern, the hook fires, the tool call is prevented, and Claude receives the stderr message explaining why. The matcher field is a regex — "Bash" targets only Bash tool calls. You can broaden it with "Bash|Edit|Write" to cover file operations, or use "mcp__.*" to guard all MCP tool calls.

Multiple hooks in the same array run sequentially. The first one to exit with code 2 wins — subsequent hooks in the chain are skipped, and the tool call is blocked.

Try This

Create a minimal security hook. Add this to .claude/settings.json:

{“hooks”: {“PreToolUse”: [{“matcher”: “Bash”, “hooks”: [{“type”: “command”, “command”: “grep -q ‘rm -rf’ && exit 2 || exit 0”}]}]}}

Now test it: claude -p “Delete all files in /tmp” —dangerously-skip-permissions. Does the hook block the rm -rf even though permissions are bypassed?

Audit Logging

For compliance and forensics, you need a record of everything Claude does. PostToolUse hooks fire after a tool has executed successfully, making them the right place for audit logging — the tool has already run, so the hook cannot block it, but it can record the full context of what happened.

Add a PostToolUse logging hook to .claude/settings.json:

{
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "./.claude/hooks/audit-log.sh",
"async": true
}
]
}
]
}
}

The logging script at .claude/hooks/audit-log.sh:

#!/usr/bin/env bash
INPUT=$(cat)
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
LOG_DIR="$PROJECT_DIR/.claude/logs"
mkdir -p "$LOG_DIR"
echo "${TIMESTAMP}|${TOOL}|${TOOL_INPUT}" >> "$LOG_DIR/audit.log"
exit 0

Notice two key details. First, the hook omits the matcher field — no matcher means it fires for every tool type, capturing Bash commands, file edits, MCP calls, and everything else. Second, "async": true runs the hook in the background so logging never slows down Claude’s execution. Since this is a PostToolUse hook, exit code 2 does not undo anything — the tool already ran.

You can also log PreToolUse events to capture attempted operations that were allowed. Together, Pre and Post logs give you a complete picture: what was attempted, what was allowed, and what happened.

Command Hooks for Validation

Shell-based command hooks are the simplest way to build input validation. The hook receives the full tool call payload as JSON on stdin, which means your script can parse and inspect any aspect of the tool input before deciding whether to allow or block.

Here is a more targeted validator that blocks write operations to specific protected paths:

.claude/hooks/protect-paths.sh
#!/usr/bin/env bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""')
FILE_PATH=""
if [ "$TOOL" = "Edit" ] || [ "$TOOL" = "Write" ]; then
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
elif [ "$TOOL" = "Bash" ]; then
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
fi
PROTECTED=(".env" "credentials" "secrets" "id_rsa" ".ssh/")
for pattern in "${PROTECTED[@]}"; do
if echo "$FILE_PATH" | grep -qi "$pattern"; then
echo "BLOCKED: operation on protected path matching '$pattern'" >&2
exit 2
fi
done
exit 0

Wire it into settings with a broad matcher to cover both file operations and Bash commands:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Edit|Write",
"hooks": [
{
"type": "command",
"command": "./.claude/hooks/protect-paths.sh"
}
]
}
]
}
}

Command hooks must be fast. PreToolUse hooks gate every matched tool call, so anything over 500ms will make Claude feel sluggish. Keep validation logic simple — pattern matching and string checks, not network calls or heavy computation.

HTTP Hooks for Central Policy

For teams that need centralized control, HTTP hooks send the tool call payload as a POST request to a policy server. This lets you enforce organization-wide rules from a single endpoint rather than distributing shell scripts to every developer machine.

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "http",
"url": "http://localhost:8080/hooks/validate",
"headers": {"Authorization": "Bearer $POLICY_TOKEN"},
"allowedEnvVars": ["POLICY_TOKEN"],
"timeout": 5
}
]
}
]
}
}

The policy server receives the full hook input as the POST body and responds with a JSON decision. To block a tool call from the server side, return a JSON body with the decision and reason fields:

{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Command contains DROP TABLE"
}
}

The permissionDecision field supports three values: "allow" to approve, "deny" to block, or "ask" to fall through to the normal permission prompt. This gives the policy server fine-grained control — it can hard-block known-dangerous patterns, auto-approve known-safe operations, and defer ambiguous cases to the user.

HTTP hooks have a default timeout of 30 seconds, but for security validation you should keep this short (5 seconds or less). If the policy server is unreachable and the request times out, the hook is treated as a non-blocking error — the tool call proceeds. Design your architecture with this fail-open behavior in mind.

Security Hook Patterns

Security Hook Pattern Reference

PatternEventHandlerBehavior
Block destructive commandsPreToolUsecommandExit 2 on rm -rf, DROP TABLE, mkfs patterns
Block writes to protected pathsPreToolUsecommandExit 2 when file path matches .env, .ssh, secrets
Block all MCP write operationsPreToolUsecommandMatcher mcp__.__write. with exit 2
Log all tool executionsPostToolUsecommand (async)Append tool name, input, and timestamp to audit log
Log all MCP operationsPreToolUsecommandMatcher mcp__.*, pipe stdin to log file
Central policy enforcementPreToolUsehttpPOST to policy server, deny/allow/ask per response
AI-powered command reviewPreToolUsepromptSend command to a fast model for safety evaluation
Alert on sensitive file accessPostToolUsehttpPOST to alerting endpoint when sensitive paths are touched
Prevent premature agent stopStopcommandExit 2 if verification script finds incomplete work
Exit Code Semantics Are Reversed

In standard Unix conventions, exit code 2 typically means a usage error or misuse. In Claude hooks, exit code 2 means block the operation. Exit code 0 means allow. Any other non-zero exit code (1, 3, etc.) is treated as a non-blocking error — stderr is shown in verbose mode, but the tool call proceeds. If you bring Unix muscle memory to hook scripts, you will accidentally allow operations you meant to block.

Note

Hooks fire even when —dangerously-skip-permissions is active. This is by design — hooks are the one safety layer that cannot be bypassed. Use them as the foundation of your defense-in-depth strategy.

Hooks vs CLAUDE.md: When to Use Which

The enforcement distinction between hooks and CLAUDE.md is critical for security:

  • Hooks = deterministic enforcement. A PreToolUse hook that exits with code 2 always blocks the operation. No prompt, no CLAUDE.md instruction, no conversation context can override it. Hooks execute as shell processes outside of Claude’s reasoning — they are not subject to prompt injection or persuasion.

  • CLAUDE.md = advisory guidance. Instructions in CLAUDE.md are loaded into Claude’s context as part of the system prompt. Claude follows them as best-effort guidance, but they can be overridden by sufficiently persuasive prompt injections, complex task flows, or context window pressure during compaction.

Use hooks for: Security-critical rules that must never be violated — blocking writes to production configs, preventing curl to external URLs, auditing all Bash commands, enforcing test execution before commits.

Use CLAUDE.md for: Development preferences and conventions — coding style, response format, project architecture decisions, compaction instructions, workflow preferences.

The common mistake is putting security rules in CLAUDE.md and expecting them to be enforced. A CLAUDE.md instruction saying “never delete files without asking” is helpful guidance, but a prompt injection in a malicious file could override it. A PreToolUse hook checking for rm commands cannot be overridden regardless of what is in Claude’s context.

Now Do This

Create a hook script at .claude/hooks/block-destructive.sh that exits 2 when stdin contains rm -rf, DROP TABLE, or force push. Add it as a PreToolUse hook for Bash. This is your first unbyppassable guardrail — it works even with —dangerously-skip-permissions.