Skip to content

Hooks

Hooks are automated scripts that execute in response to specific events during Claude Code sessions. They enable automation, validation, permission management, and custom workflows.

Overview

Hooks are automated actions (shell commands, HTTP webhooks, LLM prompts, or subagent evaluations) that execute automatically when specific events occur in Claude Code. They receive JSON input and communicate results via exit codes and JSON output.

Key features:

  • Event-driven automation
  • JSON-based input/output
  • Support for command, prompt, HTTP, and agent hook types
  • Pattern matching for tool-specific hooks

🔧 Under the Hood: 26 Hook Events

Claude Code fires 26 lifecycle events across 8 categories: tool lifecycle, permission, session, agent lifecycle, task, context, filesystem, and UI/config. PreToolUse and PostToolUse are the most powerful — PreToolUse can block tool execution entirely. Full event catalog → Plugin Engine

Configuration

Hooks are configured in settings files with a specific structure:

  • ~/.claude/settings.json - User settings (all projects)
  • .claude/settings.json - Project settings (shareable, committed)
  • .claude/settings.local.json - Local project settings (not committed)
  • Managed policy - Organization-wide settings
  • Plugin hooks/hooks.json - Plugin-scoped hooks
  • Skill/Agent frontmatter - Component lifetime hooks

Basic Configuration Structure

{
"hooks": {
"EventName": [
{
"matcher": "ToolPattern",
"hooks": [
{
"type": "command",
"command": "your-command-here",
"timeout": 60
}
]
}
]
}
}

Key fields:

FieldDescriptionExample
matcherPattern to match tool names (case-sensitive)"Write", "Edit|Write", "*"
hooksArray of hook definitions[{ "type": "command", ... }]
typeHook type: "command" (bash), "prompt" (LLM), "http" (webhook), or "agent" (subagent)"command"
commandShell command to execute"$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh"
timeoutOptional timeout in seconds (default 60)30
onceIf true, run the hook only once per sessiontrue

Matcher Patterns

PatternDescriptionExample
Exact stringMatches specific tool"Write"
Regex patternMatches multiple tools"Edit|Write"
WildcardMatches all tools"*" or ""
MCP toolsServer and tool pattern"mcp__memory__.*"

Hook Types

Claude Code supports four hook types:

Command Hooks

The default hook type. Executes a shell command and communicates via JSON stdin/stdout and exit codes.

{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/validate.py\"",
"timeout": 60
}

HTTP Hooks

Added in v2.1.63.

Remote webhook endpoints that receive the same JSON input as command hooks. HTTP hooks POST JSON to the URL and receive a JSON response. HTTP hooks are routed through the sandbox when sandboxing is enabled. Environment variable interpolation in URLs requires an explicit allowedEnvVars list for security.

{
"hooks": {
"PostToolUse": [{
"type": "http",
"url": "https://my-webhook.example.com/hook",
"matcher": "Write"
}]
}
}

Key properties:

  • "type": "http" — identifies this as an HTTP hook
  • "url" — the webhook endpoint URL
  • Routed through sandbox when sandbox is enabled
  • Requires explicit allowedEnvVars list for any environment variable interpolation in the URL

Prompt Hooks

LLM-evaluated prompts where the hook content is a prompt that Claude evaluates. Primarily used with Stop and SubagentStop events for intelligent task completion checking.

{
"type": "prompt",
"prompt": "Evaluate if Claude completed all requested tasks.",
"timeout": 30
}

The LLM evaluates the prompt and returns a structured decision (see Prompt-Based Hooks for details).

Agent Hooks

Subagent-based verification hooks that spawn a dedicated agent to evaluate conditions or perform complex checks. Unlike prompt hooks (single-turn LLM evaluation), agent hooks can use tools and perform multi-step reasoning.

{
"type": "agent",
"prompt": "Verify the code changes follow our architecture guidelines. Check the relevant design docs and compare.",
"timeout": 120
}

Key properties:

  • "type": "agent" — identifies this as an agent hook
  • "prompt" — the task description for the subagent
  • The agent can use tools (Read, Grep, Bash, etc.) to perform its evaluation
  • Returns a structured decision similar to prompt hooks

