You write “never edit package-lock.json” in CLAUDE.md, and Claude respects it almost every time. The trouble is the word “almost”. A line in memory is advice, and in a long session advice can slip. When something has to happen every single time, you need a mechanism the model cannot overlook.
That mechanism is a hook. This post is the third in a series on the Claude Code: Pro Level course and covers two lessons: PreToolUse hooks and hook recipes.
A hook is your command, not a request to the model
A hook is a command you write that Claude Code runs at a set moment in the session — whatever Claude decides. It is attached to an event:
| Event | When it fires |
|---|---|
SessionStart | when the session starts |
UserPromptSubmit | when you send a request |
PreToolUse | before every tool call |
PostToolUse | after a successful tool call |
Stop | when Claude finishes answering |
Notification | when Claude Code needs your attention |
Of these, PreToolUse is the one that can stop an action before it happens.
The PreToolUse guard
Before a tool runs, Claude Code starts your script and feeds it JSON describing the call — which file is about to be edited, for example. The script’s exit code decides what happens next:
- 0 — all clear, the call goes ahead;
- 2 — the call is blocked, and whatever the script printed to stderr goes to Claude as the reason.
Claude reads that reason — something like “Blocked: package-lock.json is protected” — and changes its plan instead of retrying blindly.
The detail that makes this a real guard: PreToolUse fires before the permission mode is checked. So the block holds in every mode, Auto included.
You register the hook in a settings file:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.sh", "args": [] }
]
}
]
}
}
matcherpicks the tools:Edit|Writemeans file edits and writes only;${CLAUDE_PROJECT_DIR}fills in the project path, so the hook is found from anywhere;- with
"args": []the script runs directly, without a shell, and the path does not need quoting.
Inside a session, /hooks shows which hooks were picked up.
Test the hook without Claude
You do not need a session to know whether a guard works. Feed the script the JSON Claude Code would send and look at the exit code:
echo "{\"tool_input\":{\"file_path\":\"$PWD/package-lock.json\"}}" | .claude/hooks/protect-files.sh; echo "exit $?"
exit 2 means it will block in a session too. Make this a habit: a hook you cannot test on its own is a hook you cannot trust, and a bug in one quietly affects every session.
Three recipes that pay off from day one
Hooks do more than forbid. The recipes lesson adds three.
Tidy up after every edit — PostToolUse
Attach PostToolUse to Edit|Write and your script runs after each file edit: a formatter, a linter, or a plain trailing-whitespace cleanup. It removes a whole class of “now fix the formatting” follow-ups.
This one is Node, so it needs no jq. Save it as .claude/hooks/format-after-edit.mjs: it takes the edited file’s path from the JSON on stdin and runs the project’s own Prettier on it, and in a project without Prettier it does nothing.
// PostToolUse hook: formats the file Claude just edited with the project's own Prettier.
// Claude Code passes a description of the call on stdin. A formatter problem must never
// break the session, so every way out of this script is exit 0.
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
let input = "";
process.stdin.on("data", (c) => (input += c)).on("end", () => {
let file = "";
try {
file = JSON.parse(input).tool_input?.file_path ?? "";
} catch {
process.exit(0); // unexpected input — don't get in the way
}
const prettier = join(process.env.CLAUDE_PROJECT_DIR ?? ".", "node_modules/prettier/bin/prettier.cjs");
if (!file || !existsSync(prettier)) process.exit(0); // no Prettier in this project — nothing to do
try {
execFileSync(process.execPath, [prettier, "--write", "--ignore-unknown", file], { stdio: "ignore" });
} catch {
// the file does not parse yet — leave it as it is
}
});
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/format-after-edit.mjs"] }
]
}
]
Starting node with the script in args runs it without a shell, which also works on Windows.
Call you over — Notification
You left a long task running and went to make tea, and Claude has been waiting for an approval ever since. A Notification hook can show a system notification when Claude Code needs you. Notifications work differently on Mac and Linux, so this one belongs in your own ~/.claude/settings.json, not in the project:
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{ "type": "command", "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'" }
]
}
]
}
}
That is macOS. On Linux, use notify-send 'Claude Code' 'Claude Code needs your attention' as the command. permission_prompt fires when Claude is waiting for an approval, idle_prompt when it finished a while ago and you have not answered. If nothing appears on a Mac, allow notifications for Script Editor in System Settings — osascript goes through it and fails silently without permission.
Check at the end — Stop
Stop fires once Claude finishes answering. It is the place to check the whole working tree: did a protected file change, do the quick tests still pass.
Exit code 2 from a Stop hook sends Claude back to work with your stderr text as the reason. If the check can never pass, that is a loop, so the script lets Claude stop when stop_hook_active is true — that flag means Claude is already continuing because of a Stop hook. This one objects when a lockfile changed:
// Stop hook: runs each time Claude finishes an answer. If a lockfile changed, exit 2
// sends Claude back to work, with the stderr text as the reason. Keep this fast: it runs
// after every single answer.
import { execFileSync } from "node:child_process";
let input = "";
process.stdin.on("data", (c) => (input += c)).on("end", () => {
let data = {};
try {
data = JSON.parse(input);
} catch {
process.exit(0);
}
// Claude is already continuing because of this hook: let it stop, or it loops.
if (data.stop_hook_active) process.exit(0);
let status = "";
try {
status = execFileSync("git", ["status", "--porcelain"], { cwd: data.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
} catch {
process.exit(0); // not a git repository — nothing to check
}
const changed = status.split("\n").filter(Boolean).map((line) => line.slice(3).split(" -> ").pop());
const LOCKFILES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
const touched = changed.filter((f) => LOCKFILES.includes(f.split("/").pop()));
if (touched.length) {
console.error(`Lockfile changed: ${touched.join(", ")}. Undo it, or tell me why the dependencies had to change.`);
process.exit(2);
}
});
Save it as .claude/hooks/check-at-stop.mjs and register it under Stop, which takes no matcher:
"Stop": [
{
"hooks": [
{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-at-stop.mjs"] }
]
}
]
The script looks at what is uncommitted right now, not at what this one answer changed. So when you asked for a new dependency and the lockfile change is legitimate, commit it: until you do, the hook sends Claude back once after every answer.
Fast in PostToolUse, heavy in Stop
A hook runs on every event it is attached to. A full test run in PostToolUse slows down every single edit. Keep PostToolUse to fast things like formatting or a one-file check, and move heavy checks to Stop, where they run once per answer.
The boundary people forget
A hook with matcher: "Edit|Write" sees only the editing tools. If Claude changes a file with a shell command — echo X >> .env, sed -i, rm — that hook notices nothing, because commands go through the Bash tool.
You have two ways to close the gap:
- a separate hook with
matcher: "Bash"that inspects the command; - a
Stophook that checks the working tree at the end of the answer, which does not care how a change was made.
Hooks are also how you observe Claude Code, not just restrain it. The previous post on subagents, workflows and agent teams uses a SubagentStart hook to log every subagent Claude starts.
Hooks are code with your rights
Hooks run with your user’s permissions: in an interactive session once you have trusted the folder, and in claude -p without any question at all. Before you trust someone else’s repository, or run Claude in it from a script, read its hooks.
Frequently asked questions
How is a hook different from a rule in CLAUDE.md?
A rule in CLAUDE.md is context the model can miss in a long session. A hook is a command Claude Code runs on an event every time, regardless of what Claude decides.
How does a PreToolUse hook block an action?
It exits with code 2. The call is blocked, and whatever the script printed to stderr is passed to Claude as the reason.
Does the block work in Auto mode?
Yes. PreToolUse fires before the permission mode is checked, so the block holds in every mode.
Why did my Edit|Write hook miss a change to .env?
The change was made with a shell command, which goes through the Bash tool. Add a hook with a Bash matcher, or a Stop hook that checks the whole working tree.
Where to practice
The PreToolUse lesson has a practice terminal where you create .claude/hooks/protect-files.sh and register it on PreToolUse with the Edit|Write matcher. After that, the recipes lesson shows where each of the three recipes belongs.