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

Canonical URL: https://zero2vibecode.com/blog/claude-code-hooks-pretooluse
Date: 2026-10-18 (updated 2026-10-22)
Tags: claude-code, security, agents

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.

That mechanism is a hook. This post is the third in a series on the [Claude Code: Pro Level](https://zero2vibecode.com/learn/claude-code-pro) course and covers two lessons: [PreToolUse hooks](https://zero2vibecode.com/learn/claude-code-pro/2-1-pretooluse-hooks) and [hook recipes](https://zero2vibecode.com/learn/claude-code-pro/2-2-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:

```json
{
  "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:

```bash
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](https://zero2vibecode.com/learn/claude-code-pro/2-2-hook-recipes) 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.

```javascript
// 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
  }
});
```

```json
"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:

```json
{
  "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:

```javascript
// 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:

```json
"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 `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](https://zero2vibecode.com/blog/subagents-workflows-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](https://zero2vibecode.com/learn/claude-code-pro/2-1-pretooluse-hooks) 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](https://zero2vibecode.com/learn/claude-code-pro/2-2-hook-recipes) shows where each of the three recipes belongs.
