Stop Hooks
Block Claude from ending a response while tests, builds, or lint checks still fail. Four patterns plus loop protection.
Problem: Claude wraps up a response, but the work isn't actually finished. Tests still fail. Files sit half-written. You ask "are you done?" and get a yes, while the build is red.
Quick Win: Drop this Stop hook in and Claude cannot end the turn until tests are green:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python .claude/hooks/test-gate.py"
}
]
}
]
}
}#!/usr/bin/env python3
import json
import sys
import subprocess
input_data = json.load(sys.stdin)
# CRITICAL: Prevent infinite loops
if input_data.get('stop_hook_active', False):
sys.exit(0)
# Run tests
result = subprocess.run(['npm', 'test'], capture_output=True, timeout=60)
if result.returncode != 0:
output = {
"decision": "block",
"reason": "Tests are failing. Fix them before completing."
}
print(json.dumps(output))
sys.exit(0)
sys.exit(0)From here on, Claude literally cannot close out a turn with a failing suite.
How the Stop Hook Works
Every time Claude goes to finish a response, this hook fires. Three things can happen:
- Allow stopping - Exit 0, and the turn ends cleanly.
- Block stopping - Return
{"decision": "block", "reason": "..."}, and Claude keeps going. - Run validations - Fire off tests, checks, or any script you want.
The Payload
{
"session_id": "uuid-string",
"stop_hook_active": false,
"transcript_path": "/path/to/transcript.jsonl"
}Pay attention to stop_hook_active. A true value means Claude is already in a forced-continue state from an earlier block. Miss this flag and you get a runaway loop.
Pattern 1: Test Gate
Hold the turn open until every test passes:
#!/usr/bin/env python3
import json
import sys
import subprocess
input_data = json.load(sys.stdin)
if input_data.get('stop_hook_active', False):
sys.exit(0)
result = subprocess.run(
['npm', 'test', '--passWithNoTests'],
capture_output=True,
timeout=120
)
if result.returncode != 0:
# Extract last 10 lines of test output for context
stderr = result.stderr.decode()[-500:] if result.stderr else ""
print(json.dumps({
"decision": "block",
"reason": f"Tests failing. Output: {stderr}"
}))
sys.exit(0)
sys.exit(0)Pattern 2: Build Validation
Block until the project compiles:
#!/usr/bin/env python3
import json
import sys
import subprocess
input_data = json.load(sys.stdin)
if input_data.get('stop_hook_active', False):
sys.exit(0)
result = subprocess.run(
['npm', 'run', 'build'],
capture_output=True,
timeout=180
)
if result.returncode != 0:
print(json.dumps({
"decision": "block",
"reason": "Build failed. Fix compilation errors before completing."
}))
sys.exit(0)
sys.exit(0)Pattern 3: Lint Check
No leaving the turn with lint errors on the board:
#!/usr/bin/env python3
import json
import sys
import subprocess
input_data = json.load(sys.stdin)
if input_data.get('stop_hook_active', False):
sys.exit(0)
result = subprocess.run(
['npx', 'eslint', 'src/', '--max-warnings=0'],
capture_output=True,
timeout=60
)
if result.returncode != 0:
print(json.dumps({
"decision": "block",
"reason": "Lint errors detected. Run eslint --fix or resolve manually."
}))
sys.exit(0)
sys.exit(0)Pattern 4: Task Completion Marker
Gate the turn on a specific task flag:
#!/usr/bin/env python3
import json
import sys
from pathlib import Path
input_data = json.load(sys.stdin)
if input_data.get('stop_hook_active', False):
sys.exit(0)
# Check for incomplete task marker
marker = Path('.claude/incomplete-task')
if marker.exists():
task_info = marker.read_text().strip()
print(json.dumps({
"decision": "block",
"reason": f"Task incomplete: {task_info}. Finish it before stopping."
}))
sys.exit(0)
sys.exit(0)Drop the marker in place as you begin work:
echo "Implement user authentication" > .claude/incomplete-task
Clear it when the job is done:
rm .claude/incomplete-task
Preventing Infinite Loops
Here's why the stop_hook_active flag matters. Without it you get this:
Claude responds → Stop hook fires → "block" → Claude continues
↓
Claude responds → Stop hook fires → INFINITE LOOP (without flag check)Always check the flag first:
if input_data.get('stop_hook_active', False):
sys.exit(0) # Allow stopping, break the loopCombining Multiple Checks
One hook can chain several gates:
#!/usr/bin/env python3
import json
import sys
import subprocess
input_data = json.load(sys.stdin)
if input_data.get('stop_hook_active', False):
sys.exit(0)
checks = [
(['npm', 'run', 'lint'], "Lint errors"),
(['npm', 'run', 'typecheck'], "Type errors"),
(['npm', 'test'], "Test failures"),
]
for cmd, error_msg in checks:
result = subprocess.run(cmd, capture_output=True, timeout=120)
if result.returncode != 0:
print(json.dumps({
"decision": "block",
"reason": f"{error_msg} detected. Fix before completing."
}))
sys.exit(0)
sys.exit(0)When to Use Stop Hooks
Good use cases:
- Gating on a green test suite before "task complete"
- Making sure the build still compiles
- Catching lint and type errors
- Any custom rule for what "done" means to you
Bad use cases:
- Anything that runs longer than the 60 second timeout
- Checks that hit the network and flake
- Prompts that need a human answer (no interaction here)
Configuration
Wire it up in .claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python .claude/hooks/stop-validation.py"
}
]
}
]
}
}Several hooks can run at once, in parallel. A block from any one of them keeps Claude going.
Debugging
Stuck in a loop?
- Double-check that you read
stop_hook_activeat the top of the script - Log it:
echo "stop_hook_active: $stop_hook_active" >> ~/.claude/stop-debug.log
Block not landing?
- The JSON must look like
{"decision": "block", "reason": "..."} - Use exit code 0, not 2. Exit 2 is for a different blocking path.
Tests running long?
- Hook timeout is 60 seconds
- Run a smaller subset, or speed the suite up
The "Ralph Wilgum" Pattern
This one comes from a community technique and uses Stop hooks to force a persistent task loop:
- Drop a task marker at the start of the session
- Have the Stop hook block while the marker is present
- Require Claude to delete the marker as proof of completion
- No more accidental "I'm done" with work still open
The result: Claude shifts from best-effort to guaranteed finish.
Next Steps
- Read the main Hooks Guide to see every hook type
- Wire up Context Recovery so sessions survive compaction
- Set up Skill Activation for automatic skill loading
- Check out Permission Hooks for auto-approval flows
Stop configuring. Start building.
Claude Code Setup Hooks
Run a deterministic setup script, then hand the output to an agent for diagnosis. One command, three modes, zero drifted docs.
Self-Validating Claude Code Agents
Wire PostToolUse hooks, Stop hooks, and read-only reviewer agents into agent definitions so bad output never reaches you.