Complete hook system reference, verified against https://code.claude.com/docs/en/hooks (June 2026).
Stale contract warning: old guides describe a
$TOOL_INPUTenv-var contract. That is dead. Hooks receive a JSON payload on stdin and respond via exit code + stdout JSON.
| Event | Fires | Matcher matches | Blocking (exit 2) |
|---|---|---|---|
SessionStart |
Session begins | startup, resume, clear, compact |
No |
SessionEnd |
Session ends | End reason: clear, resume, logout, prompt_input_exit, ... |
No |
Setup |
Only with --init/--init-only/--maintenance |
init, maintenance |
No |
UserPromptSubmit |
Before prompt is processed (30s default timeout) | (none) | Yes — prompt rejected and erased |
UserPromptExpansion |
When a /command expands |
Command/skill name | Yes — blocks expansion |
PreToolUse |
Before each tool call | Tool name | Yes — tool call prevented |
PermissionRequest |
When a permission dialog would show | Tool name | Yes — permission denied |
PermissionDenied |
After a tool call is denied | Tool name | No (retry output supported) |
PostToolUse |
After tool succeeds | Tool name | No (stderr fed back; tool already ran) |
PostToolUseFailure |
After tool fails | Tool name | No (tool already failed) |
PostToolBatch |
After a parallel tool batch resolves | (none) | Yes — stops loop before next model call |
Stop |
Main agent finishes a turn | (none) | Yes — prevents stop, conversation continues |
StopFailure |
Turn ends in API error | Error type (rate_limit, overloaded, ...) |
No (output ignored) |
SubagentStart / SubagentStop |
Subagent spawn / finish | Agent type (e.g. Explore, custom names) |
Start: No / Stop: Yes |
TaskCreated / TaskCompleted |
Task list changes | (none) | Yes — rolls back / prevents completion |
TeammateIdle |
Agent-team teammate goes idle | (none) | Yes — prevents idle |
Notification |
Notification sent | permission_prompt, idle_prompt, auth_success, ... |
No |
MessageDisplay |
While a message streams (10s timeout) | (none) | No (displayContent rewrite, screen-only) |
ConfigChange |
Settings file changed mid-session | user_settings, project_settings, local_settings, policy_settings, skills |
Yes — blocks the change (except policy) |
CwdChanged |
Working directory changes | (none) | No (CLAUDE_ENV_FILE available) |
FileChanged |
Watched file changes | Literal filenames, e.g. .envrc\|.env |
No (CLAUDE_ENV_FILE available) |
PreCompact / PostCompact |
Before / after compaction | manual, auto |
Pre: Yes — blocks compaction / Post: No |
InstructionsLoaded |
CLAUDE.md / rules file loads | Load reason: session_start, nested_traversal, path_glob_match, include, compact |
No (async, observability) |
WorktreeCreate / WorktreeRemove |
Worktree lifecycle | (none) | Create: any non-zero fails creation |
Elicitation / ElicitationResult |
MCP server requests user input / response | MCP server name | Yes — denies / blocks response |
Every hook gets JSON on stdin. Common fields:
{
"session_id": "…",
"transcript_path": "/abs/path/to/transcript.jsonl",
"cwd": "/working/dir",
"hook_event_name": "PreToolUse",
"permission_mode": "default|plan|acceptEdits|auto|dontAsk|bypassPermissions"
}
agent_id / agent_type appear in subagent context; effort: {"level": "low|medium|high|xhigh|max"} on tool-context events.
Event-specific fields:
| Event | Extra stdin fields |
|---|---|
Tool events (PreToolUse, PermissionRequest, PermissionDenied) |
tool_name, tool_input (tool-specific object) |
PostToolUse |
+ tool_output (string or object) |
PostToolUseFailure |
+ error_message |
PermissionDenied |
+ denial_reason |
SessionStart |
source (startup\|resume\|clear\|compact), model |
Setup |
trigger (init\|maintenance) |
UserPromptSubmit |
prompt |
UserPromptExpansion |
command, expansion |
StopFailure |
error_type, error_message |
SubagentStart/SubagentStop |
agent_type, agent_id |
Notification |
notification_type, message |
ConfigChange |
source |
FileChanged |
file_path, change_type (create\|modify\|delete) |
CwdChanged |
directory |
TaskCreated/TaskCompleted |
task_id, task_title |
InstructionsLoaded |
file_path, memory_type, load_reason, globs?, trigger_file_path?, parent_file_path? |
Elicitation |
server_name, form_fields[] (name, type, label, required) |
WorktreeCreate/WorktreeRemove |
worktree_id, worktree_path (remove) |
Read it with jq:
#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
| Code | Meaning |
|---|---|
0 |
Success. stdout parsed as JSON if valid, else treated as plain text context |
2 |
Blocking error. stdout ignored; stderr is fed to Claude as feedback |
| other | Non-blocking error. stderr logged, first line shown in transcript, execution continues |
Universal fields (any event):
{
"continue": false, // false stops Claude entirely
"stopReason": "why we stopped", // shown when continue:false
"suppressOutput": true, // hide stdout from transcript
"systemMessage": "warning shown to user",
"terminalSequence": "\u001b]…" // raw OSC sequence (v2.1.141+)
}
Event-specific output goes inside hookSpecificOutput with a required hookEventName:
| Event | hookSpecificOutput fields |
|---|---|
PreToolUse |
permissionDecision: allow\|deny\|ask\|defer, permissionDecisionReason, updatedInput (replace tool args), additionalContext |
PermissionRequest |
decision: { "behavior": "allow\|deny", "updatedInput": {…} } |
PermissionDenied |
retry: true (let the model retry) |
PostToolUse |
updatedToolOutput (replace the result Claude sees), additionalContext |
SessionStart / SubagentStart |
additionalContext, watchPaths: [...] (feeds FileChanged), reloadSkills: true; SessionStart only: sessionTitle, initialUserMessage |
Stop / SubagentStop |
additionalContext (inject feedback and continue) |
PostToolBatch / Setup |
additionalContext |
MessageDisplay |
displayContent (screen-only rewrite, transcript untouched) |
Elicitation / ElicitationResult |
action: accept\|decline\|cancel, content: {field: value} |
WorktreeCreate |
worktreePath (or print path on stdout for command hooks) |
Top-level decision/reason pattern (alternative to exit 2) for UserPromptSubmit, UserPromptExpansion, PostToolUse, PostToolUseFailure, PostToolBatch, ConfigChange, PreCompact, Stop, SubagentStop:
{ "decision": "block", "reason": "explanation Claude sees" }
Five types. All accept timeout (seconds), if (permission-rule filter), statusMessage.
command (default workhorse){
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check.sh",
"args": ["--fast"],
"async": false,
"asyncRewake": false,
"shell": "bash",
"timeout": 600
}
args): command string runs via shell (sh -c, Git Bash, or PowerShell with shell: "powershell"); pipes, &&, globs work.args): resolved as executable on PATH, spawned directly, no shell.async: true: background, never blocks, output discarded.asyncRewake: true: background, but wakes Claude on exit 2 with stderr as a system reminder. Implies async.http{ "type": "http", "url": "https://hooks.internal/check",
"headers": {"Authorization": "$HOOK_TOKEN"}, "allowedEnvVars": ["HOOK_TOKEN"] }
POST; 2xx = success (body parsed as JSON or plain text); non-2xx = non-blocking error. Env interpolation in headers requires allowedEnvVars.
mcp_tool{ "type": "mcp_tool", "server": "my-server", "tool": "validate",
"input": {"cmd": "${tool_input.command}"} }
Calls a configured MCP server's tool; ${path.to.field} interpolates from the stdin payload.
prompt (default timeout 30s){ "type": "prompt", "prompt": "Is this command destructive? $ARGUMENTS", "model": "haiku" }
One-shot yes/no judgment by a fast model; returns the decision as JSON.
agent (default timeout 60s){ "type": "agent", "prompt": "Verify the edited file still compiles. $ARGUMENTS" }
Spawns a subagent with tool access; returns a decision.
| Pattern | Interpreted as |
|---|---|
"*", "", omitted |
Match everything |
Letters/digits/_/\| only |
Exact name or \|-list: Bash, Edit\|Write |
| Anything else | JavaScript regex: ^Notebook, mcp__memory__.* |
bash never matches Bash).matcher must be a string, not an array — an array is a schema error and the hook is silently dropped (visible in /doctor).mcp__<server>__<tool>; match a whole server with regex mcp__memory__.*.if filtersNarrow within a matched event using permission-rule syntax — "if": "Bash(git *)" runs only for git commands (subcommands inside && chains and $() are checked; leading FOO=bar assignments stripped). Edit(*.ts) filters by file pattern. Fails open if the command can't be parsed — use the permission system for hard enforcement.
| Location | Scope |
|---|---|
~/.claude/settings.json |
All your projects |
.claude/settings.json |
Project (committed) |
.claude/settings.local.json |
Project (gitignored) |
| Managed policy settings | Organization |
Plugin hooks/hooks.json (or inline in plugin.json) |
While plugin enabled |
| Skill or agent frontmatter | While that component is active |
Settings-file shape:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check.sh" } ] }
]
}
}
Skill/agent frontmatter shape (YAML):
---
name: secure-operations
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/security-check.sh"
---
For subagents, Stop hooks auto-convert to SubagentStop. Plugin subagents ignore hooks frontmatter (security restriction).
Edits to settings.json hooks take effect in the running session after a brief delay — no restart. disableAllHooks: true turns everything off (managed hooks only by managed-level setting). Identical handlers are deduplicated. Browse live config with /hooks.
| Variable | Available | Value |
|---|---|---|
CLAUDE_PROJECT_DIR |
All hooks | Project root |
CLAUDE_PLUGIN_ROOT |
Plugin hooks | Plugin install dir (changes on update) |
CLAUDE_PLUGIN_DATA |
Plugin hooks | Persistent data dir (survives updates) |
CLAUDE_ENV_FILE |
SessionStart, Setup, CwdChanged, FileChanged |
File to append export VAR=… lines; persists into later Bash calls |
CLAUDE_EFFORT |
Tool-context events | low…max |
CLAUDE_CODE_REMOTE |
All | "true" on web, unset locally |
Path placeholders in hook config: ${CLAUDE_PROJECT_DIR}, ${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA}, and (plugins) ${user_config.*}.
Block dangerous commands (PreToolUse on Bash):
#!/bin/bash
CMD=$(jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qE 'rm -rf /|git push --force.*(main|master)'; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive command blocked by policy"}}'
fi
exit 0
Audit log (PostToolUse, matcher *):
#!/bin/bash
cat | jq -c '{ts: now|todate, tool: .tool_name, input: .tool_input}' >> ~/.claude/audit.jsonl
exit 0
Inject project context + reload skills at session start (SessionStart):
#!/bin/bash
jq -n --arg ctx "$(git -C "$CLAUDE_PROJECT_DIR" status --short)" \
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $ctx, reloadSkills: true}}'
Force a re-check before stopping (Stop):
#!/bin/bash
if ! npm test --silent >/dev/null 2>&1; then
echo "Tests are failing — fix them before finishing." >&2
exit 2
fi
exit 0
Rewrite tool input (PreToolUse updatedInput):
#!/bin/bash
INPUT=$(cat)
SAFE=$(echo "$INPUT" | jq '.tool_input.command |= sub("^pip install"; "uv pip install")')
jq -n --argjson ti "$(echo "$SAFE" | jq '.tool_input')" \
'{hookSpecificOutput: {hookEventName: "PreToolUse", updatedInput: $ti}}'
"$VAR"); validate paths (reject .. traversal)${CLAUDE_PROJECT_DIR} instead of relative paths (hooks run from varying cwd)UserPromptSubmit hooks fast (30s default timeout); set explicit timeout elsewhereset -euo pipefail plus jq fallbacks (// empty) so malformed payloads don't crash into exit-2 blocksclaude --debug hooks # live: events fired, matchers checked, exit codes, output
/hooks # what's registered this session
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo "exit=$?"
See debugging-reference.md for the hook-not-firing decision tree.