32 KiB
Hook Reference
Comprehensive technical reference for Claude Code event hooks.
Table of Contents
- Hook Configuration Schema
- Hook Events
- Matcher Patterns
- Input Format
- Output Format
- Environment Variables
- Exit Codes
- Hook Chaining
- Security Best Practices
- MCP Integration
- Plugin Hooks
- Advanced Patterns
Hook Configuration Schema
Location
Hooks are configured in JSON settings files:
| Location | Scope | Committed |
|---|---|---|
~/.claude/settings.json |
Personal (all projects) | No |
.claude/settings.json |
Project (shared with team) | Yes |
.claude/settings.local.json |
Project (local overrides) | No |
plugin/hooks/hooks.json |
Plugin | Yes |
Basic Structure
{
"hooks": {
"<EventName>": [
{
"matcher": "<ToolPattern>",
"hooks": [
{
"type": "command",
"command": "<shell-command>",
"timeout": 30
}
]
}
]
}
}
Field Reference
hooks (root)
Type: Object Required: Yes Description: Root object containing all hook definitions
{
"hooks": {
// Event configurations here
}
}
Event Name Keys
Type: String (key) Required: At least one Valid values:
PreToolUsePostToolUseUserPromptSubmitNotificationStopSubagentStopPreCompactSessionStartSessionEnd
Description: Event type that triggers the hook
{
"hooks": {
"PreToolUse": [...],
"PostToolUse": [...],
"SessionStart": [...]
}
}
Event Configuration Array
Type: Array of objects Description: Array of matcher/hooks pairs for an event
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write(*.py)",
"hooks": [...]
},
{
"matcher": "Edit(*.ts)",
"hooks": [...]
}
]
}
}
matcher
Type: String Required: Yes Description: Pattern to match tools or event types
Syntax options:
- Simple:
"Write"- Exact tool name - Regex:
"Edit|Write"- OR pattern - Wildcard:
"*"- All tools - File pattern:
"Write(*.py)"- File extension - MCP:
"mcp__server__tool"- MCP tool pattern
{"matcher": "Write|Edit"}
hooks (nested)
Type: Array of objects Required: Yes Description: Commands to execute when matcher triggers
{
"hooks": [
{
"type": "command",
"command": "black \"$file\"",
"timeout": 30
}
]
}
type
Type: String
Required: Yes
Valid values: "command"
Description: Hook execution type (currently only "command" supported)
command
Type: String Required: Yes Description: Shell command to execute
Features:
- Variable expansion:
$file,$CLAUDE_PROJECT_DIR - Stdin: Receives JSON input
- Stdout: Shown to user
- Stderr: Error messages
- Exit code: Controls behavior
{
"type": "command",
"command": "./.claude/hooks/format-code.sh"
}
timeout
Type: Number (seconds) Required: No Default: 30 Description: Maximum execution time
{
"type": "command",
"command": "./slow-operation.sh",
"timeout": 60
}
Complete Example
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-bash.sh",
"timeout": 5
}
]
},
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/check-paths.sh",
"timeout": 3
}
]
}
],
"PostToolUse": [
{
"matcher": "Write(*.ts)",
"hooks": [
{
"type": "command",
"command": "biome check --write \"$file\"",
"timeout": 10
}
]
},
{
"matcher": "Write(*.py)",
"hooks": [
{
"type": "command",
"command": "black \"$file\"",
"timeout": 10
}
]
}
],
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "echo 'Session started' && git status",
"timeout": 5
}
]
}
]
}
}
Hook Events
PreToolUse
Executes before a tool runs. Can block or modify execution.
Timing: After Claude creates tool parameters, before tool execution
Input: Tool name and full input parameters
Can block: Yes (exit code 2)
Common matchers:
Bash- Shell commandsWrite- File writingEdit- File editingRead- File readingGrep- Content searchGlob- File patternsWebFetch- Web operationsWebSearch- Web searchTask- Subagent tasks*- All tools
Use cases:
- Validate bash commands before execution
- Check file paths for security issues
- Block dangerous operations
- Add context before execution
- Enforce security policies
- Log tool invocations
Example:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/validate-bash.sh",
"timeout": 5
}]
}
]
}
}
PostToolUse
Executes after a tool completes successfully.
Timing: Immediately after tool returns success
Input: Tool name, input parameters, and execution result
Can block: No (but can report issues)
Common matchers:
Write(*.ext)- Specific file typesEdit(*.ext)- Specific file typesWrite|Edit- Any file modification*- All successful tools
Use cases:
- Auto-format code files
- Run linters
- Update documentation
- Trigger builds
- Send notifications
- Update indexes
Example:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit(*.ts)",
"hooks": [{
"type": "command",
"command": "biome check --write \"$file\"",
"timeout": 10
}]
}
]
}
}
UserPromptSubmit
Executes when user submits a prompt to Claude.
Timing: After user submits, before Claude processes
Input: User prompt text and session metadata
Can block: No
Matcher: Always *
Use cases:
- Add timestamp or date context
- Add environment information
- Log user activity
- Pre-process or augment prompts
- Add project context
Example:
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/add-context.sh",
"timeout": 2
}]
}
]
}
}
Notification
Executes when Claude Code sends a notification.
Timing: When notification is triggered
Input: Notification message and metadata
Can block: No
Matcher: Always *
Use cases:
- Send to external systems (Slack, email)
- Log notifications
- Trigger alerts
- Update dashboards
- Archive important messages
Example:
{
"hooks": {
"Notification": [
{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/send-to-slack.sh",
"timeout": 5
}]
}
]
}
}
Stop
Executes when main Claude agent finishes responding.
Timing: After Claude completes response
Input: Session metadata and completion reason
Can block: No
Matcher: Always *
Use cases:
- Clean up temporary resources
- Send completion notifications
- Update external systems
- Log session metrics
- Archive conversation
Example:
{
"hooks": {
"Stop": [
{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/on-completion.sh",
"timeout": 5
}]
}
]
}
}
SubagentStop
Executes when a subagent (Task tool) finishes.
Timing: After subagent completes
Input: Subagent metadata and result
Can block: No
Matcher: Always *
Use cases:
- Track subagent usage
- Log subagent results
- Trigger follow-up actions
- Update metrics
- Debug subagent behavior
Example:
{
"hooks": {
"SubagentStop": [
{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/log-subagent.sh",
"timeout": 3
}]
}
]
}
}
PreCompact
Executes before conversation compacts.
Timing: Before compact operation starts
Input: Compact trigger type
Can block: No
Matchers:
manual- User triggered via/compactauto- Automatic compact
Use cases:
- Backup conversation
- Archive important context
- Update external summaries
- Log compact events
- Prepare for reset
Example:
{
"hooks": {
"PreCompact": [
{
"matcher": "manual",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/backup-conversation.sh",
"timeout": 10
}]
}
]
}
}
SessionStart
Executes when session starts or resumes.
Timing: At session initialization
Input: Session start reason
Can block: No
Matchers:
startup- Claude Code startsresume- Session resumes (--resume,--continue)clear- After/clearcommandcompact- After compact operation
Use cases:
- Display welcome message
- Show git status
- Load project context
- Check for updates
- Initialize resources
Example:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [{
"type": "command",
"command": "echo 'Welcome!' && git status",
"timeout": 5
}]
}
]
}
}
SessionEnd
Executes when session ends.
Timing: Before session terminates
Input: End reason
Can block: No
Matchers (reasons):
clear- User ran/clearlogout- User logged outprompt_input_exit- Exited during prompt inputother- Other reasons
Use cases:
- Clean up resources
- Save state
- Log session metrics
- Send completion notifications
- Archive transcripts
Example:
{
"hooks": {
"SessionEnd": [
{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/cleanup.sh",
"timeout": 5
}]
}
]
}
}
Matcher Patterns
Simple String Match
Match exact tool name:
{"matcher": "Write"} // Only Write tool
{"matcher": "Edit"} // Only Edit tool
{"matcher": "Bash"} // Only Bash tool
{"matcher": "Read"} // Only Read tool
Regex Patterns
Use | for OR logic:
{"matcher": "Edit|Write"} // Edit OR Write
{"matcher": "Read|Grep|Glob"} // Any read operation
{"matcher": "Notebook.*"} // Any Notebook tool
{"matcher": "Write|Edit|NotebookEdit"} // Multiple tools
Regex features:
|- OR operator.- Any character*- Zero or more+- One or more^- Start of string$- End of string
Examples:
{"matcher": "^Write$"} // Exactly "Write", no prefix/suffix
{"matcher": ".*Edit.*"} // Contains "Edit" anywhere
{"matcher": "Bash|WebFetch"} // Bash or WebFetch
Wildcard Match
Match all tools:
{"matcher": "*"} // Matches everything
Use cases:
- Logging all tool usage
- Global validation
- Universal context injection
- Metrics collection
File Pattern Match
Match tools with specific file patterns:
{"matcher": "Write(*.py)"} // Write Python files
{"matcher": "Edit(*.ts)"} // Edit TypeScript files
{"matcher": "Write(*.md)"} // Write Markdown files
{"matcher": "Write|Edit(*.js)"} // Write or Edit JavaScript
Supported patterns:
*.ext- Any file with extensionpath/*.ext- Files in specific directory**/*.ext- Recursive file match
Examples:
{"matcher": "Write(*.tsx)"} // React components
{"matcher": "Write|Edit(*.rs)"} // Rust files
{"matcher": "Write(src/**/*.ts)"} // TS files in src/
{"matcher": "Edit(.env*)"} // .env files
MCP Tool Match
Match MCP server tools:
{"matcher": "mcp__memory__.*"} // Any memory MCP tool
{"matcher": "mcp__github__.*"} // Any GitHub MCP tool
{"matcher": "mcp__.*__.*"} // Any MCP tool
{"matcher": "mcp__linear__create_issue"} // Specific MCP tool
MCP tool naming: mcp__<server-name>__<tool-name>
Examples:
// Match all memory operations
{"matcher": "mcp__memory__.*"}
// Match specific GitHub operations
{"matcher": "mcp__github__(create_issue|create_comment)"}
// Match all MCP tools
{"matcher": "mcp__.*__.*"}
// Match Linear issue creation
{"matcher": "mcp__linear__create_issue"}
Complex Matchers
Combine patterns with regex:
// Format Python or TypeScript files
{"matcher": "Write|Edit(*.py)|Write|Edit(*.ts)"}
// Format code files, exclude tests
{"matcher": "Write|Edit(*.ts|*.py)"}
// Bash or any MCP tool
{"matcher": "Bash|mcp__.*__.*"}
// Read operations (multiple tools)
{"matcher": "Read|Grep|Glob|WebFetch"}
Input Format
JSON Schema
Hooks receive JSON on stdin:
interface HookInput {
session_id: string;
transcript_path: string;
cwd: string;
hook_event_name: string;
tool_name?: string;
tool_input?: Record<string, any>;
reason?: string;
[key: string]: any;
}
Common Fields
All Events
{
"session_id": "abc123-def456-ghi789",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/working/directory",
"hook_event_name": "PreToolUse"
}
Tool Events (PreToolUse, PostToolUse)
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/project/root",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/project/root/src/file.ts",
"content": "export const foo = 'bar';"
}
}
Session Events
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/project/root",
"hook_event_name": "SessionStart",
"reason": "startup"
}
Tool-Specific Input
Bash Tool
{
"tool_name": "Bash",
"tool_input": {
"command": "git status",
"description": "Check git status"
}
}
Write Tool
{
"tool_name": "Write",
"tool_input": {
"file_path": "/absolute/path/to/file.ts",
"content": "file contents here"
}
}
Edit Tool
{
"tool_name": "Edit",
"tool_input": {
"file_path": "/absolute/path/to/file.ts",
"old_string": "const foo = 'old';",
"new_string": "const foo = 'new';",
"replace_all": false
}
}
Read Tool
{
"tool_name": "Read",
"tool_input": {
"file_path": "/absolute/path/to/file.ts",
"offset": 0,
"limit": 2000
}
}
Reading Input
Bash
#!/usr/bin/env bash
set -euo pipefail
# Read entire input
INPUT=$(cat)
# Parse with jq
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Check if field exists
if [[ -z "$TOOL_NAME" ]]; then
echo "Error: tool_name not found" >&2
exit 1
fi
Bun/TypeScript
#!/usr/bin/env bun
import { stdin } from "process";
interface HookInput {
session_id: string;
tool_name?: string;
tool_input?: Record<string, any>;
hook_event_name: string;
}
// Read stdin
const chunks: Buffer[] = [];
for await (const chunk of stdin) {
chunks.push(chunk);
}
const input: HookInput = JSON.parse(Buffer.concat(chunks).toString());
// Access fields
const toolName = input.tool_name;
const filePath = input.tool_input?.file_path;
// Validate
if (!toolName) {
console.error("Error: tool_name missing");
process.exit(1);
}
Python
#!/usr/bin/env python3
import json
import sys
# Read input
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error parsing JSON: {e}", file=sys.stderr)
sys.exit(1)
# Access fields
tool_name = input_data.get("tool_name", "")
file_path = input_data.get("tool_input", {}).get("file_path", "")
# Validate
if not tool_name:
print("Error: tool_name missing", file=sys.stderr)
sys.exit(1)
Output Format
Exit Codes (Simple)
Most common approach:
#!/usr/bin/env bash
# Success - continue execution
echo "Validation passed"
exit 0
# Blocking error - show to Claude
echo "Error: dangerous operation detected" >&2
exit 2
# Non-blocking error - show to user
echo "Warning: minor issue detected" >&2
exit 1
Behavior:
| Exit Code | Behavior | Stdout | Stderr |
|---|---|---|---|
| 0 | Success | Shown to user | Ignored |
| 2 | Block (PreToolUse only) | Ignored | Shown to Claude |
| 1 or other | Non-blocking error | Ignored | Shown to user |
JSON Output (Advanced)
For complex responses:
{
"continue": true,
"stopReason": "Optional stop message",
"suppressOutput": false,
"systemMessage": "Warning or info message",
"decision": "block",
"reason": "Explanation for decision",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Dangerous operation",
"additionalContext": "Context for Claude"
}
}
Field Reference
continue (boolean)
true: Continue executionfalse: Stop execution
stopReason (string)
- Message explaining why stopped
- Shown to user
suppressOutput (boolean)
true: Hide stdout from userfalse: Show stdout
systemMessage (string)
- Info/warning message
- Shown to user
decision (string)
"block": Block operation (PreToolUse)"approve": Approve operationundefined: No decision
reason (string)
- Explanation for decision
- Shown in context
hookSpecificOutput (object)
- Event-specific data
- See below for details
PreToolUse JSON Output
{
"continue": false,
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Path traversal detected in file path",
"additionalContext": "The file path contains '..' which could allow directory traversal"
}
}
Permission decisions:
"allow": Approve tool use"deny": Block tool use"ask": Ask user for permission
Example: Bash with JSON Output
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Check for path traversal
if echo "$FILE_PATH" | grep -q '\.\.'; then
# Output JSON response
cat << EOF
{
"continue": false,
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Path traversal detected",
"additionalContext": "File path contains '..' which is not allowed"
}
}
EOF
exit 0
fi
# Approve
echo "Path validation passed"
exit 0
Environment Variables
Available Variables
$CLAUDE_PROJECT_DIR
Type: String Availability: All hooks Description: Absolute path to project root directory
"$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh"
Use cases:
- Reference project scripts
- Construct relative paths
- Check project structure
$file
Type: String Availability: PostToolUse hooks for Write/Edit tools Description: Absolute path to affected file
"biome check --write \"$file\""
Use cases:
- Auto-format files
- Run linters
- Update related files
${CLAUDE_PLUGIN_ROOT}
Type: String Availability: Plugin hooks only Description: Absolute path to plugin root directory
{
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/process.sh"
}
Use cases:
- Reference plugin scripts
- Load plugin resources
- Access plugin data
Custom Variables
Define in settings.json:
{
"env": {
"CUSTOM_VAR": "value",
"API_KEY": "secret"
},
"hooks": {
"PostToolUse": [...]
}
}
Access in hooks:
#!/usr/bin/env bash
echo "Custom var: $CUSTOM_VAR"
Exit Codes
Standard Exit Codes
0 - Success, continue
1 - Non-blocking error
2 - Blocking error (PreToolUse only)
3+ - Non-blocking error
Exit Code Behavior
Exit 0 (Success)
#!/usr/bin/env bash
echo "Validation passed"
exit 0
Behavior:
- Execution continues
- Stdout shown to user
- Stderr ignored
Use for:
- Successful validation
- Informational output
- Non-critical messages
Exit 1 (Warning)
#!/usr/bin/env bash
echo "Warning: potential issue detected" >&2
exit 1
Behavior:
- Execution continues
- Stderr shown to user
- Stdout ignored
Use for:
- Warnings
- Non-critical issues
- Suggestions
Exit 2 (Block)
#!/usr/bin/env bash
echo "Error: dangerous operation blocked" >&2
exit 2
Behavior:
- PreToolUse: Blocks tool execution
- PostToolUse: Reports error (doesn't block)
- Stderr shown to Claude
- Stdout ignored
Use for:
- Security violations
- Policy enforcement
- Dangerous operations
Error Handling
Always handle errors gracefully:
#!/usr/bin/env bash
set -euo pipefail
# Check dependencies
if ! command -v jq &>/dev/null; then
echo "Error: jq not installed" >&2
exit 1
fi
# Validate input
INPUT=$(cat) || {
echo "Error: failed to read stdin" >&2
exit 1
}
# Parse with error handling
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') || {
echo "Error: failed to parse JSON" >&2
exit 1
}
# Validate required fields
if [[ -z "$TOOL_NAME" ]]; then
echo "Error: tool_name missing" >&2
exit 1
fi
Hook Chaining
Multiple Hooks per Event
Execute multiple hooks sequentially:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write(*.ts)",
"hooks": [
{
"type": "command",
"command": "biome check --write \"$file\"",
"timeout": 10
},
{
"type": "command",
"command": "tsc --noEmit \"$file\"",
"timeout": 15
},
{
"type": "command",
"command": "./.claude/hooks/update-index.sh",
"timeout": 5
}
]
}
]
}
}
Execution:
- Runs in order
- If one fails (non-zero exit), subsequent hooks still run
- All output collected and shown
Cross-Event Coordination
Use shared state for coordination:
# PreToolUse: Record operation
#!/usr/bin/env bash
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
echo "$TOOL_NAME $(date +%s)" >> /tmp/claude-operations.log
exit 0
# PostToolUse: Update metrics
#!/usr/bin/env bash
OPERATIONS=$(wc -l < /tmp/claude-operations.log)
echo "Total operations: $OPERATIONS" >&2
exit 0
Security Best Practices
1. Input Validation
Always validate and sanitize inputs:
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Check for path traversal
if echo "$FILE_PATH" | grep -qE '\.\.|^/etc/|^/root/|^/home/[^/]+/\.ssh/'; then
echo "❌ Dangerous path detected: $FILE_PATH" >&2
exit 2
fi
# Check for sensitive files
if echo "$FILE_PATH" | grep -qE '\.env$|\.git/config|id_rsa|credentials'; then
echo "❌ Sensitive file access blocked: $FILE_PATH" >&2
exit 2
fi
# Validate file extension
if [[ "$FILE_PATH" =~ \.(exe|sh|bin)$ ]]; then
echo "⚠ Warning: executable file" >&2
fi
2. Command Injection Prevention
Always quote variables:
# ❌ WRONG - vulnerable to injection
rm $FILE_PATH
# ✅ CORRECT - properly quoted
rm "$FILE_PATH"
# ❌ WRONG - vulnerable
eval "$COMMAND"
# ✅ CORRECT - use array or avoid eval
bash -c "$COMMAND"
3. Path Security
Use absolute paths and validate:
#!/usr/bin/env bash
# Get absolute path
SCRIPT_PATH="$CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh"
# Validate script exists
if [[ ! -f "$SCRIPT_PATH" ]]; then
echo "Error: script not found: $SCRIPT_PATH" >&2
exit 1
fi
# Validate script is executable
if [[ ! -x "$SCRIPT_PATH" ]]; then
echo "Error: script not executable: $SCRIPT_PATH" >&2
exit 1
fi
# Execute safely
"$SCRIPT_PATH" "$@"
4. Sensitive Data Protection
Never log or expose sensitive data:
#!/usr/bin/env bash
INPUT=$(cat)
# ❌ WRONG - logs sensitive data
echo "Input: $INPUT" >> /tmp/debug.log
# ✅ CORRECT - log only safe fields
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
echo "Tool: $TOOL_NAME" >> /tmp/debug.log
# ✅ Filter sensitive fields
echo "$INPUT" | jq 'del(.tool_input.password, .tool_input.api_key)' >> /tmp/debug.log
5. Timeout Protection
Set appropriate timeouts:
{
"hooks": {
"PreToolUse": [
{
"hooks": [{
"type": "command",
"command": "./.claude/hooks/validate.sh",
"timeout": 5
}]
}
],
"PostToolUse": [
{
"hooks": [{
"type": "command",
"command": "./.claude/hooks/format.sh",
"timeout": 30
}]
}
]
}
}
Guidelines:
- Validation: 3-5 seconds
- Formatting: 10-30 seconds
- Network operations: 30-60 seconds
- Heavy operations: Consider running async
6. Error Recovery
Handle failures gracefully:
#!/usr/bin/env bash
set -euo pipefail
# Trap errors
trap 'echo "Error on line $LINENO" >&2' ERR
# Validate dependencies
for cmd in jq git; do
if ! command -v "$cmd" &>/dev/null; then
echo "Error: $cmd not installed" >&2
exit 1
fi
done
# Main logic with error handling
if ! INPUT=$(cat 2>&1); then
echo "Error: failed to read stdin" >&2
exit 1
fi
# Parse with validation
if ! TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name' 2>&1); then
echo "Error: invalid JSON input" >&2
exit 1
fi
MCP Integration
Matching MCP Tools
MCP tools follow pattern: mcp__<server>__<tool>
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__memory__.*",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/log-memory-ops.sh"
}]
},
{
"matcher": "mcp__github__create_issue",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/validate-issue.sh"
}]
}
]
}
}
Common MCP Servers
// Memory operations
{"matcher": "mcp__memory__.*"}
// GitHub operations
{"matcher": "mcp__github__.*"}
// Linear operations
{"matcher": "mcp__linear__.*"}
// Filesystem operations
{"matcher": "mcp__filesystem__.*"}
// All MCP tools
{"matcher": "mcp__.*__.*"}
MCP Hook Example
#!/usr/bin/env bash
# Log MCP operations
set -euo pipefail
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
SERVER=$(echo "$TOOL_NAME" | cut -d'_' -f3)
OPERATION=$(echo "$TOOL_NAME" | cut -d'_' -f4-)
echo "[$(date -Iseconds)] MCP $SERVER: $OPERATION" >> "$CLAUDE_PROJECT_DIR/.claude/mcp-operations.log"
exit 0
Plugin Hooks
Plugin Hook Configuration
Hooks are auto-discovered from {plugin}/hooks/hooks.json. Do NOT define hooks in plugin.json.
Location: {plugin}/hooks/hooks.json
Important: The file requires a root-level "hooks" wrapper around the event types:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/format-code.sh",
"timeout": 30
}]
}
]
}
}
Note: This differs from project-level hooks in
.claude/settings.json, which also have a"hooks"wrapper but are configured differently. Plugin hooks use${CLAUDE_PLUGIN_ROOT}for paths.
Plugin-Specific Variables
Use ${CLAUDE_PLUGIN_ROOT} for plugin paths:
{
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/helper.sh"
}
Plugin Hook Best Practices
1. Use relative paths with variable:
{
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/process.sh"
}
2. Include dependencies in plugin:
plugin/
├── .claude-plugin/
│ └── plugin.json
├── hooks/
│ └── hooks.json
└── scripts/
├── process.sh
└── utils.sh
3. Document hook requirements:
{
"name": "my-plugin",
"description": "Plugin with auto-formatting",
"requirements": {
"binaries": ["jq", "black"],
"notes": "PostToolUse hooks require black for Python formatting"
}
}
Advanced Patterns
Conditional Execution
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Only format during work hours
HOUR=$(date +%H)
if [[ $HOUR -lt 9 || $HOUR -gt 17 ]]; then
echo "Skipping format outside work hours"
exit 0
fi
# Only format if file is in src/
if [[ ! "$FILE_PATH" =~ ^.*/src/ ]]; then
exit 0
fi
# Format the file
black "$FILE_PATH"
Async Operations
#!/usr/bin/env bash
# Run expensive operation in background
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Start background job
(
sleep 2
expensive-operation "$FILE_PATH"
echo "Background operation completed" >> /tmp/claude-bg.log
) &
# Return immediately
echo "Background operation started"
exit 0
State Management
#!/usr/bin/env bash
# Track state across hooks
STATE_FILE="/tmp/claude-state.json"
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
# Load state
if [[ -f "$STATE_FILE" ]]; then
STATE=$(cat "$STATE_FILE")
else
STATE='{"operations": []}'
fi
# Update state
STATE=$(echo "$STATE" | jq ".operations += [\"$TOOL_NAME\"]")
echo "$STATE" > "$STATE_FILE"
# Report
COUNT=$(echo "$STATE" | jq '.operations | length')
echo "Total operations: $COUNT"
exit 0
Multi-File Operations
#!/usr/bin/env bash
# Update related files
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# If component updated, update index
if [[ "$FILE_PATH" =~ /components/.*\.tsx$ ]]; then
INDEX_FILE="$(dirname "$FILE_PATH")/index.ts"
# Regenerate index
echo "// Auto-generated by hook" > "$INDEX_FILE"
for file in "$(dirname "$FILE_PATH")"/*.tsx; do
NAME=$(basename "$file" .tsx)
echo "export { $NAME } from './$NAME';" >> "$INDEX_FILE"
done
echo "Updated $INDEX_FILE"
fi
exit 0
Notification Integration
#!/usr/bin/env bash
# Send Slack notification
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Only notify for important files
if [[ "$FILE_PATH" =~ /src/core/ ]]; then
WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}"
if [[ -n "$WEBHOOK_URL" ]]; then
curl -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"Core file modified: $(basename "$FILE_PATH")\"}" \
2>/dev/null
fi
fi
exit 0
Validation Pipeline
#!/usr/bin/env bash
# Multi-stage validation
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Stage 1: Check for dangerous commands
if echo "$COMMAND" | grep -qE '\brm\s+-rf\s+/|\bmkfs\b|\bdd\s+if='; then
echo "❌ Dangerous command blocked" >&2
exit 2
fi
# Stage 2: Check for deprecated commands
if echo "$COMMAND" | grep -qE '\bgrep\b|\bfind\b'; then
echo "⚠ Consider using rg or fd instead" >&2
fi
# Stage 3: Check for common mistakes
if echo "$COMMAND" | grep -qE 'git\s+push\s+--force'; then
echo "⚠ Force push detected - use with caution" >&2
fi
exit 0
Performance Monitoring
#!/usr/bin/env bash
# Track hook performance
START=$(date +%s%N)
# Hook logic here
INPUT=$(cat)
# ... process ...
# Calculate duration
END=$(date +%s%N)
DURATION=$(( (END - START) / 1000000 )) # milliseconds
# Log performance
echo "[$(date -Iseconds)] Hook duration: ${DURATION}ms" >> /tmp/claude-perf.log
exit 0