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:
- Does the thing I am afraid of reach this event at all?
- 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.