Interpretive guidance for designing Claude Code hooks. Helps you understand hook lifecycle, when to use hooks vs other patterns, and common pitfalls. Use when creating or reviewing hooks.
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
This skill provides interpretive guidance and best practices for creating Claude Code hooks. It helps you understand what the docs mean and how to create excellent hooks.
Claude Code changes rapidly and is post-training knowledge. Fetch these docs when creating hooks to ensure current syntax:
Key insight: Hooks provide guaranteed, deterministic execution at specific lifecycle events.
What this means:
Decision question: Do you need this to happen every single time, or is "usually" okay?
Examples:
Execution flow:
User Input → Claude Thinks → Tool Execution
↑ ↓
Hooks fire here ────┘
Critical implications:
Exit 0: Hook succeeded, continue execution
UserPromptSubmit and SessionStart where stdout becomes context for ClaudeExit 2: Blocking error, stop and handle
Other exit codes: Non-blocking error
Best practice: Use exit 2 sparingly - it's powerful but disruptive. Use it for security/safety enforcement, not preferences.
Complete list of available events:
| Event | When It Fires | Matcher Applies |
|---|---|---|
| PreToolUse | After Claude creates tool params, before processing | Yes |
| PostToolUse | Immediately after successful tool completion | Yes |
| PermissionRequest | When permission dialogs shown to users | No |
| Notification | When Claude Code sends notifications | No |
| UserPromptSubmit | When users submit prompts, before Claude processes | No |
| Stop | When main Claude agent finishes responding | No |
| SubagentStop | When subagents (Task tool calls) complete | No |
| PreCompact | Before compacting operations | No |
| SessionStart | When sessions start or resume | No |
| SessionEnd | When sessions terminate | No |
Command Hooks (type: "command"):
Prompt-Based Hooks (type: "prompt"):
Stop, SubagentStop, UserPromptSubmit, PreToolUseRule of thumb: If you can write it as a bash script = command hook. If you need judgment = prompt hook.
Bash is ideal for:
Python is better for:
For Python-based hooks requiring dependencies or complex logic, use UV's single-file script format with inline metadata. This provides self-contained, executable scripts without separate environment setup.
When to use Python hooks:
Pattern: Use Skill tool: skill="box-factory:uv-scripts"
The uv-scripts skill provides complete patterns for creating Python hook scripts with inline dependencies, proper shebangs, and Claude Code integration.
Quick example:
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["ruff"]
# ///
import subprocess
import sys
import os
def main():
file_paths = os.environ.get("CLAUDE_FILE_PATHS", "").split()
if not file_paths:
sys.exit(0)
result = subprocess.run(["ruff", "check", *file_paths])
sys.exit(result.returncode)
if __name__ == "__main__":
main()
Key advantages:
Matchers specify which tools trigger hooks (applies to PreToolUse and PostToolUse only):
Exact matching:
"matcher": "Write"
Regex patterns with pipe:
"matcher": "Edit|Write"
"matcher": "Notebook.*"
Wildcard (match all):
"matcher": "*"
Empty matcher:
Omit for events like UserPromptSubmit that don't apply to specific tools.
Note: Matchers are case-sensitive.
Located in ~/.claude/settings.json, .claude/settings.json, or .claude/settings.local.json:
{
"hooks": {
"EventName": [
{
"matcher": "ToolPattern",
"hooks": [
{
"type": "command",
"command": "bash-command",
"timeout": 30
}
]
}
]
}
}
Timeout field: Optional, specified in seconds (default 60).
All hooks receive JSON via stdin:
Base structure (all events):
{
"session_id": "string",
"transcript_path": "path/to/transcript.jsonl",
"cwd": "current/working/directory",
"permission_mode": "default|plan|acceptEdits|bypassPermissions",
"hook_event_name": "EventName"
}
Event-specific fields:
tool_name, tool_inputpromptBest practice: Parse stdin JSON to access context, don't rely only on environment variables.
Two approaches for returning results:
Just use exit codes and stderr for errors. Most common for straightforward hooks.
Return structured JSON for sophisticated control:
{
"continue": true,
"stopReason": "Custom message",
"suppressOutput": true,
"systemMessage": "Warning to display",
"hookSpecificOutput": {
"hookEventName": "EventName",
"additionalContext": "string"
}
}
Key insight: PostToolUse hooks have two output channels with different visibility:
For messages visible DIRECTLY to users (no verbose mode required):
Use systemMessage field - displays immediately to users:
{
"systemMessage": "Markdown formatted: path/to/file.md"
}
ANSI escape codes work: You can colorize systemMessage output:
# Red error text
error_msg = f"\033[31mLinter error in {file_path}:\033[0m"
# Green success
success_msg = f"\033[32mFormatted: {file_path}\033[0m"
For context injected into Claude's awareness:
Use additionalContext in hookSpecificOutput. This appears to Claude as a <system-reminder> with prefix PostToolUse:{ToolName} hook additional context::
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Linter output details for Claude to act on"
}
}
Visibility behavior (empirically tested):
systemMessage → User sees immediately in terminaladditionalContext → Claude receives as system-reminder; user sees only in verbose mode (CTRL-O)Complete output pattern:
import json
output = {
"systemMessage": "Formatted successfully: file.md", # Shows to user directly
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Linter details for Claude" # Claude gets this as system-reminder
}
}
print(json.dumps(output), flush=True)
sys.exit(0)
Common mistake: Using only additionalContext when user feedback is needed. Users won't see it without verbose mode.
Correct pattern:
systemMessage (visible immediately, supports ANSI colors)additionalContext (Claude receives as system-reminder)SessionStart hooks use a different output pattern than PostToolUse. The additionalContext becomes Claude's context at session start.
Correct SessionStart JSON format:
{
"systemMessage": "Message shown directly to user",
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Context injected for Claude to use during session"
}
}
Key differences from PostToolUse:
hookEventName MUST be "SessionStart" (not "PostToolUse")additionalContext becomes persistent session context for ClaudeBash example:
#!/bin/bash
PLUGIN_ROOT="$(dirname "$(dirname "$0")")"
cat <<EOF
{
"systemMessage": "[my-plugin] Loaded from: $PLUGIN_ROOT",
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "MY_PLUGIN_ROOT=$PLUGIN_ROOT - Use this path for plugin resources."
}
}
EOF
exit 0
Common mistake: Using bare systemMessage without hookSpecificOutput:
// Wrong - missing hookSpecificOutput structure
{"systemMessage": "Plugin loaded"}
// Correct - full structure
{
"systemMessage": "Plugin loaded",
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Plugin context for Claude"
}
}
For modifying or blocking tool execution:
{
"permissionDecision": "allow|deny|ask",
"updatedInput": {
"modified": "tool parameters"
}
}
Use case: Modify tool inputs before execution (e.g., add safety flags to bash commands).
Available in command hooks:
| Variable | Purpose |
|---|---|
$CLAUDE_PROJECT_DIR | Absolute path to project root |
$CLAUDE_ENV_FILE | File path for persisting env vars (SessionStart only) |
${CLAUDE_PLUGIN_ROOT} | Plugin directory path (for plugin hooks) |
$CLAUDE_CODE_REMOTE | "true" for remote, empty for local execution |
Best practice: Always quote variables: "$CLAUDE_PROJECT_DIR" not $CLAUDE_PROJECT_DIR
Use Hook when:
Use Agent when:
Use Command (Slash Command) when:
Purpose: Initialize session state, inject context
{
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "cat .claude/project-context.md"
}
]
}
]
}
Key: stdout becomes Claude's context. Use to load project guidelines, conventions, or state.
Purpose: Validate or enrich prompts before Claude sees them
{
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/inject-security-reminders.sh"
}
]
}
]
}
Key: stdout goes to Claude. Can add context or use exit 2 to block prompts.
Purpose: Validate or modify before execution
{
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/security-check.sh"
}
]
}
]
}
Power move: Exit 2 to block dangerous commands and explain why to Claude.
Purpose: React to successful tool completion
{
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "prettier --write \"$CLAUDE_FILE_PATHS\" 2>/dev/null || true"
}
]
}
]
}
Common uses: Format code, run linters, update documentation, cleanup.
CRITICAL for PostToolUse: To communicate results to users, hooks must output JSON to stdout with systemMessage:
#!/usr/bin/env -S uv run --quiet --script
# /// script
# dependencies = []
# ///
import json
import sys
def output_json_response(system_message=None, additional_context=None):
"""Output JSON response for Claude to process."""
response = {}
if system_message:
response["systemMessage"] = system_message # Visible directly to user
if additional_context:
response["hookSpecificOutput"] = {
"hookEventName": "PostToolUse",
"additionalContext": additional_context # Only visible in verbose mode
}
print(json.dumps(response), flush=True)
# Read hook input from stdin
hook_input = json.load(sys.stdin)
file_path = hook_input.get("tool_input", {}).get("file_path")
# Run linter/formatter
# ...
# Communicate result directly to user
output_json_response(system_message=f"Formatted successfully: {file_path}")
sys.exit(0)
Common mistakes:
additionalContext when user feedback is needed (requires verbose mode)Purpose: Session cleanup, final actions
{
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/auto-commit.sh"
}
]
}
]
}
Note: Claude already responded, can't change that. Use for cleanup, notifications, test runs.
Problem: Overly aggressive hook blocks all operations
{
"PreToolUse": [
{
"matcher": "*",
"hooks": [{"type": "command", "command": "exit 2"}]
}
]
}
Result: Claude can't do anything, unusable.
Better: Selective blocking with clear criteria for security/safety only.
Problem: Hook takes 30+ seconds, blocks user experience
npm install # Slow, blocking
exit 0
Impact: Claude waits, terrible UX.
Better: Fast validation or background execution
npm outdated | head -5 # Quick check
exit 0
Or: Adjust timeout for legitimately long operations:
{
"type": "command",
"command": "long-running-task.sh",
"timeout": 120
}
Problem: Errors disappear into the void
important-check || true
exit 0
Result: User never knows check failed.
Better: Clear error communication
if ! important-check; then
echo "Check failed: [specific reason]" >&2
exit 1 # Non-blocking, but visible
fi
exit 0
Problem: Hook expects user input
read -p "Confirm? " response
exit 0
Result: Hook hangs indefinitely (no user to respond).
Better: Fully automated decisions based on stdin JSON or environment.
Problem: Hook doesn't validate inputs, vulnerable to path traversal
cat "$SOME_PATH" # Dangerous if not validated
Result: Could access sensitive files outside project.
Better: Validate and sanitize
if [[ "$SOME_PATH" == *".."* ]]; then
echo "Path traversal detected" >&2
exit 2
fi
# Continue safely
Official guidance: Skip sensitive files (.env, .git/, credentials). Validate inputs from stdin.
Problem: Unquoted shell variables break with spaces
prettier --write $CLAUDE_FILE_PATHS # Breaks if path has spaces
Better: Always quote variables
prettier --write "$CLAUDE_FILE_PATHS"
Problem: Hook uses additionalContext when user feedback is needed
# Wrong - Only visible in verbose mode (CTRL-O)
import json
output = {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Formatted successfully: file.md"
}
}
print(json.dumps(output), flush=True)
sys.exit(0)
Result: User must enable verbose mode to see feedback.
Better: Use systemMessage for direct user visibility
# Correct - Visible immediately to user
import json
output = {
"systemMessage": "Formatted successfully: file.md"
}
print(json.dumps(output), flush=True)
sys.exit(0)
Why:
systemMessage displays directly to users (no verbose mode required)additionalContext only visible in verbose mode (CTRL-O) or as Claude's contextCritical warning from docs: "Claude Code hooks execute arbitrary shell commands on your system automatically."
Implications:
Protection mechanism:
/hooks menuBest practices:
.. in paths)$CLAUDE_PROJECT_DIR.env, credentials, .git/)View hook execution:
Press CTRL-R in Claude Code to see:
Add logging to hooks:
echo "Hook triggered: $(date)" >> ~/.claude/hook-log.txt
echo "Input: $SOME_VAR" >> ~/.claude/hook-log.txt
# Continue with hook logic
exit 0
Parse stdin for debugging:
# Save stdin to debug
cat > /tmp/hook-debug.json
cat /tmp/hook-debug.json | jq '.' # Pretty print
exit 0
Return JSON to modify tool parameters:
#!/bin/bash
# Read stdin
INPUT=$(cat)
# Add safety flag to bash commands
MODIFIED=$(echo "$INPUT" | jq '.tool_input.command = .tool_input.command + " --safe-mode"')
# Return modified input
echo "$MODIFIED" | jq '{permissionDecision: "allow", updatedInput: .tool_input}'
exit 0
Adjust timeout per hook:
{
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "./long-build.sh",
"timeout": 300
}
]
}
]
}
Use Claude Haiku for context-aware decisions:
{
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "prompt",
"command": "Analyze this bash command for security risks. If dangerous, explain why and recommend safer alternative."
}
]
}
]
}
When creating hooks for plugins:
Structure:
my-plugin/
├── .claude-plugin/plugin.json
└── hooks/
└── hooks.json
Reference plugin root:
"${CLAUDE_PLUGIN_ROOT}/scripts/hook-script.sh"
See plugin-design skill for complete plugin context.
Key constraint: Claude Code plugins distribute via git clone - tests are included. Keep them lightweight.
my-plugin/
├── hooks/
│ └── my_hook.py
└── tests/
└── test_my_hook.py # Lightweight, focused tests
Manual testing with stdin simulation:
# Test with realistic Claude Code input
echo '{"tool_name": "Write", "tool_input": {"file_path": "test.py"}, "cwd": "/tmp"}' | ./hooks/my_hook.py
echo "Exit code: $?"
Automated tests with pytest:
import subprocess
import json
def test_hook_allows_valid_file():
input_data = json.dumps({
"tool_name": "Write",
"tool_input": {"file_path": "src/app.py"},
"cwd": "/project"
})
result = subprocess.run(
["./hooks/my_hook.py"],
input=input_data,
capture_output=True,
text=True
)
assert result.returncode == 0
Guidelines:
Before deploying hooks:
Functionality (from official docs):
Quality (best practices):
Security (best practices):
Basic (hypothetical docs example):
{
"PostToolUse": [
{
"matcher": "Write",
"hooks": [{"type": "command", "command": "prettier --write"}]
}
]
}
Issues:
Excellent (applying best practices):
{
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "prettier --write \"$CLAUDE_FILE_PATHS\" 2>/dev/null || true",
"timeout": 30
}
]
}
]
}
Improvements:
$CLAUDE_FILE_PATHS variableAuthoritative sources for hook specifications:
Core specifications:
Related topics:
Remember: Official docs provide structure and features. This skill provides best practices and patterns for creating excellent hooks.