| name | hook-development |
| description | Canonical guide to authoring Claude Code hooks (prompt-based and command-based). PROACTIVELY activate for: (1) creating a hook, (2) adding a PreToolUse hook, (3) adding a PostToolUse hook, (4) validating tool use before execution, (5) implementing prompt-based hooks, (6) blocking dangerous commands, (7) setting up event-driven automation, (8) configuring hooks.json, (9) using ${CLAUDE_PLUGIN_ROOT} in hook commands for portability, (10) hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification), (11) hook matchers and filtering, (12) debugging hooks that do not fire. Provides: hooks.json schema, event/matcher reference, prompt-vs-command hook patterns, security guidance, validation script, and end-to-end working examples. |
Hook Development for Claude Code Plugins
Overview
Hooks are event-driven automation that execute in response to Claude Code events. Use hooks to validate operations, enforce policies, load context, and integrate external tools.
Two hook types:
- Prompt-based (recommended): LLM-driven, context-aware decisions
- Command-based: Shell commands for fast, deterministic checks
Hook Events Reference
| Event | When | Common Use |
|---|
| PreToolUse | Before tool executes | Validate, approve/deny, modify input |
| PostToolUse | After tool completes | Test, lint, log, provide feedback |
| Stop | Main agent stopping | Verify task completeness |
| SubagentStop | Subagent stopping | Validate subagent work |
| UserPromptSubmit | User sends prompt | Add context, validate, preprocess |
| SessionStart | Session begins | Load context, set environment |
| SessionEnd | Session ends | Cleanup, logging |
| PreCompact | Before context compaction | Preserve critical information |
| Notification | Notification shown | Custom alert reactions |
Configuration Formats
Plugin hooks.json (in hooks/hooks.json)
Uses wrapper format with hooks field:
{
"description": "What these hooks do (optional)",
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh",
"timeout": 10
}
]
}
]
}
}
User settings format (in .claude/settings.json)
Direct format, no wrapper:
{
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "script.sh" }]
}
]
}
Critical difference: Plugin hooks.json wraps events inside {"hooks": {...}}. Settings format puts events at top level.
Prompt-Based Hooks (Recommended)
Use LLM reasoning for context-aware decisions:
{
"type": "prompt",
"prompt": "Evaluate if this tool use is appropriate. Check for: system paths, credentials, path traversal. Return 'approve' or 'deny'.",
"timeout": 30
}
Supported events: PreToolUse, PostToolUse, Stop, SubagentStop, UserPromptSubmit
Benefits: Context-aware, flexible, better edge case handling, easier to maintain.
Command Hooks
Execute shell commands for deterministic checks:
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh",
"timeout": 60
}
Always use ${CLAUDE_PLUGIN_ROOT} for portable paths.
Exit Codes
| Code | Meaning |
|---|
| 0 | Success (stdout shown in transcript) |
| 2 | Blocking error (stderr fed back to Claude) |
| Other | Non-blocking error |
Matchers
Control which tools trigger hooks:
"matcher": "Write"
"matcher": "Write|Edit|Bash"
"matcher": "mcp__.*__delete.*"
"matcher": "*"
Matchers are case-sensitive.
Hook Input/Output
Input (all hooks receive via stdin)
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.txt",
"cwd": "/current/working/dir",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": { "file_path": "/path/to/file" }
}
Event-specific fields: tool_name, tool_input, tool_result, user_prompt, reason
Access in prompts: $TOOL_INPUT, $TOOL_RESULT, $USER_PROMPT
Output
Standard (all hooks):
{
"continue": true,
"suppressOutput": false,
"systemMessage": "Message for Claude"
}
PreToolUse decisions:
{
"hookSpecificOutput": {
"permissionDecision": "allow|deny|ask",
"updatedInput": { "field": "modified_value" }
}
}
Stop/SubagentStop decisions:
{
"decision": "approve|block",
"reason": "Explanation"
}
Environment Variables
| Variable | Available | Purpose |
|---|
$CLAUDE_PLUGIN_ROOT | All hooks | Plugin directory (portable paths) |
$CLAUDE_PROJECT_DIR | All hooks | Project root path |
$CLAUDE_ENV_FILE | SessionStart only | Persist env vars for session |
SessionStart can persist variables:
echo "export PROJECT_TYPE=nodejs" >> "$CLAUDE_ENV_FILE"
Common Patterns
Validate file writes (PreToolUse)
{
"PreToolUse": [{
"matcher": "Write|Edit",
"hooks": [{
"type": "prompt",
"prompt": "Check if this file write is safe. Deny writes to: .env, credentials, system paths, or files with path traversal (..). Return 'approve' or 'deny' with reason."
}]
}]
}
Auto-test after changes (PostToolUse)
{
"PostToolUse": [{
"matcher": "Write|Edit",
"hooks": [{
"type": "command",
"command": "npm test -- --bail",
"timeout": 60
}]
}]
}
Verify task completion (Stop)
{
"Stop": [{
"matcher": "*",
"hooks": [{
"type": "prompt",
"prompt": "Verify: tests run, build succeeded, all questions answered. Return 'approve' to stop or 'block' with reason to continue."
}]
}]
}
Load project context (SessionStart)
{
"SessionStart": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/load-context.sh",
"timeout": 10
}]
}]
}
Security Best Practices
In command hook scripts:
#!/bin/bash
set -euo pipefail
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path')
if [[ ! "$file_path" =~ ^[a-zA-Z0-9_./-]+$ ]]; then
echo '{"decision": "deny", "reason": "Invalid path"}' >&2
exit 2
fi
if [[ "$file_path" == *".."* ]]; then
echo '{"decision": "deny", "reason": "Path traversal detected"}' >&2
exit 2
fi
if [[ "$file_path" == *".env"* ]]; then
echo '{"decision": "deny", "reason": "Sensitive file"}' >&2
exit 2
fi
echo "$file_path"
Lifecycle and Limitations
Hooks load at session start. Changes to hook configuration require restarting Claude Code.
- Editing
hooks/hooks.json won't affect the current session
- Adding new hook scripts won't be recognized until restart
- All matching hooks run in parallel (not sequentially)
- Hooks don't see each other's output - design for independence
To test changes: Exit Claude Code, restart with claude or claude --debug.
Debugging
claude --debug
echo '{"tool_name": "Write", "tool_input": {"file_path": "/test"}}' | \
bash ${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh
output=$(./hook-script.sh < test-input.json)
echo "$output" | jq .
Validation Checklist