The hook lifecycle — picking the event that actually fires
By the end you can
- Place the hook events you will use on the three lifecycle cadences
- Choose the event that fires for the thing you actually want to catch
- Write a matcher that matches, including for MCP tool names
- Use the if field as a filter and know why it is not a gate
- Say which settings file a hook came from and how the layers merge
Lesson 5.2 had you register a Stop hook, and it worked — partly because there was only one place it could go. A check that decides whether a turn may end has to run when the turn ends.
Nothing else you will automate is that easy. "Scan every file change", "block
writes to infra/", "load the open issues at startup": each has two or three
events that sound right, and the wrong choice does not produce an error. It
produces a handler that is valid JSON, visible in /hooks, and never runs.
Level 8 was the first time you handed work to something that is not you. This is
the first time you configure something that runs without being asked — and a gate
that silently never fires is worse than no gate, because you stop watching.
What a hook is
Hooks are user-defined shell commands, HTTP endpoints, MCP tool calls, LLM prompts, or subagents that execute automatically at specific points in Claude Code's lifecycle. — Hooks reference
Five kinds of handler, not one, and type is a required field on every one of
them — there is no default to fall back on. Lesson 9.3 chooses between them;
here every example sets type: "command", the shell-command kind.
Claude Code runs hooks at specific points during a session. When an event fires and a matcher matches, Claude Code passes JSON context about the event to your hook handler. — Hooks reference
Three moving parts, nesting in that order: the hook event is the key, the matcher group filters when it fires, and each hook handler in the inner array is a thing that runs.
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command",
"if": "Bash(rm *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh" }
]
}
]So three things can fail before your script starts: the event does not fire for
what you meant, the matcher does not match, or if filters you out. This lesson
is those three, in that order.
Three cadences, and where your event sits
The docs sort the events you will reach for first into three rhythms:
- once per session:
SessionStartandSessionEnd- once per turn:
UserPromptSubmit,Stop, andStopFailure- on every tool call inside the agentic loop:
PreToolUseandPostToolUse, exceptEndConversationcalls, which skip both
Seven events out of a summary table with a great many more in it — FileChanged,
PreCompact, Notification, SubagentStart and the rest fire on their own
occasions. But those seven are the spine, and cadence is the first thing to
settle, because it multiplies everything the handler costs.
| Cadence | Events | A 200-tool-call session pays for it |
|---|---|---|
| Once per session | SessionStart, SessionEnd | Once |
| Once per turn | UserPromptSubmit, Stop, StopFailure | Once per thing you type |
| Every tool call | PreToolUse, PostToolUse | 200 times |
Lesson 4.1 described the agentic loop as gather context, take action, verify
results, repeated — and warned you not to confuse those three with the
explore-plan-implement-commit workflow. The bottom row is inside that loop:
two seconds of script on an unfiltered PostToolUse is two seconds attached to
every read, every grep, every edit, all session.
The events worth knowing by name
Start from the goal, not the event list. The right-hand column is the docs' own description of when each one fires.
| What you want | Event | When it fires |
|---|---|---|
| Block a tool call before it happens | PreToolUse | Before a tool call executes. Can block it |
| React to a change that landed | PostToolUse | After a tool call succeeds |
| React to a call that broke | PostToolUseFailure | After a tool call fails |
| Validate or enrich what you typed | UserPromptSubmit | When you submit a prompt, before Claude processes it |
| Gate the end of a turn | Stop | When Claude finishes responding |
| Load context as a session opens | SessionStart | When a session begins or resumes |
| Notice a specific file changing on disk | FileChanged | When a watched file changes on disk |
PostToolUse is not "after a tool call" — it is after a tool call
succeeds. A hook meant to catch the failing case belongs on
PostToolUseFailure; on PostToolUse it never sees a failure, and looks like it
works until the day that matters.
SessionStart is for context that has to be computed. The docs are blunt
that it does not replace the files you wrote in Level 6: for static context that
doesn't require a script, use CLAUDE.md instead. A hook that cats a fixed
file at startup is a slower, less visible CLAUDE.md. A hook that lists this
week's open issues is a hook.
The same mistake has a bigger version — Edit and Write are not the only way a
file gets written:
Claude can also create or modify files by running shell commands. If your hook must see every file change, such as for compliance scanning or audit logging, add a
Stophook that scans the working tree once per turn. — Automate actions with hooks
Bash running sed -i writes a file too. The guide's answer is to change
cadence — scan the tree once per turn on Stop — or, for per-call coverage, to
also match Bash|PowerShell and list modified and untracked files with
git status --porcelain.
Matchers: exact string, list, or regular expression
You do not choose which path a matcher takes. The characters in it choose.
| Your matcher contains | Evaluated as | Example |
|---|---|---|
"*", "", or the field is omitted | Match all | Fires on every occurrence of the event |
Only letters, digits, _, spaces, ,, | — and, from v2.1.195, - | Exact string, or a list separated by | or , | Bash matches only Bash; Edit|Write and Edit, Write each match either tool exactly |
| Any other character | JavaScript regular expression, unanchored | ^Notebook matches any tool whose name starts with Notebook |
The word doing the damage is unanchored:
A matcher on the regular-expression path is tested with JavaScript's
RegExp.prototype.test, which succeeds on a match anywhere in the value.Edit.*matches bothEditandNotebookEdit; wrap the pattern in^and$, as in^Edit$, when you need a whole-string match. — Hooks reference
So Edit.*, which reads like "Edit and things beginning with Edit", also fires
for NotebookEdit. If you want one tool, name it — Edit is an exact match and
needs no punctuation. If you want a pattern, anchor it.
Two events narrow the exact-match set further: on FileChanged and
StopFailure it is letters, digits, _ and | only, so a hyphen, space or
comma there keeps the matcher on the regular-expression path on every version.
Not every event supports a matcher, either. UserPromptSubmit, Stop,
PostToolBatch, WorktreeCreate and others always fire on every occurrence, and
"if you add a matcher field to an event without matcher support, it is
silently ignored." Nothing warns you; the hook just fires more often than you
designed for.
MCP tool names need the .*
MCP server tools appear in tool events as ordinary tool names, shaped
mcp__<server>__<tool> — mcp__memory__create_entities,
mcp__github__search_repositories. Which makes the server prefix look like a
perfectly good matcher. It is not:
To match every tool from a server, append
.*to the server prefix. The.*is required: a matcher likemcp__memoryormcp__brave-searchcontains only exact-match characters, so it is compared as an exact string and matches no tool. — Hooks reference
mcp__memory is not a prefix. It is a complete tool name that no tool has.
| Matcher | Matches |
|---|---|
mcp__memory | Nothing — compared as an exact string |
mcp__memory__.* | Every tool from the memory server |
mcp__.*__write.* | Any tool starting with write, from any server |
mcp__plugin_my-plugin_db__.* | Every tool from the server a plugin my-plugin bundles under the key db |
That last row is its own trap: a plugin-bundled server's tools carry a scoped
segment, mcp__plugin_<plugin-name>_<server-name>__<tool>, so a matcher written
against the bare server key never fires for them.
Before v2.1.195 the same mistake gave two opposite wrong answers. Hyphens were
not yet in the exact-match set, so mcp__brave-search took the regex path and
matched every tool from that server, while mcp__memory matched none. The
mcp__brave-search__.* form is correct on every version — which is why you write
it that way instead of reasoning about which build you are on.
if is a filter, not a gate
The if field narrows a handler using permission-rule syntax — "Bash(git *)",
"Edit(*.ts)" — so it only spawns when the tool call matches. It is the cheapest
way to keep a per-tool-call hook from running 200 times, and it has three hard
edges.
It only exists on tool events.
Only evaluated on tool events:
PreToolUse,PostToolUse,PostToolUseFailure,PermissionRequest, andPermissionDenied. On other events, a hook withifset never runs. — Hooks reference
Read that second sentence twice. Not "the if is ignored" — the hook never
runs. An if copied onto a Stop hook disables it completely, in a settings
file that still looks correct.
It holds one rule, and only one. "There is no &&, ||, or list syntax for
combining rules; to apply multiple conditions, define a separate hook handler for
each." Two conditions means two handlers, both running in parallel.
On Bash it is best-effort by design. Leading VAR=value assignments are
stripped; each subcommand of a && chain is checked; commands inside $() and
backticks are checked. And when Claude Code cannot tell what will run, as when
the command name is a variable, it runs your hook anyway. Erring toward
running is the safe direction for a filter, and it is why the docs refuse to
let you treat it as enforcement:
Because the
iffilter is best-effort, use the permission system rather than a hook to enforce a hard allow or deny. — Hooks reference
if decides whether your script is worth spawning. Permission rules decide what
is allowed, and they are enforced by Claude Code rather than by the model
(Permissions) — Lesson 6.2's
"guidance is not enforcement" one layer down. Level 10 is the second half of that
sentence.
Where hooks live, and how the layers merge
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json | All your projects | No, local to your machine |
.claude/settings.json | Single project | Yes, can be committed |
.claude/settings.local.json | Single project | No, gitignored when Claude Code saves a setting to it |
| Managed policy settings | Organization-wide | Yes, admin-controlled |
Plugin hooks/hooks.json | While the plugin is enabled | Yes, bundled with the plugin |
| Skill frontmatter | The rest of the session once the skill is invoked | Yes, in the skill file |
| Subagent frontmatter | While that subagent is running | Yes, in the subagent file |
Three mechanisms, three ways of combining, and mixing them up is how people
mispredict their own configuration. CLAUDE.md files and rules all load and
their text concatenates (6.1). Two skills with one name resolve to a single
winner and the rest are shadowed (7.6). Hooks do neither — they all run:
Hook entries merge across settings levels rather than replacing each other: user, project, and local settings add their own hooks without removing managed ones. — Hooks reference
All matching hooks run in parallel. If you define the same handler in more than one settings file, it runs once. A plugin's or skill's copy of the same handler stays separate. — Hooks reference
Three consequences. There is no ordering to rely on, so a hook must not assume
another one already ran. An identical handler in both your user settings and the
project's is deduplicated to a single run — but a plugin shipping the same script
is a separate handler and runs again. And a hook that fires unexpectedly may not
be yours: once you accept the workspace trust dialog for the folder — which is
what releases hooks from any settings file, including your own — a cloned
repo's .claude/settings.json adds its hooks to yours. And hooks from settings
files, managed policy settings and plugins also run inside subagents, so the
delegate you spawned in Level 8 fires your PreToolUse handlers too.
Which is why "where did this hook come from" is a menu, not a guess. /hooks
browses everything configured, grouped by event, and exists partly to "check
which settings file a hook came from": each handler is labelled with its type
and its source — User Settings, Project Settings, Local Settings,
Plugin Hooks, or Session Hooks. The menu is read-only, so you still add and
change hooks by editing the JSON.
Before you write the handler
- Which cadence? Per session, per turn, or per tool call — that is what your handler's runtime gets multiplied by.
- Does that event see the thing? A
@reference is not a tool call. Ased -iis not anEdit. A failure is not aPostToolUse. - Which path does the matcher take? Exact string, list, or unanchored regex — and if regex, is it anchored, and does the name have a hyphen?
- Is
iffiltering or gating? Filtering is fine. Gating is a permission rule.
Get any of them wrong and there is no error message, because nothing failed. Something simply did not happen.
Checkpoint
-
You add a
PostToolUsehook withmatcher: "Edit.*"to run a formatter, and it turns out to be running on notebooks. Then a teammate reports that files Claude wrote throughsed -iwere never formatted at all. Name both bugs, and say which one a better matcher fixes.Show answerAnswer
Two bugs, and only the first one is about the matcher.
Edit.*contains characters outside the exact-match set, so it takes the regular-expression path, and that path is unanchored — it is tested withRegExp.prototype.test, which succeeds on a match anywhere in the value, so it fires forNotebookEdittoo. Fix it by naming the tool:Editis an exact match and needs no punctuation, or anchor the pattern as^Edit$.The second bug no matcher can fix, because the event never sees the change.
sed -iis aBashcall, not anEdit, so nothing you write on theEditside ofPostToolUsewill ever fire for it. The documented answers are to change cadence — aStophook that scans the working tree once per turn, which is what the guide recommends when a hook must see every file change — or, for per-call coverage, to also matchBash|PowerShelland list modified and untracked files withgit status --porcelain. -
A compliance hook is supposed to log every use of your
memoryMCP server. It is registered onPreToolUsewithmatcher: "mcp__memory", and the log file is empty after a week. What happened — and what would that same matcher have done instead if the server were namedbrave-searchon a build older than v2.1.195?Show answerAnswer
It matched nothing, all week.
mcp__memorycontains only letters, digits and_, so it is on the exact-match path and is compared as a complete tool name — and no tool is called that. The real names are shapedmcp__<server>__<tool>, somcp__memoryis not a prefix, it is a full name no tool has. To match every tool from the server, append.*:mcp__memory__.*.With
brave-searchon a build older than v2.1.195 the same mistake gives the opposite wrong answer. Hyphens only joined the exact-match set in v2.1.195, so before thatmcp__brave-searchtook the regular-expression path and, being unanchored, matched every tool from that server — an over-firing hook rather than a silent one.mcp__brave-search__.*is correct on every version, which is why you write it that way instead of reasoning about which build you are on. One more thing to check if the log is still empty: a plugin-bundled server's tools carry a scoped segment,mcp__plugin_<plugin-name>_<server-name>__<tool>, so a matcher written against the bare server key never fires for them. -
Your user settings and the project's
.claude/settings.jsonboth register the sameStophook script, and an enabled plugin bundles it a third time. How many times does it run when a turn ends, and in what order?Show answerAnswer
Twice, in no order you can rely on. Hook entries merge across settings levels rather than replacing each other, so nothing is shadowed the way a duplicate skill name would be — but an identical handler defined in more than one settings file runs once, so your user copy and the project copy collapse into a single run. A plugin's copy of the same handler stays separate and runs again. Two handlers, two runs.
There is no ordering because all matching hooks run in parallel, which is a design constraint and not just trivia: a hook must never assume another one already ran. If you want to see which sources actually contributed,
/hooksgroups everything by event and labels each handler with its source —User Settings,Project Settings,Local Settings,Plugin HooksorSession Hooks.