Hooks
A hook is a shell command that Claude Code runs at a fixed point in the lifecycle, and it
runs whether or not the model would have chosen to. Everything so far has been persuasion.
CLAUDE.md asks. A skill asks in more detail. A plan asks in advance. All of it shapes what
Claude tries to do, and none of it is a guarantee.
A PreToolUse hook can refuse a tool call outright. That is the difference, and it is the only mechanism in this course that is
enforcement rather than instruction.
A rule stockroom already has
data/seed.json is generated by scripts/seed.mjs. The README says so, in the “Known
problems” section, in the polite voice of a README:
data/seed.jsonis generated byscripts/seed.mjsand committed. Regenerate it rather than editing it by hand.
A hand edit is not wrong so much as futile: the next person to run the generator silently throws it away. This is exactly the shape of rule that a README is bad at and a hook is good at.
Write it
.claude/hooks/protect-seed.sh:
#!/bin/bash
# Blocks edits to data/seed.json. It is generated by scripts/seed.mjs and a hand edit
# is lost the next time anybody regenerates it.
path=$(jq -r '.tool_input.file_path // ""')
case "$path" in
*/data/seed.json)
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "data/seed.json is generated. Edit scripts/seed.mjs and run `node scripts/seed.mjs` instead."
}
}'
;;
*)
exit 0
;;
esac
Make it executable, then wire it up in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-seed.sh"
}
]
}
]
}
}
Three levels of nesting, which is the part everyone gets wrong the first time. The event, then a list of matcher groups, then a list of handlers inside each group.
The hook gets the whole tool call as JSON on stdin: tool_name, tool_input, cwd,
session_id, permission_mode and more. Here it only needs tool_input.file_path.
What happens, and why it is better than blocking
Ask for the edit:
Change the unitCost of BOLT-M8 to 45 in data/seed.json.
What comes back:
Done! Changed the unitCost of BOLT-M8 from 42 to 45 in
scripts/seed.mjsand regenerateddata/seed.json.
It did not fail. It did the right thing instead, because permissionDecisionReason is shown
to Claude and that reason said what to do. A denial that only says “no” spends a turn; one
that says “edit this other file and run that command” spends a turn and gets you the outcome.
Write your reasons as instructions. It is the highest-value sentence in the whole hook.
To prove the hook is what did it, take the alternative away:
Use the Edit tool to change "unitCost": 42 to 45 in data/seed.json. Do not touch any
other file and do not run any script.
I cannot make this change. The error indicates that
data/seed.jsonis a generated file. The system requires that I edit the source scriptscripts/seed.mjsand runnode scripts/seed.mjsto update it. However, you explicitly instructed me not to touch any other file, which makes it impossible.
That is the hook talking, quoted back at you.
The events
There are about thirty. These are the ones you will use:
| Event | When it fires | What it is for |
|---|---|---|
PreToolUse | Before a tool runs | The only one that can refuse. |
PostToolUse | After a tool succeeds | Formatters, linters, “run the tests after every edit to src/”. |
UserPromptSubmit | Before Claude sees your prompt | Can add context or refuse it. |
SessionStart | When a session begins | Its stdout is visible to Claude, so this is where you inject something that changes per day: a ticket number, a branch, a deploy status. |
Stop | When Claude finishes a turn | Exit 2 makes it keep going, which is how you build “do not stop while tests are failing”. |
matcher filters within an event. For tool events it matches the tool name: Bash,
Edit|Write, mcp__.*__write.* if it contains regex characters, * for everything.
Exit codes
Two matter.
Exit 0 means the hook is happy. If it printed JSON, the JSON decides what happens next.
If it printed anything else, that goes to the debug log and is ignored, except on
UserPromptSubmit and SessionStart where Claude sees it.
Exit 2 blocks, on events that can block, and your stderr becomes the reason. The script
above uses JSON instead, because JSON lets you say deny with a reason in one place and
allow from the same script later.
Any other exit code is a hook that broke. It does not block, and you get an error notice in the transcript rather than silence, which is the correct behaviour and also how you find out your script has a typo.
When is a hook the wrong tool?
When you want advice, not enforcement. A hook that fires on every edit and prints opinions is a hook you will disable in a week.
When it is slow. PostToolUse runs after every matching call. A hook that takes four
seconds turns a ten-edit session into forty seconds of waiting, and the default timeout is
ten minutes, so nothing will save you from yourself.
When the rule needs judgement. “Block edits to generated files” is a hook. “Block bad architecture” is not; it is a code review, and lesson 27 is about that.
Debugging one that never fires
In order, because it is nearly always one of the first two:
-
Is it executable?
chmod +x. This is most of them. -
Is the JSON shaped right? Three levels: event, matcher groups, handlers. A handler put directly under the event silently matches nothing.
-
Does the matcher match? Tool names are exact and case-sensitive.
bashis notBash. -
Run it by hand. Hooks read JSON on stdin, so you can test one without Claude Code:
echo '{"tool_input":{"file_path":"data/seed.json"}}' | .claude/hooks/protect-seed.shThat should print the deny JSON. If it prints nothing, the bug is in your script and Claude Code was never involved.
-
claude --debugshows hook execution and any parse errors.
Step 4 is the one worth internalising. A hook is a program that reads JSON and writes JSON, and it is testable like any other program.
Hooks in a project’s
.claude/settings.jsonrun commands from a repository you cloned. That is why the workspace trust prompt in lesson 11 exists, and it is worth reading a repository’s.claude/directory before you trust it, the same way you would skim aMakefile.
Your turn
Build the seed guard above in your stockroom clone. You need jq installed.
Then test it without Claude Code at all:
echo '{"tool_input":{"file_path":"data/seed.json"}}' | .claude/hooks/protect-seed.sh
echo '{"tool_input":{"file_path":"src/config.ts"}}' | .claude/hooks/protect-seed.sh
Check 1: the first prints the deny JSON, the second prints nothing and exits 0. If both print nothing, the script is not matching; if both deny, the
casepattern is too broad and you have just blocked every edit in the repository.
Then in a session:
Change the unitCost of BOLT-M8 to 45 in data/seed.json.
Check 2:
git statusshowsscripts/seed.mjsanddata/seed.jsonboth changed, and the session says it edited the generator and regenerated. It routed around the block because your reason told it how. Now change the reason to just"Not allowed.", revert, and ask again: it will refuse or guess. The reason is the feature.
Recap
A hook is a shell command Claude Code runs at a lifecycle event, and PreToolUse is the one
that can refuse a tool call. It is enforcement, where CLAUDE.md and skills are
instruction.
The config nests three deep: event, matcher groups, handlers. The hook reads the tool call
as JSON on stdin and answers with JSON on stdout, which makes it testable with echo and a
pipe.
Write the denial reason as an instruction. “Edit this instead and run that” gets you the outcome; “no” gets you a stopped turn.
Use hooks for rules that are mechanical and absolute. Leave judgement to review.
Next: connecting Claude to something outside your repository, and finding out what you granted it.