Hooks for Deterministic Automation
Guarantees CLAUDE.md can't make
- Explain why hooks are deterministic guarantees while CLAUDE.md instructions are merely advisory — and when to reach for one over the other
- Name the hook events (PreToolUse, PostToolUse, PermissionRequest, Stop, SessionStart, InstructionsLoaded, Setup) and the init/maintenance matchers
- Configure hooks in .claude/settings.json, inspect them with /hooks, and run Setup hooks via --init, --init-only, and --maintenance
- Wire up the canonical use cases: lint/format on edit, block dangerous patterns, run tests at turn end, and debug instruction loading
- Use hooks to make unattended and CI runs trustworthy by pairing a Stop hook with real verification
CLAUDE.md asks Claude to do something; a hook makes it happen. Hooks are shell commands Claude Code runs deterministically at fixed lifecycle points — this lesson covers the event types, how to configure them in .claude/settings.json, and the canonical jobs (lint on edit, block dangerous writes, run tests at turn end) that you should never trust to advice alone.
- 1Advice vs. guarantee: the one idea that matters
- 2How a hook fires: events and matchers
- 3Configuring hooks in settings.json
- 4Setup hooks and the CLI flags that trigger them
- 5The four canonical hooks
- 6Hooks are what make unattended runs trustworthy
Advice vs. guarantee: the one idea that matters
Everything you put in CLAUDE.md is advice. It is delivered to Claude as context — a polite, persistent note at the top of the conversation — and the model usually follows it. But "usually" is not "always." As the context window fills, as a session compacts, or simply because the rule got lost in a long file, Claude can quietly stop honoring an instruction. The docs are blunt about this: "If Claude keeps doing something you don't want despite having a rule against it, the file is probably too long and the rule is getting lost... convert it to a hook."
A hook is a guarantee. It is a shell command that Claude Code itself runs at a fixed point in the lifecycle — not something Claude chooses to do, but something the tool does, every time, with zero exceptions. The model's cooperation, memory, and attention are irrelevant: the script runs whether Claude is paying attention or not.
That single distinction drives every decision in this lesson:
| CLAUDE.md instruction | Hook | |
|---|---|---|
| Nature | Advisory request | Deterministic action |
| Who acts | Claude (if it remembers) | Claude Code (always) |
| Survives compaction? | Can be summarized away | Yes — config, not context |
| Fails how? | Silently ignored | Runs every time; can block |
| Good for | Preferences, style, conventions | Things that must happen |
The official guidance is a one-liner worth memorizing: use hooks for actions that must happen every time with zero exceptions. If forgetting the action would cost you — a missing format pass, a dangerous write, an unverified turn — it does not belong in a sentence. It belongs in a hook.
Key insight
The tell: a rule Claude keeps breaking
The clearest signal that something should be a hook is a CLAUDE.md rule Claude repeatedly violates. You can phrase it more firmly, add "IMPORTANT" and "YOU MUST," prune the file — but advice has a ceiling. The moment the cost of non-compliance is real, stop asking and start enforcing. That is what hooks are for.
How a hook fires: events and matchers
A hook is the pairing of an event (when it fires) with a command (what runs). Claude Code emits events at lifecycle points throughout a session; you register a command against the event you care about, optionally narrowed by a matcher so it only runs for specific tools.
Claude Code emits many events across the session lifecycle — the ones below are the most commonly used. For the complete, authoritative list, see the hooks reference documentation.
The events you can hook:
| Event | Fires when... | Canonical use |
|---|---|---|
| PreToolUse | Before a tool runs (e.g. an Edit or Bash call) | Block a dangerous action before it happens |
| PostToolUse | After a tool completes successfully | Lint/format the file Claude just edited |
| PermissionRequest | A permission prompt is about to be shown | Auto-decide or log permission requests |
| Stop | Claude is about to end its turn | Run the test suite; block the stop until it passes |
| SessionStart | A session begins | Load environment, warm caches, print status |
| InstructionsLoaded | After CLAUDE.md / rules / memory load | Debug which instruction files loaded and why |
| Setup | On project setup / init / maintenance runs | One-time or recurring environment setup |
PreToolUse and PostToolUse are the workhorses — they bracket every tool call, so they're where "do X after every edit" and "never let Y happen" live. A matcher lets you scope them: a PostToolUse hook can match only Edit/Write events so it doesn't fire after a read.
Stop is the verification hook. When Claude decides its work "looks done" and tries to end the turn, a Stop hook runs your check (tests, a build, a script) and can block the turn from ending until the check passes — turning Claude back to keep working. This isn't unbounded: to prevent an infinite loop, Claude Code itself force-ends the turn after a fixed number of consecutive blocks. This is a hard limit enforced by the tool, not something the model can negotiate or work around.
Setup is special: it carries two matchers, init and maintenance, so the same event can distinguish a first-time bootstrap from a periodic upkeep run. You trigger these from the CLI (next section).
Note
PreToolUse blocks; PostToolUse reacts
Reach for PreToolUse when you need to prevent something — it runs before the tool and can deny the call. Reach for PostToolUse when you need to respond to something that already happened, like formatting a file Claude just wrote. Choosing the wrong one is the most common hook mistake: you can't lint a file before it's edited, and you can't block a write after it's done.
Configuring hooks in settings.json
Hooks live in .claude/settings.json under a top-level hooks key, organized by event name. Each entry pairs an optional matcher with one or more shell commands to run.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "npx prettier --write \"$CLAUDE_FILE_PATH\"" }
]
}
],
"PreToolUse": [
{
"matcher": "Write",
"hooks": [
{ "type": "command", "command": ".claude/hooks/block-migrations.sh" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "npm test" }
]
}
]
}
}Two ways to author them:
- Let Claude write the hook. This is the documented fast path — try prompts like "Write a hook that runs eslint after every file edit" or "Write a hook that blocks writes to the migrations folder." Claude generates the JSON and the script.
- Edit
.claude/settings.jsonby hand for full control over matchers and commands.
To see what's currently wired up, run /hooks — it browses every configured hook so you can confirm a hook is active (and catch one you forgot you added). Because hooks are part of settings, they follow the normal settings precedence (project .claude/settings.local.json overrides project .claude/settings.json overrides ~/.claude/settings.json), so you can keep a personal hook out of the shared, committed config.
Tip
Commit team hooks, keep personal ones local
Put hooks the whole team needs (format-on-edit, test-on-stop) in the committed .claude/settings.json so everyone gets the same guarantees. Put experiments or machine-specific hooks in .claude/settings.local.json (gitignored). The hook your teammate forgot to run is exactly the kind of thing a committed hook eliminates.
Watch out
A hook runs real shell — review it like code
Hook commands execute on your machine with your privileges, every time the event fires. A sloppy hook can be slow (a full lint on every keystroke-sized edit) or destructive. Keep hook scripts in version control, review them, and prefer fast, scoped commands. A PostToolUse hook that runs your entire 10-minute test suite after every edit will make Claude unbearable to work with.
Setup hooks and the CLI flags that trigger them
Most hooks fire on their own as a session runs. Setup hooks are different — they exist to bootstrap or maintain a project's environment, and you invoke them explicitly from the CLI. Three flags drive them:
| Flag | What it does |
|---|---|
--init | Runs Setup hooks (and SessionStart hooks), then continues the session normally |
--init-only | Runs Setup (and SessionStart) hooks, then exits immediately — pure bootstrap, no session |
--maintenance | Runs Setup hooks with the maintenance matcher (print mode) — for periodic upkeep, not first-run setup |
# First-time project bootstrap, then drop into a session
claude --init
# Bootstrap only — run setup hooks and exit (great for CI image prep)
claude --init-only
# Periodic maintenance run (fires Setup hooks matched to 'maintenance')
claude --maintenanceThe init and maintenance matchers on the Setup event are what let one event serve two purposes: an init-matched Setup hook installs dependencies, scaffolds config, or seeds a database the first time; a maintenance-matched Setup hook re-runs upkeep (regenerate types, prune caches, refresh fixtures) on a schedule or in CI. Using --init-only in a container image build means the heavy setup happens once at build time, not on every session start.
Example
Setup vs. SessionStart
They overlap but differ in intent. SessionStart fires every time any session opens — use it for lightweight, per-session prep (printing the current branch, checking a daemon). Setup is the deliberate, invoked bootstrap (--init) or upkeep (--maintenance) pass — use it for the expensive, occasional work you don't want on every single session. --init conveniently runs both.
The four canonical hooks
Four patterns cover the vast majority of real-world hooks. Learn these and you'll recognize when a problem is really a hook in disguise.
1. Lint / format on edit — PostToolUse. The classic. After Claude edits or writes a file, run your formatter or linter so the result is always consistent — Claude never has to remember to format, because the tool does it.
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write",
"hooks": [ { "type": "command", "command": "npx eslint --fix \"$CLAUDE_FILE_PATH\"" } ] } ] } }2. Block dangerous patterns — PreToolUse. Before a tool runs, inspect the proposed action and deny it if it crosses a line — writing to a migrations folder, touching a protected file, running a destructive command. The hook returns a non-zero / blocking result and the action never happens. "Write a hook that blocks writes to the migrations folder" is a documented example.
3. Run tests at turn end — Stop. Make a green test suite the condition for finishing. A Stop hook runs your tests when Claude tries to end its turn and blocks the stop until they pass, so Claude iterates until the suite is green. (Recall the hard limit: Claude Code itself force-ends the turn after a fixed number of consecutive blocks, so a permanently-failing test can't trap it forever — this is enforced by the tool, not a choice the model makes.)
4. Debug instruction loading — InstructionsLoaded. When you're not sure which CLAUDE.md, rule, or memory file is actually being loaded (or why a rule isn't taking effect), an InstructionsLoaded hook fires right after instructions load, letting you log or inspect the resolved set. It's the diagnostic counterpart to the four-tier CLAUDE.md hierarchy.
The pattern across all four: each replaces "I hope Claude remembers to..." with "the tool will always...".
Tip
Match the event to the verb
A quick mnemonic: before an action that must be stopped → PreToolUse. after an action that must be cleaned up → PostToolUse. at the end of a turn that must be verified → Stop. at load time when you need to see what loaded → InstructionsLoaded. Pick the event by the verb in your requirement.
Hooks are what make unattended runs trustworthy
Hooks matter most exactly when you are not watching. In an interactive session, you are the safety net — you see a bad edit and hit Esc. In a headless claude -p run, a CI job, a background agent, or an overnight fan-out, there is no human in the loop. Advice that Claude might forget is not good enough; you need guarantees that hold whether anyone is looking or not.
This is why the canonical unattended pattern is a Stop hook paired with real verification. Recall the core best practice: Claude stops when the work "looks done," and without a check, "looks done" is the only signal it has. A Stop hook supplies the missing check as a deterministic gate — it runs your tests/build/script and refuses to let the turn end until the check actually passes. Combined with scoped permissions (--allowedTools, --max-turns, --max-budget-usd), it lets an automated run finish correctly rather than finish plausibly.
# Unattended run: a committed Stop hook runs the tests; Claude can't
# "finish" until they're green. Permissions are scoped for safety.
claude -p "implement the feature from PLAN.md" \
--permission-mode dontAsk \
--allowedTools "Edit" "Bash(npm test)" "Bash(git commit *)"And hooks remain the right tool for hard prohibitions in automation, complementing deny permission rules. A PreToolUse hook can block a class of dangerous actions deterministically — and unlike a conversational "please don't," it cannot be compacted away or forgotten on turn 40 of an unattended run.
One guarantee no hook overrides: protected-path writes are never auto-approved (.git, .claude except its subdirectories, .npmrc, .gitconfig, shell rc files) — except under bypassPermissions. So even in an aggressive automation setup, those sensitive files keep their prompt unless you have explicitly chosen to skip all checks in a throwaway container.
Watch out
A Stop hook is not a substitute for a real check
The hook is only as good as the command it runs. A Stop hook that runs echo done guarantees nothing. The value comes from a command that genuinely returns pass/fail on the thing you care about — a test suite, a type-check, a build, a script that diffs output against a fixture. Wire the hook to a meaningful verification, or you've automated a rubber stamp.
Try it: Turn an advisory rule into a guaranteed hook
Take one thing you currently hope Claude does and make it impossible for Claude to skip.
- Find the advisory rule. In a real project, pick a CLAUDE.md instruction Claude sometimes ignores — most likely 'format/lint after editing.' Confirm the gap: make Claude edit a file in a sloppy style and watch whether it formats.
- Let Claude write the hook. Prompt: "Write a hook that runs
<your formatter>after every file edit." Inspect the JSON it adds to.claude/settings.json— note thePostToolUseevent and theEdit|Writematcher. - Verify it fires. Run
/hooksto confirm the hook is registered. Then have Claude make another messy edit and confirm the formatter now runs automatically, every time — even though you removed the CLAUDE.md line. - Add a guardrail. Ask Claude to "write a hook that blocks writes to the
migrations/folder." Confirm it's aPreToolUsehook. Test it: ask Claude to createmigrations/test.sqland confirm the write is blocked before it happens. - Close the loop with verification. Add a
Stophook that runs your test suite (npm test,pytest, etc.). Give Claude a task that breaks a test and watch it iterate — the Stop hook refuses to let the turn end until tests pass. (Remember Claude Code itself force-ends the turn after a fixed number of consecutive blocks.) - Reflect. Write one paragraph: which of these three did you previously trust to CLAUDE.md advice, and what would the worst-case cost have been the one time Claude forgot? That cost is your test for whether something should be a hook.
Key takeaways
- 1CLAUDE.md is advisory (Claude may forget it, especially after compaction); a hook is deterministic — Claude Code runs it every time with zero exceptions. If Claude keeps breaking a rule, convert it to a hook.
- 2The events are PreToolUse, PostToolUse, PermissionRequest, Stop, SessionStart, InstructionsLoaded, and Setup; Setup carries init and maintenance matchers to separate first-run bootstrap from periodic upkeep.
- 3Hooks are configured in .claude/settings.json under a hooks key (event → matcher → command); run /hooks to view what's wired up, and let Claude write hooks for you with a plain-English prompt.
- 4Trigger Setup hooks from the CLI: --init runs Setup + SessionStart then continues, --init-only runs them and exits, --maintenance runs Setup hooks matched to 'maintenance'.
- 5Canonical hooks: lint/format on edit (PostToolUse), block dangerous patterns (PreToolUse), run tests at turn end (Stop — Claude Code force-ends the turn unconditionally after a fixed number of consecutive blocks to prevent infinite loops; the halt is non-negotiable tool behavior), debug instruction loading (InstructionsLoaded).
- 6For unattended/CI runs, pair a Stop hook with real verification so 'looks done' becomes 'check passed' — and remember protected-path writes are never auto-approved except under bypassPermissions.
Quiz
Lock in what you learned
Check your understanding
0 / 4 answered
1.You've written 'always run the formatter after editing a file' in CLAUDE.md three times, with 'IMPORTANT' and 'YOU MUST', and Claude still skips it on long sessions. What is the correct fix?
2.You want to stop Claude from ever writing to the migrations/ folder during an automated run. Which event is correct, and why?
3.What do --init, --init-only, and --maintenance do, and how do the init/maintenance matchers relate to them?
4.For an unattended `claude -p` run, you add a Stop hook that runs `echo 'done'`. Why is this not actually a safety net, and what would make it one?
Go deeper
Hand-picked sources to keep learning
The 'hooks are deterministic, CLAUDE.md is advisory' principle, /hooks, letting Claude write hooks, and the Stop-hook verification gate (Claude Code force-ends after a fixed number of consecutive blocks).
The authoritative event reference: PreToolUse, PostToolUse, PermissionRequest, Stop, SessionStart, InstructionsLoaded, Setup, and matcher syntax.
Step-by-step walkthrough of writing your first hooks in .claude/settings.json.
Where /hooks lives among the built-in slash commands; also /permissions for the deny-rule complement to PreToolUse hooks.
Exact behavior of --init, --init-only, --maintenance, --allowedTools, and the headless -p flag.
Changelog and issues — useful for tracking hook events and matchers as they evolve.