Skip to content
zero2vibecodelearn vibe coding
Claude Code: Pro LevelUpdated: By Mikhail Kuzmitskii

Claude Code hooks: PreToolUse guards and three recipes that pay off

A rule in CLAUDE.md is advice Claude can miss. A hook is your own command that Claude Code runs at a set moment, every time. How a PreToolUse hook blocks an edit with exit code 2, three recipes worth adding today, and the boundary people forget.

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.

A robot pushing a wheelbarrow toward a closed iron gate while a glowing hand signals it to stop

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:

EventWhen it fires
SessionStartwhen the session starts
UserPromptSubmitwhen you send a request
PreToolUsebefore every tool call
PostToolUseafter a successful tool call
Stopwhen Claude finishes answering
Notificationwhen 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": [] }
        ]
      }
    ]
  }
}
  • matcher picks the tools: Edit|Write means 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

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

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.

Read next

Try it hands-on

Free interactive courses on this topic — in your browser, from zero.