# Writing a hook that blocks a mistake

> Write a Claude Code hook that runs before each commit and blocks it when the lockfile changed and the manifest did not, test it by hand, then make the mistake on purpose and read what the agent does with the message.

Page: https://schubergphilis.github.io/ai-training/customizing-agents/first-hook/
License: CC BY-SA 4.0, https://creativecommons.org/licenses/by-sa/4.0/

In this lesson we write one hook for a coding agent and watch it block a commit. The
hook runs before every commit the agent tries. When `package-lock.json`
changed and `package.json` didn't, it stops the commit and tells the
agent what to do instead. We test it by hand first, then register it,
then give the agent a task that makes the mistake on purpose and read
what the agent does with the message.

The events, the exit codes and the settings format here are Claude
Code's, checked against the vendor's documentation on the date at the
end of the lesson (Hooks reference, Claude Code documentation). Other coding agents offer the
same feature under other names and formats. The method is the same
there, and the syntax may differ.

## Where a hook runs and what it returns

**Hooks** are commands of yours that the agent product runs at fixed
points in a session. Claude Code calls these points events. A few of
them: `SessionStart` when a session begins or resumes, `UserPromptSubmit`
when you send a prompt, `PreToolUse` before a tool call runs,
`PostToolUse` after a tool call has succeeded, and `Stop` when Claude has
finished its reply. The reference page lists many more (Hooks reference, Claude Code documentation).
This lesson uses one of them, `PreToolUse` on the `Bash` tool, which runs
before every shell command the agent asks to run.

A command hook gets the event as JSON on its standard input. For a Bash
call that JSON holds the tool name, the command in `tool_input.command`,
and the working directory in `cwd`. A hook that writes plain text, or
nothing, answers with its exit code (Hooks reference, Claude Code documentation):

- Exit code 0 means no objection. The call goes on to the usual
  permission check, and Claude never sees what the hook wrote to
  standard error.
- Exit code 2 blocks. For `PreToolUse` the tool call doesn't run, and
  Claude gets the text the hook wrote to standard error as the reason.
- Any other exit code, 1 included, is a non-blocking error. The call runs
  anyway, and the transcript shows a hook error notice with the first line
  of standard error.

Exit code 1 is the usual failure code for a shell script, and here it
lets the action through. A hook that must stop something exits with 2.

A hook can also answer with a JSON object on standard output instead.
For `PreToolUse`, a `permissionDecision` of `deny` in that object blocks
the call even when the hook exits with 0 (Hooks reference, Claude Code documentation). This
lesson writes plain text and lets the exit code decide, which is the
simpler of the two.