Hook Events

Claude Code supports 25 hook events:

EventWhen TriggeredMatcher InputCan BlockCommon Use
SessionStartSession begins/resumes/clear/compactstartup/resume/clear/compactNoEnvironment setup
InstructionsLoadedAfter CLAUDE.md or rules file loaded(none)NoModify/filter instructions
UserPromptSubmitUser submits prompt(none)YesValidate prompts
PreToolUseBefore tool executionTool nameYes (allow/deny/ask)Validate, modify inputs
PermissionRequestPermission dialog shownTool nameYesAuto-approve/deny
PostToolUseAfter tool succeedsTool nameNoAdd context, feedback
PostToolUseFailureTool execution failsTool nameNoError handling, logging
NotificationNotification sentNotification typeNoCustom notifications
SubagentStartSubagent spawnedAgent type nameNoSubagent setup
SubagentStopSubagent finishesAgent type nameYesSubagent validation
StopClaude finishes responding(none)YesTask completion check
StopFailureAPI error ends turn(none)NoError recovery, logging
TeammateIdleAgent team teammate idle(none)YesTeammate coordination
TaskCompletedTask marked complete(none)YesPost-task actions
TaskCreatedTask created via TaskCreate(none)NoTask tracking, logging
ConfigChangeConfig file changes(none)Yes (except policy)React to config updates
CwdChangedWorking directory changes(none)NoDirectory-specific setup
FileChangedWatched file changes(none)NoFile monitoring, rebuild
PreCompactBefore context compactionmanual/autoNoPre-compact actions
PostCompactAfter compaction completes(none)NoPost-compact actions
WorktreeCreateWorktree being created(none)Yes (path return)Worktree initialization
WorktreeRemoveWorktree being removed(none)NoWorktree cleanup
ElicitationMCP server requests user input(none)YesInput validation
ElicitationResultUser responds to elicitation(none)YesResponse processing
SessionEndSession terminates(none)NoCleanup, final logging

PreToolUse

Runs after Claude creates tool parameters and before processing. Use this to validate or modify tool inputs.

Configuration:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-bash.py"
}
]
}
]
}
}

Common matchers: Task, Bash, Glob, Grep, Read, Edit, Write, WebFetch, WebSearch

Output control:

  • permissionDecision: "allow", "deny", or "ask"
  • permissionDecisionReason: Explanation for decision
  • updatedInput: Modified tool input parameters

PostToolUse

Runs immediately after tool completion. Use for verification, logging, or providing context back to Claude.

Configuration:

{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/security-scan.py"
}
]
}
]
}
}

Output control:

  • "block" decision prompts Claude with feedback
  • additionalContext: Context added for Claude

UserPromptSubmit

Runs when user submits a prompt, before Claude processes it.

Configuration:

{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-prompt.py"
}
]
}
]
}
}

Output control:

  • decision: "block" to prevent processing
  • reason: Explanation if blocked
  • additionalContext: Context added to prompt

Stop and SubagentStop

Run when Claude finishes responding (Stop) or a subagent completes (SubagentStop). Supports prompt-based evaluation for intelligent task completion checking.

Additional input field: Both Stop and SubagentStop hooks receive a last_assistant_message field in their JSON input, containing the final message from Claude or the subagent before stopping. This is useful for evaluating task completion.

Configuration:

{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Evaluate if Claude completed all requested tasks.",
"timeout": 30
}
]
}
]
}
}

SubagentStart

Runs when a subagent begins execution. The matcher input is the agent type name, allowing hooks to target specific subagent types.

Configuration:

{
"hooks": {
"SubagentStart": [
{
"matcher": "code-review",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-init.sh"
}
]
}
]
}
}

SessionStart

Runs when session starts or resumes. Can persist environment variables.

Matchers: startup, resume, clear, compact

Special feature: Use CLAUDE_ENV_FILE to persist environment variables (also available in CwdChanged and FileChanged hooks):

#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=development' >> "$CLAUDE_ENV_FILE"
fi
exit 0

SessionEnd

Runs when session ends to perform cleanup or final logging. Cannot block termination.

