Which hook event can stop a tool call, and which only watches (Chapter 7)

Chapter 3 said a hook that exits 2 stops the call. That is true on the event Chapter 3 was talking about, and it is not true everywhere, because when the hook runs decides whether stopping is even possible.

Claude Code fires hooks at dozens of named moments. The ones you are likely to meet are below. The two that matter most are PreToolUse, which runs before a tool call, and PostToolUse, which runs after it. Only the first one can stop the call. The second is a witness: by the time it runs, the file is written, the branch is reset, the command has executed. A PostToolUse hook can complain, log, and tell the model something is wrong so that the next call is different. It cannot prevent this one.

This sounds obvious written down. It is not obvious at three in the morning when you are copying a hook out of a blog post, and the header says PostToolUse because the author wanted to run a linter.

What the repository actually uses

The examples/ directory of cc-safe-setup holds 916 hook scripts. Each one declares its event in a header comment. Counting the first event named on that line — a script that names two events is counted under the first — against the repository as it stood on 2026-09-20, the same tree the catalogue in Chapter 10 was built from:

Event Hooks
PreToolUse 554
PostToolUse 139
(no event declared in the header) 67
SessionStart 61
Stop 38
PermissionRequest 15
UserPromptSubmit 12
Notification 12
PreCompact 7
everything else (SessionEnd 3, CwdChanged 2, FileChanged 2, SubagentStop 1, and the three +-joined headers below) 11

The count comes from the header line of each file, not from anyone’s summary of the collection. Where a header names more than one event the first is counted — with one exception: three headers join two names with a plus sign and no space, and because the reader splits on whitespace those fall into the last row instead. They are deny-bypass-detector, permission-denial-enforcer and settings-json-model-guard, and the index in Chapter 10 prints their event exactly as the header wrote it, so you can check that for yourself. Eleven headers mention a second event; nine name it outright and two hedge it in parentheses. The 67 the header does not pin down are not broken (three of them say none outright) — they are mostly small utilities whose author left the field out — but you have to open them to know when they run.

Two things are worth reading out of that table.

The first is the shape of the collection: three fifths of it is PreToolUse. That is where refusal of a tool call lives, so that is where the guards are.

The second is the tail. SessionStart and Notification together are about seventy scripts, and neither event can refuse anything. They put the current branch in front of the model at the start of a session; they route a notification somewhere you will see it. Both are useful. Do not install one and then wonder why it never blocked. PreCompact sits next to them in the table and does not belong with them: it is seven scripts, and exit 2 there does block — see below.

What each event can and cannot do

The authority for this is the exit-code table in Claude Code’s own hooks documentation, which lists, event by event, whether exit code 2 blocks anything.

PreToolUse — sees the tool call before it happens; exit 2 blocks it. Everything in Chapter 3 lives here. It can also answer the permission question directly by printing {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}, which is how most of the auto-approve hooks in this collection are written. The hookEventName is not decoration — leave it out and the block is not read as a permission decision at all.

PermissionRequest — runs when Claude Code is about to ask you to approve something. This is the event that exists only to answer, so it is where auto-approval belongs when you want the built-in prompt’s context. Exit code 2 is not honoured here. Deny through the decision object instead. A hook on this event that ends exit 2 expecting a refusal is doing nothing at all. None of the 15 PermissionRequest hooks here make that mistake, but eighteen hooks make the equivalent one on other events — Chapter 8 counts them.

PostToolUse — runs after the call succeeded. Exit 2 does not block, because there is nothing left to block; stderr is shown to Claude so the next call can be different. Good for a syntax check on what was just written, or an error counter. Cannot undo.

UserPromptSubmit — runs on what you typed, before the model sees it. Exit 2 stops the prompt from being processed, and what you typed does not reach the model. That is a genuinely useful place to stop a whole class of request before any tool is involved. Be precise about what is lost: the model never sees it, and the refusal message is what you get back. Whether the text is still sitting in your input box afterwards is a detail of the client, and not one I have checked.

SessionStart — runs when a session begins or resumes, including after a /clear and after compaction. Use it to put facts in front of the model that it would otherwise guess at: the branch, the last handoff note, the deploy state.

