CCLearn

The hook lifecycle — picking the event that actually fires

verified against Claude Code 2.1.261 · 2026-09-0913 min

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.

json
"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: SessionStart and SessionEnd
  • once per turn: UserPromptSubmit, Stop, and StopFailure
  • on every tool call inside the agentic loop: PreToolUse and PostToolUse, except EndConversation calls, which skip both

— Hooks reference

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.

CadenceEventsA 200-tool-call session pays for it
Once per sessionSessionStart, SessionEndOnce
Once per turnUserPromptSubmit, Stop, StopFailureOnce per thing you type
Every tool callPreToolUse, PostToolUse200 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 wantEventWhen it fires
Block a tool call before it happensPreToolUseBefore a tool call executes. Can block it
React to a change that landedPostToolUseAfter a tool call succeeds
React to a call that brokePostToolUseFailureAfter a tool call fails
Validate or enrich what you typedUserPromptSubmitWhen you submit a prompt, before Claude processes it
Gate the end of a turnStopWhen Claude finishes responding
Load context as a session opensSessionStartWhen a session begins or resumes
Notice a specific file changing on diskFileChangedWhen 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 Stop hook 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 containsEvaluated asExample
"*", "", or the field is omittedMatch allFires 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 characterJavaScript 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 both Edit and NotebookEdit; 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 like mcp__memory or mcp__brave-search contains 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.

MatcherMatches
mcp__memoryNothing — 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, and PermissionDenied. On other events, a hook with if set 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 if filter 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

LocationScopeShareable
~/.claude/settings.jsonAll your projectsNo, local to your machine
.claude/settings.jsonSingle projectYes, can be committed
.claude/settings.local.jsonSingle projectNo, gitignored when Claude Code saves a setting to it
Managed policy settingsOrganization-wideYes, admin-controlled
Plugin hooks/hooks.jsonWhile the plugin is enabledYes, bundled with the plugin
Skill frontmatterThe rest of the session once the skill is invokedYes, in the skill file
Subagent frontmatterWhile that subagent is runningYes, 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

  1. Which cadence? Per session, per turn, or per tool call — that is what your handler's runtime gets multiplied by.
  2. Does that event see the thing? A @ reference is not a tool call. A sed -i is not an Edit. A failure is not a PostToolUse.
  3. 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?
  4. Is if filtering 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

  1. You add a PostToolUse hook with matcher: "Edit.*" to run a formatter, and it turns out to be running on notebooks. Then a teammate reports that files Claude wrote through sed -i were never formatted at all. Name both bugs, and say which one a better matcher fixes.

    Show answer

    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 with RegExp.prototype.test, which succeeds on a match anywhere in the value, so it fires for NotebookEdit too. Fix it by naming the tool: Edit is 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 -i is a Bash call, not an Edit, so nothing you write on the Edit side of PostToolUse will ever fire for it. The documented answers are to change cadence — a Stop hook 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 match Bash|PowerShell and list modified and untracked files with git status --porcelain.

  2. A compliance hook is supposed to log every use of your memory MCP server. It is registered on PreToolUse with matcher: "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 named brave-search on a build older than v2.1.195?

    Show answer

    It matched nothing, all week. mcp__memory contains 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 shaped mcp__<server>__<tool>, so mcp__memory is not a prefix, it is a full name no tool has. To match every tool from the server, append .*: mcp__memory__.*.

    With brave-search on 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 that mcp__brave-search took 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.

  3. Your user settings and the project's .claude/settings.json both register the same Stop hook 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 answer

    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, /hooks groups everything by event and labels each handler with its source — User Settings, Project Settings, Local Settings, Plugin Hooks or Session Hooks.

Sources