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 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:
| 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.
{
"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:
{
"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:
{
"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. 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 work the same way in ~/.codex/hooks.json, with only type: "command". OpenCode has no hooks file: TypeScript plugins in .opencode/plugins/ that block by throwing.
What to do
- Add a
Stophook 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-commithook. - Add a
PreToolUsehook returningpermissionDecision: "deny"for 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.