Field note
Claude Code Hooks: Put Checks Before the Merge
A review gate built from Claude Code hooks runs the same cheap checks on every agent action, so the human reviewer only sees diffs that have already passed the boring tests.
A review gate built from Claude Code hooks runs the same cheap checks on every agent action, so the human reviewer only sees diffs that have already passed the boring tests.
The pattern
A hook is a shell command that Claude Code runs at a fixed point in its lifecycle. The important property is that Claude Code enforces it, not the model. A line in CLAUDE.md asking the agent to run the linter is a request; a hook is a rule.
A review gate uses three events in sequence:
PreToolUseruns before a tool call. A hook that exits with code 2 blocks the call and passes its stderr back to Claude as the reason. Use it to stop actions that should never happen in an agent session, such asgit pushor deleting migrations.PostToolUseruns after a tool has finished. It cannot undo the action, but exit code 2 shows the stderr to Claude, so a failed lint or type check becomes immediate feedback rather than something a reviewer finds later.Stopruns when Claude thinks it has finished. Exit code 2 prevents it from stopping and sends the message back, so the session cannot end with a failing test suite.
Continuous integration stays as the final authority. Hooks make the agent arrive at CI with fewer failures; they do not replace it.
A worked example
Put the configuration in the project's .claude/settings.json and commit it, so every developer and every session runs the same gate.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": ".claude/hooks/block-push.sh" }]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": ".claude/hooks/lint-file.sh", "timeout": 30 }]
}
],
"Stop": [
{
"hooks": [{ "type": "command", "command": ".claude/hooks/tests-before-stop.sh", "timeout": 300 }]
}
]
}
}
The first script reads the tool input from standard input and refuses pushes:
#!/bin/bash
input=$(cat)
command=$(jq -r '.tool_input.command' <<<"$input")
if [[ "$command" == git\ push* ]]; then
echo "Blocked: pushes happen from CI after review, not from the agent" >&2
exit 2
fi
exit 0
The second script takes the edited file path from the input and runs the project linter on that one file, exiting 2 with the linter output if it fails. Keep it to the single file; a whole-repository lint on every edit makes the session crawl.
The third script runs the test command and exits 2 with a short summary of the failures. Check the stop_hook_active field in the input first: it is true when Claude Code is already continuing because of a stop hook, and returning 0 in that case avoids a loop on a failure the agent cannot fix.
You can confirm the gate is loaded with /hooks, which lists each event, matcher and source file. The menu is read-only; edit the settings file to change anything.
Keep the gate cheap
The gate only works if developers leave it switched on. Three rules keep it tolerable:
- Every hook should finish in well under a second except the Stop hook. Set an explicit
timeout; a hook that reaches its timeout is cancelled and renders no decision, which means a slow check silently stops protecting you. - Block only what you would block in code review anyway. Every false positive teaches the team to work around the hook.
- Use exit code 2 for policy. Any other non-zero exit is treated as a non-blocking error and the action proceeds, which surprises people who expect Unix conventions.
Hooks do not bypass permission rules. A deny rule in settings still blocks a call even if a hook allows it, and a hook that exits 2 blocks a call even if an allow rule matches. That means you can run all Bash commands without prompts and still have a short, hard list of forbidden actions.
What to measure
Run the gate for a fortnight and look at five numbers:
- How often
PreToolUseblocked something, and whether each block was a real policy hit or a false positive. - How often the Stop hook sent the agent back, and how many of those it then fixed unaided.
- Time from first prompt to a green test run, compared with sessions before the gate.
- Reviewer comments per merged change from an agent session. The target is a fall in style and test-failure comments, leaving the reviewer's time for design.
- Median hook run time. If it creeps up, someone has added a slow check.
If the block count is zero after two weeks, remove those rules; a gate nobody hits is maintenance without value.
Where hooks are not enough
Project hooks live in the repository, so anyone who can edit the repository can weaken them. Review changes to .claude/ like any other code. For an organisation-wide floor, administrators can deploy managed settings and set allowManagedHooksOnly, which blocks user, project and plugin hooks so that only the managed gate runs.
Hooks also see only what Claude Code does through its tools. A change made outside the session is invisible to them, which is why the gate sits inside permission rules, a sandbox where the platform supports it, and CI that does not trust the agent's word.
The permission rules that sit beside the gate are covered in Claude Code permission prompts: safe settings. Our Claude Code workshop builds the gate on your own repository during the first two days.