Claude Code Hooks Expert
You are an expert on Claude Code hooks - automated scripts that execute at specific lifecycle events. Help users create, debug, and optimize hooks.
Quick Reference
Hook Events
| Event | Matcher Support | Use Case |
|---|---|---|
| PreToolUse | Yes | Block/allow tools, auto-approve, modify inputs |
| PostToolUse | Yes | Auto-format, lint, log, validate results |
| PermissionRequest | Yes | Programmatic permission handling |
| UserPromptSubmit | No | Validate prompts, add context, block sensitive |
| Stop | No | Prevent exit, continue work |
| SubagentStop | No | Control subagent lifecycle |
| Notification | No | Custom alerts |
| SessionStart | Yes (startup/resume/clear/compact) |
Load context, set env vars |
| SessionEnd | No | Cleanup, logging |
| PreCompact | Yes (manual/auto) |
Pre-compaction actions |
Configuration Locations
~/.claude/settings.json # User-level (all projects)
.claude/settings.json # Project-level (committed)
.claude/settings.local.json # Local project (gitignored)
Component frontmatter # Skills, Agents, Commands
plugins/*/hooks/hooks.json # Plugin hooks
Basic Structure
{
"hooks": {
"EventName": [
{
"matcher": "ToolPattern",
"hooks": [
{
"type": "command",
"command": "your-command-here",
"timeout": 60
}
]
}
]
}
}
Exit Codes
| Code | Meaning | Output Handling |
|---|---|---|
| 0 | Success | stdout parsed for JSON or added as context |
| 2 | Blocking error | stderr shown, tool blocked (PreToolUse) |
| Other | Non-blocking error | stderr shown in verbose mode |
Input Schema
Common Fields (All Hooks)
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/working/directory",
"permission_mode": "default|plan|acceptEdits|dontAsk|bypassPermissions",
"hook_event_name": "PreToolUse"
}
Tool-Specific Input
Bash:
{
"tool_name": "Bash",
"tool_input": {
"command": "npm test",
"description": "Run tests",
"timeout": 120000,
"run_in_background": false
}
}
Write:
{
"tool_name": "Write",
"tool_input": {
"file_path": "/path/to/file.ts",
"content": "file content"
}
}
Edit:
{
"tool_name": "Edit",
"tool_input": {
"file_path": "/path/to/file.ts",
"old_string": "original",
"new_string": "replacement",
"replace_all": false
}
}
MCP Tools:
{
"tool_name": "mcp__server__tool_name",
"tool_input": { /* tool-specific */ }
}
Output Schema
PreToolUse Decision
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow|deny|ask",
"permissionDecisionReason": "Auto-approved: documentation file",
"updatedInput": {
"command": "modified command"
},
"additionalContext": "Context for Claude"
},
"suppressOutput": true
}
Stop/SubagentStop Decision
{
"decision": "block",
"reason": "Tests failed - must continue debugging"
}
UserPromptSubmit Decision
{
"decision": "block",
"reason": "Prompt contains sensitive information"
}
Or just print plain text to stdout for context injection.
Common Fields
{
"continue": true,
"stopReason": "Message when continue=false",
"suppressOutput": false,
"systemMessage": "Warning shown to user"
}
Matcher Patterns
| Pattern | Matches |
|---|---|
"Bash" |
Exact match only |
"Edit|Write" |
Multiple tools (pipe) |
"Edit.*" |
Regex pattern |
"mcp__memory__.*" |
MCP tool pattern |
"*" or "" |
All tools |
Environment Variables
| Variable | Description |
|---|---|
CLAUDE_PROJECT_DIR |
Project root path |
CLAUDE_CODE_REMOTE |
"true" for web, empty for CLI |
CLAUDE_PLUGIN_ROOT |
Plugin directory (plugins only) |
CLAUDE_ENV_FILE |
File to persist env vars (SessionStart only) |
Templates
Python Hook Template
#!/usr/bin/env python3
"""Hook description."""
import json
import sys
def main():
try:
data = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(1)
tool_name = data.get("tool_name", "")
tool_input = data.get("tool_input", {})
# Your logic here
# Block with error message
# print("Error message", file=sys.stderr)
# sys.exit(2)
# Allow with JSON output
# output = {"hookSpecificOutput": {...}}
# print(json.dumps(output))
sys.exit(0)
if __name__ == "__main__":
main()
Bash Hook Template
#!/bin/bash
set -euo pipefail
# Read input
INPUT=$(cat)
# Parse with jq
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Your logic here
# Block: print to stderr, exit 2
# echo "Error message" >&2
# exit 2
# Allow
exit 0
Component-Scoped Hook (Skill/Agent frontmatter)
---
name: my-skill
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/validate.sh"
PostToolUse:
- matcher: "Write|Edit"
hooks:
- type: command
command: "./scripts/format.sh"
once: true # Run only once per session
---
Common Patterns
1. Auto-Approve Safe Operations
if tool_name == "Read" and file_path.endswith((".md", ".txt", ".json")):
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Documentation file"
}
}
print(json.dumps(output))
2. Block Dangerous Commands
BLOCKED = ["rm -rf /", "DROP TABLE", "sudo rm"]
if any(pattern in command for pattern in BLOCKED):
print(f"Blocked: {command}", file=sys.stderr)
sys.exit(2)
3. Auto-Format After Edit
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs -I {} prettier --write {}"
}
]
}
]
}
}
4. Add Context at Session Start
#!/bin/bash
echo "Project: $(basename \"$CLAUDE_PROJECT_DIR\")"
echo "Branch: $(git branch --show-current 2>/dev/null || echo 'N/A')"
echo "Node: $(node -v 2>/dev/null || echo 'N/A')"
5. Prevent Premature Exit
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Check if all tasks are complete. Respond {\"ok\": true} to stop, {\"ok\": false, \"reason\": \"...\"} to continue."
}
]
}
]
}
}
6. Command Logging
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '\"[\\(.hook_event_name)] \\(.tool_input.command)\"' >> ~/.claude/command-log.txt"
}
]
}
]
}
}
7. File Protection
PROTECTED = [".env", "package-lock.json", ".git/", "secrets/"]
file_path = tool_input.get("file_path", "")
if any(p in file_path for p in PROTECTED):
print(f"Protected file: {file_path}", file=sys.stderr)
sys.exit(2)
Security Best Practices
- Quote all variables:
"$VAR"not$VAR - Validate inputs: Check for empty, null, unexpected types
- Block path traversal: Reject paths with
.. - Use absolute paths: For scripts and files
- Skip sensitive files:
.env,.git/, credentials - Test manually first: Before adding to config
- Keep hooks fast: < 60s timeout default
Debugging
# List registered hooks
claude /hooks
# Run with debug output
claude --debug
# Test script manually
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./hook.py
# Check hook linter
.claude/plugins/*/skills/hook-development/scripts/hook-linter.sh ./my-hook.sh
Naming Convention
Faion Network Convention (Global)
For shared/reusable hooks:
Pattern: faion-{event}-{purpose}-hook.{ext}
| Type | Pattern | Example |
|---|---|---|
| Pre-tool | faion-pre-{tool}-{purpose}-hook |
faion-pre-bash-security-hook.py |
| Post-tool | faion-post-{tool}-{purpose}-hook |
faion-post-edit-format-hook.sh |
| Session | faion-session-{phase}-{purpose}-hook |
faion-session-start-context-hook.sh |
| Stop | faion-stop-{purpose}-hook |
faion-stop-validation-hook.py |
Project-Specific Convention (Local)
For project-specific hooks that should NOT be committed to faion-network:
Pattern: {project}-{event}-{purpose}-hook.{ext}
| Example | Description |
|---|---|
myapp-pre-bash-lint-hook.sh |
Lint before bash in myapp |
shopify-post-edit-sync-hook.py |
Sync after edits |
acme-session-start-env-hook.sh |
Load ACME env vars |
Setup:
# Add to .gitignore at the same level as .claude/
echo ".claude/scripts/hooks/{project}-*" >> .gitignore
Attribution (add comment at top of hook):
# Created with faion.net framework
Rules Summary
| Scope | Prefix | Suffix | Gitignore |
|---|---|---|---|
| Global | faion- |
-hook.{ext} |
No |
| Project | {project}- |
-hook.{ext} |
Yes (parent) |
Extensions: .py (Python), .sh (Bash), .js (Node)
Hook Directories
~/.claude/scripts/hooks/
├── faion-pre-bash-security-hook.py # Global
├── faion-post-edit-format-hook.sh # Global
├── myapp-pre-bash-lint-hook.sh # Project (gitignored)
└── myapp-session-start-env-hook.sh # Project (gitignored)
Full structure: docs/directory-structure.md
Related Conventions
- Skills:
faion-{name}-skillor{project}-{name}-skill - Agents:
faion-{name}-agentor{project}-{name}-agent - Commands:
{verb}or{project}-{action}
References
- Official Hooks Documentation
- Load detailed references with
/faion-make-hooks-skill:references/input-schemas.md- Complete input schemasreferences/output-schemas.md- Complete output schemasreferences/templates.md- Ready-to-use templatesreferences/patterns.md- Common patterns and examples