# Hooks turn a wish into a guarantee

A CLAUDE.md line is a request the model usually honours. A hook is code the harness runs, so it happens every time. Choose hooks only for rules that have no exceptions.

## The difference between asking and doing

Write "always run the formatter" in CLAUDE.md and Claude will, most of the time. Most of the time is not a guarantee, because the model reads the line and weighs it against everything else in its context.

A **hook** removes the weighing. It is your code, attached to a point in Claude Code's lifecycle, and the harness runs it whether or not the model remembers. The model is not asked. The program around the model does it.

That is the whole idea, and it follows from how the loop works: the harness, not the model, executes things.

## The events

A hook attaches to an event. They split into two kinds.

Events that can **block**, using exit code 2, include `PreToolUse`, `UserPromptSubmit`, `Stop`, `SubagentStop`, `PreCompact` and `ConfigChange`. Stopping the action is the point of these.

Events that **only observe** include `SessionStart`, `SessionEnd`, `PostToolUse`, `PostToolUseFailure`, `Notification`, `SubagentStart`, `CwdChanged`, `FileChanged` and `PostCompact`. They run after something happened, so they can react but not undo.

Handlers come in five types: `command`, `http`, `mcp_tool`, `prompt` and `agent`. Only `command` and `http` are deterministic. A `prompt` or `agent` hook asks another model, so it is judgment again, just in a different place.

## Two concrete examples

A formatter on every edit. A `PostToolUse` hook with the matcher `Edit|Write` runs your formatter after each file change. The rule "remember to format" is gone, because nothing needs remembering.

A type check before Claude may stop. A `Stop` hook running this command:

```bash
pnpm tsc --noEmit 1>&2 || exit 2
```

If the check fails, the command exits with code 2, the turn is blocked, and the error text on stderr goes back to Claude. It keeps fixing until the check passes. The `1>&2` matters: stderr is the channel that reaches Claude.

```terminal
$ add a rating field to the review form
* Edits src/ReviewForm.tsx
* Hook (Edit): formatter runs
* Tries to finish
* Hook (Stop): tsc fails, "Property 'rating' is missing in ReviewProps"
* Edits src/types.ts
* Tries to finish, hook passes
# Claude never decided to type-check. The hook made finishing conditional on it.
```

**Question:** A Stop hook exits with code 2 and writes an error to stderr. What happens?

- The session closes
- The hook is skipped and logged
- Claude is blocked from stopping and receives the stderr text
- The error is shown to you only

**Answer:** Claude is blocked from stopping and receives the stderr text. Exit code 2 blocks on events that can block, and stderr is fed back to Claude. Any other non-zero code is a non-blocking error.


## Rules that have exceptions do not belong here

Hooks are rigid by design. That is their value and their cost. The official advice is to use them only for rules with zero exceptions and to keep preferences in CLAUDE.md. If you have forty hooks, most of them are probably preferences.

There are also limits that protect you from your own hooks. A Stop hook that blocks eight times in a row without a tool call in between is overridden and the turn ends. `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP` raises that number.

> **Choose exit codes or JSON, not both**
A hook can signal with an exit code, or print JSON with fields like `decision` and `hookSpecificOutput`. Pick one style per hook. Also, a crashed or timed-out hook normally lets the action through. Setting `onFailure: "block"` (version 2.1.295 and later) makes it block instead.


## Where they are configured

Hooks live in `~/.claude/settings.json`, `.claude/settings.json`, `.claude/settings.local.json`, managed settings, a plugin's `hooks/hooks.json`, and in skill or subagent frontmatter, where they are active only while that skill or subagent runs. Type `/hooks` to browse what is active.

A matcher of `*`, empty or omitted matches everything. Plain names separated by `|` match exactly, and any other character turns it into an unanchored JavaScript regular expression. To narrow a handler further, the `if` field takes permission-rule syntax such as `Bash(git *)`.

> **Make one rule a guarantee**
Pick a rule from your CLAUDE.md that you have seen broken. Ask whether it truly has no exceptions. If so, write it as a hook, trigger the situation, and look in `/hooks` to confirm it is registered.


**Question:** Which rule is a good candidate for a hook?

- Prefer small functions
- Run the formatter after every file edit
- Explain your reasoning briefly
- Use friendly variable names

**Answer:** Run the formatter after every file edit. Formatting after an edit is mechanical and has no exceptions. The other three are preferences, which belong in CLAUDE.md.
