Hooks: verification as a loop, not a step
A PostToolUse hook that runs tests after every edit turns verification into something that happens automatically, not something you remember to ask for.
Asking an agent to "run the tests after you're done" is a request it can skip, forget, or satisfy halfway. A hook is not a request. It's a shell command Claude Code runs automatically at a fixed point in the lifecycle — in this case, after every edit — regardless of what the model decides to do.
What a PostToolUse hook actually is
Claude Code hooks fire on lifecycle events; PostToolUse fires after a tool call succeeds, and you can scope it with a matcher so it only triggers for specific tools, like Edit or Write (Claude Code hooks docs). The hook receives the event as JSON on stdin — including tool_name, tool_input, and the tool's own output — and can return a message back into the agent's context. Configuration lives in .claude/settings.json, so it's shared with the team and version-controlled like any other project file:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": ".claude/hooks/run-tests.sh", "timeout": 60 }
]
}
]
}
}
The script itself just runs your test command and reports the result back as systemMessage or additionalContext, which the agent reads on its next turn.
Why "loop" and not "step"
A single verification step — run tests once at the end — catches problems after the agent has already moved on and rationalized the change as complete. A loop, wired through a hook that fires on every edit, means the agent sees the test failure while it's still in the same train of thought that caused it. In the figure, the hook box is the one link that always fires: the agent can forget to check its own work, but it can't skip the hook, because the hook isn't the agent's decision to make.
This is also why hooks matter more than CLAUDE.md instructions for anything that must always happen. The Claude Code docs draw this line directly: CLAUDE.md is context the model "tries to follow," while a hook "execute[s] as shell commands at fixed lifecycle events and appl[ies] regardless of what Claude decides to do." Read that as: put "always run tests before considering a task done" in a hook, not just in prose.
Fast suite vs full suite
Running your entire suite after every single-line edit is slow enough that people turn hooks off. The practical pattern is two tiers: a fast, narrow subset (the file's own unit tests, or pytest -k <module>) on every edit via PostToolUse, and the full suite on a less frequent trigger — a Stop hook, or a separate CI run before merge. The fast loop catches regressions while the agent is still working on that file; the full suite catches cross-module breakage before you accept the diff.
Try it today
- Pick a repo with a fast, narrow test command that targets one module or file.
- Write a small script that reads
tool_input.file_pathfrom stdin, maps it to its test file, and runs just that test. - Register it as a
PostToolUsehook scoped toEdit|Writein.claude/settings.json. - Ask the agent to make a change that you expect to break a test, and watch whether it self-corrects before handing the diff back to you.
- Add a second, slower hook on
Stopthat runs the full suite once, as a final gate.
Where this breaks
- A hook that's too slow gets bypassed in spirit — people set long timeouts or stop using the tool that triggers it.
- A hook only verifies what its command checks; it won't catch a logically wrong fix that happens to pass narrow tests.
- Hook scripts run with real permissions on your machine — treat them with the same review you'd give any CI script, since a bad matcher can fire on the wrong tool calls.
Next: a definition of done that stops agents from deleting the test instead of fixing the bug.