Reason field values:

  • clear - User cleared the session
  • logout - User logged out
  • prompt_input_exit - User exited via prompt input
  • other - Other reason

Configuration:

{
"hooks": {
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-cleanup.sh\""
}
]
}
]
}
}

Notification Event

Updated matchers for notification events:

  • permission_prompt - Permission request notification
  • idle_prompt - Idle state notification
  • auth_success - Authentication success
  • elicitation_dialog - Dialog shown to user

Component-Scoped Hooks

Hooks can be attached to specific components (skills, agents, commands) in their frontmatter:

In SKILL.md, agent.md, or command.md:

---
name: secure-operations
description: Perform operations with security checks
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/check.sh"
once: true # Only run once per session
---

Supported events for component hooks: PreToolUse, PostToolUse, Stop

This allows defining hooks directly in the component that uses them, keeping related code together.

Hooks in Subagent Frontmatter

When a Stop hook is defined in a subagent’s frontmatter, it is automatically converted to a SubagentStop hook scoped to that subagent. This ensures that the stop hook only fires when that specific subagent completes, rather than when the main session stops.

---
name: code-review-agent
description: Automated code review subagent
hooks:
Stop:
- hooks:
- type: prompt
prompt: "Verify the code review is thorough and complete."
# The above Stop hook auto-converts to SubagentStop for this subagent
---

PermissionRequest Event

Handles permission requests with custom output format:

{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow|deny",
"updatedInput": {},
"message": "Custom message",
"interrupt": false
}
}
}

Hook Input and Output

JSON Input (via stdin)

All hooks receive JSON input via stdin:

{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/working/directory",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/path/to/file.js",
"content": "..."
},
"tool_use_id": "toolu_01ABC123...",
"agent_id": "agent-abc123",
"agent_type": "main",
"worktree": "/path/to/worktree"
}

Common fields:

FieldDescription
session_idUnique session identifier
transcript_pathPath to the conversation transcript file
cwdCurrent working directory
hook_event_nameName of the event that triggered the hook
agent_idIdentifier of the agent running this hook
agent_typeType of agent ("main", subagent type name, etc.)
worktreePath to the git worktree, if the agent is running in one

Exit Codes

Exit CodeMeaningBehavior
0SuccessContinue, parse JSON stdout
2Blocking errorBlock operation, stderr shown as error
OtherNon-blocking errorContinue, stderr shown in verbose mode

JSON Output (stdout, exit code 0)

{
"continue": true,
"stopReason": "Optional message if stopping",
"suppressOutput": false,
"systemMessage": "Optional warning message",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "File is in allowed directory",
"updatedInput": {
"file_path": "/modified/path.js"
}
}
}

Environment Variables

VariableAvailabilityDescription
CLAUDE_PROJECT_DIRAll hooksAbsolute path to project root
CLAUDE_ENV_FILESessionStart, CwdChanged, FileChangedFile path for persisting env vars
CLAUDE_CODE_REMOTEAll hooks"true" if running in remote environments
${CLAUDE_PLUGIN_ROOT}Plugin hooksPath to plugin directory
${CLAUDE_PLUGIN_DATA}Plugin hooksPath to plugin data directory
CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MSSessionEnd hooksConfigurable timeout in milliseconds for SessionEnd hooks (overrides default)

Prompt-Based Hooks

For Stop and SubagentStop events, you can use LLM-based evaluation:

{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Review if all tasks are complete. Return your decision.",
"timeout": 30
}
]
}
]
}
}

LLM Response Schema:

{
"decision": "approve",
"reason": "All tasks completed successfully",
"continue": false,
"stopReason": "Task complete"
}

Examples

Example 1: Bash Command Validator (PreToolUse)

File: .claude/hooks/validate-bash.py

#!/usr/bin/env python3
import json
import sys
import re
BLOCKED_PATTERNS = [
(r"\brm\s+-rf\s+/", "Blocking dangerous rm -rf / command"),
(r"\bsudo\s+rm", "Blocking sudo rm command"),
]
def main():
input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name", "")
if tool_name != "Bash":
sys.exit(0)
command = input_data.get("tool_input", {}).get("command", "")
for pattern, message in BLOCKED_PATTERNS:
if re.search(pattern, command):
print(message, file=sys.stderr)
sys.exit(2) # Exit 2 = blocking error
sys.exit(0)
if __name__ == "__main__":
main()

