The complete guide to Claude Code setup (Opus 4.6, Sonnet 4.6, Haiku 4.5). 1M token context window. 100+ hours saved. 25 hook events. Agent teams and task management. Production-tested patterns for skills, hooks, and MCP integration.
Claude Code hooks are customizable scripts that run at specific points in the AI workflow, enabling automation, validation, and context injection. This guide covers all 25 hook events, 3 hook types, async execution, and production-tested patterns.
Purpose: Automate workflows with event-driven hooks Source: Anthropic blog “How to Configure Hooks” Evidence: 20 hooks in production, 96% test validation Updated: Mar 31, 2026 — Added PermissionDenied hook (20th). Mar 6, 2026 — Added InstructionsLoaded hook (19th), agent_id/agent_type fields in hook events, worktree status line field, TeammateIdle/TaskCompleted continue:false support
| Hook | Trigger | Use For |
|---|---|---|
| SessionStart | Session begins | Inject git status, context, env vars |
| UserPromptSubmit | User sends message | Skill matching, prompt preprocessing |
| PreToolUse | Before tool executes | Block dangerous operations, validation |
| PostToolUse | After tool runs | Auto-format, logging, monitoring |
| PreCompact | Before context compaction | Backup transcripts, save state |
| PermissionRequest | Permission dialog appears | Auto-approve safe commands |
| PermissionDenied | Auto mode classifier denies a command | Log denials, custom retry logic, observability |
| Notification | Claude sends a notification | Custom alerts, logging, integrations |
| Stop | Response ends | Suggest skill creation, cleanup |
| SessionEnd | Session closes | Save summaries, final checkpoint |
| PostToolUseFailure | Tool call fails | Log errors, track failure patterns |
| SubagentStart | Subagent spawns | Monitor agent lifecycle, logging |
| SubagentStop | Subagent completes | Log results, track agent activity |
| TeammateIdle | Teammate agent becomes idle | Pause teammates, reassign work |
| TaskCompleted | A task finishes (Agent Teams) | Reassign work, trigger follow-ups |
| InstructionsLoaded | CLAUDE.md or .claude/rules/*.md loaded | Track which instructions are active |
| Setup | --init / --maintenance |
Install deps, configure environments |
| ConfigChange | Config file changes mid-session | Security auditing, live reloading |
| WorktreeCreate | Agent worktree is created | Custom VCS setup (SVN, Perforce, Hg) |
| WorktreeRemove | Agent worktree is removed | Cleanup after agent completion |
Session Lifecycle: SessionStart → UserPromptSubmit → … → Stop → SessionEnd
Tool Lifecycle: PreToolUse → (tool runs) → PostToolUse / PostToolUseFailure
Agent Lifecycle: SubagentStart → (agent works) → SubagentStop
Agent Teams: TeammateIdle (idle detection), TaskCompleted (task completion)
Worktree Lifecycle: WorktreeCreate → (agent works in isolation) → WorktreeRemove
Instructions: InstructionsLoaded (CLAUDE.md and rules file loading)
Other: PreCompact (context management), PermissionRequest (security), Notification (alerts), Setup (initialization), ConfigChange (config monitoring)
File: .claude/settings.json
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/session-start.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/prettier-format.sh",
"statusMessage": "✨ Formatting file..."
}
]
}
]
}
}
Claude Code supports three distinct hook types. Each serves a different purpose and complexity level.
type: "command")Shell script execution. The hook receives JSON via stdin and can return JSON on stdout. This is the most common type and what all examples in this guide use by default.
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/my-hook.sh"
}
]
}
type: "prompt")Single-turn LLM evaluation. Instead of running a shell script, the hook sends a prompt to an LLM which evaluates the situation and returns a decision. No tools are available to the LLM – it makes its decision based solely on the prompt and the event context provided.
{
"PreToolUse": [
{
"matcher": { "tool_name": "Bash" },
"hooks": [
{
"type": "prompt",
"prompt": "Evaluate if this bash command is safe to run. Block any destructive commands like rm -rf, git push --force, or DROP TABLE. Return ALLOW for safe commands, DENY for dangerous ones."
}
]
}
]
}
When to use prompt hooks:
Tradeoffs:
type: "agent")Multi-turn hook with full tool access. The hook prompt is given to an agent that can use tools (Read, Bash, Grep, etc.) to investigate the situation before making a decision. This is the most powerful but also the most expensive hook type.
{
"PreToolUse": [
{
"matcher": { "tool_name": "Write" },
"hooks": [
{
"type": "agent",
"prompt": "Review the file being written. Check if it follows project conventions by reading similar files in the same directory. Block if it violates established patterns."
}
]
}
]
}
When to use agent hooks:
Tradeoffs:
| Aspect | command |
prompt |
agent |
|---|---|---|---|
| Execution | Shell script | Single LLM turn | Multi-turn LLM + tools |
| Latency | Milliseconds | 1-3 seconds | 5-30+ seconds |
| Cost | Free (local) | 1 LLM call | Multiple LLM calls |
| Tool access | External commands | None | Full Claude tools |
| Setup effort | Script file | Inline prompt | Inline prompt |
| Best for | Automation, CI | Quick safety checks | Deep code review |
Any hook can be made asynchronous by adding "async": true. Async hooks run in the background without blocking Claude’s workflow.
{
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/log-analytics.sh",
"async": true
}
]
}
]
}
Key behaviors:
When to use async:
Hooks can be defined in multiple locations. They are loaded and merged in this order (later scopes add to, but don’t override, earlier ones):
| Priority | Location | Scope | Use Case |
|---|---|---|---|
| 1 | ~/.claude/settings.json |
User (all projects) | Personal workflow automation |
| 2 | .claude/settings.json |
Project (committed) | Team-shared hooks |
| 3 | .claude/settings.local.json |
Local (not committed) | Personal overrides for a project |
| 4 | Managed policy | Enterprise (admin-managed) | Organization-wide enforcement |
| 5 | Plugin hooks | Installed plugins | Plugin-provided automation |
| 6 | Skill/agent frontmatter | YAML hooks: field |
Skill-specific hooks |
How merging works: Hooks from all scopes are combined. If the same event has hooks in multiple scopes, all hooks run (they don’t replace each other). This means a user-level SessionStart hook and a project-level SessionStart hook both execute.
Skill frontmatter hooks support a once field to limit execution:
hooks:
PreToolUse:
- matcher: { tool_name: "Bash" }
hooks:
- type: command
command: "./check.sh"
once: true # Only runs once per session, not on every match
Different hook events handle decisions differently. Understanding these patterns is essential for writing hooks that correctly block, allow, or modify behavior.
PreToolUse hooks use hookSpecificOutput to communicate decisions:
{
"hookSpecificOutput": {
"decision": "allow"
}
}
Valid decisions for PreToolUse:
"allow" – permit the tool call to proceed"deny" – block the tool call (Claude sees the denial)"ask_user" – pause and ask the user for confirmationExample deny with reason:
{
"hookSpecificOutput": {
"decision": "deny",
"reason": "Cannot write files to project root. Use src/ or memory-bank/ instead."
}
}
Events other than PreToolUse use a top-level decision field:
{
"decision": "block",
"reason": "Explanation shown to the user"
}
Exit code 2 has different effects depending on the hook event:
| Event | Exit Code 2 Effect |
|---|---|
| PreToolUse | Blocks the tool call |
| PostToolUse | Ignored (tool already ran) |
| UserPromptSubmit | Blocks the prompt from being processed |
| Notification | Ignored |
| Stop | Ignored |
| SessionEnd | Ignored |
| PostToolUseFailure | Ignored |
| TeammateIdle | Pauses the idle teammate |
| TaskCompleted | Can reassign the completed task |
| SubagentStart | Ignored |
| SubagentStop | Ignored |
| Setup | Ignored (cannot block) |
| ConfigChange | Blocks config change (except policy) |
| WorktreeCreate | Fails worktree creation |
| WorktreeRemove | Ignored (cannot block) |
Rule of thumb: Exit code 2 only matters for “Pre” events (where blocking makes sense), agent team events (where pausing/reassignment makes sense), ConfigChange (security enforcement), and WorktreeCreate (VCS setup validation).
When matching MCP (Model Context Protocol) tool calls in PreToolUse or PostToolUse, use the mcp__<server>__<tool> naming pattern:
{
"PreToolUse": [
{
"matcher": {
"tool_name": "mcp__postgres__query"
},
"hooks": [
{
"type": "command",
"command": ".claude/hooks/validate-sql-query.sh"
}
]
}
]
}
More examples:
{
"PreToolUse": [
{
"matcher": { "tool_name": "mcp__slack__post_message" },
"hooks": [
{
"type": "command",
"command": ".claude/hooks/review-slack-message.sh"
}
]
},
{
"matcher": { "tool_name": "mcp__github__create_pull_request" },
"hooks": [
{ "type": "command", "command": ".claude/hooks/validate-pr.sh" }
]
}
]
}
Pattern: The tool name follows the format mcp__<server-name>__<tool-name>, where the server name comes from your MCP configuration and the tool name is defined by the MCP server.
Claude Code passes data via stdin as JSON, NOT via environment variables!
| Variable | Description | Available In |
|---|---|---|
$CLAUDE_PROJECT_DIR |
Absolute path to project root | All hooks |
$CLAUDE_CODE_REMOTE |
“true” in web, not set in CLI | All hooks |
$CLAUDE_ENV_FILE |
Path to persist env vars | SessionStart only |
{
"command": "npx prettier --write \"$CLAUDE_TOOL_INPUT_FILE_PATH\" 2>/dev/null || true"
}
Why it fails: $CLAUDE_TOOL_INPUT_FILE_PATH doesn’t exist! It evaluates to empty string, so npx prettier --write "" formats ALL files in the project and hangs forever.
Create .claude/hooks/prettier-format.sh:
#!/bin/bash
# Read JSON from stdin with timeout (prevents hang)
JSON_INPUT=$(timeout 2 cat)
# Extract file path from JSON (the CORRECT way!)
FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // empty')
# Validate and format
if [ -n "$FILE_PATH" ] && [ -f "$FILE_PATH" ]; then
case "$FILE_PATH" in
*.js|*.ts|*.json|*.css|*.html|*.md|*.yaml)
timeout 10 npx prettier --write "$FILE_PATH" 2>/dev/null || true
;;
esac
fi
exit 0
{
"session_id": "abc123",
"tool_name": "Write",
"tool_input": {
"file_path": "/absolute/path/to/file.txt",
"content": "file content here"
},
"tool_response": { "success": true }
}
Evidence: Feb 7, 2026 — Production branch stuck on “✨ Formatting file…” during AI Training System implementation. Root cause: $CLAUDE_TOOL_INPUT_FILE_PATH was empty → prettier scanned 99+ files. Fix: stdin JSON parsing with jq.
Hooks that read JSON from stdin must use timeout to prevent infinite hangs.
The problem: Claude Code pipes JSON to hook scripts via stdin. Occasionally — especially under high context load or rapid sequential tool calls — the stdin pipe doesn’t close properly. If your hook uses $(cat) to read stdin, it blocks forever waiting for EOF, causing Claude Code to appear “stuck.”
The fix: Always use timeout when reading stdin in hooks:
# WRONG — can hang forever if stdin pipe not closed
JSON_INPUT=$(cat)
# BETTER — exits after 2 seconds max
JSON_INPUT=$(timeout 2 cat)
# BEST — timeout + suppress stderr + fallback on failure
JSON_INPUT=$(timeout 2 cat 2>/dev/null || true)
Affected hook types: Any hook that reads stdin — PreToolUse, PostToolUse, PreCompact, Stop, UserPromptSubmit. The SessionStart hook typically doesn’t read stdin so is unaffected.
Cascading errors: When a PreToolUse hook hangs or errors, the PostToolUse hooks for the same tool call can also error — even if they use timeout correctly. Fix the root cause (the hanging hook) and the cascade disappears.
How to test:
# Simulate a never-closing stdin pipe
mkfifo /tmp/test-fifo
(sleep 100 > /tmp/test-fifo) &
BG=$!
# Should complete in ~2s (not hang forever)
time bash .claude/hooks/your-hook.sh < /tmp/test-fifo
kill $BG; rm /tmp/test-fifo
Evidence: Feb 2026 — Production. PostToolUse:Read hook hung during multi-file implementation session. Root cause: $(cat) in skill-access-monitor.sh. Fix: $(timeout 2 cat). Verified: 2016ms completion vs infinite hang.
Evidence: Mar 2026 — PreToolUse:Edit hook error and two cascading PostToolUse:Edit hook error messages on every Edit during a heavy API test session. Root cause: a PreToolUse hook for Sacred pattern checking used bare $(cat) without timeout. Under load (57 concurrent Gemini API calls), stdin pipe closure was delayed, cat hung, and Claude Code killed the hook. The PostToolUse hooks (which used timeout 2 cat correctly) also errored as a cascade. Fix: $(timeout 2 cat 2>/dev/null || true) in the PreToolUse hook. Verified: zero hook errors after fix.
Related: Even with timeout, the timeout command itself can write to stderr on signal delivery (e.g., “Terminated”). See Suppress stderr in Best Practices.
Hooks that run external commands (like git fetch) should also use timeout to prevent hangs from network or I/O failures.
The problem: A SessionStart hook running git fetch origin hangs if the network is down or the remote is unresponsive. The hook’s 600-second timeout budget is generous, but users see Claude Code as frozen.
The fix: Wrap external commands with timeout:
# WRONG — hangs if network is down
git fetch origin --quiet 2>/dev/null
# CORRECT — fails fast after 5 seconds
timeout 5 git fetch origin --quiet 2>/dev/null
When to use: Any hook calling network services (git fetch, curl, API calls). The timeout should be short (2-5 seconds) since hooks should not block the user experience.
Evidence: Feb 2026 — Intermittent SessionStart hook errors traced to git fetch network failures. Adding timeout 5 eliminated the issue. Hook runs reliably at ~700ms average.
Fires when the user sends a message, before Claude processes it. Use for skill matching, input preprocessing, or injecting context.
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/pre-prompt.sh"
}
]
}
]
}
}
stdin JSON: {"session_id": "...", "prompt": "user's message text"}
Production use: Pre-prompt skill matching — reads user query, searches skill index, injects top 3 matching skills into context.
Fires before a tool executes. Return non-zero exit code to block the tool call.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-root-file-creation.sh"
}
]
}
]
}
}
stdin JSON: {"session_id": "...", "tool_name": "Write", "tool_input": {"file_path": "/path/file.txt", "content": "..."}}
Production use: Block file creation in project root directory (enforce organized file structure).
Fires when the session closes (user exits or session times out).
{
"hooks": {
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/session-end.sh"
}
]
}
]
}
}
stdin JSON: {"session_id": "..."}
Production use: Save session summary, suggest creating a skill from patterns observed during the session.
Fires when a tool call fails (non-zero exit, timeout, error). Useful for monitoring and debugging.
{
"hooks": {
"PostToolUseFailure": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/tool-failure-logger.sh"
}
]
}
]
}
}
Example script (.claude/hooks/tool-failure-logger.sh):
#!/bin/bash
set -euo pipefail
INPUT=$(timeout 2 cat 2>/dev/null || echo '{}')
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "unknown"' 2>/dev/null || echo "unknown")
ERROR=$(echo "$INPUT" | jq -r '.error // "no error"' 2>/dev/null || echo "no error")
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
LOG_FILE="${CLAUDE_PROJECT_DIR:-.}/.claude/logs/tool-failures.log"
mkdir -p "$(dirname "$LOG_FILE")"
echo "[$TIMESTAMP] FAIL: $TOOL_NAME - $ERROR" >> "$LOG_FILE"
# Rotate log at 100 lines
if [ "$(wc -l < "$LOG_FILE")" -gt 100 ]; then
tail -100 "$LOG_FILE" > "$LOG_FILE.tmp" && mv "$LOG_FILE.tmp" "$LOG_FILE"
fi
exit 0
Fire when a subagent (via Task() tool) spawns and completes. Use for monitoring agent lifecycle.
{
"hooks": {
"SubagentStart": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/subagent-monitor.sh"
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/subagent-monitor.sh"
}
]
}
]
}
}
Example script (.claude/hooks/subagent-monitor.sh):
#!/bin/bash
set -euo pipefail
INPUT=$(timeout 2 cat 2>/dev/null || echo '{}')
AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // "unknown"' 2>/dev/null || echo "unknown")
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
LOG_FILE="${CLAUDE_PROJECT_DIR:-.}/.claude/logs/subagent-activity.log"
mkdir -p "$(dirname "$LOG_FILE")"
echo "[$TIMESTAMP] ${CLAUDE_HOOK_EVENT:-unknown}: $AGENT_TYPE" >> "$LOG_FILE"
exit 0
Production use: Track which agents are spawned, how often, and correlate with tool failures.
Fires when Claude Code sends a notification (e.g., task completed, waiting for input). Use for custom alert routing or logging.
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/notification-handler.sh"
}
]
}
]
}
}
stdin JSON: {"message": "Task completed successfully", "title": "Claude Code"}
Example use cases:
Exit code 2: Ignored (notification has already been generated).
Fires when a teammate agent becomes idle in an Agent Teams configuration. Use to monitor agent utilization or pause idle agents to conserve resources.
{
"hooks": {
"TeammateIdle": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/teammate-idle.sh"
}
]
}
]
}
}
Exit code 2: Pauses the idle teammate, preventing it from picking up new work until explicitly resumed.
JSON output (v2.1.69): Return {"continue": false, "stopReason": "..."} to stop the teammate entirely, matching Stop hook behavior.
Example use cases:
continue: falseFires when a task is completed in an Agent Teams configuration. Use to trigger follow-up actions or reassign work.
{
"hooks": {
"TaskCompleted": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/task-completed.sh"
}
]
}
]
}
}
Exit code 2: Can reassign the completed task (e.g., for review by another agent or additional processing).
JSON output (v2.1.69): Return {"continue": false, "stopReason": "..."} to stop the teammate, matching Stop hook behavior.
Example use cases:
continue: falseFires when Claude Code is invoked with --init, --init-only, or --maintenance flags. Use for first-time project setup tasks like installing dependencies or configuring environments.
{
"hooks": {
"Setup": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/project-setup.sh"
}
]
}
]
}
}
Example script (.claude/hooks/project-setup.sh):
#!/bin/bash
set -euo pipefail
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
# Install dependencies if package.json exists
if [ -f "$PROJECT_DIR/package.json" ]; then
cd "$PROJECT_DIR" && npm install --silent 2>/dev/null || true
fi
# Set up git hooks
if [ -d "$PROJECT_DIR/.git" ] && [ -f "$PROJECT_DIR/.husky/pre-commit" ]; then
cd "$PROJECT_DIR" && npx husky install 2>/dev/null || true
fi
exit 0
Key behaviors:
npm install, pip install -r requirements.txt, environment validationFires when a configuration file changes mid-session. The matcher field filters by config type: user_settings, project_settings, local_settings, policy_settings, or skills.
{
"hooks": {
"ConfigChange": [
{
"matcher": "user_settings|project_settings|local_settings",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/config-audit.sh"
}
]
}
]
}
}
Example script (.claude/hooks/config-audit.sh):
#!/bin/bash
set -euo pipefail
INPUT=$(timeout 2 cat 2>/dev/null || echo '{}')
CONFIG_TYPE=$(echo "$INPUT" | jq -r '.config_type // "unknown"' 2>/dev/null || echo "unknown")
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
LOG_FILE="${CLAUDE_PROJECT_DIR:-.}/.claude/logs/config-changes.log"
mkdir -p "$(dirname "$LOG_FILE")"
echo "[$TIMESTAMP] Config changed: $CONFIG_TYPE" >> "$LOG_FILE"
exit 0
Key behaviors:
policy_settings (enterprise policies cannot be blocked)user_settings, project_settings, local_settings, policy_settings, skillsFires when an agent worktree is created for agents configured with isolation: worktree. This enables custom VCS setup for projects using non-git version control (SVN, Perforce, Mercurial).
{
"hooks": {
"WorktreeCreate": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/worktree-init.sh"
}
]
}
]
}
}
Example script (.claude/hooks/worktree-init.sh):
#!/bin/bash
set -euo pipefail
INPUT=$(timeout 2 cat 2>/dev/null || echo '{}')
WORKTREE_PATH=$(echo "$INPUT" | jq -r '.worktree_path // empty' 2>/dev/null)
if [ -n "$WORKTREE_PATH" ]; then
# Example: Initialize Perforce workspace in worktree
# p4 client -o | sed "s|Root:.*|Root: $WORKTREE_PATH|" | p4 client -i
echo "Worktree initialized at: $WORKTREE_PATH"
fi
exit 0
Key behaviors:
isolation: worktree in their configurationFires when an agent worktree is removed after the agent completes its work. Use for cleanup tasks.
{
"hooks": {
"WorktreeRemove": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/worktree-cleanup.sh"
}
]
}
]
}
}
Example script (.claude/hooks/worktree-cleanup.sh):
#!/bin/bash
set -euo pipefail
INPUT=$(timeout 2 cat 2>/dev/null || echo '{}')
WORKTREE_PATH=$(echo "$INPUT" | jq -r '.worktree_path // empty' 2>/dev/null)
if [ -n "$WORKTREE_PATH" ]; then
# Clean up VCS artifacts, temp files, etc.
rm -rf "$WORKTREE_PATH/.p4config" 2>/dev/null || true
echo "Worktree cleaned up: $WORKTREE_PATH"
fi
exit 0
Key behaviors:
Fires when CLAUDE.md or .claude/rules/*.md files are loaded into context. Use for tracking which instruction files are active in a session.
{
"hooks": {
"InstructionsLoaded": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/instructions-loaded.sh"
}
]
}
]
}
}
Example use cases:
Exit code 2: Ignored (instructions have already been loaded).
Hook events include contextual fields beyond the event-specific data. These fields were expanded in v2.1.69:
Subagent-related hooks (SubagentStart, SubagentStop) and hooks fired within an --agent session now include:
{
"session_id": "abc123",
"agent_id": "a7b082d2bec075d54",
"agent_type": "general-purpose",
"tool_name": "Write",
"tool_input": { "..." }
}
agent_id: Unique identifier for the subagent instanceagent_type: The subagent type (e.g., general-purpose, Explore, Plan, code-reviewer)Use these to track which agent triggered a hook, correlate events across agents, or apply different validation rules per agent type.
When running in a --worktree session, status line hook commands receive a worktree object:
{
"worktree": {
"name": "feature-auth",
"path": "/project/.claude/worktrees/feature-auth",
"branch": "claude/feature-auth",
"originalDir": "/project"
}
}
Use this to detect worktree sessions and apply worktree-specific behavior.
{
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/session-start.sh" }
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/pre-prompt.sh" }
]
}
],
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-root-file-creation.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": ".claude/hooks/prettier-format.sh" }
]
}
],
"PreCompact": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/pre-compact.sh" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/stop-hook.sh" }
]
}
],
"SessionEnd": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/session-end.sh" }
]
}
],
"PostToolUseFailure": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/tool-failure-logger.sh"
}
]
}
],
"SubagentStart": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/subagent-monitor.sh" }
]
}
],
"SubagentStop": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/subagent-monitor.sh" }
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/notification-handler.sh"
}
]
}
],
"TeammateIdle": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/teammate-idle.sh" }
]
}
],
"TaskCompleted": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/task-completed.sh" }
]
}
],
"Setup": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/project-setup.sh" }
]
}
],
"ConfigChange": [
{
"matcher": "user_settings|project_settings|local_settings",
"hooks": [
{ "type": "command", "command": ".claude/hooks/config-audit.sh" }
]
}
],
"WorktreeCreate": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/worktree-init.sh" }
]
}
],
"WorktreeRemove": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/worktree-cleanup.sh" }
]
}
],
"InstructionsLoaded": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/instructions-loaded.sh" }
]
}
]
}
}
Note: PermissionRequest is configured separately per permission type.
Instead of writing a shell script, you can define a hook as a prompt that gets evaluated by an LLM. The LLM receives the event context and returns an allow/deny decision. No tools are available – the decision is based solely on the prompt text and event data.
{
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "prompt",
"prompt": "Check if this change follows coding standards. Verify naming conventions, file organization, and that no secrets or credentials are being written. Return ALLOW if safe, DENY with reason if not."
}
]
}
]
}
When to use: Quick safety evaluations, style checks, or convention enforcement that can be decided from the event context alone without reading other files.
Tradeoff: Adds 1-3 seconds of LLM inference latency per matched event. Use command hooks for latency-sensitive paths.
Agent hooks spawn a subagent with tool access for multi-turn verification. The agent can read files, search code, and run commands before making a decision. This is the most powerful but also the most expensive hook type.
{
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "agent",
"prompt": "Verify this command is safe to run. Check if it modifies any protected files by reading .gitignore and .claude/settings.json. Verify it does not delete files outside the project directory.",
"tools": ["Read", "Grep", "Glob"]
}
]
}
]
}
When to use: Complex validations that require file system inspection, comparison against existing patterns, or multi-step reasoning.
Tradeoff: Multiple LLM calls + tool execution per invocation. Can add 5-30+ seconds of latency. Use sparingly on high-frequency events.
PreToolUse hooks can return additionalContext in their JSON output to inject context that the model sees alongside the tool result. This lets hooks guide Claude’s behavior without blocking the tool call.
{
"additionalContext": "Remember: this project uses tabs not spaces. All new files must include the copyright header from .claude/templates/header.txt"
}
Example hook script:
#!/bin/bash
JSON_INPUT=$(timeout 2 cat 2>/dev/null || echo '{}')
FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
# Inject project-specific reminders for certain file types
case "$FILE_PATH" in
*.sql)
echo '{"additionalContext": "All SQL must use parameterized queries. Never concatenate user input."}'
;;
*.test.*)
echo '{"additionalContext": "Test files must include cleanup in afterEach. No test pollution."}'
;;
esac
exit 0
Key behavior: The additionalContext string is shown to the model alongside the tool result. It does not block the tool – it adds guidance for the model’s next response.
Hooks can be defined directly in skill or agent YAML frontmatter, scoped to the component’s lifecycle. These hooks only fire while the skill or agent is active.
---
name: my-deployment-skill
description: Deployment workflow with safety hooks
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: "command"
command: ".claude/hooks/block-production-commands.sh"
PostToolUse:
- matcher: "Write"
hooks:
- type: "command"
command: ".claude/hooks/validate-deployment-config.sh"
---
Key behaviors:
once: true field to limit execution to once per sessionThe Stop and SubagentStop hooks now receive Claude’s final response text in the input JSON via the last_assistant_message field. This eliminates the need to parse transcripts to access the model’s last output.
{
"session_id": "abc123",
"last_assistant_message": "I've completed the refactoring. Here's a summary of changes..."
}
Example use case: Extract action items, summaries, or structured data from Claude’s final response for logging or follow-up workflows.
#!/bin/bash
INPUT=$(timeout 2 cat 2>/dev/null || echo '{}')
LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // empty' 2>/dev/null)
if [ -n "$LAST_MSG" ]; then
# Log the final response for session history
echo "$LAST_MSG" >> "${CLAUDE_PROJECT_DIR:-.}/.claude/logs/session-responses.log"
fi
exit 0
This example shows a command-based hook used in production to enforce code quality patterns on every file write. The shell script does deterministic path matching — only validating src/**/*.js files and instantly allowing everything else:
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/sacred-pattern-check.sh",
"statusMessage": "Checking Sacred patterns..."
}
]
}
The script (.claude/hooks/sacred-pattern-check.sh):
#!/bin/bash
# Sacred pattern check — only validates src/**/*.js files
# All other files are allowed immediately
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.filePath // empty')
# Allow if no file path or file is outside src/**/*.js
if [ -z "$FILE_PATH" ]; then exit 0; fi
if [[ "$FILE_PATH" != */src/*.js ]]; then exit 0; fi
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // .tool_input.new_string // empty')
# Check Sacred patterns only on src/**/*.js files
if echo "$CONTENT" | grep -qP '\.id\b' && echo "$CONTENT" | grep -qi 'employee'; then
if ! echo "$CONTENT" | grep -q 'employee_id'; then
echo "Sacred violation: Use employee_id, not bare id for employee lookups" >&2
exit 2
fi
fi
exit 0
Key patterns demonstrated:
[[ ]] pattern matching is reliable — no LLM interpretation neededgrep and regexsrc/**/*.js exit immediately with code 0jq to extract file_path and content from hook inputPitfall — Why not use
type: "prompt"for file-scoped checks?A
type: "prompt"hook delegates every matched event to an LLM for evaluation. While the prompt can say “if the file is NOT in src/, always allow”, LLMs don’t reliably follow file-scoping instructions — they may block edits to plan files, markdown, or other non-matching files. For path-based filtering, always usetype: "command"with a shell script that does deterministic matching. Reservetype: "prompt"for checks where LLM judgment is genuinely needed (e.g., reviewing code quality of the content itself, not deciding whether to check it).
For non-critical monitoring, use async: true to avoid blocking:
{
"SubagentStart": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/subagent-monitor.sh",
"async": true
}
]
}
],
"PostToolUseFailure": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/tool-failure-logger.sh",
"async": true
}
]
}
]
}
Rule of thumb: Use async: true for logging/monitoring hooks. Keep synchronous for validation/blocking hooks.
Hooks must exit with code 0 unless they intentionally want to block an action (PreToolUse exit code 2). A non-zero exit from a non-blocking hook causes Claude Code to display an error and can disrupt the workflow.
#!/bin/bash
# CORRECT: Always exit 0 in non-blocking hooks
JSON_INPUT=$(timeout 2 cat 2>/dev/null || echo '{}')
# ... process input ...
# Ensure exit 0 even if processing fails
exit 0
Pattern: Use || true or explicit exit 0 at the end of every hook script. Even if earlier commands fail, the hook should not block Claude.
#!/bin/bash
# Safe pattern: trap ensures exit 0 on any failure
trap 'exit 0' ERR
set -euo pipefail
# ... your hook logic ...
exit 0
Source: Anthropic Chief of Staff agent cookbook pattern. In production, 100% of hooks should exit 0 (except intentional PreToolUse blockers).
Claude Code treats any output on stderr as a hook error — even when the exit code is 0. This causes the confusing UserPromptSubmit hook error message (or similar for other events) that appears intermittently.
Root cause: Subcommands like python, jq, git, grep, and timeout can write warnings or error messages to stderr. Even suppressing stderr on individual commands (2>/dev/null) isn’t enough — some messages leak from subshells, command substitutions, or signal handlers.
#!/bin/bash
# ✅ CORRECT: Suppress ALL stderr at script level
exec 2>/dev/null
# Now all commands are safe — no stderr leakage possible
USER_MESSAGE=$(timeout 2 cat)
GIT_BRANCH=$(git branch --show-current)
VIOLATIONS=$(python check_sizes.py | grep -c "violation" || echo "0")
echo "Hook output goes to stdout"
exit 0
#!/bin/bash
# ❌ WRONG: Suppressing stderr per-command is fragile
USER_MESSAGE=$(timeout 2 cat 2>/dev/null) # OK for cat...
GIT_BRANCH=$(git branch --show-current 2>/dev/null) # OK for git...
# But command substitutions, subshells, or signals can still leak stderr
Key rule: Add exec 2>/dev/null as the first line after the shebang in every hook script. This globally redirects stderr to /dev/null for the entire script, guaranteeing zero stderr output regardless of what subcommands do.
Evidence: Mar 2026 — Intermittent UserPromptSubmit hook error traced to stderr leakage from python and timeout subcommands. Adding exec 2>/dev/null eliminated the issue completely.
For complex JSON processing or logic that’s cumbersome in bash, use Python hook scripts instead:
{
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/report-tracker.py"
}
]
}
]
}
Example (.claude/hooks/report-tracker.py):
#!/usr/bin/env python3
import sys, json
try:
data = json.loads(sys.stdin.read())
tool_name = data.get("tool_name", "unknown")
# Complex JSON processing is much easier in Python
if tool_name == "Write":
file_path = data.get("tool_input", {}).get("file_path", "")
# Track which files were written during session
with open(".claude/logs/files-written.log", "a") as f:
f.write(f"{file_path}\n")
except Exception:
pass # Never crash, never block
sys.exit(0) # Always exit 0
When to use Python hooks:
jq gymnastics in bashSource: Anthropic Chief of Staff agent uses report-tracker.py and script-usage-logger.py as production hook patterns.
Use .claude/settings.local.json for personal hook overrides that should not be committed to git:
// .claude/settings.local.json (NOT committed)
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/my-personal-formatter.sh"
}
]
}
]
}
}
Local hooks merge with project hooks (they don’t replace them). This is useful for:
This example demonstrates using PreToolUse + PostToolUse together to enforce a maximum file size rule. The two hooks are complementary: PreToolUse catches new oversized files before they hit disk, while PostToolUse detects existing files growing past the threshold.
Problem: In a large codebase (600+ source files), “god files” accumulate over time. A max-500-lines-per-file rule exists, but without enforcement it is regularly violated. Manually checking every write is impractical.
Solution: Two non-blocking hooks that show warnings to Claude, so it can self-correct.
File: .claude/hooks/file-size-precheck.sh
This fires before the Write tool. It reads the content about to be written from tool_input.content, counts lines, and warns if the new file would exceed thresholds.
#!/bin/bash
# PreToolUse hook: Check Write content size BEFORE file is created
JSON_INPUT=$(timeout 2 cat 2>/dev/null || true)
FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
[ -z "$FILE_PATH" ] && exit 0
# Only check source code files
case "$FILE_PATH" in
*.js|*.jsx|*.ts|*.tsx|*.py|*.sh) ;; # Check these
*) exit 0 ;; # Skip non-source files
esac
# Skip test files, node_modules, dist
case "$FILE_PATH" in
*/node_modules/*|*/dist/*|*package-lock*|*/tests/*|*/scripts/baselines/*) exit 0 ;;
esac
# Count lines in the content being written
CONTENT_LINES=$(echo "$JSON_INPUT" | jq -r '.tool_input.content // empty' 2>/dev/null | wc -l)
[ "$CONTENT_LINES" -le 300 ] && exit 0
BASENAME=$(basename "$FILE_PATH")
[ ! -f "$FILE_PATH" ] && FILE_STATUS="NEW file" || FILE_STATUS="overwriting existing"
if [ "$CONTENT_LINES" -gt 500 ]; then
echo ""
echo "======================================================================="
echo "FILE SIZE: $BASENAME will be $CONTENT_LINES lines ($FILE_STATUS)"
echo "======================================================================="
echo "This file exceeds the 500-line limit. Consider splitting into:"
echo " - Main module: core logic (<400 lines)"
echo " - Helper module: extracted functions"
echo "Allowing write -- but please split this file next."
echo "======================================================================="
elif [ "$CONTENT_LINES" -gt 400 ]; then
echo ""
echo "-----------------------------------------------------------------------"
echo "WARNING: $BASENAME will be $CONTENT_LINES lines ($FILE_STATUS)"
echo "-----------------------------------------------------------------------"
echo "Approaching 500-line limit. Plan extraction now."
echo "-----------------------------------------------------------------------"
fi
exit 0
File: .claude/hooks/file-size-warning.sh
This fires after Write|Edit. It checks the actual file on disk and uses a /tmp cache to track growth between edits. Only warns on files >500 lines if they grew by 20+ lines in this edit (avoids noise on existing large files).
#!/bin/bash
# PostToolUse hook: Warn when edited files GROW past thresholds
JSON_INPUT=$(timeout 2 cat 2>/dev/null || true)
FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
[ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ] && exit 0
# Only check source code files
case "$FILE_PATH" in
*.js|*.jsx|*.ts|*.tsx|*.py|*.sh) ;;
*) exit 0 ;;
esac
case "$FILE_PATH" in
*/node_modules/*|*/dist/*|*package-lock*|*/tests/*|*/scripts/baselines/*) exit 0 ;;
esac
LINE_COUNT=$(wc -l < "$FILE_PATH" 2>/dev/null || echo "0")
BASENAME=$(basename "$FILE_PATH")
# Growth detection via file size cache
CACHE_DIR="/tmp/.claude-file-sizes"
mkdir -p "$CACHE_DIR" 2>/dev/null
CACHE_KEY=$(echo "$FILE_PATH" | md5sum | cut -d' ' -f1)
CACHE_FILE="$CACHE_DIR/$CACHE_KEY"
PREV_SIZE=0
[ -f "$CACHE_FILE" ] && PREV_SIZE=$(cat "$CACHE_FILE" 2>/dev/null || echo "0")
echo "$LINE_COUNT" > "$CACHE_FILE"
GROWTH=$((LINE_COUNT - PREV_SIZE))
if [ "$LINE_COUNT" -gt 500 ]; then
# Only warn if it GREW significantly (20+ lines)
if [ "$GROWTH" -ge 20 ] && [ "$PREV_SIZE" -gt 0 ]; then
echo ""
echo "-----------------------------------------------------------------------"
echo "GROWING: $BASENAME grew +${GROWTH} lines -> now $LINE_COUNT lines"
echo "-----------------------------------------------------------------------"
echo "Consider extracting the new code into a separate module."
echo "-----------------------------------------------------------------------"
fi
elif [ "$LINE_COUNT" -gt 400 ]; then
echo ""
echo "WARNING: $BASENAME is $LINE_COUNT lines (approaching 500 limit)"
elif [ "$LINE_COUNT" -gt 300 ] && [ "$PREV_SIZE" -le 300 ] && [ "$PREV_SIZE" -gt 0 ]; then
echo "NOTE: $BASENAME crossed 300 lines ($PREV_SIZE -> $LINE_COUNT). Monitor growth."
fi
exit 0
Add both hooks to .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/file-size-precheck.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/file-size-warning.sh"
}
]
}
]
}
}
| Decision | Rationale |
|---|---|
| Non-blocking (exit 0 always) | Writes proceed; Claude sees the warning and self-corrects. Blocking writes mid-session causes more disruption than value. |
| PreToolUse on Write only (not Edit) | The Write tool creates/overwrites entire files. Edit makes small changes – the PostToolUse hook handles growth detection for edits. |
| Growth detection via md5sum cache | In a codebase with 250+ existing files over 500 lines, warning on every edit to those files is pure noise. The cache tracks file sizes between edits, and only warns if a large file grew by 20+ lines. |
| Source files only | Only checks .js, .ts, .py, .sh (and variants). Skips docs, configs, test files, generated files, and node_modules. |
| Three thresholds (300 / 400 / 500) | 300 = informational note (only on first crossing), 400 = warning, 500 = strong alert with split suggestions. Progressive awareness, not a cliff. |
Claude writes a 620-line service file:
-> PreToolUse fires: "FILE SIZE: service.js will be 620 lines (NEW file)"
-> Claude sees the warning, splits into service.js (380L) + service-helpers.js (240L)
Claude edits an existing 510-line file, adding 25 lines:
-> PostToolUse fires: "GROWING: service.js grew +25 lines -> now 535 lines"
-> Claude extracts the new code into a helper module instead
Claude edits an existing 520-line file, changing 3 lines:
-> PostToolUse: SILENT (no growth, avoids noise on legacy files)
Testing: Create a test file and pipe mock JSON to validate both hooks:
# Test PreToolUse with a 600-line file
CONTENT=$(python3 -c "print('\n'.join(['line ' + str(i) for i in range(600)]))")
echo "{\"tool_input\":{\"file_path\":\"/tmp/test.js\",\"content\":\"$CONTENT\"}}" | \
bash .claude/hooks/file-size-precheck.sh
Production: 25 hooks, 6-8 hours/year ROI
See: examples/production-claude-hooks/
Full guide: Templates in template/.claude/hooks/
Claude Code’s source code verifies workspace trust before executing ANY hook. The shouldSkipHookDueToTrust() function (utils/hooks.ts:286-296) checks workspace trust for ALL hooks, preventing hook execution in untrusted workspaces as a defense-in-depth measure. Non-interactive mode (SDK) skips this check.
Takeaway: If you’re building custom hook systems, consider adding a trust check that verifies the hook is running in a known workspace before executing side effects.
As of v2.1.88, Claude Code supports 27 hook events (up from the 18 commonly documented):
Tool Events: PreToolUse, PostToolUse, PostToolUseFailure, PermissionDenied, PermissionRequest Session Events: SessionStart, SessionEnd, Stop, StopFailure Agent Events: SubagentStart, SubagentStop Compaction Events: PreCompact, PostCompact User Events: UserPromptSubmit, Notification Infrastructure Events: Setup, ConfigChange, CwdChanged, FileChanged, WorktreeCreate, WorktreeRemove, InstructionsLoaded Collaboration Events: TeammateIdle, TaskCreated, TaskCompleted Elicitation Events: Elicitation, ElicitationResult
Previous: 12: Memory Bank Next: 14: Git vs Claude Hooks