Claude Code hooks — deep reference
Hooks are Claude Code’s deterministic-automation layer: user-defined commands, HTTP endpoints, MCP-tool calls, prompt invocations, or agent dispatches that fire at specific points in the session lifecycle. They receive structured JSON context on stdin and return decisions through exit codes and JSON stdout. Hooks are how you enforce auto-formatting on every edit, block destructive bash commands, log every tool call, inject session-specific context at startup, write export statements that mutate the agent’s environment, send a desktop notification when Claude pauses, and dozens of similar deterministic policies.
See also
- claude-code-cli —
settings.jsonhosts the hook config;--debug "hooks"for diagnostics - claude-code-subagents-and-skills — hooks scoped to specific skills and subagents
- claude-agent-sdk — SDK exposes the same hook system programmatically
- hidden-tricks-and-gotchas — undocumented hook behavior
1. Hook cadences and events
Per code.claude.com/docs/en/hooks, events fire at these cadences:
| Cadence | Events |
|---|---|
| Once per session | SessionStart, SessionEnd, Setup |
| Once per turn | UserPromptSubmit, UserPromptExpansion, Stop, StopFailure |
| Per tool call | PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied, PostToolBatch |
| Async / event-driven | FileChanged, CwdChanged, ConfigChange, WorktreeCreate, WorktreeRemove, Notification, InstructionsLoaded |
| Subagent-specific | SubagentStart, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted |
| Context management | PreCompact, PostCompact |
| MCP elicitation | Elicitation, ElicitationResult |
Event semantics
- SessionStart — fires on every new session (incl. resumed). Matcher: session source (
startup,resume,clear,compact). - Setup — runs from
--init/--maintenanceflags. Matcher:init,maintenance. - SessionEnd — fires on exit. Matcher: end reason (
clear,resume,logout,other). - UserPromptSubmit — fires when user submits a prompt (before Claude sees it). Can inject
additionalContextor block submission. - UserPromptExpansion — fires when
@file/!commandexpansion happens in the prompt. - PreToolUse — fires before each tool call. Can
allow/deny/ask/defer, modify input, inject context. - PostToolUse — fires after each tool call. Can inject additional context for Claude’s next turn.
- PostToolUseFailure — fires when a tool errors. Same shape as PostToolUse.
- PermissionRequest / PermissionDenied — fired on permission prompts.
PermissionDeniedcan requestretry: true. - PostToolBatch — fires after a batch of parallel tool calls completes.
- Stop — fires when Claude tries to end its turn. Can block (
decision: "block") to force continuation with a reason. - StopFailure — fires when the model errors; output is always ignored.
- PreCompact / PostCompact — surround context auto-compaction.
- FileChanged — file mutations detected by Claude Code’s watcher. Matcher: literal filenames pipe-separated (
.envrc|.env). - CwdChanged —
cdin bash,--add-dir, etc. - ConfigChange —
settings.jsonreload. - WorktreeCreate —
claude -wcreating a worktree; exit code non-zero aborts worktree creation. - Notification — Claude wants to surface something. Matcher:
permission_prompt,idle_prompt,auth_success. - InstructionsLoaded — system prompt + tools assembled; last hook before first model call.
- SubagentStart / SubagentStop — subagent lifecycle. Matcher: agent type (
general-purpose,Explore,Plan, or custom name). - TeammateIdle — agent-teams teammate goes idle.
- TaskCreated / TaskCompleted — for the task-list subsystem.
- Elicitation — MCP elicitation request; can auto-respond.
- ElicitationResult — fires after elicitation completes.
2. Matchers
The matcher field filters when a hook fires within an event type:
| Pattern | Behavior |
|---|---|
"Bash" | Exact tool name |
"Edit|Write|MultiEdit" | Pipe-separated exact matches |
"^Notebook" | JS regex |
"mcp__memory__.*" | MCP tool pattern |
"*" | All (default) |
Per-event matcher types
| Event | Matcher field | Examples |
|---|---|---|
| Tool events (Pre/PostToolUse, etc.) | Tool name | Bash, Edit|Write, mcp__.* |
| SessionStart | Source | startup, resume, clear, compact |
| Setup | Trigger | init, maintenance |
| SessionEnd | Reason | clear, resume, logout, other |
| Notification | Type | permission_prompt, idle_prompt, auth_success |
| SubagentStart/Stop | Agent type | general-purpose, Explore, Plan, custom |
| FileChanged | Filenames | .env|.envrc|secrets.* |
| CwdChanged, UserPromptSubmit, PostToolBatch, Stop | (no matcher; always fires) | — |
3. Handler types
Five handler types, all returning the same JSON output schema (or using exit codes for command handlers):
3.1 Command (shell command or executable)
{
"type": "command",
"command": "./.claude/hooks/validate.sh",
"args": ["--strict"],
"timeout": 30,
"async": false,
"asyncRewake": false,
"shell": "bash"
}Exec form (args present): command is the executable, spawned directly with args. No shell interpretation. Safe for paths with spaces.
Shell form (args absent): command string passed to shell. Supports pipes, &&, globs, env expansion.
shell accepts "bash" (default) or "powershell".
3.2 HTTP
{
"type": "http",
"url": "http://localhost:8080/hook",
"headers": {"Authorization": "Bearer $MY_TOKEN"},
"allowedEnvVars": ["MY_TOKEN"],
"timeout": 600
}HTTP response handling:
- 2xx + empty body → success
- 2xx + plain text → success with context
- 2xx + JSON → parsed as JSON output (same schema as command JSON output)
- Non-2xx, timeout, connection failure → non-blocking error
- HTTP cannot block via exit code — must return 2xx with JSON
{"decision": "block", ...}or appropriatehookSpecificOutputto block
3.3 MCP tool
{
"type": "mcp_tool",
"server": "security",
"tool": "scan_file",
"input": {
"file_path": "${tool_input.file_path}"
}
}Invokes a tool on an already-configured MCP server. ${tool_input.X} interpolates from the originating tool’s input. Useful for “before every Edit, run security MCP tool” patterns without a wrapper script.
3.4 Prompt
{
"type": "prompt",
"prompt": "Is this operation safe to proceed? $ARGUMENTS",
"model": "claude-haiku-4-5"
}Runs a sub-LLM call with the prompt. $ARGUMENTS (and event-specific interpolations) substituted. Default model: fast (Haiku). 30-second default timeout. Returns a yes/no decision (block / allow) or JSON output.
3.5 Agent
{
"type": "agent",
"agent": "security-reviewer"
}Dispatches a subagent. 60-second default timeout. Used for richer policy decisions where a single prompt isn’t enough.
4. Common fields (all handler types)
| Field | Type | Default | Purpose |
|---|---|---|---|
type | string | required | command, http, mcp_tool, prompt, agent |
if | string | — | Tool-event-only. Permission rule syntax (Bash(git *)). Hook only fires if matched. |
timeout | integer | varies (see below) | Seconds before cancel |
statusMessage | string | — | Custom spinner message while hook runs |
Timeout defaults: 600s (command, http, mcp_tool), 30s (prompt), 60s (agent), 30s for UserPromptSubmit tools.
5. Path placeholders
| Placeholder | Resolves to |
|---|---|
${CLAUDE_PROJECT_DIR} | Project root |
${CLAUDE_PLUGIN_ROOT} | Plugin install dir (plugin hooks only) |
${CLAUDE_PLUGIN_DATA} | Plugin persistent data dir |
${user_config.X} | User config values (plugin hooks only) |
${tool_input.X} | Input field from the originating tool call |
$ARGUMENTS | Hook arguments (varies by event) |
6. Stdin (JSON payload received by hook)
All events deliver these fields:
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/working/directory",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"effort": {"level": "high"}
}Subagent events add:
{ "agent_id": "uuid", "agent_type": "Explore" }Tool events add:
{ "tool_name": "Bash", "tool_input": { /* parameters Claude is about to pass */ } }PostToolUse events add:
{ "tool_response": { /* result returned to Claude */ } }UserPromptSubmit events add:
{ "prompt": "the submitted prompt text" }7. Exit codes (command handlers)
| Exit | Meaning | Effect |
|---|---|---|
| 0 | Success | Stdout (if JSON) parsed for decisions; otherwise no decision |
| 2 | Blocking error | stderr fed back as error to Claude / action blocked (varies by event); stdout/JSON ignored |
| Other | Non-blocking error | stderr shown to user; execution continues |
Special cases:
WorktreeCreate— any non-zero exit code aborts worktree creationUserPromptSubmit— 30s default timeoutStopFailure— exit code and output always ignored
8. JSON output schema
All handler types emit the same JSON schema on stdout (or as HTTP 2xx body):
{
"continue": true,
"stopReason": "Build failed",
"suppressOutput": false,
"systemMessage": "Warning: Production environment",
"terminalSequence": "\\033]777;notify;Title;Body\\007",
"decision": "block",
"reason": "Permission denied",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Database writes prohibited",
"additionalContext": "Context injected for Claude",
"updatedInput": { },
"retry": true,
"action": "accept",
"content": { },
"worktreePath": "/path/to/worktree"
}
}Field-by-field
| Field | Applies to | Effect |
|---|---|---|
continue: false | all events | Stops all processing; takes precedence over event-specific decisions |
stopReason | all events | Message shown to user when continue: false |
suppressOutput | all events | Hide hook stdout from transcript |
systemMessage | all events | User-visible warning (not shown to model) |
terminalSequence | all events | OSC terminal escape sequence (notifications) — allowlist below |
decision: "block" | UserPromptSubmit, UserPromptExpansion, PostToolUse, Stop, SubagentStop, PreCompact, ConfigChange | Blocks the action |
reason | blocking events | Explanation for block |
hookSpecificOutput.hookEventName | always | Repeats event name (sanity check) |
hookSpecificOutput.permissionDecision | PreToolUse, PermissionRequest | "allow", "deny", "ask", "defer" |
hookSpecificOutput.permissionDecisionReason | permission events | Why |
hookSpecificOutput.additionalContext | SessionStart, Setup, UserPromptSubmit, UserPromptExpansion, PreToolUse, PostToolUse, SubagentStart | Injected into Claude’s window (10,000 char limit; excess saved to file) |
hookSpecificOutput.updatedInput | PreToolUse, PermissionRequest | Modified tool input before execution |
hookSpecificOutput.retry | PermissionDenied | Allow model to retry the denied tool call |
hookSpecificOutput.action | Elicitation, ElicitationResult | "accept", "decline", "cancel" |
hookSpecificOutput.content | Elicitation, ElicitationResult | Form field values |
hookSpecificOutput.worktreePath | WorktreeCreate | Created worktree path |
Allowed terminal escape sequences
Only these are passed through (anything else stripped for security):
- OSC 0, 1, 2 — window/icon titles
- OSC 9 — iTerm2, ConEmu, Windows Terminal, WezTerm notifications
- OSC 99 — Kitty notifications
- OSC 777 — urxvt, Ghostty, Warp notifications
- BEL
Example notification hook (works in modern terminals):
#!/bin/bash
input=$(cat)
title="Claude Code"
body=$(jq -r '.message // "Needs attention"' <<<"$input")
seq=$(printf '\\033]777;notify;%s;%s\\007' "$title" "$body")
jq -nc --arg seq "$seq" '{terminalSequence: $seq}'9. Environment variables available to hook processes
| Variable | Value |
|---|---|
CLAUDE_PROJECT_DIR | Project root path |
CLAUDE_PLUGIN_ROOT | Plugin root (plugin hooks only) |
CLAUDE_PLUGIN_DATA | Plugin data dir |
CLAUDE_ENV_FILE | Path to write export VAR=... statements (SessionStart, Setup, CwdChanged, FileChanged) — persists across the rest of the session |
CLAUDE_CODE_REMOTE | "true" in web env |
CLAUDE_EFFORT | Current effort level (tool-use contexts) |
Mutating session env via CLAUDE_ENV_FILE
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE"
echo 'export PYTHONPATH=src' >> "$CLAUDE_ENV_FILE"
fiThe env is set for subsequent Bash tool calls in the session. Only valid in SessionStart, Setup, CwdChanged, and FileChanged events.
10. Configuration locations
| Location | Scope | Shared? |
|---|---|---|
~/.claude/settings.json → .hooks | All projects | No |
.claude/settings.json → .hooks | One project | Yes (repo) |
.claude/settings.local.json → .hooks | One project | No (gitignored) |
| Managed policy settings | Org-wide | Yes (admin) |
<plugin>/hooks/hooks.json | When plugin enabled | Yes (bundled) |
Skill / subagent frontmatter hooks: | Component active | Yes (component file) |
Admin can enforce managed hooks only via allowManagedHooksOnly: true — user/project/plugin hooks then ignored; managed hooks from enabledPlugins still respected.
11. Worked examples
Block destructive bash commands
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"if": "Bash(rm *)",
"command": "./.claude/hooks/block-rm.sh"
}]
}]
}
}./.claude/hooks/block-rm.sh:
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -qE '\\brm\\s+-rf|\\brm\\s+--recursive'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive rm -rf blocked."
}
}'
else
exit 0
fiAuto-format on every Edit / Write
{
"hooks": {
"PostToolUse": [{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/format.sh"
}]
}]
}
}#!/bin/bash
input=$(cat)
file=$(jq -r '.tool_input.file_path' <<<"$input")
case "$file" in
*.py) black "$file" 2>/dev/null ;;
*.ts|*.tsx|*.js|*.jsx) prettier --write "$file" 2>/dev/null ;;
*.go) gofmt -w "$file" 2>/dev/null ;;
*.rs) rustfmt "$file" 2>/dev/null ;;
esacInject project context at session start
{
"hooks": {
"SessionStart": [{
"matcher": "startup|resume",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/inject-context.sh"
}]
}]
}
}#!/bin/bash
context=$(cat <<'EOF'
Current sprint: SP-204
Active feature flag: NEW_CHECKOUT_ENABLED=false
Production database is in maintenance mode until 17:00 UTC.
EOF
)
jq -n --arg ctx "$context" '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: $ctx
}
}'HTTP hook to a local validator
{
"hooks": {
"PreToolUse": [{
"matcher": "Edit|Write",
"hooks": [{
"type": "http",
"url": "http://localhost:9000/validate-edit",
"timeout": 10
}]
}]
}
}Server returns:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "File contains syntax errors per local linter"
}
}MCP-tool hook
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "mcp_tool",
"server": "security",
"tool": "shell_audit",
"input": {"command": "${tool_input.command}"}
}]
}]
}
}Notification on idle
{
"hooks": {
"Notification": [{
"matcher": "idle_prompt",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/desktop-notify.sh"
}]
}]
}
}12. Security considerations
- Hooks run with project privileges. A malicious project’s
.claude/settings.jsoncan run arbitrary code on first open. Claude Code prompts for workspace trust before honoring project hooks. - Managed hooks enforce policy. Admins use
allowManagedHooksOnly+disableAllHooksimmunity to lock down behavior. - No
/dev/ttyaccess. Hooks run in own session — useterminalSequencefor notifications, not direct terminal escapes. - JSON injection defenses. If
additionalContextis framed as system instructions, Claude’s prompt-injection defenses may flag it. Frame as factual statements. - Path traversal. Always use
${CLAUDE_PROJECT_DIR}and validate paths. - Env var interpolation in HTTP headers requires explicit
allowedEnvVarsallowlist — prevents accidental leakage of all env to remote endpoints.
13. Common pitfalls
- Forgetting
jq -nfor JSON output — outputting a plain string vs JSON misses the decision fields. - Returning JSON from a non-zero exit: exit code 2 ignores stdout JSON — pick one path.
additionalContextover 10,000 chars — gets saved to file instead of injected. Trim aggressively.- Matcher regex confusion —
"Bash"is exact, not prefix. Use"^Bash"if you wanted regex anchors. - Hook deduplication — identical handlers (same command+args or URL) run once per event. Add a no-op arg to force duplicates.
async: truedoesn’t block — fire-and-forget. UseasyncRewake: trueif you want the hook to wake Claude on completion via exit 2.- Plugin hooks ignore some fields. Per docs, plugin hooks don’t honor
hooks,mcpServers, orpermissionModefor security. - Output cap at 10,000 chars for all output strings. Larger outputs saved to a file with a preview path provided.