For the first seven lessons, every way you influenced Claude was "saying it to the model": CLAUDE.md, plans, Skills. Hooks take a different road — no negotiating with the model; you insert deterministically-executed shell commands directly into its execution flow. Formatting, blocking edits to sensitive files, forcing tests before finishing — all of it lives in this lesson.
Why this lesson comes today
This lesson stands squarely on two existing foundations. D3 (permission modes and sandboxing) is static gatekeeping: a tool call is either "allowed or not allowed". Hooks give you a second kind of control on the same pathway: run your code before and after the call — it can block, rewrite, and feed back — and a PreToolUse hook returning deny stops even bypassPermissions mode, an angle permission rules can't cover. And the comparison thread left open by D7 (Skills) closes here: a Skill "teaches the model how to do it", but whether it complies is ultimately the model's call; a hook "happens no matter what the model thinks".
Looking ahead, hooks keep reappearing: the tools of D9 (MCP) can be matched by hooks too (mcp__server__tool); in D13 (Headless and CI) hooks are the only reliable gatekeeper when no one is watching; and D14 (Plugins) packages hooks for distribution to a whole team. Skip this lesson and all three of those are missing a piece.
Core concepts, explained
OfficialA hook is a user-defined shell command (it can also be an HTTP endpoint or an LLM prompt) that Claude Code runs automatically at specific points in its lifecycle, giving you deterministic control: certain actions are guaranteed to happen, rather than pinned on the model "remembering to do them on its own". The docs also give a selection principle: anything expressible as a deterministic rule should be a command hook; for decisions needing judgment, use type: "prompt" (a single-turn model evaluation) or the experimental type: "agent" (a tool-equipped subagent check).
One table to tell them apart: advice, gates, force
Our takeLine up the mechanisms from earlier lessons side by side and hooks' position becomes clear:
| Mechanism | What it is | Who executes it | Can the model ignore it? |
|---|---|---|---|
| CLAUDE.md / Skills (D4, D7) | Instructions written for the model | The model reads them and follows | Yes — it's advice, not a constraint |
| Permission rules (D3) | Static allow / ask / deny gates | Claude Code checks the table on tool calls | No, but they can only "pass or block" — no extra actions attached |
| Hooks (this lesson) | Your code attached to lifecycle points | Claude Code runs it unconditionally | No — the model isn't even involved, and hooks can also block, rewrite, and feed back |
Where hooks plug into the agentic loop
OfficialEvents fire on three rhythms: once per session (SessionStart / SessionEnd), once per turn (UserPromptSubmit, Stop), and PreToolUse / PostToolUse firing on every single tool call in the agentic loop. The diagram below shows the trunk path:
The events you'll actually use
OfficialThe reference lists 31 events in total; below are the most commonly used. Each event accepts a matcher filter (tool events match on tool name, e.g. Bash, Edit|Write, regex mcp__.*; leave it empty to fire every time; matching is case-sensitive):
| Event | When it fires | Typical use |
|---|---|---|
SessionStart | When a session starts or resumes (matcher distinguishes startup/resume/compact, etc.) | Inject context; restore key info after compaction |
UserPromptSubmit | After you submit a prompt, before Claude processes it | Validate or block prompts, attach environment info |
PreToolUse | Before every tool call executes, can block | Protect sensitive files, forbid dangerous commands |
PermissionRequest | When Claude Code is about to show you a permission prompt | Auto-approve specific requests on your behalf |
PostToolUse | After every successful tool call (already executed, irreversible) | Auto-format, write audit logs |
Notification | When Claude Code sends a notification (waiting for your input, waiting for permission) | Desktop notifications so you don't have to watch the terminal |
Stop / SubagentStop | When Claude (or a subagent) finishes its turn, can send it back | Done-gating: no stopping while tests fail |
PreCompact / SessionEnd | Before context compaction / at session termination | Back up transcripts, clean up temp files |
Where the configuration lives
OfficialHooks are written under the hooks key of a settings file, nested three levels deep: event name → matcher group → handler array. Below is the official "run Prettier after edits" example; drop it into .claude/settings.json at the project root and it's live:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
Which file it lives in decides its scope:
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json | All of your projects | No, this machine only |
.claude/settings.json | One project | Yes, commit it to the repo |
.claude/settings.local.json | One project | No, gitignored by default |
| Enterprise managed policy settings | The whole organization | Yes, admin-controlled |
Plugin hooks/hooks.json | While the plugin is enabled | Yes, ships with the plugin (D14) |
| Skill / agent frontmatter | While that component is active | Yes, lives in the component file |
Type /hooks in a session to browse every hook currently in effect, by event — note this menu is read-only; to add, remove or change hooks, edit the settings JSON directly (or have Claude do it for you). To switch everything off at once, set "disableAllHooks": true in settings (hooks from managed settings excepted).
How exit codes and output steer the flow
OfficialWhen an event fires, Claude Code pipes the event data as JSON into your script's stdin (including session_id, cwd, hook_event_name, plus tool_name and tool_input for tool events); your script talks back with its exit code:
| Exit code | Meaning | Effect |
|---|---|---|
0 | No objection | The action proceeds normally. For PreToolUse this is not approval — the normal permission flow still runs; stdout from UserPromptSubmit and SessionStart is injected into Claude's context |
2 | Block | The action is stopped, with stderr fed back to Claude as the reason so it can adjust. The exact effect varies by event: PreToolUse blocks the tool call, Stop prevents finishing, and PostToolUse — the tool already ran — can only feed back, not undo |
| Other | Non-blocking error | The action executes anyway; a hook error notice appears in the transcript |
When you need finer control than "block / don't block", exit 0 and print a JSON object to stdout: PreToolUse can return permissionDecision as "allow" (skip the permission prompt), "deny" (block and tell Claude the permissionDecisionReason), or "ask" (hand it to the user to confirm); Stop / PostToolUse use a top-level {"decision": "block", "reason": "..."}. Pick one mechanism or the other: with exit 2, any JSON on stdout is ignored.
Three high-frequency misconceptions
- Official"exit 1 counts as failure too, so surely it blocks" — it doesn't. For most events only exit 2 blocks; exit 1 is a non-blocking error and the action executes anyway. To enforce policy, you must write
exit 2. - Official"the hook allowed it, so we have permission" — backwards. A hook's deny is harder than any permission mode (it blocks even under
bypassPermissions), but a hook's allow can't beat a deny rule in settings: hooks can only tighten, never loosen — D3's gates always stand. - Our take"could a Stop hook lock Claude into a work loop forever?" — the docs left a safety valve: the input includes a
stop_hook_activefield for your script to self-check, and after 8 consecutive blocks with no progress Claude Code force-ends the turn. Checking that field should be the first line of any Stop hook you write.
Hands-on: doable today
Goal: 30 minutes to configure two hooks — an audit log (PostToolUse) and a sensitive-file blocker (PreToolUse) — and watch a block happen with your own eyes. The examples parse JSON with jq; first confirm jq --version prints something (macOS: brew install jq).
- In a test project, create
.claude/settings.jsonwith the two hooks:{ "hooks": { "PostToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.command' >> ~/.claude/command-log.txt" } ] } ], "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" } ] } ] } } - Create the blocker script
.claude/hooks/protect-files.sh(copied verbatim from the official example), and runchmod +x .claude/hooks/protect-files.sh:#!/bin/bash INPUT=$(cat) FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/") for pattern in "${PROTECTED_PATTERNS[@]}"; do if [[ "$FILE_PATH" == *"$pattern"* ]]; then echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2 exit 2 fi done exit 0 - Start Claude Code and type
/hooks. Expected: one hook each under PostToolUse and PreToolUse, showing the event, matcher, source file, and command. - Trigger the log hook: have Claude run any command (say, "list the files in this directory"), then
cat ~/.claude/command-log.txt. Expected: the file has gained the command that just ran — a successful hook shows nothing in the UI; checking the effect is how you verify. - Trigger the blocker hook:
touch .envfirst, then ask Claude to "add a comment line to .env". Expected: the edit is stopped before executing, and Claude receives the Blocked reason from the script's stderr and explains to you that it changed approach or gave up. - The lazy route: the docs explicitly say you can have Claude write hooks for you. Paste the block below straight into Claude Code and let it configure and demo everything itself:
Set up two hooks for the current project, written into .claude/settings.json:
1. PostToolUse with matcher "Bash": use jq to extract .tool_input.command and append it to ~/.claude/command-log.txt;
2. PreToolUse with matcher "Edit|Write": call .claude/hooks/protect-files.sh, a script that checks whether the file path matches .env, package-lock.json, or .git/ — on a match, print the reason to stderr and exit 2 to block; otherwise exit 0.
Make the script chmod +x. Once configured: first explain the three-level structure of this JSON (event → matcher → handlers), then run a test command to verify the log hook, and finally try editing .env to demonstrate a block.
How to know you've learned it
- Without notes, you can explain the essential difference between hooks and Skills (deterministic execution vs. model discretion), and how hooks relate to D3's permission rules (deny is harder; allow can't overreach).
- You can match needs to events: which event for auto-formatting? For protecting sensitive files? For "no clocking out while tests fail"? (PostToolUse / PreToolUse / Stop)
- Both hands-on hooks verified: new lines in the command log; the
.envedit blocked with Claude receiving the reason; both findable in/hooks. - Self-check: a PreToolUse hook script exits with exit 1 — is the tool call blocked? What about exit 0?
permissionDecision: "deny") blocks; exit 1 is a non-blocking error and the action executes anyway; exit 0 means "no objection" and the call continues through the normal permission flow — which is not the same as approval. If you couldn't answer, re-read "How exit codes and output steer the flow".