In the last lesson a hook blocked an edit. But hooks do more than forbid. Here are three recipes that pay for themselves from day one.
Recipe 1. Tidy up after an edit — PostToolUse
PostToolUse fires after a successful tool call. Attach it to Edit|Write, and your script runs after every file edit: a formatter, a linter, or a simple cleanup of trailing whitespace.
Here is a formatter hook in Node — no jq needed. Save it as .claude/hooks/format-after-edit.mjs. It reads the edited file’s path from the JSON Claude Code sends on stdin and runs the project’s own Prettier on it; in a project without Prettier it quietly 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
}
});
Register it in the project’s .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/format-after-edit.mjs"] }
]
}
]
}
}
With args set, Claude Code starts node directly, without a shell, and fills in ${CLAUDE_PROJECT_DIR} — so the hook works from any folder, and on Windows too, where node is a real program rather than a script shim.
This removes a whole class of small “now fix the formatting” requests: the file is brought into the project’s style on its own.
Recipe 2. Call you over — Notification
A long task, you went to make tea, and Claude has been waiting for an approval for ages. A hook on the Notification event can show a system notification when Claude Code needs your attention.
A hook like this is about you, not the project: notifications work differently on Mac and Linux. So register it in your own ~/.claude/settings.json. On macOS:
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{ "type": "command", "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'" }
]
}
]
}
}
On Linux, swap the command for notify-send 'Claude Code' 'Claude Code needs your attention'.
The matcher picks which notifications count: permission_prompt is Claude waiting for an approval, idle_prompt is Claude finished about a minute ago and you have not typed since. An empty matcher, "", fires on every kind.
If nothing pops up
On macOS, osascript notifications come from the Script Editor app, and without permission they fail silently. Run osascript -e 'display notification "test"' once, then allow notifications for Script Editor in System Settings. On Linux, notify-send needs a desktop session — over SSH or in a container there is nothing to show it.
Recipe 3. Check at the end — Stop
Stop fires when Claude has finished answering. It is the best place to check the whole working tree at once: did protected files change, do the quick tests pass.
A Stop hook that exits with code 2 does not end anything — it sends Claude back to work, with your stderr text as the reason. That is the point, and also the trap: if the check can never pass, Claude keeps going round. So the first thing the script does is look at stop_hook_active, which is true when Claude is already continuing because of a Stop hook, and let it stop.
Save this as .claude/hooks/check-at-stop.mjs. It asks git which files changed and objects if a lockfile is among them:
// 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);
}
});
And register it next to the other one — Stop 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.
Only fast checks belong here: a git status, a lint of the changed files. A full test suite in a Stop hook makes you wait after every answer, including “sure, done”. And git only sees files it tracks — an ignored file such as .env needs a different check, for example comparing its checksum.
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: commands go through the Bash tool.
To see everything, there are two ways:
- a separate hook with
matcher: "Bash"that inspects the command; - a
Stophook that checks the working tree at the end of the answer — it does not care how the change was made.
Test the hook without Claude
You can run any hook script by hand, feeding it the JSON Claude Code would send. If a script cannot be tested on its own, it cannot be trusted either: a bug in a hook quietly breaks every session.
For the Stop recipe, change a lockfile and run:
echo "{\"cwd\":\"$PWD\",\"stop_hook_active\":false}" | node .claude/hooks/check-at-stop.mjs; echo "exit $?"
exit 2 and the “Lockfile changed” line mean it will send Claude back in a real session. Run it again with "stop_hook_active":true and it must say exit 0.
Quick check
You want to guarantee that after Claude answers, the .env file in the project has not changed — however it might have been changed. Which hook?
Careful
A hook runs on every event. A slow script in PostToolUse — a full test run, say — slows down every single edit. Put fast things in PostToolUse: formatting, a check on one file. Heavy checks go in Stop, once per answer.