Configuration:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/validate-bash.py\""
}
]
}
]
}
}

Example 2: Security Scanner (PostToolUse)

File: .claude/hooks/security-scan.py

#!/usr/bin/env python3
import json
import sys
import re
SECRET_PATTERNS = [
(r"password\s*=\s*['\"][^'\"]+['\"]", "Potential hardcoded password"),
(r"api[_-]?key\s*=\s*['\"][^'\"]+['\"]", "Potential hardcoded API key"),
]
def main():
input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name", "")
if tool_name not in ["Write", "Edit"]:
sys.exit(0)
tool_input = input_data.get("tool_input", {})
content = tool_input.get("content", "") or tool_input.get("new_string", "")
file_path = tool_input.get("file_path", "")
warnings = []
for pattern, message in SECRET_PATTERNS:
if re.search(pattern, content, re.IGNORECASE):
warnings.append(message)
if warnings:
output = {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": f"Security warnings for {file_path}: " + "; ".join(warnings)
}
}
print(json.dumps(output))
sys.exit(0)
if __name__ == "__main__":
main()

Example 3: Auto-Format Code (PostToolUse)

File: .claude/hooks/format-code.sh

#!/bin/bash
# Read JSON from stdin
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('tool_name', ''))")
FILE_PATH=$(echo "$INPUT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('tool_input', {}).get('file_path', ''))")
if [ "$TOOL_NAME" != "Write" ] && [ "$TOOL_NAME" != "Edit" ]; then
exit 0
fi
# Format based on file extension
case "$FILE_PATH" in
*.js|*.jsx|*.ts|*.tsx|*.json)
command -v prettier &>/dev/null && prettier --write "$FILE_PATH" 2>/dev/null
;;
*.py)
command -v black &>/dev/null && black "$FILE_PATH" 2>/dev/null
;;
*.go)
command -v gofmt &>/dev/null && gofmt -w "$FILE_PATH" 2>/dev/null
;;
esac
exit 0

Example 4: Prompt Validator (UserPromptSubmit)

File: .claude/hooks/validate-prompt.py

#!/usr/bin/env python3
import json
import sys
import re
BLOCKED_PATTERNS = [
(r"delete\s+(all\s+)?database", "Dangerous: database deletion"),
(r"rm\s+-rf\s+/", "Dangerous: root deletion"),
]
def main():
input_data = json.load(sys.stdin)
prompt = input_data.get("user_prompt", "") or input_data.get("prompt", "")
for pattern, message in BLOCKED_PATTERNS:
if re.search(pattern, prompt, re.IGNORECASE):
output = {
"decision": "block",
"reason": f"Blocked: {message}"
}
print(json.dumps(output))
sys.exit(0)
sys.exit(0)
if __name__ == "__main__":
main()

Example 5: Intelligent Stop Hook (Prompt-Based)

{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Review if Claude completed all requested tasks. Check: 1) Were all files created/modified? 2) Were there unresolved errors? If incomplete, explain what's missing.",
"timeout": 30
}
]
}
]
}
}

Example 6: Context Usage Tracker (Hook Pairs)

Track token consumption per request using UserPromptSubmit (pre-message) and Stop (post-response) hooks together.

File: .claude/hooks/context-tracker.py

