decision.behavior: allow/deny/ask; decision.message on deny
PermissionDenied
YES (via stderr)
fires after auto mode classifier denial; {retry:true} -> model retries (v2.1.89+)
UI (PTU only)
Silently modifies tool params. Claude unaware of change. Most reliable injection for SA prompts via UI.prompt.
Routing Decision Guide
Goal
Channel
Event
Inject context for Claude
AC
SS, PTU, UserPromptSubmit
Inject into SA
UI.prompt
PTU (matcher: Task)
Block tool
permissionDecision:"deny"
PTU
Block session stop
decision:"block" + reason
Stop
Feedback at stop (no block)
AC
Stop, SubagentStop (v2.1.163+)
Inject into SA context
AC
SubagentStart
Post-tool feedback
AC
POT (stable)
Modify tool params
UI
PTU
Show user warning
systemMessage
any
Block user prompt
decision:"block"
UserPromptSubmit
Auto-allow permission
decision:"allow"
PR
Control teammates
{continue, stopReason} JSON
TeammateIdle, TaskCompleted, TaskCreated
Authoritative Per-Event Output Channels
Consult BEFORE choosing output. Wrong channel = silently ignored (no error). UI=updatedInput.
Event
Add context / affect model
Do NOT use (IGNORED)
SS
AC
UI
UserPromptSubmit
AC (cannot rewrite prompt); decision:"block"+reason to reject
UI — IGNORED
PTU
AC; UI to rewrite tool args; permissionDecision
updatedToolOutput
POT
AC; updatedToolOutput
UI
Stop / SubagentStop
decision:"block"+reason (also AC feedback v2.1.163+)
AC for blocking
PreCompact
no model-facing output
--
PR
UI / permission fields
--
UserPromptSubmit CANNOT rewrite the prompt; UI (updatedInput) is ignored there — use AC. To deliver per-turn context, AC is the channel for SS / UserPromptSubmit / PTU / POT. UI rewrites args ONLY on PTU (and PR). Root cause of the forced-eval.mjs bug: it emitted UI.prompt on UserPromptSubmit → silently dropped by CC 2.1.x.
2. All 27 Hook Events
MD (v2.1.152): transforms/hides assistant message text at display layer only; non-blocking.
NOT a hooks.json event: post-session lifecycle hook (v2.1.169) = self-hosted runner hook, runs after session ends + before workspace deleted. Configure on runner, not in hooks.json.
POSTs FULL hook JSON payload to URL (axios), blocks for response, parses JSON body as hook output (decision / AC). Both directions. In the POSTed payload the user-prompt field is named prompt (v2.1.63+)
600s
external API/webhook, remote delegation
mcp_tool
invokes a tool on an already-configured MCP server and AWAITS it synchronously; returned text content parsed exactly like a command hook's stdout JSON (can return decision:block or hookSpecificOutput.additionalContext)
600s
reuse an MCP tool as gate/injector
prompt
inline-LLM allow/block GATE: evaluates the prompt, decides allow vs block, surfaces a reason on block. Its NL text is NOT added to the model's context
30s
quick validation / policy gate
agent
LLM-agent allow/block GATE, same semantics as prompt (evaluate condition -> allow or block+reason). NOT a general subagent whose output is injected. Experimental
60s
complex condition gate
prompt/agent = gates (allow/block only). command/http/mcp_tool = can both gate AND inject context.
mcp_tool config fields
Field
Req
Description
server
yes
name of a configured MCP server
tool
yes
tool name to invoke
input
no
args object; string values support ${...} interpolation from hook input JSON (e.g. "${tool_input.file_path}")
Format: ToolName(pattern) — same syntax as permission rules.
5. Environment Variables
Variable
Description
Available
$CLAUDE_PROJECT_DIR
project root
all hooks
$CLAUDE_PLUGIN_ROOT
plugin install dir
plugin hooks
$CLAUDE_PLUGIN_DATA
persistent per-plugin data dir, survives updates (v2.1.78+); ~/.claude/plugins/data/<plugin-id>/
plugin hooks
$CLAUDE_CODE_REMOTE
"true" in remote env
all hooks
$CLAUDE_ENV_FILE
path for persistent env vars
SS, CwdChanged, FileChanged
$CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS
SessionEnd hooks timeout in ms (DEF 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
CLAUDE_CODE_SAFE_MODE
1 = start CC with ALL customizations disabled (CLAUDE.md, plugins, skills, hooks, MCP); also --safe-mode flag; use for hook debug isolation (v2.1.169+)
startup
CLAUDE_CODE_DISABLE_BUNDLED_SKILLS
1 = hide bundled skills/workflows/built-in cmds; also disableBundledSkills setting (v2.1.169+)
startup
Plugin Persistent State (CLAUDE_PLUGIN_DATA)
Persistent per-plugin data dir, survives updates (v2.1.78+). Hook-only env var.
Aspect
Details
env var
process.env.CLAUDE_PLUGIN_DATA (hooks only)
path
~/.claude/plugins/data/<plugin-id>/
read in hooks
const pluginData = process.env.CLAUDE_PLUGIN_DATA
Protected-path (v3.4.70): Write/Edit to ~/.claude/* (incl. $CLAUDE_PLUGIN_DATA) blocked in ALL modes (bypassPermissions, headless). Check runs BEFORE hooks — whitelists dead. Exceptions: commands|agents|skills|worktrees. !=design skills with $CLAUDE_PLUGIN_DATA as Write target -> silent fail. Primary: project-relative .claude/<subdir>/ + whitelist. $CLAUDE_PLUGIN_DATA = read-only/interactive-only/Bash-only (Bash redirect currently bypasses check, but brittle).
6. Output Schemas
PTU — Allow with context
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","additionalContext":"Context string for Claude"}}
PTU — Deny
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Reason Claude will see"}}
{"decision":"block","reason":"Task not complete. Continue with phase 3."}
SubagentStop — Block
{"decision":"block","reason":"Review not finished. Check remaining files."}
SS — Context injection
{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Injected context for Claude","sessionTitle":"My session title","reloadSkills":true},"systemMessage":"Status shown to user only"}
reloadSkills:true re-scans skill dirs; sessionTitle sets session title on startup + resume (v2.1.152+).
SubagentStart — Inject into SA
{"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)"}
retry:true -> model retries denied tool call. Fires after auto mode classifier denies a tool. !=same as PR (user-facing). Use for headless/CI flows to programmatically override denial.
Caching: store validation outcomes (5-min cache) to avoid redundant processing
Cross-Event Workflows: SS -> count tests | POT -> increment | Stop -> verify count > 0
15. Hook Type Selection
Need
Type
Why
allow/block policy gate
prompt
inline LLM evaluates, allow or block+reason (no context injection)
LLM-agent condition gate
agent
same gate semantics as prompt; experimental
deterministic ops
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 svc
reuse a configured MCP tool
mcp_tool
awaits MCP tool, parses result as command JSON (gate or inject)
DEF: command for deterministic/performance-critical; prompt/agent only when an allow/block gate needs LLM judgment.
Lifecycle: hooks load at session start. Config changes require /clear or new session.
#!/usr/bin/env node
import{readFileSync,existsSync}from'fs';constinput=JSON.parse(readFileSync(0,'utf8'));if(input.stop_hook_active){console.log('{}');process.exit(0);}constlogPath=`${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('{}');}
Test: run with CLAUDE_DEBUG=1, check verbose (Ctrl+O). Isolate hook bugs: claude --safe-mode / CLAUDE_CODE_SAFE_MODE=1 starts CC with ALL customizations off (CLAUDE.md, plugins, skills, hooks, MCP) to confirm hook is cause (v2.1.169+)
Validate: run checklist
18. Validation Checklist
#
Check
1
correct event type matches intended trigger
2
matcher pattern (regex for tools, string for sources)
3
output schema correct for event
4
routing channel (AC vs UI vs decision)
5
fail-safe: output({}) in catch block
6
stop_hook_active 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 for scope
10
performance <1s for blocking hooks
11
check routing matrix for broken channels
12
syntax check (bash -n or node --check)
13
if field (v2.1.85+) to reduce overhead when applicable