The complete guide to Claude Code. Opus 4.7, Sonnet 4.6, Haiku 4.5. 1M token context window. 27 hook events. 43 production-tested chapters across 6 topical Parts (Foundation, Workflow, Extension, Context Engineering, Advanced, Reference). Three install tiers. CC 2.1.121+ compatible.
Reference for the 27 hook events in CC 2.1.111, grouped by lifecycle phase. Every hook receives JSON on stdin; the legacy env vars $CLAUDE_HOOK_INPUT, $CLAUDE_HOOK_EVENT, and $CLAUDE_TOOL_INPUT are dead and always empty. Use $CLAUDE_PROJECT_DIR for portable paths.
Hooks from all scopes (global, project, local) run in parallel — they don’t override one another. Identical commands are deduplicated; different commands for the same event all fire.
Every hook reads a single JSON object on stdin. The canonical safe pattern:
#!/bin/bash
# Read stdin safely — timeout prevents hang, fallback prevents crash
INPUT=$(timeout 2 cat 2>/dev/null || true)
# Extract fields with jq (always use default // empty)
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // empty' 2>/dev/null)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
# Portable paths — never hard-code your home dir
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
LOG="${CLAUDE_PROJECT_DIR:-.}/.claude/logs/events.log"
echo "[${EVENT}] tool=${TOOL} file=${FILE}" >> "$LOG"
Inline hooks configured in settings.json follow the same pattern:
{
"command": "INPUT=$(cat); echo \"[${(echo \"$INPUT\" | jq -r '.hook_event_name')}]\" >> $CLAUDE_PROJECT_DIR/.claude/logs/events.log"
}
| Variable | Value | Available in |
|---|---|---|
CLAUDE_PROJECT_DIR |
Absolute path to project root | All hooks |
CLAUDE_ENV_FILE |
Writable env file path (export lines get sourced by CC) | SessionStart, CwdChanged, FileChanged |
| Dead variable | Use instead |
|---|---|
$CLAUDE_HOOK_INPUT |
INPUT=$(cat) then echo "$INPUT" \| jq |
$CLAUDE_HOOK_EVENT |
jq -r '.hook_event_name' from stdin |
$CLAUDE_TOOL_INPUT |
jq -r '.tool_input' from stdin |
$CLAUDE_TOOL_INPUT_FILE_PATH |
jq -r '.tool_input.file_path' from stdin |
$CLAUDE_TOOL_NAME |
jq -r '.tool_name' from stdin |
PreToolUse hooks that block (exit 2) must write the user-visible reason to stderr (>&2). CC reads stderr and shows it to the model as the block reason. Stdout is reserved for hook JSON protocol output (e.g. hookSpecificOutput). A hook that exits 2 with its block message on stdout produces “No stderr output” in the model’s view — the model sees that it was blocked but cannot see why, and cannot self-recover.
# WRONG — block message invisible to the model
echo "BLOCKED: <reason>"
exit 2
# CORRECT — message reaches the model via stderr
echo "BLOCKED: <reason>" >&2
exit 2
# Multi-line block — wrap in { ... } >&2 once
{
echo "==="
echo "BLOCK REASON:"
echo " - $missing_thing"
echo "==="
} >&2
exit 2
Internal pipe-stages (echo "$X" | grep ...) stay on stdout — those are inputs to other commands, not user-facing.
| Exit | Meaning |
|---|---|
0 |
Pass — tool proceeds; stderr is informational only |
2 |
Block — stderr is shown to the model as the block reason; stdout is ignored |
| Other | Treated as hook failure; behavior depends on event and CC version |
tool_input is authoritative — never ls -t disk stateFor PreToolUse hooks, the canonical source for “which tool call is happening” is .tool_input.* in the stdin envelope. Reading file state via ls -t / find -newer is racy under parallel sessions — two sessions writing to the same directory concurrently can swap which file your hook validates. Read directly from stdin:
# ExitPlanMode envelope carries both: .tool_input.plan (markdown) AND .tool_input.planFilePath (path)
PLAN_PATH=$(echo "$INPUT" | jq -r '.tool_input.planFilePath // empty')
if [ -n "$PLAN_PATH" ] && [ -f "$PLAN_PATH" ]; then
validate "$PLAN_PATH"
else
# Defensive fallback only if stdin is unavailable / malformed
validate "$(ls -t "$DIR"/*.md | head -1)"
fi
When trusting a path from stdin, validate it falls inside an expected scope (case "$path" in "$EXPECTED"/*) ... ;; esac) before reading.
Before shipping a PreToolUse hook that can exit 2:
>&2, not bare echotool_input.* consumed from stdin, not derived from disk state--selftest) or unit fixturesSessionStart--continue, --resume).hook_event_name, session_id, cwd, workspace.git_worktree (2.1.97+), source (e.g. startup, resume).export KEY=value lines to $CLAUDE_ENV_FILE.InstructionsLoadedsession_start, nested_traversal, path_glob_match, include, compact.hook_event_name, session_id, matcher, files (array of loaded CLAUDE.md paths).CwdChangedhook_event_name, session_id, old_cwd, new_cwd.$CLAUDE_ENV_FILE).UserPromptSubmithook_event_name, session_id, prompt (the text), cwd.{"decision":"block","reason":"..."} to cancel the submission.PostCompacthook_event_name, session_id, trigger (manual or auto), summary_length.Tool-use hooks support matchers scoped to tool name. Matcher syntax mirrors permission rules — e.g. "Bash(git *)", "Write|Edit", or "*" for all tools.
PreToolUsehook_event_name, session_id, tool_name, tool_input (object), cwd.{"decision":"block","reason":"..."} to prevent the call, or {"decision":"defer"} in headless -p to pause for --resume (2.1.89).permissions.deny in settings overrides any PreToolUse “ask” (2.1.99).PostToolUsehook_event_name, session_id, tool_name, tool_input, tool_response (string or structured), cwd.Edit|Write, format, emit metrics, tail-log results.Classic formatting hook (single matcher on Write/Edit):
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "FILE=$(cat | jq -r '.tool_input.file_path // empty'); [ -n \"$FILE\" ] && npx prettier --write \"$FILE\" 2>/dev/null || true"
}
]
}
PermissionRequesthook_event_name, session_id, tool_name, tool_input, cwd.# CORRECT
echo '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
# WRONG — "approve" is not a valid behavior
echo '{"decision": "approve"}'
Valid behavior values: allow, deny, ask.
PermissionDeniedhook_event_name, session_id, tool_name, tool_input, reason.{"retry": true} to retry once (2.1.89).FileChangedhook_event_name, session_id, file_path, change_type (created, modified, deleted).$CLAUDE_ENV_FILE).SubagentStartAgent() / Task() dispatched.Explore, Plan, general-purpose, named subagent, etc.).hook_event_name, session_id, agent_id (stable key), agent_type, prompt_length, cwd.SubagentStophook_event_name, session_id, agent_id, agent_type, duration_ms, last_assistant_message (privacy-sensitive — redact before logging).TeammateIdlehook_event_name, session_id, teammate_id, idle_ms.SendUserMessage.TaskCreatedTaskCreate (e.g. user or Claude tracks work).hook_event_name, session_id, task_id, subject, status.TaskCompletedcompleted (or cancelled, per tracker).hook_event_name, session_id, task_id, subject, completed_at.WorktreeCreate-w, --worktree).hook_event_name, session_id, worktree_path, worktree_name, branch.npm install, symlink shared caches, warm the new worktree.WorktreeRemovehook_event_name, session_id, worktree_path, worktree_name.EnterWorktreehook_event_name, session_id, worktree_path.ExitWorktreehook_event_name, session_id, worktree_path.PreCompacthook_event_name, session_id, trigger (manual or auto), context_usage_pct.exit 2 or {"decision":"block"} cancels compaction.Elicitationhook_event_name, session_id, server_name, prompt, schema.ElicitationResulthook_event_name, session_id, server_name, result (structured per schema).Stophook_event_name, session_id, stop_reason, turn_count.SessionEndclear, resume, logout, prompt_input_exit, other.hook_event_name, session_id, matcher, duration_ms, total_turns.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS=5000.settings.jsonHooks are declared per event, with an optional matcher and one or more command entries. Blocks run in parallel.
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash(rm -rf *)",
"hooks": [
{
"type": "command",
"command": "echo '{\"decision\":\"block\",\"reason\":\"rm -rf blocked by policy\"}'"
}
]
},
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/protect-secrets.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "FILE=$(cat | jq -r '.tool_input.file_path // empty'); [ -n \"$FILE\" ] && npx prettier --write \"$FILE\" 2>/dev/null || true"
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/session-summary.sh"
}
]
}
]
}
}
jq. Fall back with // empty so missing fields don’t crash.${CLAUDE_PROJECT_DIR:-$PWD}; never hard-code your home directory. Hooks may run from any cwd."command" in settings.json must still read stdin via $(cat). Putting data in the command string doesn’t work.PermissionRequest output format: the response MUST be {"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}. Wrong shape (e.g. {"decision":"approve"}) fails silently — the prompt still appears.permissions.deny beats PreToolUse “ask” (2.1.99): explicit denies in settings take precedence.hooks block — only the unknown entry is ignored. Before 2.1.99, a typo disabled all hooks.SessionEnd has a 3000ms budget by default (extend via CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS). Other events default to longer budgets; keep hooks fast.part3-extension/01-hooks.md — hooks authoring tutorial (deep dive, step-by-step)part6-reference/02-cli-flags-and-env.md — CLI flags and env vars, including hook-side envpart6-reference/06-security-checklist.md — hook-injection risks and safe patternspart6-reference/01-cc-version-history.md — when each event/matcher became available