How Do Claude Code Hooks Work?
Claude Code hooks are commands the CLI runs at fixed points in a turn. Each one receives a JSON event on standard input and answers with JSON on standard output. A PreToolUse hook can allow, deny, ask about or defer a tool call before it runs, and can rewrite the call arguments, which makes a hook the one place a rule is enforced rather than merely requested of the model.
Most of what is written about hooks stops at the configuration file. The harder part is not registering a command, it is knowing which event to listen to, what your hook is allowed to decide, and why a hook that clearly ran appears to have had no effect. This page covers those, from running a hook-governed coding session every day. Where our experience and the official reference disagreed, the reference won, and two of those corrections are below as the warnings they earned.
What can a hook actually do?
Three things, and they are worth separating because most hooks only use the first.
It can decide. A PreToolUse hook returns hookSpecificOutput.permissionDecision with one of four values: allow, deny, ask or defer. Deny refuses the call, ask routes it to the person, allow approves it outright, and defer hands the question back to the normal permission flow. That fourth value matters more than it looks: it lets a hook have an opinion only about the cases it understands and stay out of the way otherwise, which is how you avoid a hook that must classify everything correctly to be safe to install.
It can rewrite. Both PreToolUse and PermissionRequest accept updatedInput, which replaces the tool arguments before execution. This is the most underused capability in the system. A command with one unsafe flag, a request carrying a value that should not leave the machine, a path that should be redirected rather than refused: each is a correction rather than a refusal, and a correction does not interrupt the work.
It can add context. additionalContext puts a string into the model's context at the point the hook fired, wrapped as a system reminder. It is supported on about eleven events and capped at 10,000 characters per hook per event. This is how you tell the agent something rather than tell the human.
Which events are worth wiring first?
There are around thirty, and the reference is authoritative for the full list. Five carry most of the value for anyone trying to govern what an agent does:
- PreToolUse fires before a tool runs and is the only place a call can be stopped or corrected. Everything else is a record.
- PostToolUse fires after a tool actually ran, which makes it the evidence that something happened rather than a claim that it would.
- UserPromptSubmit carries the person's own message. It is also one of the four events whose plain standard output becomes model-visible context.
- PermissionRequest fires when a permission prompt is raised, carrying
tool_name,tool_input,tool_use_idandpermission_mode. It can answer the prompt through adecisionobject, andappliedRulestops the person being asked the same thing again. - PermissionDenied fires when the CLI's own permission layer has already refused a call, carrying
denied_reasonandclassifier_verdict. The denial has happened, so exit codes and standard error are ignored here; the one lever isretry.
Two more deserve a mention because delegated work is usually invisible otherwise: SubagentStart and SubagentStop. If your agent spawns sub-agents, those are the only events that tell you a separate line of work began and ended, and SubagentStart can inject context into the sub-agent's own transcript.
Which hook output reaches the model, and which does not?
This is the question behind most first hooks that appear to do nothing. The short version is that printing is usually the wrong channel, because on a normal exit 0 your output goes to the debug log and the model never sees it.
| What you return | Reaches the model? |
|---|---|
| Plain stdout, exit 0, most events | No. Debug log only. |
| Plain stdout, exit 0, on UserPromptSubmit, UserPromptExpansion, SessionStart or PostModelSwitch | Yes. These four treat it as context. |
hookSpecificOutput.additionalContext | Yes, on the events that support it. The reliable channel. |
permissionDecisionReason on a deny or ask | Yes. The agent reads why it was refused. |
| stderr, exit 2, on PreToolUse | No. The person sees it, the model does not. |
| stderr, exit 2, on PostToolUse or PostToolUseFailure | Yes, because the tool already ran. |
The practical rule: decide who the message is for before choosing how to emit it. A diagnostic for the person debugging the hook can be printed. Anything the agent is supposed to act on has to be a decision reason or additionalContext, or it is a message into a log nobody reads. We learned this the long way round, by writing a careful explanation to standard error and then wondering why the agent kept making the same choice.
How do you tell what happened to an action you held?
By which events arrive, not by any single field, and the asymmetry here is the thing to design around.
PostToolUse fires only after a tool actually ran. So for a call you escalated with ask, the arrival of PostToolUse is the evidence that a person said yes. A refusal, by contrast, fires nothing at all. That means the absence of PostToolUse is not a decline: it is indistinguishable from an answer that has not come yet, or from a person who walked away. The honest treatment is to record the ask as unanswered and read it by age, never to infer a refusal from silence.
The join key across events is tool_use_id, which is the one identifier both PreToolUse and the later events carry. If you need to correlate a decision you made with an outcome you observe, that is the field to key on; a hash of the tool input works as a fallback where an id is not present.
One field is easy to misread. permission_mode tells you how a prompt would be answered, not that one was. Under default or plan a person is at the keyboard; under an autopilot mode the mode itself answers. So an approval recorded while an autopilot mode is set is the configuration agreeing with itself, which is a very different fact from a human having read the prompt. If you log who answered, log which of those two it was, and never infer it on an action where nothing was asked.
What does a hook cost you when it breaks?
More than you would expect, because a hook sits in the critical path of every tool call and its failures are quiet.
Under set -e, one unexpected non-zero exit turns the hook into a no-op that no longer governs anything, and nothing announces that it has stopped working. A rule you believe is enforced and is not is worse than no rule, because you plan around it. Two specific traps are worth stating plainly. First, bash -n will not catch a C-style // comment in a shell script, because // parses as a perfectly valid command invocation, so a syntax check can pass on a file that exits on its first line at runtime. Second, any network call inside a hook needs an explicit timeout, or a slow backend becomes a stalled agent.
The decision worth making deliberately is what happens when whatever your hook consults is unreachable. Failing open keeps the session working and silently stops governing. Failing closed keeps the guarantee and can halt a developer's afternoon. Either can be right. What is not right is discovering which one you chose by accident, months later.
What can a hook not see?
Three limits are structural rather than defects, and they are worth knowing before you decide how much to build into a hook, whichever way you decide.
A hook is stateless across turns. Each invocation receives one event. The action that completes a pattern developed over several turns is almost always unremarkable on its own: a scope widened a little at a time, a refused request re-asked in a new framing, a sub-agent taking more than the parent it claims. Judging a trajectory rather than a call means holding state across turns, and holding it somewhere a new session cannot clear.
A hook is scoped to one host and one tool. Configured on your machine, it governs that CLI on that machine. It says nothing about the same developer's other agent, a teammate's laptop, or an agent running in CI, and nothing aggregates them into a view. That is unremarkable for one person and becomes the whole problem at the point where somebody has to state a posture for more than one.
A hook is editable by what it governs. It is a file in a repository the agent can write to, registered in settings the agent can read. For a rule you are enforcing on yourself, that is fine. For a rule somebody else is relying on, the thing being governed should not also be able to amend the governing, which pushes the decision somewhere the session cannot reach.
None of this is an argument against writing hooks. It is an argument for knowing which of the three you are accepting. A hook is an excellent enforcement point, and the detection, the memory and the authority are separate questions: answer each with your own code, with a service, or with a deliberate decision that the blast radius is small enough not to.
Related reading
- What is action governance? The layer that decides what proceeds, what is held, and what is blocked.
- What is approval fatigue? Why routing every action to a person is the wrong fix, and what precision keeps an ask worth reading.
- Where can an agent be governed? Hooks are one surface among several, with different things visible at each.
- When does an AI agent need governance? The point at which approving everything by hand stops scaling.
Sources
- Claude Code documentation, Hooks reference: the authoritative list of events, payload fields, output shapes and exit-code behaviour
- Claude Code documentation, Settings: where hook commands are registered per event
Shrike supplies those three things into this surface. A PreToolUse hook sends the action and receives a verdict with detection behind it, rather than a pattern list you maintain. Session state is held server-side, so a pattern that develops across turns is judged across turns and no new session clears it. The policy is declared once by an operator and applies to every agent, rather than existing per machine in a file the agent can edit. A held action's outcome is recorded when PostToolUse arrives, and the CLI's own refusals are kept beside ours so the two authority models can be compared rather than confused.
The hook is published rather than described. The shrike-security plugin registers all five events above, plus PostToolUseFailure, across Bash, Write, Edit, NotebookEdit, WebSearch and WebFetch, and installs with /plugin marketplace add Shrike-Security/shrike-claude-plugin. It is Apache-2.0 and public, so the hook can be read before it is run. See action governance for the model, or the quickstart for the SDK path.