Hook Creator
opusQuick reference
| Field | Value |
|---|---|
| Model | opus |
| Tools | Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch |
| Triggers | ”Create PreToolUse hook”, “hook doesn’t work”, hook debugging, lifecycle hooks |
Creates production-quality Claude Code hooks (bash and JS/mjs) with correct message routing, JSON schemas, and fail-safe design.
Reference version: 2.1.85+ | 25 hook events | 4 hook types (command, http, prompt, agent)
Session Lifecycle
InstructionsLoaded -> SessionStart -> UserPromptSubmit -> PermissionRequest -> PreToolUse
-> [Tool] -> PostToolUse / PostToolUseFailure -> Notification -> Stop -> StopFailure
-> PreCompact -> PostCompact -> SessionEnd
Background: CwdChanged, FileChanged, ConfigChange
Subagent Lifecycle
PreToolUse:Task -> TaskCreated -> SubagentStart -> [work] -> SubagentStop -> PostToolUse:Task
Agent Teams Lifecycle
TeammateIdle (exit 0=stop, 1=continue) | TaskCompleted (exit 0=accept, 1=redo)
Quick Start
| Goal | Event | Output |
|---|---|---|
| Inject context | PreToolUse | additionalContext |
| Block tool | PreToolUse | permissionDecision:"deny" |
| Modify input | PreToolUse | updatedInput |
| Block stop | Stop | decision:"block" + reason |
| Session init | SessionStart | additionalContext |
| Auto-allow permission | PermissionRequest | decision: {behavior:"allow"} |
| Post-tool feedback | PostToolUse | additionalContext |
| Control teammates | TeammateIdle | {continue: false, stopReason: "..."} |
| React to config change | ConfigChange | Exit code or JSON |
| React to file change | FileChanged | Exit code or JSON |
Message Routing Matrix
Primary reference for output channel delivery per event.
additionalContext (in hookSpecificOutput)
| Event | Claude sees? | Delivery | Notes |
|---|---|---|---|
| SessionStart | YES | <system-reminder> | Stable |
| UserPromptSubmit | YES | <system-reminder> appended | Stable |
| PreToolUse | YES | <system-reminder> | Stable |
| PostToolUse | YES | <system-reminder> | Stable (Issue #15345 confirms) |
| PostToolUseFailure | YES | Needs verification | Presumed working, limited data |
| SubagentStart | YES | Injected into subagent context | Not parent |
| Notification | YES | <system-reminder> | Stable |
| Stop | N/A | Field not supported | Use reason |
| SubagentStop | N/A | Field not supported | Use reason |
| PreCompact | N/A | Field not supported | Use systemMessage |
| SessionEnd | N/A | Field not supported | Informational event |
| TeammateIdle | N/A | JSON {continue, stopReason} (v2.1.52+) | — |
| TaskCompleted | N/A | JSON {continue, stopReason} (v2.1.52+) | — |
| TaskCreated | N/A | JSON {continue, stopReason} (v2.1.52+) | — |
stdout (exit 0, JSON)
| Event | Claude sees? | Notes |
|---|---|---|
| SessionStart | YES | Parsed, context injected |
| UserPromptSubmit | YES | Parsed, context injected |
| PreToolUse | YES | Parsed, context injected |
| All others | NO | Verbose mode only (Ctrl+O) |
systemMessage
Goes to user UI only — Claude does NOT see it. Exception: async hooks deliver on next turn.
stderr (exit 2)
| Event type | Claude sees? | Notes |
|---|---|---|
| Blocking (PreToolUse, PermissionRequest, UserPromptSubmit, Stop, SubagentStop, TeammateIdle, TaskCompleted, TaskCreated, ConfigChange, WorktreeCreate, Elicitation, ElicitationResult) | YES | Delivered as error context |
| Non-blocking (SessionStart, PostToolUse, PostToolUseFailure, PreCompact, PostCompact, Notification, SessionEnd, SubagentStart, InstructionsLoaded, StopFailure, CwdChanged, FileChanged, WorktreeRemove) | NO | User UI only |
decision + reason
| Event | Claude sees reason? | Notes |
|---|---|---|
| Stop | YES | decision:"block" + reason -> Claude continues, sees reason |
| SubagentStop | YES | decision:"block" + reason -> subagent continues, sees reason |
| PostToolUse | YES (via additionalContext) | No decision field; reason delivered as feedback |
| UserPromptSubmit | NO (UI only) | decision:"block" -> prompt rejected, Claude does NOT see reason |
| PreToolUse | YES | permissionDecisionReason delivered when deny |
| PermissionRequest | N/A | decision.behavior: allow/deny/ask. decision.message on deny |
updatedInput (PreToolUse only)
Silently modifies tool parameters. Claude unaware of change. Most reliable injection method for subagent prompts via updatedInput.prompt.
Routing Decision Guide
| Goal | Best channel | Event |
|---|---|---|
| Inject context for Claude | additionalContext | SessionStart, PreToolUse, UserPromptSubmit |
| Inject into subagent | updatedInput.prompt | PreToolUse (matcher: Task) |
| Block tool execution | permissionDecision:"deny" | PreToolUse |
| Block session stop | decision:"block" + reason | Stop |
| Inject into subagent context | additionalContext | SubagentStart |
| Post-tool feedback | additionalContext | PostToolUse (stable) |
| Modify tool parameters | updatedInput | PreToolUse |
| Show user warning | systemMessage | Any event |
| Block user prompt | decision:"block" | UserPromptSubmit |
| Auto-allow permission | decision: "allow" | PermissionRequest |
| Control teammates | {continue, stopReason} JSON | TeammateIdle, TaskCompleted, TaskCreated |
| Prompt gate | decision:"block" | UserPromptSubmit |
All 25 Hook Events
Event Reference
| # | Event | Blocking? | Matcher | Key stdin fields | Version |
|---|---|---|---|---|---|
| 1 | SessionStart | No | source: startup, resume, clear, compact | source, model, agent_type | — |
| 2 | UserPromptSubmit | Yes (exit 2 / decision:block) | No | user_prompt | — |
| 3 | PreToolUse | Yes (allow/deny/ask) | Tool name regex | tool_name, tool_input, tool_use_id | — |
| 4 | PermissionRequest | Yes (allow/deny) | Tool name regex | tool_name, tool_input, permission_suggestions | — |
| 5 | PostToolUse | No | Tool name regex | tool_name, tool_input, tool_response, tool_use_id | — |
| 6 | PostToolUseFailure | No | Tool name regex | tool_name, tool_input, tool_use_id, error, is_interrupt | — |
| 7 | Notification | No | notification_type | message, title, notification_type | — |
| 8 | SubagentStart | No | Agent type | agent_id, agent_type | — |
| 9 | SubagentStop | Yes (decision:block) | Agent type | stop_hook_active, agent_id, agent_type, agent_transcript_path, last_assistant_message | — |
| 10 | Stop | Yes (decision:block) | No | stop_hook_active, last_assistant_message | — |
| 11 | PreCompact | No | trigger: manual, auto | transcript_path | — |
| 12 | PostCompact | No | trigger: manual, auto | transcript_path | 2.1.76 |
| 13 | SessionEnd | No | reason: clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, other | — | — |
| 14 | TeammateIdle | Yes (exit 2 only) | No | teammate_name, team_name | — |
| 15 | TaskCompleted | Yes (exit 2 only) | No | task_id, task_subject, task_description, teammate_name, team_name | — |
| 16 | ConfigChange | Yes | source: user_settings, project_settings, local_settings, policy_settings, skills | source, file_path | 2.1.49 |
| 17 | WorktreeCreate | Yes | No | — | 2.1.50 |
| 18 | WorktreeRemove | No | No | — | 2.1.50 |
| 19 | InstructionsLoaded | No | load_reason: session_start, nested_traversal, path_glob_match, include, compact | file_path, memory_type, load_reason, globs, trigger_file_path, parent_file_path | 2.1.69 |
| 20 | Elicitation | Yes | MCP server name | MCP-specific fields | 2.1.76 |
| 21 | ElicitationResult | Yes | MCP server name | MCP-specific fields | 2.1.76 |
| 22 | StopFailure | No | error_type: rate_limit, authentication_failed, billing_error, invalid_request, server_error, max_output_tokens, unknown | error, error_details, last_assistant_message | 2.1.78 |
| 23 | CwdChanged | No | No | — | 2.1.83 |
| 24 | FileChanged | No | filename (basename) | file_path | 2.1.83 |
| 25 | TaskCreated | Yes | No | task_id, task_subject, task_description, teammate_name, team_name | 2.1.84 |
Common stdin fields (ALL events)
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript",
"cwd": "/project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"agent_id": "uuid (subagents only, v2.1.69+)",
"agent_type": "Explore|Plan|custom (subagents + --agent, v2.1.69+)"
}Blocking Behavior
| Exit code | Meaning | stdout | stderr |
|---|---|---|---|
| 0 | Success | Parsed as JSON. For TeammateIdle/TaskCompleted: teammate terminates | Verbose mode |
| 1 | Error (non-fatal) | For TeammateIdle/TaskCompleted: teammate continues. Others: error | Verbose mode |
| 2 | Critical error | IGNORED | Delivered to Claude (blocking) or user (non-blocking) |
Exit code behavior by event
| Event | exit 0 | exit 1 | exit 2 |
|---|---|---|---|
| PreToolUse | JSON processed | Tool call cancelled | stderr -> Claude |
| Stop | JSON processed | Ignored | stderr -> Claude |
| SubagentStop | JSON processed | Ignored | stderr -> Claude |
| SessionStart | JSON processed | Warning in UI | stderr -> UI |
| PreCompact | JSON processed | Compact continues | stderr -> UI |
| TeammateIdle | Teammate terminates | Teammate continues | stderr -> UI |
| TaskCompleted | Task accepted | Task re-assigned | stderr -> UI |
| PostToolUse | JSON processed | Warning | stderr -> UI |
Hook Types
| Type | Description | Timeout | Use case |
|---|---|---|---|
command | Shell command, JSON stdin/stdout | 600s | Custom logic, file I/O, external tools |
http | POST JSON to URL, receives JSON response (v2.1.63+) | 600s | External API/webhook integration, remote delegation |
prompt | Single LLM call (Haiku) | 30s | Quick validation, content generation |
agent | Subagent with Read/Grep/Glob, up to 50 turns | 60s | Complex analysis, multi-step checks |
Common fields for all types
| Field | Description | Applies to |
|---|---|---|
type | Required: "command", "http", "prompt", "agent" | All |
if | Conditional filter (permission rule syntax, v2.1.85+): "Bash(git *)", "Edit(*.ts)" | Tool events |
timeout | Seconds before cancellation | All |
statusMessage | Spinner text while hook runs | All |
once | true = run once per session (skills only) | Skills |
HTTP hook example (v2.1.63+)
{
"type": "http",
"url": "http://localhost:8080/hooks/pre-tool-use",
"timeout": 30,
"headers": { "Authorization": "Bearer $MY_TOKEN" },
"allowedEnvVars": ["MY_TOKEN"]
}Configuration Locations
Priority (highest first):
| # | Location | Scope | Notes |
|---|---|---|---|
| 1 | .claude/settings.local.json | Project (gitignored) | Highest priority, personal project |
| 2 | .claude/settings.json | Project (committable) | Team-shared |
| 3 | ~/.claude/settings.local.json | Global (gitignored) | Personal global |
| 4 | ~/.claude/settings.json | Global (committable) | User global |
| 5 | Enterprise policy | Organization | MDM/admin |
| 6 | Plugin hooks/hooks.json | Plugin-scoped | Additive (merged, not overridden) |
| 7 | Agent/Skill frontmatter YAML | Component-scoped | While component active |
Merge rule: Hooks from different sources are merged (not overridden). For a single event, ALL registered hooks execute in parallel.
settings.json format
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash /path/to/hook.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node /path/to/hook.mjs"
}
]
}
]
}
}hooks.json format (plugin)
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "node $CLAUDE_PLUGIN_ROOT/hooks/session-start.mjs"
}
]
}
]
}
}Agent/Skill frontmatter YAML
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/validate.sh"Conditional if field (v2.1.85+)
Reduces hook overhead — hook only fires when if condition matches (permission rule syntax):
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"if": "Bash(git *)",
"hooks": [{"type": "command", "command": "bash validate-git.sh"}]
}]
}
}Format: ToolName(pattern) — same syntax as permission rules.
Environment Variables
| Variable | Description | Available |
|---|---|---|
$CLAUDE_PROJECT_DIR | Project root | All hooks |
$CLAUDE_PLUGIN_ROOT | Plugin installation dir | Plugin hooks |
$CLAUDE_PLUGIN_DATA | Persistent plugin data dir (survives updates, v2.1.78+) | Plugin hooks |
$CLAUDE_CODE_REMOTE | "true" in remote env | All hooks |
$CLAUDE_ENV_FILE | Path for persistent env vars | SessionStart, CwdChanged, FileChanged |
$CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS | SessionEnd hooks timeout in ms (default 1500ms, v2.1.78+) | SessionEnd hooks |
$CLAUDE_CODE_SUBPROCESS_ENV_SCRUB | 1 = scrub Anthropic/cloud credentials from subprocess env (v2.1.83+) | All hooks |
$CLAUDE_PLUGIN_OPTION_<KEY> | Plugin userConfig values (v2.1.78+) | Plugin hooks |
Output Schemas
PreToolUse — Allow with context
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"additionalContext": "Context string for Claude"
}
}PreToolUse — Deny
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Reason Claude will see"
}
}PreToolUse — Modify input
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"prompt": "Modified prompt text",
"other_field": "preserved"
}
}
}Stop — Block
{
"decision": "block",
"reason": "Task not complete. Continue with phase 3."
}SubagentStop — Block
{
"decision": "block",
"reason": "Review not finished. Check remaining files."
}SessionStart — Context injection
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Injected context for Claude"
},
"systemMessage": "Status shown to user only"
}SubagentStart — Inject into subagent
{
"hookSpecificOutput": {
"hookEventName": "SubagentStart",
"additionalContext": "Context injected into SUBAGENT (not parent)"
}
}UserPromptSubmit — Block
{
"decision": "block",
"reason": "Reason shown to USER only (Claude does NOT see this)"
}PermissionRequest — Allow/Deny/Ask
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow"
}
}
}
behavior | Effect |
|---|---|
allow | Auto-allow the operation |
ask | Show standard permission dialog |
deny | Reject without prompting user |
PermissionRequest — Allow with permission mutation
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow",
"updatedInput": { "command": "npm test" },
"updatedPermissions": [{
"type": "addRules",
"rules": [{ "toolName": "Bash", "ruleContent": "npm *" }],
"behavior": "allow",
"destination": "session"
}]
}
}
}PreToolUse — Answer AskUserQuestion (v2.1.85+)
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"question": "Which database?",
"answer": "PostgreSQL"
}
}
}PostToolUse — Feedback
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Post-tool feedback for Claude"
}
}TeammateIdle/TaskCompleted/TaskCreated — JSON control (v2.1.52+)
{
"continue": false,
"stopReason": "Task limit reached. Stopping teammate."
}Elicitation — MCP form response (v2.1.76+)
{
"hookSpecificOutput": {
"hookEventName": "Elicitation",
"action": "accept",
"content": { "field_name": "value" }
}
}
action | Effect |
|---|---|
accept | Auto-fill MCP form with content |
decline | Decline the elicitation |
cancel | Cancel the elicitation |
WorktreeCreate — Return path (v2.1.84+, http hooks)
{
"hookSpecificOutput": {
"hookEventName": "WorktreeCreate",
"worktreePath": "/path/to/created/worktree"
}
}Empty pass-through
{} Bash Hook Template
#!/bin/bash
set -euo pipefail
# Hook: <EventName> | Matcher: <matcher>
# Purpose: <description>
# Read JSON from stdin
INPUT=$(cat)
# Parse common fields
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // empty')
# Parse event-specific fields
# TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
# TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // empty')
# PROMPT=$(echo "$INPUT" | jq -r '.prompt // empty')
# --- Infinite loop protection (Stop/SubagentStop hooks) ---
# STOP_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
# if [ "$STOP_ACTIVE" = "true" ]; then
# echo '{}'
# exit 0
# fi
# --- Main logic ---
# Example: pass-through (no-op)
echo '{}'
# Example: inject context (PreToolUse)
# jq -n --arg ctx "Your context here" '{
# hookSpecificOutput: {
# hookEventName: "PreToolUse",
# permissionDecision: "allow",
# additionalContext: $ctx
# }
# }'
# Example: block stop
# jq -n --arg reason "Task incomplete" '{
# decision: "block",
# reason: $reason
# }'
# Example: deny tool
# jq -n --arg reason "Not allowed" '{
# hookSpecificOutput: {
# hookEventName: "PreToolUse",
# permissionDecision: "deny",
# permissionDecisionReason: $reason
# }
# }'JS/mjs Hook Template
#!/usr/bin/env node
/**
* Hook: <EventName> | Matcher: <matcher>
* Purpose: <description>
*/
// --- stdin/stdout helpers ---
async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
function output(response) {
console.log(JSON.stringify(response));
}
// --- Main ---
async function main() {
try {
const input = await readStdin();
const { session_id, cwd, hook_event_name } = input;
// Event-specific fields:
// PreToolUse: tool_name, tool_input, tool_use_id
// PostToolUse: tool_name, tool_input, tool_response, tool_use_id
// PostToolUseFailure: tool_name, tool_input, tool_use_id, error, is_interrupt
// Stop: stop_hook_active, last_assistant_message
// SubagentStart: agent_id, agent_type
// SubagentStop: stop_hook_active, agent_id, agent_type, agent_transcript_path
// UserPromptSubmit: user_prompt
// SessionStart: source, model, agent_type
// PreCompact: transcript_path
// ConfigChange: source, file_path
// StopFailure: error, error_details, last_assistant_message
// FileChanged: file_path
// InstructionsLoaded: file_path, memory_type, load_reason, globs
// TaskCreated/TaskCompleted: task_id, task_subject, task_description
// --- Infinite loop protection (Stop/SubagentStop) ---
// if (input.stop_hook_active) {
// output({});
// return;
// }
// --- Main logic ---
// Pass-through
output({});
// Inject context (PreToolUse):
// output({
// hookSpecificOutput: {
// hookEventName: 'PreToolUse',
// permissionDecision: 'allow',
// additionalContext: 'Context for Claude'
// }
// });
// Modify tool input (PreToolUse):
// output({
// hookSpecificOutput: {
// hookEventName: 'PreToolUse',
// permissionDecision: 'allow',
// updatedInput: { ...input.tool_input, prompt: 'Modified prompt' }
// }
// });
// Block stop:
// output({ decision: 'block', reason: 'Task incomplete' });
} catch (error) {
// Fail-safe: allow on error (never trap user)
console.error(`Hook error: ${error.message}`);
output({});
}
}
main();JS/mjs with Shared Utils (library pattern)
For multi-hook projects, extract readStdin/output into lib/utils.mjs:
// lib/utils.mjs
export async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
export function output(response) {
console.log(JSON.stringify(response));
}// hooks/my-hook.mjs
import { readStdin, output } from './lib/utils.mjs'; Matcher Patterns
| Event | Matcher type | Examples |
|---|---|---|
| PreToolUse | Tool name (regex) | Bash, Write|Edit, Task, mcp__.* |
| PostToolUse | Tool name (regex) | Bash, Read, Task |
| PostToolUseFailure | Tool name (regex) | Bash |
| PermissionRequest | Tool name (regex) | Bash, Write |
| SessionStart | Source string | startup, resume, clear, compact |
| SessionEnd | Reason string | clear, resume, logout, prompt_input_exit, other |
| SubagentStart | Agent type | developer, Explore, my-agent |
| SubagentStop | Agent type | developer, reviewer |
| PreCompact / PostCompact | Trigger | manual, auto |
| Notification | Type string | permission_prompt, idle_prompt, auth_success, elicitation_dialog |
| ConfigChange | Source string | user_settings, project_settings, local_settings, policy_settings, skills |
| InstructionsLoaded | Load reason | session_start, nested_traversal, path_glob_match, include, compact |
| FileChanged | Filename (basename) | .envrc, .env |
| StopFailure | Error type | rate_limit, authentication_failed, billing_error, invalid_request, server_error, max_output_tokens, unknown |
| Elicitation / ElicitationResult | MCP server name | Server name string |
| Stop | No matcher | Always fires |
| UserPromptSubmit | No matcher | Always fires |
| TeammateIdle / TaskCompleted / TaskCreated | No matcher | Always fires |
| WorktreeCreate / WorktreeRemove | No matcher | Always fires |
| CwdChanged | No matcher | Always fires |
Omit
matcher-> hook fires for ALL instances of that event.
Common Hook Patterns
Inject context into all subagents
{
"hooks": {
"PreToolUse": [
{
"matcher": "Task",
"hooks": [{
"type": "command",
"command": "node /path/to/inject-context.mjs"
}]
}
]
}
}Hook modifies tool_input.prompt via updatedInput.
Gate dangerous tools
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "bash /path/to/validate-bash.sh"
}]
}
]
}
}Hook checks tool_input.command, returns permissionDecision:"deny" if dangerous.
Block stop until task complete
{
"hooks": {
"Stop": [
{
"hooks": [{
"type": "command",
"command": "node /path/to/check-task.mjs"
}]
}
]
}
}Hook reads task state, returns decision:"block" with reason if incomplete.
Log all tool calls
{
"hooks": {
"PostToolUse": [
{
"hooks": [{
"type": "command",
"command": "node /path/to/logger.mjs",
"async": true
}]
}
]
}
}Async — no performance impact. Writes to log file.
Inject project context on session start
{
"hooks": {
"SessionStart": [
{
"hooks": [{
"type": "command",
"command": "bash /path/to/session-init.sh"
}]
}
]
}
}Returns additionalContext with project state.
Production Examples
Security Gate (PreToolUse:Bash)
#!/bin/bash
set -euo pipefail
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qE '(rm -rf /|sudo rm|chmod 777|dd if=)'; then
jq -n --arg r "Blocked: dangerous command ($CMD)" '{
hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}
}'
exit 0
fi
echo '{}'Test Enforcement (Stop)
#!/usr/bin/env node
import { readFileSync, existsSync } from 'fs';
const input = JSON.parse(readFileSync(0, 'utf8'));
if (input.stop_hook_active) { console.log('{}'); process.exit(0); }
const logPath = `${input.cwd}/.claude/test-run.log`;
if (!existsSync(logPath)) {
console.log(JSON.stringify({
decision: 'block',
reason: 'No tests run. Execute test suite before stopping.'
}));
} else {
console.log('{}');
}Context Injection (SessionStart)
#!/bin/bash
set -euo pipefail
INPUT=$(cat)
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
CFG="$CWD/.project-config.json"
if [ -f "$CFG" ]; then
RULES=$(jq -r '.rules // empty' "$CFG")
jq -n --arg ctx "Project rules: $RULES" '{
hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:$ctx},
systemMessage:"Loaded project config"
}'
else
echo '{}'
fiTool Logger (PostToolUse, async)
#!/usr/bin/env node
import { readFileSync, appendFileSync } from 'fs';
const input = JSON.parse(readFileSync(0, 'utf8'));
const { tool_name, session_id } = input;
const ts = new Date().toISOString();
const log = `${ts} | ${session_id} | ${tool_name}\n`;
try {
appendFileSync(`${input.cwd}/.claude/tool-log.txt`, log);
console.log('{}');
} catch (e) {
console.log('{}');
} Official Patterns Reference
| # | Pattern | Event | Purpose |
|---|---|---|---|
| 1 | Security Validation | PreToolUse | Block writes to system dirs or credential files |
| 2 | Test Enforcement | Stop | Verify tests were executed before completion |
| 3 | Context Loading | SessionStart | Auto-detect project type, load env config |
| 4 | Notification Logging | Notification | Track notifications for audit/logging |
| 5 | MCP Tool Monitoring | PreToolUse | Validate destructive MCP operations |
| 6 | Build Verification | Stop | Ensure project compiles after modifications |
| 7 | Permission Confirmation | PreToolUse | Prompt for rm/delete/drop operations |
| 8 | Code Quality Checks | PostToolUse | Run linters/formatters on file edits |
| 9 | Temporarily Active | Any | Use flag files to enable/disable hooks |
| 10 | Configuration-Driven | Any | Read JSON settings for validation behavior |
Advanced Techniques
Multi-Stage Validation
Combine command + prompt hooks: fast deterministic checks (command) -> intelligent analysis (prompt).
Conditional Execution
Hooks adapt to: environment (CI/local), user context (admin/regular), project settings.
State Sharing
Sequential hooks communicate via temp files: Hook A -> /tmp/risk.json -> Hook B reads.
Dynamic Config
.claude-hooks-config.json: {"strictMode":true,"allowedCommands":["npm test"],"maxFileSize":1048576}
Caching
Store validation outcomes (5-min cache) to avoid redundant processing.
Cross-Event Workflows
SessionStart -> count tests | PostToolUse -> increment | Stop -> verify count > 0
Hook Type Selection
| Need | Hook Type | Why |
|---|---|---|
| Context-aware decisions | prompt | Natural language reasoning |
| Flexible evaluation | prompt | No bash scripting needed |
| Deterministic operations | command | Reliable, fast |
| File system tasks | command | Direct access |
| External tool integration | command | System calls |
| Performance-critical | command | Lower latency |
| External API / webhook | http | No subprocess, direct HTTP POST |
| Remote delegation | http | Offload to external service |
| File-reading analysis | agent | Read/Grep/Glob access, multi-step |
Default: prompt hooks for most cases; command hooks for deterministic/performance-critical.
Lifecycle: Hooks load at session start. Config changes require
/clearor new session.
Async Hooks
{
"type": "command",
"command": "node /path/to/hook.mjs",
"async": true
}
| Behavior | Details |
|---|---|
| Execution | Background, non-blocking |
decision fields | IGNORED |
systemMessage | Delivered on NEXT turn (not instant) |
additionalContext | May not arrive before Claude processes |
| Blocking events | Always synchronous (PreToolUse, Stop, SubagentStop, UserPromptSubmit, PermissionRequest) |
| Use case | Logging, metrics, slow file operations |
Sync/Async recommendation by event
| Event | Sync/Async | Reason |
|---|---|---|
| SessionStart | Sync (waits) | Context needed before first turn |
| PreToolUse | Sync (blocks) | Must decide allow/deny before execution |
| PostToolUse | Async OK | Result is informational |
| PreCompact | Sync (waits) | Must write handoff before compaction |
| Notification | Async OK | Informational |
Best Practices
Fail-Safe Design
| Practice | Why |
|---|---|
Always output({}) on error | Never trap user in broken state |
stop_hook_active check in Stop/SubagentStop | Prevents infinite block loop |
| Try/catch around all logic | Graceful degradation |
| Validate stdin before parsing fields | Handle missing/malformed input |
| Default to allow/pass-through | Hook failure = no effect |
Infinite Loop Protection (Stop hook)
STOP_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
if [ "$STOP_ACTIVE" = "true" ]; then
echo '{}'
exit 0
fiif (input.stop_hook_active) {
output({});
return;
}Performance
| Practice | Why |
|---|---|
| Keep hooks fast (<1s for PreToolUse) | Blocks tool execution |
Use async: true for slow operations | Background execution |
| Cache file reads | Avoid repeated I/O |
| Minimal dependencies (jq for bash, no npm for mjs) | Fast startup |
Security
| Practice | Why |
|---|---|
Validate cwd paths | Prevent path traversal |
| Sanitize stdin JSON | Prevent injection |
| Use absolute paths in commands | Avoid PATH manipulation |
Check existsSync before file reads | Prevent crashes |
Known Bugs
| Bug | Impact | Status | Workaround |
|---|---|---|---|
| #14281 | Duplicate <system-reminder> injection | Active | Make context idempotent |
Fixed Bugs (reference only)
| Bug | Was | Fixed in |
|---|---|---|
Plugin SessionStart additionalContext not delivered | v2.1.37+ (not reproducible) | |
PreToolUse additionalContext regression | v2.1.15+ | |
| SessionStart hooks not working for new sessions | v2.1.20+ | |
PreToolUse allow bypassed deny permission rules | v2.1.77 | |
| Skill hooks fired twice per event | v2.1.72 | |
Plugin Stop/SessionEnd hooks skipped after /plugin | v2.1.70 | |
SessionStart hooks called twice on --resume/--continue | v2.1.73 | |
| SessionEnd hooks unreliable | v2.1.79 | |
| Plugin scripts “Permission denied” on macOS/Linux | v2.1.86 | |
| Uninstalled plugin hooks kept firing | v2.1.83 |
Channel Reliability Matrix
| Channel | Reliability | Notes |
|---|---|---|
updatedInput (PreToolUse) | High | Stable, most reliable injection method |
additionalContext (PreToolUse) | High | Regression v2.1.12 fixed in v2.1.15+ |
additionalContext (SessionStart) | High | Stable since v2.1.37+ |
additionalContext (PostToolUse) | High | Stable (Issue #15345 confirms) |
decision/reason (Stop) | High | Stable |
systemMessage | High | Stable (but Claude does NOT see it) |
permissionDecision (PreToolUse) | High | Stable |
Validation Checklist
| # | Check | Details |
|---|---|---|
| 1 | Correct event type | Matches intended trigger |
| 2 | Matcher pattern | Regex for tools, string for sources |
| 3 | Output schema | Correct JSON structure for event |
| 4 | Routing channel | additionalContext vs updatedInput vs decision |
| 5 | Fail-safe | output({}) in catch block |
| 6 | stop_hook_active | Present in Stop/SubagentStop hooks |
| 7 | stdin parsing | Handles missing/null fields |
| 8 | Executable | chmod +x for bash, #!/usr/bin/env node for mjs |
| 9 | Config location | Correct settings file for scope |
| 10 | Performance | <1s for blocking hooks |
| 11 | Known bugs | Check routing matrix for broken channels |
| 12 | Syntax check | bash -n for bash, node --check for mjs |
| 13 | if conditional | Use if field (v2.1.85+) to reduce hook overhead when applicable |
| 14 | Hook type | command for deterministic, http for API, prompt for NL, agent for file analysis |
Version History
| Version | Event/Feature | Type |
|---|---|---|
| 2.1.49 | ConfigChange | New event |
| 2.1.50 | WorktreeCreate, WorktreeRemove | New events |
| 2.1.50 | last_assistant_message in Stop/SubagentStop stdin | New field |
| 2.1.52 | JSON response for TeammateIdle/TaskCompleted (was exit-code only) | Enhancement |
| 2.1.63 | http hook type | New type |
| 2.1.69 | InstructionsLoaded | New event |
| 2.1.69 | agent_id, agent_type in common stdin fields | New fields |
| 2.1.70 | Fix: plugin Stop/SessionEnd hooks after /plugin operation | Bug fix |
| 2.1.72 | Fix: skill hooks firing twice per event | Bug fix |
| 2.1.73 | Fix: SessionStart hooks called twice on --resume/--continue | Bug fix |
| 2.1.76 | PostCompact | New event |
| 2.1.76 | Elicitation, ElicitationResult | New events |
| 2.1.77 | Fix: PreToolUse allow no longer bypasses deny permission rules | Security fix |
| 2.1.78 | StopFailure | New event |
| 2.1.78 | CLAUDE_PLUGIN_DATA, CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS | New env vars |
| 2.1.78 | CLAUDE_PLUGIN_OPTION_<KEY> for plugin userConfig | New env var |
| 2.1.79 | Fix: SessionEnd hooks reliable execution | Bug fix |
| 2.1.83 | CwdChanged, FileChanged | New events |
| 2.1.83 | CLAUDE_CODE_SUBPROCESS_ENV_SCRUB | New env var |
| 2.1.83 | Fix: uninstalled plugin hooks no longer phantom-fire | Bug fix |
| 2.1.84 | TaskCreated | New event |
| 2.1.84 | WorktreeCreate supports type: "http" | Enhancement |
| 2.1.85 | Conditional if field for tool event hooks | New feature |
| 2.1.85 | PreToolUse can answer AskUserQuestion via updatedInput | Enhancement |
| 2.1.86 | Fix: plugin scripts “Permission denied” on macOS/Linux | Bug fix |
Sources
- Claude Code Hooks
- Claude Code Changelog
- Custom Subagents
- Bug references: #14281
Workflow
- Clarify — Ask: which event? what behavior? bash or JS? where to configure?
- Design — Select event, matcher, output schema, routing channel
- Implement — Use template, add logic, handle errors
- Configure — Add to appropriate settings/hooks.json
- Test — Run with
CLAUDE_DEBUG=1, check verbose output (Ctrl+O) - Validate — Run checklist
Deliverable Format
=== HOOK CREATED ===
File: /path/to/hook.sh or hook.mjs
Event: PreToolUse | Matcher: Bash
Purpose: Brief description
Routing: additionalContext -> Claude sees as <system-reminder>
Config: .claude/settings.json (or specify location)
VERIFICATION:
- Shebang/hashbang present
- Fail-safe error handling
- stop_hook_active check (if Stop/SubagentStop)
- Output schema matches event type
- Syntax valid