#!/usr/bin/env python3
"""
Context Usage Tracker - Tracks token consumption per request.
Uses UserPromptSubmit as "pre-message" hook and Stop as "post-response" hook
to calculate the delta in token usage for each request.
Token Counting Methods:
1. Character estimation (default): ~4 chars per token, no dependencies
2. tiktoken (optional): More accurate (~90-95%), requires: pip install tiktoken
"""
import json
import os
import sys
import tempfile
# Configuration
CONTEXT_LIMIT = 128000 # Claude's context window (adjust for your model)
USE_TIKTOKEN = False # Set True if tiktoken is installed for better accuracy
def get_state_file(session_id: str) -> str:
"""Get temp file path for storing pre-message token count, isolated by session."""
return os.path.join(tempfile.gettempdir(), f"claude-context-{session_id}.json")
def count_tokens(text: str) -> int:
"""
Count tokens in text.
Uses tiktoken with p50k_base encoding if available (~90-95% accuracy),
otherwise falls back to character estimation (~80-90% accuracy).
"""
if USE_TIKTOKEN:
try:
import tiktoken
enc = tiktoken.get_encoding("p50k_base")
return len(enc.encode(text))
except ImportError:
pass # Fall back to estimation
# Character-based estimation: ~4 characters per token for English
return len(text) // 4
def read_transcript(transcript_path: str) -> str:
"""Read and concatenate all content from transcript file."""
if not transcript_path or not os.path.exists(transcript_path):
return ""
content = []
with open(transcript_path, "r") as f:
for line in f:
try:
entry = json.loads(line.strip())
# Extract text content from various message formats
if "message" in entry:
msg = entry["message"]
if isinstance(msg.get("content"), str):
content.append(msg["content"])
elif isinstance(msg.get("content"), list):
for block in msg["content"]:
if isinstance(block, dict) and block.get("type") == "text":
content.append(block.get("text", ""))
except json.JSONDecodeError:
continue
return "\n".join(content)
def handle_user_prompt_submit(data: dict) -> None:
"""Pre-message hook: Save current token count before request."""
session_id = data.get("session_id", "unknown")
transcript_path = data.get("transcript_path", "")
transcript_content = read_transcript(transcript_path)
current_tokens = count_tokens(transcript_content)
# Save to temp file for later comparison
state_file = get_state_file(session_id)
with open(state_file, "w") as f:
json.dump({"pre_tokens": current_tokens}, f)
def handle_stop(data: dict) -> None:
"""Post-response hook: Calculate and report token delta."""
session_id = data.get("session_id", "unknown")
transcript_path = data.get("transcript_path", "")
transcript_content = read_transcript(transcript_path)
current_tokens = count_tokens(transcript_content)
# Load pre-message count
state_file = get_state_file(session_id)
pre_tokens = 0
if os.path.exists(state_file):
try:
with open(state_file, "r") as f:
state = json.load(f)
pre_tokens = state.get("pre_tokens", 0)
except (json.JSONDecodeError, IOError):
pass
# Calculate delta
delta_tokens = current_tokens - pre_tokens
remaining = CONTEXT_LIMIT - current_tokens
percentage = (current_tokens / CONTEXT_LIMIT) * 100
# Report usage
method = "tiktoken" if USE_TIKTOKEN else "estimated"
print(f"Context ({method}): ~{current_tokens:,} tokens ({percentage:.1f}% used, ~{remaining:,} remaining)", file=sys.stderr)
if delta_tokens > 0:
print(f"This request: ~{delta_tokens:,} tokens", file=sys.stderr)
def main():
data = json.load(sys.stdin)
event = data.get("hook_event_name", "")
if event == "UserPromptSubmit":
handle_user_prompt_submit(data)
elif event == "Stop":
handle_stop(data)
sys.exit(0)
if __name__ == "__main__":
main()

Configuration:

{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-tracker.py\""
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-tracker.py\""
}
]
}
]
}
}

How it works:

  1. UserPromptSubmit fires before your prompt is processed - saves current token count
  2. Stop fires after Claude responds - calculates delta and reports usage
  3. Each session is isolated via session_id in the temp filename

Token Counting Methods:

MethodAccuracyDependenciesSpeed
Character estimation~80-90%Noneunder 1ms
tiktoken (p50k_base)~90-95%pip install tiktokenunder 10ms

Note: Anthropic hasn’t released an official offline tokenizer. Both methods are approximations. The transcript includes user prompts, Claude’s responses, and tool outputs, but NOT system prompts or internal context.

Advanced Hook Patterns

Pattern: On-Demand Safety Hooks (/careful Mode)

Don’t run opinionated hooks on every tool use — gate them behind a user-invocable trigger.

