Build This Now
Build This Now
Keyboard ShortcutsStatus Line Guide
Hooks GuideCross-Platform Hooks for Claude CodeClaude Code Setup HooksStop HooksSelf-Validating Claude Code AgentsClaude Code Session HooksContext Backup Hooks for Claude CodeSkill Activation HookClaude Code Permission Hook
Get Build This Now
speedy_devvkoen_salo
Blog/Toolkit/Hooks/Stop Hooks

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:

  1. Allow stopping - Exit 0, and the turn ends cleanly.
  2. Block stopping - Return {"decision": "block", "reason": "..."}, and Claude keeps going.
  3. 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 loop

Combining 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_active at 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:

  1. Drop a task marker at the start of the session
  2. Have the Stop hook block while the marker is present
  3. Require Claude to delete the marker as proof of completion
  4. 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

More in this guide

  • Keyboard Shortcuts
    Configure custom keyboard shortcuts in Claude Code.
  • Status Line Guide
    Set up a custom Claude Code status line showing model name, git branch, cost, and context usage.
  • AI SEO and GEO Optimization
    A rundown of Generative Engine Optimization: how to get content cited inside ChatGPT, Claude, and Perplexity responses instead of just ranked on Google.
  • Claude Code vs Cursor in 2026
    A side-by-side look at Claude Code and Cursor in 2026: agent models, context windows, pricing tiers, and how each tool fits different developer workflows.
  • Claude Code VSCode Extension
    Anthropic's VS Code extension puts Claude Code inside the editor sidebar as a Spark-icon panel, with inline diffs, plan mode, subagents, and MCP support.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

Get Build This Now

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.

On this page

How the Stop Hook Works
The Payload
Pattern 1: Test Gate
Pattern 2: Build Validation
Pattern 3: Lint Check
Pattern 4: Task Completion Marker
Preventing Infinite Loops
Combining Multiple Checks
When to Use Stop Hooks
Configuration
Debugging
The "Ralph Wilgum" Pattern
Next Steps

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

Get Build This Now