45 sec9 min coredeep dive optional
Claude gates are hook-based checks in your repo that block Claude Code from finishing work until your rules pass: the destructive command never runs, the edit never lands unformatted, and the session never ends on a dirty working tree.
- Three hook events cover most gates: PreToolUse runs before a tool call, PostToolUse runs after it succeeds, and Stop fires when Claude tries to end its turn.
- A gate returns a JSON decision. On Stop,
{"decision":"block","reason":"..."}sends Claude back to work with your reason in its context. - Gates live in
.claude/settings.json, are committed with the repo, and run for every teammate and subagent with zero prompt engineering. - Prompt and agent hooks let a model judge the work instead of a script, which is how you gate “the tests actually cover the change.”
Claude gates fix the failure mode every agent session eventually hits: Claude declares the task done while the tests still fail, the formatting still drifts, or the dangerous command already ran. A gate is a small script in your repo that checks the claim at the exact moment it is made, and says no when the claim is false.

Claude gates: What You’ll Learn
Claude gates turn your definition of done into executable policy. In the next sections you will wire three gate points into one project, run a real Stop gate that refuses to end a session on a dirty working tree, and learn when to let a model judge instead of a script.
The mechanics come from Claude Code hooks, so nothing here needs a plugin or a CI runner. Everything runs locally, on every session, before you ever look at the diff. By the end you will have a settings block you can commit today.
A gate is a hook that can say no
Claude Code fires hook events at fixed points in its lifecycle, and your settings can attach shell commands to any of them. Three events matter for gates. PreToolUse fires before a tool call executes and can deny it. PostToolUse fires after a tool call succeeds, which is where format-and-lint gates live. Stop fires when Claude finishes responding, which is where the definition-of-done gate lives.
The contract is small. Your script receives JSON on stdin describing the event: the tool name, the tool input, the session. It writes a decision to stdout, or exits silently to abstain. An exit code of 2 is a blocking error, and stderr from that exit is fed back to Claude. For PreToolUse the clean path is a JSON permission decision:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by gate"
}
}On Stop the blocking decision uses a different shape, and the reason is not shown to a user. It is shown to Claude, which then keeps working on the problem you named. That asymmetry is the whole trick: a gate does not argue with the model, it hands the model its own homework.
How do Claude gates differ from permission rules?
Permissions decide what Claude may do. Gates decide whether what Claude did is acceptable. A permission rule like Bash(rm *) deny is a hard wall, evaluated by Claude Code itself, and the docs treat it as the reliable mechanism for hard allow and deny. The if field on a hook uses the same rule syntax but is best effort: when Claude Code cannot determine which commands a Bash input runs, it runs your hook regardless of the pattern.
That is why the two compose instead of compete. Put hard walls in the permission system where they are enforced deterministically. Put judgments that require running a check, reading a file, or consulting a log in Claude gates. A gate that tries to be a wall will eventually miss a path; a wall that tries to be a gate cannot read your test results.
The three places a gate can act
Before building anything, map your failure modes onto events, because Claude gates are cheapest exactly where the harm is cheapest to detect. If the harm happens when a command runs, gate PreToolUse. If the harm happens when a file lands in the wrong shape, gate PostToolUse on the Edit|Write matcher. If the harm is stopping too early, gate Stop. Most teams need exactly one gate per category, and nothing more.
| Gate point | Event | Typical check |
|---|---|---|
| Before the call | PreToolUse | Block destructive commands, protect paths, deny writes to generated code |
| After the call | PostToolUse | Format, lint, typecheck the touched files, annotate failures |
| At the finish line | Stop | Tests green, tree clean, changelog updated before the turn ends |
The matcher narrows each event to the tools you care about, and an optional if field narrows further using permission rule syntax like Bash(rm *) or Edit(*.ts). Match narrowly on purpose: a gate that runs on every tool call is a latency tax on every turn.
Build gate one: block the destructive command
This first gate is the docs’ own example, and it is worth running once so you trust the plumbing. The matcher catches Bash calls, the if rule narrows to rm subcommands, and the script denies anything containing rm -rf:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rm *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh",
"args": []
}
]
}
]
}
}The script reads the command from stdin and answers with a deny decision only when it truly matches, staying silent otherwise so normal permission flow applies:
#!/bin/bash
# .claude/hooks/block-rm.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive command blocked by gate"
}
}'
else
exit 0
fi
Save it under .claude/hooks/, run chmod +x on it, and commit both files. The ${CLAUDE_PROJECT_DIR} placeholder resolves to the project root no matter which directory the session has moved to, which is why the settings file never carries absolute paths.
Build gate two: the definition-of-done stop gate
The Stop gate is the one that changes how sessions end. Here is a real one, reduced to the smallest script that catches the most common premature stop: a dirty working tree. It blocks the stop with a reason, and passes silently once the tree is clean.
#!/bin/bash
# .claude/hooks/stop-gate.sh
# Gate: refuse to let the session end while the working tree is dirty.
CHANGED=$(git status --porcelain | wc -l)
if [ "$CHANGED" -gt 0 ]; then
jq -n --arg n "$CHANGED" '{decision: "block", reason: ($n + " uncommitted files. Run the tests, commit, then stop.")}'
else
exit 0
fi
Ran against a scratch project with two stray files, it produces exactly this block decision, and after a commit the same input exits 0 and the session ends normally:
$ echo '{}' | .claude/hooks/stop-gate.sh
{
"decision": "block",
"reason": "2 uncommitted files. Run the tests, commit, then stop."
}
$ git add -A && git commit -m "wip"
$ echo '{}' | .claude/hooks/stop-gate.sh; echo "exit=$?"
exit=0Registering it takes one block in .claude/settings.json. Note there is no matcher: Stop fires once per turn end, so the gate runs exactly where you want it and nowhere else:
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/stop-gate.sh" }
]
}
]
}
}Grow this gate by appending checks, not branches. A typical maturity path adds a test command, then a typecheck, then a coverage floor. Keep each check a line that prints its own failure reason, because the reason string is the entire user interface your gate has with the model.
What do Claude gates cost in latency and what happens when one hangs?
Command hooks have a default timeout of 600 seconds, which is generous for a test suite but painful if your gate quietly waits on a network call. Set an explicit timeout in seconds on any gate that shells out. Matching handlers on one event run in parallel, so three independent gates on PostToolUse cost roughly the slowest one, not the sum.
If a hang is worse than a pass for your workflow, newer Claude Code versions support onFailure: "block" on command hooks, which blocks the triggering action when the hook itself fails rather than letting the work continue unvalidated. If you only need to know that a long check failed after the fact, async: true runs the gate in the background without blocking the turn, and asyncRewake wakes Claude on an exit code 2 so a late failure still gets handled.
Gates that grade instead of block
Scripts are deterministic, which is their strength and their limit. The moment your rule reads like judgment, a script becomes a pile of greps. Claude Code therefore ships two more hook types aimed exactly at that gap. A prompt hook sends the event JSON to a model for single-turn evaluation and gets a JSON verdict back, with a 30 second default timeout. An agent hook spawns a subagent that can read files and run tools before returning its decision.
The docs flag agent hooks as experimental, so treat them as powerful and unsettled. The stable pattern today is a prompt hook on Stop whose prompt says: here is the diff and the test output, decide whether the stated task is actually complete, and if not, name the first unfinished thing. That is a definition-of-done gate where the checklist is prose instead of shell.
Keep the two families honest about what they are. Script gates are auditable: anyone can read the exact condition that blocked the stop. Model gates are flexible: they catch the absence of work no grep can name. Teams that rely only on model gates drift toward vibes-based CI, and teams that rely only on script gates end up grepping prose.
Wiring a prompt hook takes one handler with a prompt field instead of a command. The hook input JSON arrives in the prompt through the $ARGUMENTS placeholder, and the model’s verdict comes back in the same decision format a script would emit:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"timeout": 30,
"prompt": "You are the definition-of-done gate. Here is the session end event: $ARGUMENTS. Decide whether the stated task is genuinely complete. If it is, approve. If not, return decision block with a reason naming the first unfinished thing in one sentence."
}
]
}
]
}
}Two design rules keep this honest. First, name the deliverable in the prompt itself, because a gate that does not know what was asked cannot judge completion. Second, keep the verdict binary. A prompt hook that returns three shades of maybe hands Claude an excuse to stop negotiating instead of a reason to keep working.
Where should a team put shared gates so everyone actually runs them?
Hook resolution merges across settings files rather than picking one. ~/.claude/settings.json is local to your machine, .claude/settings.json is committed with the project, and .claude/settings.local.json holds personal overrides that Claude Code gitignores by default. A plugin can also ship gates in its own hooks/hooks.json, which is how productized rule sets travel.
The committed project file is where shared gates belong, because it needs zero distribution work: every clone carries its own enforcement. Administrators get a stronger lever in managed policy settings, where allowManagedHooksOnly restricts sessions to admin-supplied hooks. One caution from the docs: cloud sessions do not read your local user settings, so a gate that exists only in ~/.claude will silently vanish for anyone running Claude Code in the cloud.
A Worked Example
This site’s publishing pipeline runs on exactly this pattern, just at batch scale. Every automated post passes through separate checker passes for originality, facts, images, and structure before anything ships, each checker owns one gate, and a failed gate produces one repair cycle with every finding fixed in a single pass. The gate does not editorialize. It lists what failed, the producer fixes exactly that, and the gate runs again.
The lesson from running that loop weekly transfers directly to personal gates. Make the reason string actionable, make the gate idempotent so a re-run after a fix is cheap, and never let the gate fix the work itself. A gate that both judges and repairs hides its own failure modes; a gate that only refuses keeps the model doing the work it was asked to do, under a rule it cannot negotiate with.
The budget rule matters just as much as the mechanics. One repair cycle per gate finding is the discipline: when a gate blocks, the producer fixes every instance of that finding in a single pass before the gate re-runs. Teams that allow fix-one-finding-per-retry watch a fifteen-point review turn into twenty round trips. The gate’s job is to make the whole failure visible at once, and the model’s job is to clear the list, not to discover it one line at a time.
Claude gates: Common Mistakes to Avoid
Even experienced developers trip over the same four issues when they first wire Claude gates into a project. Each one is a small config or design error, and each one quietly converts your gate from enforcement into decoration.
- Gating too broadly. A
PreToolUsegate with no matcher fires on every tool call and taxes every turn. MatchBash, orEdit|Write, or nothing at all forStop. - Blocking with prose instead of decisions. A gate that prints advice to stdout without a JSON decision or exit code 2 changes nothing. Claude Code needs the structured verdict to act.
- Writing a reason the model cannot act on. “Invalid output” sends Claude in circles. “2 uncommitted files, run the tests, commit” sends Claude to work.
- Testing the gate only inside a live session. Pipe sample event JSON into the script directly first. If
echo '{}' | your-gate.shdoes not behave, no settings file will save it.
Claude gates: Best Practices
- Start with one Stop gate and grow it by appending checks. A single definition-of-done gate delivers most of the value and teaches you what your second gate should be.
- Commit gates with the repo in
.claude/settings.jsonso enforcement ships with the code instead of depending on who remembered to configure it. - Keep every reason string specific, short, and imperative. It is a work order for the model, not a log line.
- Set explicit timeouts on gates that run test suites, and prefer parallel handlers over one gate that checks everything serially.
- Review Claude gates weekly like you review CI flakes. A gate that fires constantly gets deleted; a gate that never fires was written against the wrong failure mode.
Claude gates: Frequently Asked Questions
Do Claude gates work without any API changes or extra tooling?
Yes. Gates are configuration plus scripts that run on your machine through the existing hook system. You need jq on the PATH for the JSON examples, and nothing else.
Can a gate stop Claude from editing certain files?
Yes, twice over. A permission rule denies the tool call deterministically, and a PreToolUse gate can deny with a reason when the rule needs to read a file or consult state first. Use the permission system for the hard walls and gates for the conditional ones.
What happens if a gate script crashes?
A non-zero exit other than 2 is a non-blocking error and the action continues, so a broken gate degrades to no gate rather than a blocked team. Exit code 2 is the deliberate blocking signal, and newer versions can even treat hook failure itself as a block via onFailure.
Do gates run inside subagents?
Yes. Hooks from settings files and plugins fire in subagents too, and the hook input carries fields identifying the agent. Your definition of done follows the work into delegation, which is where unsupervised sessions usually go wrong.
How is this different from running tests in CI?
CI runs after the work is declared finished; a Claude gate runs at the moment of declaration and sends Claude back to fix what failed. The two are complements, and the gate makes the CI handoff boring because the tree already passes.
Claude gates close the gap between what Claude says it finished and what your repo can prove. Wire one Stop gate this week, keep its reason strings actionable, and add the PreToolUse and PostToolUse gates as your failure modes earn them. The model does not need more encouragement. It needs a finish line that checks back.