The /careful pattern activates a PreToolUse hook only when the user enables it:

  1. User runs /careful → skill writes a flag to a temp file
  2. PreToolUse hook checks for the flag before each tool execution
  3. Hook intercepts destructive commands: git push --force, rm -rf, production deployments
  4. User runs /careful off → flag removed, hooks deactivate

This avoids constant interruption while still providing a safety net when working in dangerous territory.

When to activate /careful:

  • Before deploying to production
  • When running database migrations
  • When using --force git operations
  • When working in unfamiliar codebases

Pattern: Measuring Skill Usage

Use PreToolUse hooks to log skill invocations to an append-only file:

{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) skill-invoked\" >> ~/.claude/skill-log.txt"
}]
}]
}
}

Review weekly to identify:

  • Undertriggering: skills that exist but rarely match (wrong description field)
  • Overtriggering: skills that activate on unrelated tasks (description too broad)

Pattern: Permission Routing to a Stronger Model

Route permission requests through a more capable model for security analysis:

{
"hooks": {
"PreToolUse": [{
"matcher": "Bash(.*)",
"hooks": [{
"type": "command",
"command": "node ~/.claude/hooks/security-check.mjs \"$CLAUDE_TOOL_INPUT\""
}]
}]
}
}

The hook script calls a more powerful model (e.g., Opus) to assess the bash command for potential attack vectors before allowing execution. Particularly useful when reviewing PRs from external contributors or running untrusted code.

Example 7: Seed Auto-Mode Permissions (One-Time Setup Script)

A one-time setup script that seeds ~/.claude/settings.json with ~67 safe permission rules equivalent to Claude Code’s auto-mode baseline — without any hook, without remembering future choices. Run it once; safe to re-run (skips rules already present).

File: 09-advanced-features/setup-auto-mode-permissions.py

Terminal window
# Preview what would be added
python3 09-advanced-features/setup-auto-mode-permissions.py --dry-run
# Apply
python3 09-advanced-features/setup-auto-mode-permissions.py

What gets added:

CategoryExamples
Built-in toolsRead(*), Edit(*), Write(*), Glob(*), Grep(*), Agent(*), WebSearch(*)
Git readBash(git status:*), Bash(git log:*), Bash(git diff:*)
Git write (local)Bash(git add:*), Bash(git commit:*), Bash(git checkout:*)
Package managersBash(npm install:*), Bash(pip install:*), Bash(cargo build:*)
Build & testBash(make:*), Bash(pytest:*), Bash(go test:*)
Common shellBash(ls:*), Bash(cat:*), Bash(find:*), Bash(cp:*), Bash(mv:*)
GitHub CLIBash(gh pr view:*), Bash(gh pr create:*), Bash(gh issue list:*)

What is intentionally excluded (never added by this script):

  • rm -rf, sudo, force push, git reset --hard
  • DROP TABLE, kubectl delete, terraform destroy
  • npm publish, curl | bash, production deploys

Plugin Hooks

Plugins can include hooks in their hooks/hooks.json file:

File: plugins/hooks/hooks.json

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh"
}
]
}
]
}
}

Environment Variables in Plugin Hooks:

  • ${CLAUDE_PLUGIN_ROOT} - Path to the plugin directory
  • ${CLAUDE_PLUGIN_DATA} - Path to the plugin data directory

This allows plugins to include custom validation and automation hooks.

MCP Tool Hooks

MCP tools follow the pattern mcp__[server]__[tool]:

{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__memory__.*",
"hooks": [
{
"type": "command",
"command": "echo '{\"systemMessage\": \"Memory operation logged\"}'"
}
]
}
]
}
}

Security Considerations

Disclaimer

USE AT YOUR OWN RISK: Hooks execute arbitrary shell commands. You are solely responsible for:

  • Commands you configure
  • File access/modification permissions
  • Potential data loss or system damage
  • Testing hooks in safe environments before production use

Security Notes

  • Workspace trust required: The statusLine and fileSuggestion hook output commands now require workspace trust acceptance before they take effect.
  • HTTP hooks and environment variables: HTTP hooks require an explicit allowedEnvVars list to use environment variable interpolation in URLs. This prevents accidental leakage of sensitive environment variables to remote endpoints.
  • Managed settings hierarchy: The disableAllHooks setting now respects the managed settings hierarchy, meaning organization-level settings can enforce hook disablement that individual users cannot override.

