Most attempts to make a coding agent behave reliably start in the wrong place: a longer prompt, a stricter memory file, a rule written in capital letters. All of it is a request. The model reads it, usually complies, and occasionally does not, particularly late in a long session when the instruction is thousands of tokens behind.
Hooks are the other approach. A hook is a shell command the harness runs at a fixed point in the loop, whether the model wanted it or not. That single property is the whole reason they exist.
The distinction that matters
An instruction lives in the model's context and competes with everything else in it.
A hook lives in settings.json and is executed by the program around the model. It does not
compete with anything, it does not get forgotten, and it behaves identically on the first tool call
of a session and on the four hundredth.
If a rule must hold without exception, it cannot be a sentence. It has to be code that runs.
This is the same reasoning that puts a type checker in a build rather than a note in a README. Neither replaces judgement; both remove a category of failure from the space of possible outcomes.
The events, and what each one is good for
PreToolUse fires before a tool call is executed, and it can block it. Exit non-zero and
the call does not happen, with your output returned to the model as feedback. This is the event
that enforces hard limits: no writes outside the project, no commands against a production
database, no commit to a protected branch.
PostToolUse fires after a tool call succeeds. Its natural job is the reflex you would
otherwise ask for and sometimes not get: format the file that was just written, run the test file
matching the module that changed, regenerate an index.
SessionStart fires when a session begins, and its output is added to the context. Useful for
injecting state the model cannot know: the current branch, an open incident, the contents of a
scratch file from the previous session.
Stop and SubagentStop fire when the agent finishes a turn. This is where a completion
notification belongs, or a final check that the working tree is not left in a broken state.
UserPromptSubmit fires on each user message, before the model sees it, and can add context or
reject the prompt outright.

Shape of the configuration
Hooks go in settings.json, grouped by event, each with a matcher that selects which tools it
applies to:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "npx prettier --write \"$CLAUDE_FILE_PATHS\"" }
]
}
]
}
}
The matcher is a pattern over tool names, so Edit|Write covers both file-modifying tools and
leaves everything else untouched. The command receives context through environment variables and
through JSON on standard input, which is how it knows which file was touched.
Project level settings are the interesting ones. Committed to the repository, they give every person and every agent working in that repo the same guarantees, rather than a convention that holds only on the machine where someone configured it.
Three that earn their place
Formatting on write. A PostToolUse hook running the project formatter removes an entire class
of diff noise, and it costs nothing to reason about because it is not a request.
Blocking dangerous commands. A PreToolUse hook on the shell tool that inspects the command
and exits non-zero on a pattern you never want executed. The model receives the refusal as feedback
and adapts, which is better than a rule it might read past.
Injecting state at session start. Anything the model would otherwise have to be told, and therefore might be told wrong: the ticket in progress, the deployment currently frozen, the branch policy.
What hooks will not fix, and it is worth being clear
They do not make the model correct. A hook can stop a bad command and reformat a file. It cannot tell whether the change was a good idea. Determinism at the boundary, judgement in the middle.
They cost latency on every fire. A hook that takes three seconds is three seconds added to every matching tool call, and a coding session makes hundreds of them. Slow checks belong in continuous integration.
They are code you now maintain. A hook that silently fails, or that blocks something legitimate once a month, is worse than no hook, because the failure looks like the agent misbehaving. Test a hook the way you would test any other part of the toolchain, and make its output say clearly which hook spoke.
Configuration details change between releases. Event names, matcher syntax and the shape of the JSON on standard input have all moved as the tool has evolved. Check the current documentation for the version you are running rather than trusting a snippet, this one included.
The general principle
Agents are probabilistic in the middle and can be made deterministic at the edges. Hooks are the edges.
The related question of what an agent is allowed to reach in the first place is covered in MCP security, and the failure mode that no hook fully prevents, a model persuaded by content it reads, is in prompt injection.