Stop and SubagentStop — run when the turn ends. Too late to prevent a tool call, but exit 2 here does refuse the stop itself: it prevents Claude from stopping and sends it back to work. That makes them the right place for “did you actually run the tests” checks, and the wrong place for anything you want to merely warn about, because a warning that exits 2 turns into an argument that will not end.

PreCompact — runs immediately before the context window is compressed. This is the last moment at which anything in the current context still exists in full. If you have ever lost a plan to compaction, this is the event you wanted. Exit 2 blocks the compaction, and the event section of the reference says so in as many words. It is easy to assume otherwise, because most of the events whose names start with something other than Pre cannot block — an earlier draft of Chapter 8 assumed exactly that and got a count wrong. Read the row; do not infer it from the name.

Notification — runs when Claude Code would notify you. Useful for routing that somewhere you will actually see. It does not block.

When the thing you are afraid of never reaches an event

Every event above is anchored to something: a tool call, a session boundary, a prompt, the end of a turn, a compaction, a notification. The reflex, when you want to stop a specific thing, is to reach for PreToolUse, because that is where refusal lives.

Sometimes the thing is not a tool call. A slash command is the clear case: it does not go through the tool layer, so no PreToolUse hook will ever see it, and a guard written there is not weak — it is pointed at a door the command never walks through.

The obvious next move is UserPromptSubmit, which runs on what you typed and where exit 2 blocks the prompt outright. This repository shipped a hook written on exactly that idea, and this is what it looked like:

1 # examples/clear-command-confirm-guard.sh, as it shipped until 2026-09-19
2 # (TRIGGER: UserPromptSubmit, MATCHER: "^/clear$")
3 if echo "$PROMPT" | grep -qE '^/clear$'; then
4   echo "BLOCKED: ..." >&2
5   exit 2
6 fi

Do not install that one, and the reason is the whole point of the section. Feeding the script {"prompt":"/clear"} by hand does make it exit 2 — but that only proves the script’s own logic. It does not prove that the CLI hands a slash command to UserPromptSubmit in the first place.

For a long time this chapter said I had not checked. On 2026-09-19 I did, on 2.1.270 under Linux, with every other hook removed and one recording hook in their place. /clear never reaches UserPromptSubmit. Ordinary text does; slash commands do not. The guard above has been shipping for months and has never once been asked to decide anything. The file is still in the repository, but since 2026-09-19 it is a record rather than a guard: its header carries the measurement, its TRIGGER line reads none, and all the code does is print why it cannot work and exit 0. That is why it now counts among the 67 with no event in the header rather than among the UserPromptSubmit hooks. The measurement itself — including what does fire on /clear, and how to run this check on your own hooks — is in Chapter 9.

Take the general shape rather than the one fact: the event you register for is a claim about what the runtime will hand you, and it is the part of a hook that piping JSON at it can never test.

Two other things are worth taking from that four-line example, because they are the ordinary mistakes and not the exotic one. grep -qE '^/clear$' matches a line, not the whole prompt, so a three-line message that happens to contain /clear on its own line is refused too. And the block message it prints says the command destroys context permanently, which overstates it — a saved conversation can still be resumed.

So there are two questions under the one you think you are asking, and they fail independently:

  1. Does the thing I am afraid of reach this event at all?
  2. On this event, does exit 2 mean anything?

The section above answers the second for the events it covers — and it does not cover all of them, so check the exit-code table in the hooks documentation for anything not listed here. Nothing answers the first except trying it, and a hook that exits 2 on a hand-fed input has answered neither.

The practical rule

Before you install a hook, read one line of its header and ask three questions. What kind of action is the thing I am afraid of, and which event does that kind produce? Is this event before or after it? And on this event, how does a refusal get expressed — exit 2, a decision object in the JSON output, or not at all?

Ask the third one that way and not “does exit 2 work”, because the two are not the same. PermissionRequest ignores exit 2 and still refuses perfectly well through its decision object; a hook there that ends exit 2 is doing nothing, and a hook there that returns the right JSON is a door. If the answer to the second is “after”, or the answer to the third is “not at all”, the hook is not protection — and if you cannot answer the first, you do not yet know which of the other two even applies. It may still be worth having — a witness is how you find out what happened — but file it under evidence, not defence.