Best Practices

DoDon’t
Validate and sanitize all inputsTrust input data blindly
Quote shell variables: "$VAR"Use unquoted: $VAR
Block path traversal (..)Allow arbitrary paths
Use absolute paths with $CLAUDE_PROJECT_DIRHardcode paths
Skip sensitive files (.env, .git/, keys)Process all files
Test hooks in isolation firstDeploy untested hooks
Use explicit allowedEnvVars for HTTP hooksExpose all env vars to webhooks

Debugging

Enable Debug Mode

Run Claude with debug flag for detailed hook logs:

Terminal window
claude --debug

Verbose Mode

Use Ctrl+O in Claude Code to enable verbose mode and see hook execution progress.

Test Hooks Independently

Terminal window
# Test with sample JSON input
echo '{"tool_name": "Bash", "tool_input": {"command": "ls -la"}}' | python3 .claude/hooks/validate-bash.py
# Check exit code
echo $?

Complete Configuration Example

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/validate-bash.py\"",
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/format-code.sh\"",
"timeout": 30
},
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/security-scan.py\"",
"timeout": 10
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/validate-prompt.py\""
}
]
}
],
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-init.sh\""
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Verify all tasks are complete before stopping.",
"timeout": 30
}
]
}
]
}
}

Hook Execution Details

AspectBehavior
Timeout60 seconds default, configurable per command
ParallelizationAll matching hooks run in parallel
DeduplicationIdentical hook commands deduplicated
EnvironmentRuns in current directory with Claude Code’s environment

Troubleshooting

Hook Not Executing

  • Verify JSON configuration syntax is correct
  • Check matcher pattern matches the tool name
  • Ensure script exists and is executable: chmod +x script.sh
  • Run claude --debug to see hook execution logs
  • Verify hook reads JSON from stdin (not command args)

Hook Blocks Unexpectedly

  • Test hook with sample JSON: echo '{"tool_name": "Write", ...}' | ./hook.py
  • Check exit code: should be 0 for allow, 2 for block
  • Check stderr output (shown on exit code 2)

JSON Parsing Errors

  • Always read from stdin, not command arguments
  • Use proper JSON parsing (not string manipulation)
  • Handle missing fields gracefully

Installation

Step 1: Create Hooks Directory

Terminal window
mkdir -p ~/.claude/hooks

Step 2: Copy Example Hooks

