Hooks: Move Work Off the Model
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 deterministically, never spend model tokens asking the agent to remember it.
The rule
A rules file that says "always run the formatter after editing" is a request. The model weighs it against everything else in context and forgets it on turn nine. A hook is a shell command the harness runs on a lifecycle event, every time, whether or not the model is paying attention.
So: prose for judgement, hooks for anything mechanical. Formatting, linting, notifications, blocking edits to files that must not change — all of it leaves the prompt and moves into settings.json.
The events
Claude Code documents 31 hook events. The full set, from the hooks reference:
| 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 nesting levels: event, matcher group, handler array. Handlers live in ~/.claude/settings.json, .claude/settings.json, .claude/settings.local.json, managed policy settings, plugin hooks/hooks.json, or skill and subagent frontmatter.
{
"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. Ten events take no matcher at all. Omitted, "" or "*" matches everything. Anything beyond letters, digits, _, -, space, , and | is treated as an unanchored regex, so Edit.* also catches NotebookEdit — anchor it as ^Edit$ for one tool. timeout is in seconds, default 600 for command handlers, but UserPromptSubmit lowers that to 30 and MessageDisplay to 10. type can also be http, mcp_tool, prompt or agent. All matching hooks run in parallel.
Prefer the exec form — pass args as an array and leave shell unset — over a single string that goes to sh -c.
My setup
One Stop hook. It runs the format fix command, then the linter, after every turn:
{
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "bun fix && bun lint", "timeout": 120 }] }
]
}
}The agent finishes, the code gets formatted and linted, and "run the formatter" never appears in a rules file.
Type checking is deliberately not in there — as of writing it is too slow to pay for on every turn. That is a calculus, not a principle: when a faster TypeScript toolchain lands, the type check moves into the Stop hook. Opinion, and it depends on your repo's build times.
The slower checks go in a second net: a Husky pre-commit hook.
The balance rule: cheap and fast per turn, expensive per commit. A Stop hook that takes 40 seconds turns every reply into a stall and you will disable it 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 is the blocking code: JSON is ignored, stderr is fed back 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. The JSON is what makes PreToolUse a real guardrail:
{
"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 learns why and reroutes. Other events use top-level decision: "block" with reason. Anthropic ships a Bash command validator example worth copying as a starting point. On Stop, eight consecutive blocks force the turn to end anyway.
One security sentence, stated plainly: hooks run arbitrary shell commands with your full user permissions, from a settings file the agent itself can edit — review every hook, quote "$VAR", and use absolute paths.
Elsewhere
Codex hooks are a near-identical design: ~/.codex/hooks.json or [hooks] in config.toml, same matcher and handler shape, same exit 2 and hookSpecificOutput. Only type: "command" actually runs there. OpenCode has no hooks file — you write TypeScript plugins in .opencode/plugins/ exporting handlers like tool.execute.before, and block by throwing.
What to do
- Add a
Stophook that runs your format fix and linter, and delete those instructions 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-commithook. - Add a
PreToolUsehook returningpermissionDecision: "deny"for the files that must never be edited. - Use
exit 2, neverexit 1, when a hook is meant to block. - Read every hook command already in your
settings.jsonbefore you trust it.