Not every event can block. A `PostToolUse` hook runs after the tool has
already run, so exit code 2 there only shows the message to Claude
(Hooks reference, Claude Code documentation). A `PreToolUse` hook runs before Claude Code looks
at the permission mode, and it runs in every mode
(Automate actions with hooks, Claude Code documentation). So the mode you picked in
[Choosing a permission mode per task](https://schubergphilis.github.io/ai-training/customizing-agents/permission-modes/)
doesn't switch this hook off.

#### Checkpoint: Which event fits the job?

Match each job to the event its hook should run on.

Statements:

- Refuse a git push to main before it runs
- Run the formatter on a file right after an edit to it
- Print the open issues when a session begins
- Play a sound when Claude has finished its reply

Options:

- PreToolUse
- PostToolUse
- SessionStart
- Stop

#### Checkpoint: Which ending stops the commit?

The script has found a commit it must refuse. How should it end?

- Write the reason as plain text to standard error and exit with code 1.
- Write the reason as plain text to standard output and exit with code 0.
- Write the reason as plain text to standard error and exit with code 2.
- Write the reason as plain text to standard output and exit with code 2.

## Write the lockfile check

A lockfile such as `package-lock.json` is generated from its manifest,
`package.json`, by the package manager. When the lockfile changes and
the manifest doesn't, someone most likely edited the lockfile by hand,
and the two files no longer describe the same project. That's the
mistake the hook blocks.

From the root of your clone of the course repository, copy the fixture
project and the hook script to a place of your own. Then rename the two
sample files and give the copy its first commit. The course repository
keeps the two files under other names so that no dependency tool treats
the fixture as a real project. Claude Code runs a command hook with your
full user permissions, so read any hook script before you register it
(Hooks reference, Claude Code documentation).

If `~/hook-me` exists from an earlier try, remove it first, or `cp`
puts the new copy inside the old one.

```sh
cp -R site/examples/customizing-agents/first-hook/fixture-repo ~/hook-me
mkdir -p ~/hook-me/.claude/hooks
cp site/examples/customizing-agents/first-hook/lockfile_guard.py ~/hook-me/.claude/hooks/
cd ~/hook-me
mv package.sample.json package.json
mv package-lock.sample.json package-lock.json
git init -q && git add -A && git commit -q -m "fixture"
```

You can also type the script yourself. Here is the core of it. The
complete file is `site/examples/customizing-agents/first-hook/lockfile_guard.py`.

```python
PAIRS = {"package-lock.json": "package.json"}


COMMIT = re.compile(r"\bgit\b[^;&|\n]*?\scommit\b")


def main() -> int:
    try:
        call = json.load(sys.stdin)
        command = call["tool_input"]["command"]
        cwd = call.get("cwd", ".")
        if not isinstance(command, str) or not isinstance(cwd, str):
            raise TypeError("command and cwd must be strings")
    except (ValueError, KeyError, TypeError, AttributeError):
        print(
            "Blocked: the lockfile hook couldn't read the tool call it was given.\n"
            "Check the hook's entry in .claude/settings.local.json.",
            file=sys.stderr,
        )
        return 2
    if not COMMIT.search(command):
        return 0
    changed = changed_files(cwd)
    if changed is None:
        print(
            "Blocked: the lockfile hook couldn't ask git what changed.\n"
            "Run git status to see why, and fix that before you commit.",
            file=sys.stderr,
        )
        return 2
    message = problem(changed)
    if message is None:
        return 0
    print(message, file=sys.stderr)
    return 2
```

`COMMIT` matches `git`, then any options, then `commit`, so
`git -C . commit` counts as well as `git commit`. `changed_files` finds
the root of the repository and runs every git command from there. The
paths it compares are then the same when the agent works in a
subdirectory.
It runs `git diff --name-only HEAD`, which lists every file in the working tree
that differs from the last commit, staged or not (git-diff, Git reference documentation),
and adds the untracked files that git doesn't ignore. The hook needs all
of them, because it runs before the command. When the agent sends
`git add package-lock.json && git commit` as one command, nothing is
staged yet at the moment the hook looks. The price is that an unstaged
lockfile edit also blocks a commit of some other file, until you commit
or undo the edit. `problem` walks the changed files, and for each `package-lock.json` it checks that
the `package.json` in the same directory changed too. If one didn't, it
returns the message, and `main` prints it to standard error and exits
with 2. When the input isn't a tool call it can read, or git can't say
what changed, `main` also exits with 2 and says so. A hook that lets the
commit through whenever something goes wrong inside it would fail
silently, and nobody would notice until the bad commit.

The fixture `subdir.py` adds a second package, `app/`, as in a
monorepo with more than one package, and runs the hook with `cwd` in `app/`. First
`app/package.json` changes and `app/package-lock.json` is new and
untracked. Then, after a commit of both, the lockfile changes alone.

#### Example: The hook from a subdirectory

From the root of the course repository, run
`python3 site/examples/customizing-agents/first-hook/subdir.py` and
compare what you see with the output below.

Output:

```text
manifest changed, new untracked lockfile: exit 0
lockfile changed alone: exit 2
Blocked: app/package-lock.json changed and app/package.json did not.
```

The script is fast because it does almost nothing. It returns at once
for any command that isn't a commit, and a commit costs it one
`git diff`. A hook that makes every command slow is the kind people
switch off, and a switched-off hook doesn't check anything.

Test it before any agent sees it. Open `package-lock.json` in your
editor, change both `"version": "1.0.0"` lines to `"1.1.0"`, save, and
pipe the hook the JSON that Claude Code would send for a commit. The
vendor's troubleshooting advice is the same: feed the script sample JSON
and look at the exit code (Automate actions with hooks, Claude Code documentation).

```sh
echo '{"tool_input": {"command": "git commit -am bump"}, "cwd": "."}' | python3 .claude/hooks/lockfile_guard.py; echo "exit code $?"
```

#### Example: The hook, fed by hand

Run the command in your copy and compare what you see with the output
below.

Output:

```text
Blocked: package-lock.json changed and package.json did not.
package-lock.json is generated from package.json. Make the change in package.json
and regenerate package-lock.json from it, or undo the lockfile change with
git checkout HEAD -- package-lock.json
exit code 2
```

The first line says what's wrong, with both file names. The rest says
how to fix it, with the exact command for the undo. Claude reads this
text and nothing else about your rule, so it has to be enough on its own.

The fixture `cases.py` holds five commands against the same hook, in a
fresh copy with the same edit. It runs the first four with only the
lockfile changed, then makes the same version change in `package.json`
and runs the fifth.

#### Checkpoint: Which commands does the hook stop?

What does this print, one line per command?

```python
LOCKFILE_ONLY = [
    "git status",
    'git commit -am "Bump version"',
    'git commit --no-verify -am "Bump version"',
    'git -C . commit -am "Bump version"',
]
BOTH_FILES = ['git commit -am "Bump version"']

for command in LOCKFILE_ONLY:
    code, _ = run_hook(repo, command)
    print(f"lockfile only, {command}: exit {code}")
repo.set_version("package.json", "1.0.0", "1.1.0")
for command in BOTH_FILES:
    code, _ = run_hook(repo, command)
    print(f"both files, {command}: exit {code}")
```

The third line is the one to remember, and the fourth shows the pattern
at work. `--no-verify` tells git to skip
its own `pre-commit` and `commit-msg` hooks (git-commit, Git reference documentation). This
hook belongs to Claude Code and runs before git starts. The flag changes
nothing for it.

Now register the hook. Create `.claude/settings.local.json` in your copy.
The fixture's `.gitignore` already keeps that file out of commits.

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git *)",
            "command": "python3",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/lockfile_guard.py"]
          }
        ]
      }
    ]
  }
}
```

The `matcher` picks the tool. The `if` field takes one permission rule,
in the same syntax as in
[Allowing the commands the task needs](https://schubergphilis.github.io/ai-training/customizing-agents/allowlists/),
and the script starts only for a git command. A narrower
`Bash(git commit *)` would miss `git -C . commit`. With `args` present, Claude Code starts `python3` directly, with
no shell, and fills in `${CLAUDE_PROJECT_DIR}` with the
project root (Hooks reference, Claude Code documentation). The vendor calls the `if` filter best
effort and says to use permission rules for a hard allow or deny
(Hooks reference, Claude Code documentation). The script checks for a commit again itself for
that reason: the filter saves time, and the script decides.

A text match has limits. A commit run through a shell alias, a script
such as `./release.sh`, or a spelling the pattern doesn't know gets past
it. The hook reads the command, and it can't see what a script does
inside. So this hook catches a mistake, the kind an agent makes while it
works as asked. It doesn't stop a commit made another way. The vendor's advice for a hard allow or
deny is a permission rule (Hooks reference, Claude Code documentation), and for a rule the
project can't do without, add a check in CI (continuous integration)
that runs on every push, whatever made the commit. The hook tells the
agent at the moment it can still fix the mistake, and CI catches what
gets past.

If the script path in `args` has a typo, `python3` can't open the file
and exits with 2, so Claude Code blocks every git command with Python's
error as the reason. The interpreter exits with 2 whichever form starts
it. The code 127 comes from a shell that can't find the script itself:
a shell-form entry, one with no `args`, that runs the script by its
mistyped path makes the shell exit with 127, a non-blocking error, and
the commit runs. The fixture `failures.py`
shows both, next to a hook that got input it couldn't read.

#### Example: When the hook itself goes wrong

From the root of the course repository, run
`python3 site/examples/customizing-agents/first-hook/failures.py` and
compare what you see with the output below.

Output:

```text
input that isn't JSON: exit 2
mistyped path, exec form: exit 2
mistyped path, shell form: exit 127
```

Start `claude` in `~/hook-me`. In a folder that's new to Claude Code
you first see the workspace trust dialog, and Claude Code holds back
the hooks from every settings file until you accept it (Hooks reference, Claude Code documentation). Then type `/hooks`.
The menu lists the hook under `PreToolUse`, marked as coming from your
local settings (Hooks reference, Claude Code documentation). Close the menu and leave the
session open.

## Make the mistake on purpose

Reset the copy with `git checkout -- .`, so the hand edit from the test
is gone, and send the agent this brief. It asks for the mistake the hook
exists to stop.

```text
In package-lock.json, change the version from 1.0.0 to 1.1.0 in both
places. Don't change any other file. Then commit the change with the
message "Bump version to 1.1.0".
```

Approve the edit when Claude Code asks. When the agent sends its
`git commit`, the hook runs before any permission prompt for the
command (Automate actions with hooks, Claude Code documentation). It exits with 2, and git doesn't
start. Claude gets the hook's text as the reason for the block
(Hooks reference, Claude Code documentation). Run
`git log --oneline` in another terminal to confirm that the only commit
is still `fixture`.

Now read the agent's next reply. The brief and the hook disagree on
purpose: the brief says to touch one file, and the hook says to change
the other one or undo the edit. Write down which way the agent went. It may
have stopped to tell you about the conflict, or undone the edit. It may
also have changed `package.json` against the brief, or tried the same
commit a second way. Each answer tells you something. A second attempt at
the same commit says the message didn't give it a better move. A change
to `package.json` without asking says the agent followed the message over
the brief. If you want it to ask first, add "ask the user first" to the
message.

Here is one run of the same brief, recorded in a fresh copy with the
settings above. It ran in `claude -p`, which prints one reply and then
exits. The edit and the two git commands were allowed ahead of time, and
the exact command was
`claude -p '<the brief>' --model claude-sonnet-4-6 --permission-mode acceptEdits --allowedTools "Bash(git add *)" "Bash(git commit *)"`.
The final reply is below. Your run may take a different path.

#### Prompt · Claude Sonnet 4.6, recorded 2026-09

````text
```text
In package-lock.json, change the version from 1.0.0 to 1.1.0 in both
places. Don't change any other file. Then commit the change with the
message "Bump version to 1.1.0".
```
````

#### Response

````text
```text
A pre-commit hook is blocking the change. The hook requires that
`package.json` also be changed when `package-lock.json` changes. This is a
safeguard: `package-lock.json` is generated from `package.json`, and
changing them out of sync is considered an error.

