# Hooks: Move Work Off the Model

Course: Agentic Coding: Run Coding Agents Like an Operator — Lesson 6 of 10

Wire format, lint and guardrail commands into agent lifecycle events so the machine does them every turn instead of the model remembering to.

> **The one line:** if a shell command can do it every time, don't ask the model to remember it.

## The rule

A rules file that says "always run the formatter after editing" is a request, weighed against everything else in context and forgotten by turn nine. A hook is a shell command your harness runs on a lifecycle event, every time. Formatting, linting, notifications and blocking edits to files that must not change belong in `settings.json`, not in the prompt.

## The events

Claude Code documents 31 hook events, from the [hooks reference](https://code.claude.com/docs/en/hooks):

| Event | Fires | Blocks on exit 2 |
| :---- | :---- | :---- |
| `SessionStart` | session opens (`startup`, `resume`, `clear`, `compact`, `fork`) | no |
| `Setup` | with `--init-only`, or `--init`/`--maintenance` under `-p` | no |
| `InstructionsLoaded` | rules and memory files are loaded | no |
| `UserPromptSubmit` | you send a prompt, before the model sees it | yes, erases the prompt |
| `UserPromptExpansion` | prompt expansion step | yes |
| `PreToolUse` | before a tool call runs | yes |
| `PermissionRequest` | a permission prompt is about to be raised | yes |
| `PermissionDenied` | the auto mode classifier denies a tool call | no |
| `PostToolUse` | after a tool call succeeds | no, stderr reaches the model but the tool already ran |
| `PostToolUseFailure` | after a tool call fails | no |
| `PostToolBatch` | after a batch of tool calls | yes |
| `Notification` | the harness raises a notification | no |
| `MessageDisplay` | a message is rendered | no |
| `SubagentStart` / `SubagentStop` | a subagent starts / finishes | stop only |
| `TaskCreated` / `TaskCompleted` | a task is created / completed | yes |
| `Stop` | the turn ends and the agent replies to you | yes |
| `StopFailure` | the turn ends in failure | no |
| `TeammateIdle` | a teammate agent goes idle | yes |
| `ConfigChange` | settings change | yes |
| `CwdChanged` / `DirectoryAdded` | working directory changes / a directory is added | no |
| `FileChanged` | a watched file changes on disk | no |
| `WorktreeCreate` / `WorktreeRemove` | a worktree is created / removed | create blocks on any non-zero |
| `PreCompact` / `PostCompact` | before / after compaction | pre only |
| `Elicitation` / `ElicitationResult` | an MCP server asks the user something | yes |
| `SessionEnd` | session closes (1.5s budget) | no |

## The config shape

Three levels: event, matcher group, handler array. Handlers live in any `settings.json` (user, project, local, managed policy), in plugin `hooks/hooks.json`, or in skill and subagent frontmatter.

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/guard-env.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}
```

`matcher` filters a different field per event: tool name for tool events, start reason for `SessionStart`, agent type for `SubagentStart`, filenames for `FileChanged`. Omitted, `""` or `"*"` matches everything, and ten events take no matcher. Anything beyond letters, digits, `_`, `-`, space, `,` and `|` is an unanchored regex, so `Edit.*` also catches `NotebookEdit`; write `^Edit$` for one tool. `timeout` is in seconds, default 600, 30 on `UserPromptSubmit`, 10 on `MessageDisplay`. Prefer the exec form, `args` as an array with `shell` unset, over one string that goes to `sh -c`.

## My setup

One `Stop` hook, format fix then linter, after every turn:

```json
{
  "hooks": {
    "Stop": [
      { "hooks": [{ "type": "command", "command": "bun fix && bun lint", "timeout": 120 }] }
    ]
  }
}
```

The code gets formatted and linted, and "run the formatter" never appears in a rules file.

Type checking stays out of mine, too slow today to pay for every turn; time yours before deciding. It runs in a Husky `pre-commit` hook with the other slow checks, because a `Stop` hook that takes 40 seconds stalls every reply and you will switch it off within a day.

## Exit codes and blocking

Exit 0 means success and stdout is parsed as JSON; plain stdout reaches the model only on `UserPromptSubmit`, `UserPromptExpansion` and `SessionStart`. Exit 2 blocks: JSON is ignored, stderr goes to the model as the reason. Any other code is a non-blocking error, so `exit 1` does not block, the usual first bug.

Use exit codes or JSON, not both. JSON is what makes `PreToolUse` a real guardrail:

```json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Editing .env is blocked. Change .env.example instead."
  }
}
```

`permissionDecision` takes `allow`, `deny`, `ask` or `defer`, and `permissionDecisionReason` goes to the model, so the agent sees why and reroutes. Other events use top-level `decision: "block"` with `reason`. Start from Anthropic's [Bash command validator example](https://github.com/anthropics/claude-code/blob/main/examples/hooks/bash_command_validator_example.py). On `Stop`, eight blocks in a row end the turn anyway.

Hooks run any shell command with your full user permissions, from a settings file the agent itself can edit. Review every hook, quote `"$VAR"`, use absolute paths.

## Elsewhere

[Codex hooks](https://learn.chatgpt.com/docs/hooks) work the same way in `~/.codex/hooks.json`, with only `type: "command"`. [OpenCode](https://opencode.ai/docs/plugins/) has no hooks file: TypeScript plugins in `.opencode/plugins/` that block by throwing.

## What to do

- Add a `Stop` hook for format fix and lint, then delete those lines from your rules file.
- Time the command first; keep the per-turn hook under a few seconds.
- Put the type check and other slow checks in a Husky `pre-commit` hook.
- Add a `PreToolUse` hook returning `permissionDecision: "deny"` for files that must never be edited.
- Use `exit 2`, never `exit 1`, when a hook is meant to block.
- Read every hook command already in your `settings.json` before you trust it.

## Links

- Lesson page: https://andrey-markin.com/courses/agentic-coding/hooks
- Course: https://andrey-markin.com/courses/agentic-coding.md
- Next lesson: https://andrey-markin.com/courses/agentic-coding/mcp-without-tool-bloat.md
