Hooks — Enforced Automation
Scripts that fire on 30 session events — validating, blocking, formatting, and notifying without being asked. Five hook types, one JSON contract, and the exit code that makes rules real.
CLAUDE.md asks Claude to behave — hooks make behaving non-optional
Everything so far has been persuasion: memory suggests conventions, skills package procedures — and the model can still skip them. Hooks are automated actions that execute when specific events occur in a session: shell commands, HTTP webhooks, LLM prompts, MCP tool calls, or subagent evaluations. They receive JSON input and answer with exit codes and JSON output — deterministic code, not a request the model can ignore.
- Event-driven automation — fire on session, prompt, tool, and agent lifecycle moments
- JSON-based input/output — every hook reads structured context from stdin
- Five hook types — command, http, prompt, mcp_tool, agent
- Pattern matching — target specific tools, agents, or notification types
A hook is an event, a matcher, and an action — declared in six places
{
"hooks": {
"EventName": [
{
"matcher": "ToolPattern",
"hooks": [
{
"type": "command",
"command": "your-command-here",
"timeout": 60
}
]
}
]
}
}
timeout defaults to 60 s per command · "once": true runs a hook a single time per session · all matching hooks run in parallel, identical commands deduplicated.
| ~/.claude/settings.json | User settings — all projects |
| .claude/settings.json | Project settings — shareable, committed |
| .claude/settings.local.json | Local project settings — not committed |
| Managed policy | Organization-wide settings |
| hooks/hooks.json | Plugin-scoped hooks |
| Component frontmatter | Skill / agent / command lifetime hooks |
The matcher decides which events reach your script — and it's case-sensitive
Tool-name patterns
| "Write" | Exact string — one specific tool |
| "Edit|Write" | Regex — multiple tools |
| "*" or "" | Wildcard — all tools |
| "mcp__memory__.*" | MCP tools — mcp__<server>__<tool> pattern |
Common PreToolUse matchers: Task, Bash, Glob, Grep, Read, Edit, Write, WebFetch, WebSearch.
Events match other things
| SessionStart | startup / resume / clear / compact |
| SubagentStart/Stop | Agent type name — e.g. code-review |
| Notification | permission_prompt · idle_prompt · auth_success · elicitation_dialog |
| InstructionsLoaded | session_start · nested_traversal · path_glob_match |
| PreCompact | manual / auto |
Five hook types: scripts, webhooks, prompts, MCP tools, and full agents
| command | The default — runs a shell command, talks JSON over stdin/stdout plus exit codes. Exec form (v2.1.139): "args": ["python3", "$CLAUDE_PROJECT_DIR/.claude/hooks/validate.py", "--strict"] spawns the binary via execve() — no shell parsing, immune to shell injection. command and args are mutually exclusive; configs with both are rejected at load |
| http | Remote webhook (v2.1.63) — POSTs the same JSON input to a URL, receives a JSON response. Routed through the sandbox when sandboxing is on; env-var interpolation in URLs requires an explicit allowedEnvVars list |
| prompt | Single-turn LLM evaluation — the hook content is a prompt Claude evaluates, returning a structured decision. Primarily for Stop / SubagentStop completion checks |
| mcp_tool | Invokes a configured MCP tool directly (v2.1.118) — "server" + "tool" instead of a command. Hook input passes as the tool's arguments; use when validation logic already lives in an MCP server |
| agent | Spawns a dedicated subagent for the check — unlike single-turn prompt hooks it can use tools (Read, Grep, Bash) and reason in multiple steps, e.g. "verify changes follow the architecture guidelines, check the design docs" |
30 events trace the whole session — 12 of them can block what happens next
Exit 2 blocks. Exit 1 does not — the most common hook-writing mistake
Every hook reads the same envelope from stdin — who, where, what, and why
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/working/directory",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/path/to/file.js",
"content": "..."
},
"tool_use_id": "toolu_01ABC123...",
"agent_id": "agent-abc123",
"agent_type": "main",
"worktree": "/path/to/worktree",
"effort": { "level": "medium" }
}
| session_id | Unique session identifier |
| transcript_path | Path to the conversation transcript file |
| hook_event_name | Which event fired — one script can serve several events |
| agent_id / agent_type | Who is acting — "main" or a subagent type name |
| worktree | Git worktree path, if the agent runs in one |
| effort.level | (v2.1.133+) low / medium / high / xhigh / max |
Event-specific extras ride along: tool events add tool_name + tool_input, Stop adds last_assistant_message, PostToolUse adds duration_ms.
On exit 0, your stdout JSON steers the session — allow, deny, ask, or rewrite
{
"continue": true,
"stopReason": "Optional message if stopping",
"suppressOutput": false,
"systemMessage": "Optional warning message",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "File is in allowed directory",
"updatedInput": {
"file_path": "/modified/path.js"
}
}
}
| PreToolUse | permissionDecision: allow / deny / ask · updatedInput rewrites the tool's parameters before it runs |
| PermissionRequest | Returns a decision object: behavior allow|deny, updatedInput, message, interrupt — auto-approve or auto-deny the dialog |
| UserPromptSubmit | decision: "block" + reason stops the prompt · additionalContext enriches it |
| continue / stopReason | Top-level kill switch — false halts the session with a message |
After the tool runs, hooks can critique it, time it — or rewrite what Claude sees
| "decision": "block" | Prompts Claude with your feedback — by default it aborts the current turn |
| continueOnBlock (v2.1.139) | true surfaces the rejection as a tool_result instead, so Claude can read the reason and retry — use when the feedback is actionable ("this file is read-only; write somewhere else"), leave off when a block must halt the turn |
| additionalContext | Injects context for Claude without blocking — the security-scanner pattern |
| duration_ms (v2.1.119) | Input field: tool execution time in milliseconds, excluding permission prompts and PreToolUse hook time — on PostToolUse and PostToolUseFailure |
| updatedToolOutput (v2.1.121) | Rewrites the tool's output before Claude sees it — honored for all tools, not just MCP. Redact secrets, normalize diffs, strip ANSI codes from Bash output |
| terminalSequence (v2.1.141) | Emits raw OSC escape sequences to the host terminal — desktop notifications (OSC 9), window titles, bells, no TTY of your own required. Kitty / iTerm2 / Windows Terminal honor OSC 9 |
Stop hooks are the quality gate — "did Claude actually finish?"
// prompt-type hook on Stop { "type": "prompt", "prompt": "Review if Claude completed all requested tasks. Check: 1) Were all files created/modified? 2) Were there unresolved errors? If incomplete, explain what's missing.", "timeout": 30 } // the LLM answers with a structured decision { "decision": "approve", "reason": "All tasks completed successfully", "continue": false, "stopReason": "Task complete" }
- Both events receive last_assistant_message in their input — the final message before stopping, exactly what a completion check needs
- A blocking decision sends Claude back to work with the reason as feedback
- SubagentStop does the same for subagents — matcher is the agent type name
- prompt hooks are single-turn judgment; agent hooks can gather evidence with tools first
SessionStart provisions the session; SessionEnd cleans up — neither can be skipped
SessionStart — setup with side effects
#!/bin/bash # persist env vars into the session if [ -n "$CLAUDE_ENV_FILE" ]; then echo 'export NODE_ENV=development' \ >> "$CLAUDE_ENV_FILE" fi exit 0
Matchers: startup / resume / clear / compact. Since v2.1.152, returned JSON can set reloadSkills: true (re-scan skills — same as /reload-skills, so just-installed skills work immediately) and hookSpecificOutput.sessionTitle (names the session on startup/resume).
SessionEnd — the exit interview
- Fires once when the session terminates — cleanup and final logging
- Cannot block termination — the session is ending either way
- Input includes a reason: clear, logout, prompt_input_exit, or other
- Timeout configurable via CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS
Hooks travel with the code they guard — frontmatter and plugins carry their own
# in SKILL.md / agent.md / command.md --- name: secure-operations description: Perform operations with security checks hooks: PreToolUse: - matcher: "Bash" hooks: - type: command command: "./scripts/check.sh" once: true # only once per session ---
Supported events in component frontmatter: PreToolUse, PostToolUse, Stop. The hook lives for the component's lifetime — related code stays together.
- Subagent auto-conversion: a Stop hook in a subagent's frontmatter becomes a SubagentStop hook scoped to that agent — it fires when that subagent completes, not when the main session stops
- Plugins ship hooks in hooks/hooks.json, same structure as settings
- ${CLAUDE_PLUGIN_ROOT} resolves to the plugin's install directory — portable paths like ${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh
- ${CLAUDE_PLUGIN_DATA} points at the plugin's data directory
Eight variables connect hooks to the session that invoked them
| CLAUDE_PROJECT_DIR | All hooks — absolute path to project root. The portability rule: "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh", never a hardcoded home directory |
| CLAUDE_ENV_FILE | SessionStart, CwdChanged, FileChanged — append export lines to persist env vars into the session |
| CLAUDE_CODE_REMOTE | All hooks — "true" when running in remote environments |
| ${CLAUDE_PLUGIN_ROOT} / _DATA | Plugin hooks — plugin install directory and data directory |
| CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS | Overrides the default timeout for SessionEnd hooks |
| CLAUDE_CODE_SESSION_ID | Bash tool subprocesses (v2.1.132+) — matches session_id in hook JSON; correlate bash logs with hook telemetry |
| CLAUDE_EFFORT | Bash tool subprocesses (v2.1.133+) — mirrors effort.level |
| CLAUDE_CODE_STOP_HOOK_BLOCK_CAP | Process-wide (v2.1.143+) — max consecutive Stop-hook blocks, default 8, 0 disables |
A 20-line PreToolUse guard makes rm -rf / physically impossible
#!/usr/bin/env python3 # .claude/hooks/validate-bash.py import json, sys, re BLOCKED = [ (r"\brm\s+-rf\s+/", "Blocking dangerous rm -rf /"), (r"\bsudo\s+rm", "Blocking sudo rm command"), ] data = json.load(sys.stdin) if data.get("tool_name") != "Bash": sys.exit(0) cmd = data.get("tool_input", {}).get("command", "") for pattern, message in BLOCKED: if re.search(pattern, cmd): print(message, file=sys.stderr) sys.exit(2) # exit 2 = block sys.exit(0)
The bundled big sibling: three tiers
- pre-tool-check.sh grades each Bash command BLOCK / WARN / ALLOW — fork bombs and mkfs exit 2; git push --force and DROP TABLE warn and proceed
- stderr discipline: on exit 2, the block reason must go to stderr (>&2) — stdout shows "No stderr output" instead of your explanation
- On exit 0, stderr is silently dropped — so the WARN tier writes an audit log (.claude/hooks/audit.log) as the only reliable record
- Anchor patterns carefully: rm -rf /([[:space:]]|$), or /tmp/foo gets falsely flagged
Two events, one script: bracket each request to measure what it cost
The course's context tracker registers the same script on two events. UserPromptSubmit fires before processing — it reads the transcript, counts tokens, and saves the figure to a temp file keyed by session_id. Stop fires after the response — it recounts, subtracts, and reports the delta to stderr: "This request: ~3,200 tokens." The script branches on hook_event_name to know which side of the bracket it's on.
| Character estimation | ~4 chars/token · ~80–90% accuracy · no dependencies · <1 ms |
| tiktoken (p50k_base) | ~90–95% accuracy · pip install tiktoken · <10 ms |
No official offline tokenizer exists — both are approximations. The transcript covers prompts, responses, and tool outputs, but not system prompts or internal context.
- Sessions isolated via session_id in the temp filename — parallel sessions don't collide
- Report goes to stderr so it never interferes with hook JSON on stdout
- Ships in two variants: zero-dependency estimation and a tiktoken upgrade
The chapter ships ten hooks — each one models a pattern worth stealing
session-end.sh — the learning diary
| SessionEnd, not Stop | Fires once on exit — not after every response |
| read -r </dev/tty | Hooks own stdin (the JSON payload); interactive input must open the terminal directly |
| Guard clause at top | Exits unless the project path matches — safe to install globally |
| Store outside the repo | Progress in ~/ survives git pull |
| Python for JSON writes | Notes passed as an argument so escaping can't break the file |
A companion browser tracker stores progress in localStorage, with export/import as JSON.
And the rest of the shelf
- format-code.sh — PostToolUse on Write/Edit; runs prettier/black/gofmt by extension
- security-scan.sh — warns on hardcoded secrets via additionalContext, non-blocking
- log-bash.sh — appends every Bash command to an audit log
- validate-prompt.sh — UserPromptSubmit gate: blocks production deploys unless .deployment-approved exists; nudges "add tests" on refactor prompts via additionalContext
- dependency-check.sh — npm/pip/go/cargo vulnerability scan on manifest writes; advisory, always exits 0
- notify-team.sh — Slack/Discord/email webhooks; note: no native PostPush event exists — filter PostToolUse:Bash for "git push"
- A one-time permissions seeder (~67 safe allow rules) — a setup script, not a hook
Hooks run arbitrary shell with your permissions — treat them like production code
Do
- Validate and sanitize all inputs; block path traversal (..)
- Quote shell variables: "$VAR", never bare $VAR
- Use absolute paths with $CLAUDE_PROJECT_DIR
- Skip sensitive files: .env, .git/, keys
- Test hooks in isolation before deploying; explicit allowedEnvVars for HTTP hooks
Don't
- Trust input data blindly, or process every file indiscriminately
- Hardcode paths or expose all env vars to webhooks
- Deploy untested hooks — you own the data loss they cause
USE AT YOUR OWN RISK — the docs are explicit: you are solely responsible for what your hooks do.
Hooks fail silently by design — debug them like the pipes they are
# watch hook execution live claude --debug # or press Ctrl+O in-session for verbose mode # test a hook outside Claude entirely echo '{"tool_name": "Bash", "tool_input": {"command": "ls -la"}}' \ | python3 .claude/hooks/validate-bash.py echo $? # 0 = allow, 2 = block
| Hook not executing | Check JSON syntax · matcher matches the tool name · script exists and is chmod +x · reads stdin, not args |
| Blocks unexpectedly | Pipe sample JSON in and check the exit code; on exit 2 read stderr — that's the reason being shown |
| JSON parsing errors | Parse properly (no string hacking), handle missing fields gracefully |
| Execution model | 60 s default timeout (per-command override) · all matching hooks run in parallel · identical commands deduplicated · current directory + Claude Code's environment |
Hooks turn conventions into physics — the session enforces its own rules
30 events trace the session from start to end, and 12 of them can block. Five hook types ladder up from shell commands through webhooks and MCP tools to LLM prompts and tool-using agents. The contract is always the same: JSON in on stdin, then exit 0 to continue (stdout JSON steering permissions, inputs, even tool outputs), exit 2 to block with stderr as the reason — and exit 1 blocks nothing. Hooks live in settings at four scopes, in plugin manifests, and in component frontmatter, where a subagent's Stop quietly becomes SubagentStop.