Hooks
Turn a prompt rule that must always hold into code at a fixed point in the loop, pick the point, and write a predicate the model cannot route around.
On this page
A hook is code the harness runs at a fixed point in the loop: before a tool call executes, after its result comes back, when the model says it is done. No model is involved, so a hook decides the same way every time, costs milliseconds and no tokens, and fires on the four-hundredth call as surely as on the first. A prompt line asks the model to keep a rule; a hook makes the rule hold. The difference is the difference between a rate and a guarantee.
The prompt line that holds most of the time
A coding agent has a shell tool, and its system prompt says "Never delete files outside the build directory." The team measured the line: 200 tasks written to tempt a cleanup — "free up disk space", "start from a clean checkout" — and the line held on 197, 98.5%. They run about 40 tasks of that kind a week, so at 1.5% the agent deletes something it should not roughly every twelve days, and each time someone spends an afternoon recovering untracked files no backup held.
Rewording moves the rate, not its kind, and the rate is not the prompt author's to keep: it moves when the model behind the name is updated, as sampling and determinism warned; with depth, since by turn 40 the line sits inside 90,000 tokens of context; and when a fetched page tells the model otherwise, the path in prompt injection and least-privilege tools. A rule whose failure cannot be undone needs a check that depends on none of the three.
Where hooks sit in the loop
The points are the same in every harness:
- Before a tool call. The hook sees the tool name and the arguments, and allows, denies with a reason, or sends the call to a person. It is the only point that can prevent an effect.
- After a result. The hook sees the call and its result, and can append feedback, redact, or log. It cannot undo.
- On stop. The model has ended its turn; the hook can refuse with a reason, and the loop goes round again.
- At session start or prompt submit. The hook adds context, such as the branch or the ticket.
The Claude Code "Hooks reference" (opens in a new tab) is the cited instance of the mechanism. It names these events PreToolUse, PostToolUse, Stop, UserPromptSubmit and SessionStart, among others. A command hook receives the event as JSON on standard input, carrying the tool name, its input and the call id; a matcher chooses which tools a hook sees; and a pre-tool hook blocks a call by exiting with code 2, whose error output the model reads as the reason, or by returning a deny decision.
In your own loop the mechanism is a dozen lines:
def run_tool(call):
for check in PRE_TOOL.get(call.name, []):
verdict = check(call.args) # plain code: no model, no network
if verdict.deny:
return error_result(call.id, verdict.reason) # the model reads why
result = TOOLS[call.name](**call.args)
for after in POST_TOOL.get(call.name, []):
result = after(call, result) # annotate, redact, log; cannot undo
return result
The denial comes back as a result, not an exception, for the reason tool use and the tool-call loop gave: an exception leaves a call with no result and a malformed conversation, while a result tells the model what to do instead.
A hook is only as good as its predicate
The first hook the team wrote denied any shell command matching rm -rf. It held every time it matched. The following week the agent ran git clean -fdx to "remove build artefacts", which deleted an untracked .env and a local fixtures directory; a month later it ran find . -name '*.tmp' -delete. The hook never fired. A deny-list of strings over a shell is incomplete by construction: there are more ways to delete a file than a pattern can enumerate, and the model finds them without trying, because they are also ways to do the task.
The fixes, strongest first:
- Make the predicate exact by narrowing the surface. Deletion goes through a
delete_paths(paths)tool with structured arguments, and its pre-tool hook resolves each path, symlinks and..included, and allows only paths under the workspace'sbuild/. The shell tool gets an allow-list hook instead of a deny-list:npm test,git status,git diffand a dozen others run, and anything else is denied or sent to a person. - Decide what the unknown case does. An allow-list denies what it does not recognise; a deny-list permits it. For an irreversible action, unknown means no.
- Put a floor under the hook. Run the agent in a sandbox where the workspace is writable and everything else is read-only, the least-privilege rule from authentication and authorisation applied to a process. A predicate bug then costs a rebuild, not a home directory.
A hook is a pure function, so it gets the test a pure function gets: a table of inputs and expected verdicts, including every command that ever leaked — git clean -fdx, find -delete, a symlink from build/ to the home directory — run in CI in milliseconds.
After the call, and at the end
A post-tool hook fires after the effect. It can change what the model sees, but the file is already written, the command already ran, the request already left. That makes it the place for three jobs. Feedback: run the linter after an edit and append "lint: 2 errors in api.py" to the result, so the model fixes them on its next turn. Redaction: strip anything shaped like a credential from a result before it enters the context and the trace. Logging. Prevention lives before the call, never after.
A stop hook is a definition of done written in code. The model says it has finished; the hook runs the fast test subset; if tests fail, it refuses the stop with the failing names as the reason, and the model keeps working. Two guards make it safe. A test the model cannot fix would loop forever, so count consecutive refusals and end the run after three with a status the trace records, the exit discipline of agent loops and stop conditions. And a refusal is another turn: at turn 30 with 60k tokens of context, each one re-reads 60k tokens at P_in. A 90-second suite on every stop adds 90 seconds to every task, so the hook runs the ten-second subset touching changed files and CI runs the rest.
What a hook costs
Latency, and little else. A command hook is a process start plus its work: 10 to 50 ms for a path check, a second or two for a linter, tens of seconds for tests. Tokens are zero unless the hook returns text: a 30-token denial, a 200-token lint report. An agent making 40 tool calls a task with two 20 ms pre-tool hooks spends 1.6 s on hooks, against 48 s decoding those 40 calls at 60 tokens each and the course's 50 tokens/s, from the four numbers.
A hook that hangs stalls the loop, so every hook gets a timeout and a decision about what one means: a safety hook fails closed, a lint hook fails open.
In an interview
The interviewer is testing whether you can tell a rule that must hold from guidance the model should weigh, put the rule at the right point in the loop, and say how strong it is. The phrasing that scores: "the prompt line is a rate I measured at 98.5%; the pre-tool hook is a guarantee for every call its predicate covers, so the predicate checks structured arguments against an allow-list, and a sandbox bounds what it misses."
The mistake that loses points is answering a question about a destructive action with a prompt line, however emphatic. The second is a hook built on a deny-list of shell patterns, which looks like a guarantee and behaves like a rate.
Check yourself
1. An agent can push to the team's repository, and the rule is "never push to main and never force-push". Which hook point, what does the predicate check, and what is the floor?
A pre-tool hook, the only point that prevents the effect. Give pushing its own tool,
git_push(branch, force), and leavegit pushoff the shell tool's allow-list; the hook deniesbranchin the protected set andforcetrue. The floor is branch protection on the remote, which refuses a force-push to main whatever the agent runs.
2. A post-tool hook redacts API keys from tool results. A run called curl with a key it read from .env, and the hook redacted the key from the result. What did the hook protect, and what did it not?
It kept the key out of the context, the trace and anything the model wrote next. It did not stop the key leaving: the request had gone before the hook fired. Prevention is a pre-tool hook denying reads of
.envand denying any command that carries a key-shaped string to a host off the allow-list.
3. A stop hook runs a 4-minute test suite on every "done". Tasks average 6 minutes, 300 a day. Keep it?
It makes every task 67% longer, 20 hours of waiting a day, and each refusal re-reads the whole context. Move the full suite to CI on the pull request; keep a stop hook that runs only the tests touching changed files, about 10 seconds, with a cap of three refusals.