Skip to content
zero2vibecodelearn vibe coding
Level 2 · Make it yours 9 min Reviewed by the author · Oct 22, 2026 Verified against Claude Code v2.1.306 · Oct 22, 2026Official docs: Hooks guideSomething out of date? Tell us

Hook recipes: auto-format, notifications, a final check

Three working hook recipes — tidy a file after an edit, get notified when Claude is waiting for you, and check the whole working tree at the end of an answer. Plus the boundary people forget.

Level 2 · 0 / 4 lessons0%
In this lesson

No hands-on task in this lesson — just read it through and answer the questions.

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

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 Stop hook 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

Check your understanding

Answered 0/3
Mode:

Which event fits formatting a file right after Claude has changed it?

Where do you register a "Claude is waiting for you" notification hook?

A hook on Edit|Write does not see changes Claude makes with commands. How do you catch everything?

Finish the task in the terminal and answer the questions to complete the lesson.

📝 My notes

Saved automatically in this browser · shows up on the Notes page.

Lesson Q&A

Which event fits formatting a file right after Claude has changed it?

PostToolUse with the matcher Edit|Write — PostToolUse fires after a successful tool call — just the moment to tidy the file that has just changed.

Where do you register a "Claude is waiting for you" notification hook?

In your own ~/.claude/settings.json — it is about you, not the project — A notification is a personal habit that depends on your system. It belongs in user settings, not in the project's shared rules.

A hook on Edit|Write does not see changes Claude makes with commands. How do you catch everything?

Add a hook with the matcher Bash, or a Stop hook that checks the whole working tree — Commands go through Bash, so they need their own matcher. A Stop hook fires at the end of an answer and can check all changes at once, however they were made.

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?

Edit|Write does not see shell commands. A Stop hook looks at the outcome, not the method, so it catches everything.

Move between lessons with ← → · search with ⌘K

Written and reviewed by Mikhail Kuzmitskii