So my last post was about using "stubs" to steer Claude Code in the right direction after it executes a bad command.

There is a better way to do this using Hooks.

What are Hooks?

The very short version is that they're lifecycle callbacks. When Claude processes a hook, it's an opportunity to execute some custom code. Today in January 2026, there are 12 hooks, including PreToolUse, PostToolUse, Stop and more. They're good for all kinds of things, but typical uses are:

  • logging prompts and tool use
  • blocking specific commands
  • alerting the user when a command finishes

You could theoretically write a hook to order a pizza when all the unit tests pass. They are fully scriptable, so hooks can do just about anything.

Blocking Commands with the PreToolUse Hook

In my last post, I mentioned that I wanted to prevent certain commands from ever running. For example, my python projects use the uv package manager. I never want to use pip or pip3, but Claude sometimes tries that first, despite directives in the CLAUDE.md file.

The PreToolUse hook provides another way to block the pip command.

This is what the user sees:

● I'll install FastAPI using pip.

● Bash(pip install fastapi)
  ⎿  PreToolUse:Bash hook returned blocking error
  ⎿  Error: Please use uv add instead of pip/pip3

● I'll install FastAPI using uv add instead.

Pretty cool. The hook blocked the pip command, and also hinted that uv is the preferred package manager, so Claude knew to try that next.

Setting up a PreToolUse hook

In the spirit of agentic, I let Claude do all the modifications. I didn't write a single line of code, but I did skim the documentation first. That probably wasn't necessary, but it was helpful to know how hooks are structured in order to write a decent prompt.

Below is a prompt that sets up the "block pip" hook.

Note this is a one-off example. A slightly more thoughtful approach is needed to support multiple hooks or advanced cases. Also, agents are inherently random. The same prompt can behave differently on different systems, depending on things like the model, context and existing configuration files.

I wanted to provide an example prompt for reference, but remember:

  • It's NOT guaranteed to work for everyone
  • Be safe. Don't just copy-paste prompts from strangers on the internet!
❯ Let's write a Claude Code PreToolUse Hook.

First, refer to the documentation: https://code.claude.com/docs/en/hooks

Second, make sure the required files exist:
 - .claude/settings.local.json
 - .claude/hooks/block-pip.py

Third, update the config:
 - add block-pip.py as a PreToolUse hook
 - run this hook whenever the Bash Tool is used

Fourth, write the script:
 - prevent users from calling "pip" or "pip3"
 - use the JSON output format for errors

Once complete, exit and restart Claude.

Restarting is VERY important.

Now try it. Ask Claude to do something using pip

❯ install fastapi with pip

And if it worked, you should see something like this:

● I'll install FastAPI using pip.

● Bash(pip install fastapi)
  ⎿  PreToolUse:Bash hook returned blocking error
  ⎿  Error: pip and pip3 commands are blocked. Please use uv instead.

● I'll install FastAPI using uv instead.

The custom PreToolUse hook was invoked and it blocked the Bash Tool from running because it tried to execute a pip install command.

Pretty cool. Pretty powerful.

What could go wrong?

Lots, unfortunately. Without going into details, I experimented with the prompt a bit. Only once did it fully work correctly. The first time, the filenames and data structures were completely wrong (hallucinations). The second time, I provided a little more detail and also the documentation page. That eliminated the hallucinations, and the code looked okay, but it didn't successfully block tool use. On the final prompt, I listed out the exact steps in more detail, and that seemed to be successful.

On one hand, it's pretty cool that it worked at all.

But I do think it would be difficult for a non-programmer to judge whether a particular hook is 100% correct.

Exit Code 2 and non-blocking errors

This is a BIG gotcha. The documentation mentions that Exit Code: 2 is used to indicate a "blocking error". This means that execution of the command is prevented (at least, in the case of PreToolUse).

But.. other exit codes DO NOT prevent tool use.

Exit Code 1? Nope. Normally that indicates an error, but here the tool still runs.

This is a little bit surprising. My first attempt had used exit code 1 instead of 2. The program looked visually correct, and it exited with an error, as expected. But it was the WRONG error code. Instead of blocking the "pip" command, Claude continued to execute it.

This is very easy to get wrong.

Instead Claude can also return a detailed JSON structure containing the results of running the hook. That "json output" behavior is mentioned in the example prompt above. The JSON approach seems less error-prone.

An alternative method

I actually approached this from a totally different angle at first. Only later did I go back and try to create a hook with a single prompt.

My original method was:

  • create a generic "log everything" hook
  • gather some log data
  • ask Claude to analyze the log and look for "pip" commands
  • then write the hook based on that

Asking Claude to "inspect the logs" is a very powerful pattern. Most of the time, when Claude gets stuck or starts going in circles, it lacks observability. Finding a way for Claude to see the logs usually helps.

I wanted to get that "log all tool use" baseline first, before writing any specific hook behavior.