Skip to content

Writing a hook that blocks a mistake

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 [1]. Other coding agents offer the same feature under other names and formats. The method is the same there, and the syntax may differ.

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 [1]. 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 [1]:

  • 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 [1]. 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 [1]. A PreToolUse hook runs before Claude Code looks at the permission mode, and it runs in every mode [2]. So the mode you picked in Choosing a permission mode per task doesn’t switch this hook off.

Checkpoint · match

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

Checkpoint · choice

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

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 [1].

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

Terminal window
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.

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 [3], 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 · run it

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
manifest changed, new untracked lockfile: exit 0
lockfile changed alone: exit 2
Blocked: app/package-lock.json changed and app/package.json did not.

Output verified in CI from site/examples/customizing-agents/first-hook/subdir.py.

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 [2].

Terminal window
echo '{"tool_input": {"command": "git commit -am bump"}, "cwd": "."}' | python3 .claude/hooks/lockfile_guard.py; echo "exit code $?"
Example · run it

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

Output
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

Output verified in CI from site/examples/customizing-agents/first-hook/blocked.py.

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.

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"']
Checkpoint · predict

What does this print, one line per command?

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}")

Output verified in CI from site/examples/customizing-agents/first-hook/cases.py.

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 [4]. 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.

{
"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, 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 [1]. The vendor calls the if filter best effort and says to use permission rules for a hard allow or deny [1]. 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 [1], 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 · run it

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
input that isn't JSON: exit 2
mistyped path, exec form: exit 2
mistyped path, shell form: exit 127

Output verified in CI from site/examples/customizing-agents/first-hook/failures.py.

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 [1]. Then type /hooks. The menu lists the hook under PreToolUse, marked as coming from your local settings [1]. Close the menu and leave the session open.

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.

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 [2]. It exits with 2, and git doesn’t start. Claude gets the hook’s text as the reason for the block [1]. 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
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
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 · choice

Which text should the hook write to standard error?

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 [1].
  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 [1].
  3. A PreToolUse hook runs before the permission check, in every permission mode [2]. 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 [1].
  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.

You can now

  • Adds a hook that enforces a rule the instructions cannot

  1. Anthropic. Hooks reference. Claude Code documentation. Reference. Claude Code hooks
  2. Anthropic. Automate actions with hooks. Claude Code documentation. Reference. Claude Code hooks guide
  3. The Git project. git-diff. Git reference documentation. Reference. Git docs git-diff
  4. The Git project. git-commit. Git reference documentation. Reference. Git docs git-commit