Lesson 21 of 32
Hooks
Shell commands that run automatically at fixed points in Claude's workflow, for the things that must happen every time rather than usually.
The last two lessons gave you two ways to tell Claude things. Both share a property that is easy to overlook: neither one is binding.
CLAUDE.md is context. A skill is context. Claude reads them, and mostly
follows them, and occasionally does not — because the file was long, or the
instruction was ambiguous, or it simply judged differently this time. For most
guidance that is fine. Usually is good enough for a naming convention.
Sometimes usually is not good enough.
The distinction that matters
A hook is a shell command that Claude Code runs at a fixed point in its own lifecycle: before a tool call, after a file edit, when a session starts, when Claude finishes responding. It is not a request. Claude does not decide whether to honour it.
That gives you a clean way to sort your own rules. "Prefer named exports" is
advice, and belongs in CLAUDE.md. "Never write to the migrations directory" is
a rule, and belongs in a hook. "Format edited files with Prettier" is not really
an instruction to a model at all — it is a mechanical step, and asking a
language model to remember it every time is the wrong shape of solution.
There is a useful signal here too. If you find yourself adding IMPORTANT and
YOU MUST to a line in CLAUDE.md, you are trying to make an advisory
mechanism behave like a deterministic one. That is the moment to write a hook
instead.
What one looks like
Hooks are configured in a settings file under a hooks key. Where you put it
decides the scope, exactly as with everything else in this section:
.claude/settings.json for the project and your team, ~/.claude/settings.json
for every project on your machine, .claude/settings.local.json for personal
overrides that stay out of git.
Here is the one most people write first — run Prettier on every file Claude edits:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}Three things are going on. PostToolUse is the event — the point in the
lifecycle where this fires. "Edit|Write" is the matcher, which narrows it
to the file-editing tools rather than every tool call. And the command is what
runs.
The command gets the event's data as JSON on stdin, which is what jq is
reading here: it pulls the edited file's path out and passes it to Prettier.
Every event provides a slightly different shape of data, along with common
fields like the session ID and the working directory.
If the JSON nesting looks redundant — hooks containing an array containing
hooks — it is because the outer level groups by matcher and the inner level
lists the commands to run for that group. You can attach several commands to one
matcher.
The events
There are a lot of events, and you will use four or five. The ones worth knowing by name:
| Event | Fires |
|---|---|
SessionStart | When a session begins or resumes |
UserPromptSubmit | When you submit a prompt, before Claude processes it |
PreToolUse | Before a tool call executes — can block it |
PostToolUse | After a tool call succeeds |
Notification | When Claude Code sends a notification, including when it is waiting on you |
Stop | When Claude finishes responding |
SessionEnd | When a session terminates |
Beyond those there are events for compaction, subagents, permission decisions,
configuration file changes, working-directory changes and more. The full list is
in the reference, and /hooks shows every event name in one screen, which is a
faster way to browse than reading documentation.
Most events also accept a matcher, and what the matcher filters depends on the
event. For tool events it is the tool name. For SessionStart it is how the
session began — startup, resume, clear, compact or fork. For
Notification it is the notification type, so you can fire a desktop alert only
when Claude is waiting for a permission decision.
Blocking, and how a hook talks back
A hook communicates through its exit code and what it writes.
Exit 0 means no objection. For a PreToolUse hook this is not an approval —
the normal permission flow still applies afterwards. For UserPromptSubmit and
SessionStart, anything the command prints to stdout is added to Claude's
context, which is how you inject information at a fixed moment.
Exit 2 blocks the action. Whatever the script wrote to stderr becomes the explanation, and for most events that text is fed back to Claude so it can adjust rather than just failing.
That second behaviour is what makes a blocking hook cooperative rather than merely obstructive. Consider a script that guards protected files:
#!/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 0Wire that to PreToolUse with an Edit|Write matcher and Claude cannot touch
those files. It also learns why, in that sentence, and can route around the
restriction instead of retrying.
Two notes. The script has to be executable — chmod +x — which is a common
first failure. And for more than block-or-allow, a hook can exit 0 and print a
JSON object to stdout instead, which gives you structured control over the
decision. That is worth knowing exists; you will rarely need it.
Getting one written
You do not need to memorise any of the JSON. Describing what you want is faster and gets the event and matcher right more often than guessing does.
WhyBackground: this project has a generated file, src/api/types.ts, produced by a codegen step. Claude has edited it directly twice this month, each time producing a change that vanished on the next build. There is already a line in CLAUDE.md telling it not to.
You typed: Write a hook that blocks any edit to src/api/types.ts and tells you to change the schema and re-run codegen instead.
Claude replied: PreToolUse on Edit|Write, with a script that exits 2 when the path matches. Writing the script first.
Claude ran: Write .claude/hooks/protect-generated.sh
Claude ran: Run: chmod +x .claude/hooks/protect-generated.sh
Claude ran: Edit .claude/settings.json
WhyTwo files, not one. The script does the work and settings.json registers it. Both are in .claude/, so both are committed and the rule applies to everyone on the team, not just whoever wrote it.
You typed: /hooks
Output: PreToolUse (1) matcher Edit|Write type command source .claude/settings.json command "$CLAUDE_PROJECT_DIR"/.claude/hooks/protect-generated.shWhyConfirming registration before testing behaviour. The menu is read-only — it will not let you edit a hook — but it answers the only question that matters when something does not fire.
You typed: add an optional avatarUrl field to the User type
Claude ran: Edit src/api/types.ts
Output: Blocked: src/api/types.ts is generated. Edit schema/user.graphql and run npm run codegen.Claude replied: Right — that file is generated. I'll edit the schema instead.
Claude ran: Edit schema/user.graphql
Claude ran: Run: npm run codegen
Output: Generated 4 files in 1.2sWhyThis is the whole argument for hooks in one exchange. The CLAUDE.md line had been ignored twice. The hook was not ignored, because there was nothing to ignore — and because the block explained itself, Claude recovered in one step rather than stopping.
Rough edges worth knowing
Hooks are shell commands running on your machine with your credentials,
triggered automatically. Treat one you copied from the internet the way you
would treat any script you were about to run: read it first. A hook in a
project's .claude/settings.json came from whoever committed it.
A few practical limits. PostToolUse hooks cannot undo anything, because the
tool has already run — if you need to prevent something, it has to be
PreToolUse. Matching on Edit|Write will not catch a file Claude changes by
running a shell command, since that is the Bash tool. Stop hooks fire every
time Claude finishes responding, not only when a task is genuinely complete, and
Claude Code overrides a Stop hook that has blocked eight times in a row so a
badly written one cannot trap you in a loop.
And when a hook does not appear to work at all, the order of checks is: run
/hooks and confirm it is registered, check the matcher, and check the script is
executable. That covers almost every case. There is a whole lesson on diagnosing
configuration later in the course.
The matcher is worth one more sentence, because it has a trap in it. A plain
name like Bash, or several separated by |, is matched as an exact string.
Add any other character and the whole thing becomes a regular expression that is
tested anywhere in the tool name rather than against the whole of it — so
Edit.* fires on NotebookEdit too. If you find yourself reaching for regular
expression syntax, anchor it: ^Edit$ means what you meant.
What to take away
A hook is a shell command Claude Code runs at a fixed point in its lifecycle,
configured under a hooks key in a settings file and scoped by where that file
lives. The reason to reach for one is determinism: CLAUDE.md and skills are
advice that Claude usually follows, while a hook happens regardless, so anything
that must occur every time without exception belongs here. An event says when it
fires and a matcher narrows which occurrences count; the command receives the
event data as JSON on stdin, and exits 0 to stay out of the way or 2 to block,
with its stderr passed back to Claude as an explanation it can act on. Use
/hooks to see what is registered, and describe the hook you want to Claude
rather than writing the configuration by hand.
Next: connecting Claude to systems outside your codebase — issue trackers, databases, design tools — through the Model Context Protocol.
Check yourself
5 questions · pass 4/5 to unlock Connecting Tools With MCP
1.What is the defining difference between a CLAUDE.md rule and a hook?
2.Where do you configure a hook?
3.A
PreToolUsehook script exits with code 2 and writes a message to stderr. What happens?4.You want Prettier to run on every file Claude edits. Which event fits?
5.What does the
/hookscommand do?
5 left to answer