alexi.sh
All articlesBrowser securityNetwork privacyPrivacy toolingThreat modelingAI codingDev tooling

alexi.shAI Engineering Lab

ai-coding

Claude Code Hooks: Deterministic Rules for a Non-Deterministic Agent

PrivSec Lab5 min read
A grey metal instrument panel photographed at an angle, its front face covered with black rotary knobs, toggle switches and a small analogue dial, set into a green painted machine housing

Hooks are shell commands the harness runs at fixed points in an agent's loop. They matter because they are the only part of the system that always fires: a prompt asks a model to behave, a hook makes the behaviour a property of the tool.

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.

A pale blue steel control desk in an empty room, its sloped top set with rows of large red and green push buttons on square metal plates, a window with bright daylight on the left

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.

Photo via Pexels (source)

FAQ

What are hooks in Claude Code?
Hooks are shell commands configured in settings.json that the harness executes at fixed points in the agent loop, such as before a tool call, after a tool call, or when a session starts. They are run by the tool itself rather than decided by the model, which is what makes them deterministic.
How are hooks different from instructions in a prompt or memory file?
An instruction asks the model to behave a certain way and the model may or may not follow it, especially in a long session. A hook is executed by the harness every time its event fires, regardless of what the model intended. If a rule must hold without exception, it belongs in a hook.
Can a hook stop the agent from doing something?
Yes. A PreToolUse hook that exits with a non-zero status blocks the tool call, and its output is fed back to the model as feedback. That is the mechanism behind rules like refusing to run a command against production or rejecting a commit to a protected branch.
Where do hooks live?
In settings.json, either at user level or project level, under a hooks key organised by event name, each with a matcher and a list of commands to run. Project level settings can be committed so the whole team gets the same guarantees.
What should not be a hook?
Anything slow or unreliable. A hook runs inside the agent loop, so a command that takes several seconds is paid on every matching event. Long checks belong in continuous integration, not in a hook.