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.
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
| Event | When It Fires | Matcher |
|---|---|---|
SessionStart | Session begins or resumes | Session source: startup, resume, clear, compact |
SessionEnd | Session terminates | Exit reason: clear, logout, prompt_input_exit |
ConfigChange | A config file changes | Config source: user_settings, project_settings, etc. |
InstructionsLoaded | CLAUDE.md or rules loaded | No matcher support |
User Interaction Events
| Event | When It Fires | Matcher |
|---|---|---|
UserPromptSubmit | User submits a prompt | No matcher support |
PermissionRequest | Permission dialog appears | Tool name |
Notification | Claude sends a notification | Notification type: permission_prompt, idle_prompt |
Tool Execution Events
| Event | When It Fires | Matcher |
|---|---|---|
PreToolUse | Before a tool executes | Tool name: Bash, Edit|Write, mcp__.* |
PostToolUse | After a tool succeeds | Tool name |
PostToolUseFailure | After a tool fails | Tool name |
Agent Management Events
| Event | When It Fires | Matcher |
|---|---|---|
SubagentStart | Subagent spawned | Agent type name |
SubagentStop | Subagent finishes | Agent type name |
Stop | Main Claude finishes responding | No matcher support |
TeammateIdle | Team agent going idle | None |
TaskCompleted | Task marked complete | None |
Context & Isolation Events
| Event | When It Fires | Matcher |
|---|---|---|
PreCompact | Before context compaction | Trigger: manual, auto |
PostCompact | After context compaction | Trigger: manual, auto |
WorktreeCreate | Worktree being created | No matcher support |
WorktreeRemove | Worktree being removed | No matcher support |
MCP Integration Events
| Event | When It Fires | Matcher |
|---|---|---|
Elicitation | MCP server requests user input | None |
ElicitationResult | User responds to MCP elicitation | None |
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
| Type | Behavior | Use Case |
|---|---|---|
command | Executes a shell command. Hook input arrives on stdin as JSON. Supports timeout, async, and statusMessage options. | Bash validators, linters, log scripts, any local automation |
http | Sends 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 |
prompt | Sends 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 |
agent | Spawns 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 runtimeout(optional): seconds before cancel, default 600async(optional): run in background without blocking ClaudestatusMessage(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
| Location | Scope | Use Case |
|---|---|---|
~/.claude/settings.json | All projects | Personal safety rules that follow you everywhere |
.claude/settings.json | This project | Team-shared hooks, committed to git |
.claude/settings.local.json | This project, local only | Local-only hooks, gitignored |
Plugin hooks/hooks.json | Where plugin is enabled | Distributed hooks (only http and prompt types allowed) |
| Agent/Skill frontmatter | While component is active | Hooks scoped to a specific agent or skill lifecycle |
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 operationMatchers 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).
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 Code | Meaning | Behavior |
|---|---|---|
0 | Success / Allow | Action proceeds. If the hook printed JSON to stdout, it is processed for advanced control. |
2 | Block | Action is prevented. Stderr output is fed back to Claude as the reason. |
| Other | Non-blocking error | Hook 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
| Event | What Gets Blocked |
|---|---|
PreToolUse | Tool call is prevented entirely |
PermissionRequest | Permission is denied |
UserPromptSubmit | Prompt is not processed |
Stop, SubagentStop | Agent is prevented from stopping |
ConfigChange | Config change is blocked |
PostToolUse | Nothing — 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.
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.
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 directoryCLAUDE_PLUGIN_ROOT— plugin installation directoryCLAUDE_PLUGIN_DATA— plugin persistent data directoryCLAUDE_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:
#!/bin/bashif [ -n "$CLAUDE_ENV_FILE" ]; then echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE" echo 'export PATH="$PATH:./node_modules/.bin"' >> "$CLAUDE_ENV_FILE"fiexit 0Use —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.
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.
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 bashINPUT=$(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 fidone
exit 0 # Exit code 0 = allowWhen 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.
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 bashINPUT=$(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 0Notice 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:
#!/usr/bin/env bashINPUT=$(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 fidone
exit 0Wire 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
| Pattern | Event | Handler | Behavior |
|---|---|---|---|
| Block destructive commands | PreToolUse | command | Exit 2 on rm -rf, DROP TABLE, mkfs patterns |
| Block writes to protected paths | PreToolUse | command | Exit 2 when file path matches .env, .ssh, secrets |
| Block all MCP write operations | PreToolUse | command | Matcher mcp__.__write. with exit 2 |
| Log all tool executions | PostToolUse | command (async) | Append tool name, input, and timestamp to audit log |
| Log all MCP operations | PreToolUse | command | Matcher mcp__.*, pipe stdin to log file |
| Central policy enforcement | PreToolUse | http | POST to policy server, deny/allow/ask per response |
| AI-powered command review | PreToolUse | prompt | Send command to a fast model for safety evaluation |
| Alert on sensitive file access | PostToolUse | http | POST to alerting endpoint when sensitive paths are touched |
| Prevent premature agent stop | Stop | command | Exit 2 if verification script finds incomplete work |
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.
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
PreToolUsehook 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.
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.