Terminal window
cp 06-hooks/*.sh ~/.claude/hooks/
chmod +x ~/.claude/hooks/*.sh

Step 3: Configure in Settings

Edit ~/.claude/settings.json or .claude/settings.json with the hook configuration shown above.


WorktreeCreate and WorktreeRemove — Custom Worktree Lifecycle

Claude Code creates git worktrees automatically when you use the --worktree flag or when a subagent has isolation: worktree in its frontmatter. By default, Claude runs git worktree add using origin/HEAD as the base branch. The WorktreeCreate hook replaces this default behavior entirely when configured — giving you full control over how worktrees are created.

Why you’d want this

  • Different base branch: branch from develop or release/next instead of main
  • Custom naming conventions: enforce <team>/<ticket>/<name> branch format
  • Non-git VCS: integrate with Perforce, SVN, or Mercurial
  • Pre-population: copy additional files or secrets into the new worktree before Claude starts

WorktreeCreate: branching from develop instead of origin/HEAD

{
"hooks": {
"WorktreeCreate": [{
"matcher": "",
"hooks": [{
"type": "command",
"command": "git worktree add \"$WORKTREE_PATH\" -b \"$WORKTREE_BRANCH\" origin/develop"
}]
}]
}
}

The hook receives WORKTREE_PATH and WORKTREE_BRANCH as environment variables. Your command is responsible for the full git worktree add call — Claude’s default logic is bypassed.

Warning: When WorktreeCreate is configured, .worktreeinclude is not processed automatically. If you rely on .worktreeinclude to copy .env or other gitignored files into new worktrees, add those copy steps to your hook script.

WorktreeRemove: cleanup on exit

WorktreeRemove fires when a worktree is removed — either at subagent exit or session end. It cannot block removal (any exit code is non-blocking), but it’s useful for cleanup: removing lock files, updating CI dashboards, or archiving artifacts.

{
"hooks": {
"WorktreeRemove": [{
"matcher": "",
"hooks": [{
"type": "command",
"command": "echo \"$(date -u) Worktree removed: $WORKTREE_PATH\" >> ~/.claude/worktree-audit.log"
}]
}]
}
}

Notification Hook — Desktop Alerts When Claude Needs You

The Notification event fires whenever Claude sends a notification to the user — permission requests, idle state, auth success, and MCP elicitation dialogs. Unlike most hook events, Notification hooks cannot block or modify the notification, but they can run any command in response, which makes them perfect for OS-level desktop alerts.

Available matchers

MatcherWhen it fires
permission_promptClaude needs permission to run a tool
idle_promptClaude has finished and is waiting for your input
auth_successAuthentication completed successfully
elicitation_dialogAn MCP server is requesting user input
(empty string)All notification types

Notification flow

flowchart LR A["Claude goes idle\nor needs permission"] --> B["Notification event fires"] B --> C["Hook command runs"] C --> D["OS shows\ndesktop alert"] D --> E["You return\nto the session"]

Platform examples

macOS (osascript):

{
"hooks": {
"Notification": [{
"matcher": "idle_prompt",
"hooks": [{
"type": "command",
"command": "osascript -e 'display notification \"Claude is ready\" with title \"Claude Code\"'"
}]
}]
}
}

Linux (notify-send):

{
"hooks": {
"Notification": [{
"matcher": "idle_prompt",
"hooks": [{
"type": "command",
"command": "notify-send 'Claude Code' 'Claude is ready for input'"
}]
}]
}
}

Windows (PowerShell toast notification):

{
"hooks": {
"Notification": [{
"matcher": "idle_prompt",
"hooks": [{
"type": "command",
"command": "powershell -Command \"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.MessageBox]::Show('Claude is ready', 'Claude Code')\""
}]
}]
}
}

Tip: Use an empty matcher "" to catch all notification types in a single hook, or stack multiple matchers to handle permission_prompt and idle_prompt differently (e.g., a louder alert for permission prompts).


agent Hook Type — LLM-Powered Permission Gates

The agent hook type is the fourth hook handler (alongside command, prompt, and http). Where a prompt hook performs a single-turn LLM evaluation, an agent hook spawns a full subagent that can use tools — Read, Grep, Glob, and more — to reach its decision.

This makes agent hooks suitable for permission gates that need real reasoning: checking whether a file path is inside the allowed directory, verifying that a bash command doesn’t touch production config, or reading a design doc to confirm an architectural constraint.

Structure

{
"hooks": {
"PreToolUse": [{
"matcher": "Write|Edit",
"hooks": [{
"type": "agent",
"prompt": "The user is about to write to this path: $ARGUMENTS. Verify the path is inside the src/ or tests/ directory. If it is outside those directories, block the operation. Return your decision as JSON with 'decision': 'allow' or 'deny' and a 'reason' field.",
"timeout": 60
}]
}]
}
}

The agent receives the hook’s JSON input via $ARGUMENTS, can use tools to explore the filesystem or read files, and returns a structured allow/deny decision. Default timeout is 60 seconds.

When to use agent vs prompt

promptagent
Can use toolsNoYes (Read, Grep, Glob)
SpeedFast (~1–3s)Slower (~5–30s)
Best forSimple text evaluationPath checking, file inspection, doc lookup
CostSingle inference callFull subagent invocation

Use prompt for lightweight decisions based on the hook input alone. Use agent when the decision requires reading files, checking paths, or any multi-step reasoning that needs tools.

Cross-reference: See Agent hooks in component frontmatter for using agent hooks scoped to specific skills or subagents, and Skills for the disable-model-invocation pattern that pairs well with hooks for controlled side-effect management.


Additional Resources