To proceed, I'd need to either:
1. Also update the version in `package.json` (which you said not to do), or
2. Revert the `package-lock.json` change.

Would you like me to revert the lockfile change, or should I also update
`package.json` to match?
```
````

In this run the agent stopped, restated the rule from the message,
named the conflict with the brief, and offered you both ways out. It
calls the check a pre-commit hook, which is the name of a git hook. The
message never said which kind of hook it was, and the agent didn't need
to know. `git log --oneline` in that copy still showed only `fixture`.

#### Checkpoint: Which message can the agent act on?

Which text should the hook write to standard error?

- Blocked by lockfile_guard.py: exit status 2 from the PreToolUse hook on Bash.
- Blocked: package-lock.json changed and package.json did not. Change package.json and regenerate the lock.
- Blocked: this commit breaks one of the rules of the repository. Fix the problem and try again.
- Blocked: package-lock.json is out of date with the project. Commit again once it is up to date.

#### Pitfall: The instruction that held most of the time

A team's `AGENTS.md` said: "Never edit `package-lock.json` by hand.
Change `package.json` and let the package manager update the lockfile."
The agent followed it in almost every session. Late in one long session
it fixed a merge conflict inside the lockfile by hand and committed it,
and the two files disagreed for a week before a reviewer noticed. The
instruction was a request, and the agent weighed it against everything
else in its context. The rule from
[When instructions aren't enough](https://schubergphilis.github.io/ai-training/customizing-agents/instructions/#when-instructions-arent-enough):
a rule that must hold every time is a hook, and guidance that may bend
stays an instruction. With CI added, the hook catches the mistake while
the agent works, and a CI check behind it catches what the hook misses.

## Exercise

Do the steps of this lesson in your own copy, if you haven't already:
write or copy the hook, test it by hand with the piped JSON, register it
in `.claude/settings.local.json`, check it in `/hooks`, and send the brief
that makes the mistake. Then pick one pair of generated and source files
from a project you work on (a lockfile and its manifest, or a generated
client and its schema), and change `PAIRS` and the message to fit it. Try
it in a fresh clone or a branch you can throw away. The result is one
script, one settings entry, and a note of what the agent did after the
block. The point is to see the whole loop once, from the rule to the
agent's reaction, in a copy where a mistake costs nothing.

A good result: the piped test prints the message and `exit code 2`, the
agent's commit never lands, and your note says which way the agent went
after the block. Reflection: which line of your own instruction file
states a rule where one miss would cost you, what would a hook for it
check, and what would a hook miss that a CI check would catch?

Stretch: Change the hook's final return 2 to return 1, reset the copy with git checkout -- ., and send the same brief again. This time Claude Code asks you to approve the commit: approve it, and find the hook error notice in the transcript. Then check git log: the commit went through. Change the code back.

## Recap

1. A hook is a command of yours that Claude Code runs at a fixed event,
   such as `PreToolUse` before a tool call or `PostToolUse` after one. A
   command hook gets the event as JSON on standard input
   (Hooks reference, Claude Code documentation).
2. For a hook that writes plain text, the exit code is the answer. Code 0
   lets the call go on, code 2 blocks it and gives Claude the text on
   standard error as the reason, and any other code, 1 included, is an
   error that lets the call run. A JSON decision on standard output can
   also block (Hooks reference, Claude Code documentation).
3. A `PreToolUse` hook runs before the permission check, in every
   permission mode (Automate actions with hooks, Claude Code documentation). It can stop an action that
   an allow rule or a mode would have let through.
4. Keep the hook fast and specific: filter with `if`, return early for
   everything else, and check the rule again in the script, because the
   filter is best effort (Hooks reference, Claude Code documentation).
5. The message is all the agent learns about your rule. Name what is
   wrong, and say what to do next, in the words you would use for a new
   colleague.

#### Habit

The next time you write "always" or "never" in an agent's instruction
file, ask whether one miss would cost you. If it would, write a hook
that catches the mistake while the agent works, and say where the check
behind it runs.

## References

- Hooks reference, Claude Code documentation, https://code.claude.com/docs/en/hooks
- Automate actions with hooks, Claude Code documentation, https://code.claude.com/docs/en/hooks-guide
- git-diff, Git reference documentation, https://git-scm.com/docs/git-diff
- git-commit, Git reference documentation, https://git-scm.com/docs/git-commit
