Build This Now
Build This Now
Keyboard ShortcutsStatus Line Guide
speedy_devvkoen_salo
Blog/Toolkit/Hooks/Stop Hooks

Stop Hooks

Stop hooks block Claude Code from ending a turn while tests fail, builds break, or lint is red. Four enforcement patterns plus loop-protection safeguards.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

Published Feb 25, 2026Toolkit hubHooks index

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

Continue in Hooks

  • Claude Code Setup Hooks
    Braid scripts, agents, and docs into Claude Code setup hooks. One command runs a deterministic script, hands output to a diagnosing agent, logs living docs.
  • Context Backup Hooks for Claude Code
    A StatusLine-driven Claude Code context backup hook. Writes structured snapshots every 10K tokens so auto-compaction never eats errors, signatures, decisions.
  • Cross-Platform Hooks for Claude Code
    Cross-platform Claude Code hooks: skip .cmd, .sh, and .ps1 wrappers and invoke node directly so one .mjs file runs on macOS, Linux, and Windows across the team.
  • Hooks Guide
    Claude Code hooks from first principles: exit codes, JSON output, async commands, HTTP endpoints, PreToolUse and PostToolUse matchers, production patterns.
  • MCP Tool Hooks in Claude Code
    How to call MCP server tools directly from Claude Code hooks using type: mcp_tool — schema, substitution syntax, use cases, and production patterns.
  • Claude Code Permission Hook
    Install a three-tier Claude Code permission hook: instant allow for safe calls, instant deny for dangerous ones, LLM check for the gray area. No skip flag.

More from Toolkit

  • Keyboard Shortcuts
    Configure Claude Code keybindings.json: 17 contexts, keystroke syntax, chord sequences, modifier combinations, and how to unbind any default shortcut instantly.
  • Status Line Guide
    Set up a Claude Code status line for model name, git branch, session cost, and context usage. settings.json config, JSON input, bash, Python, Node.js scripts.
  • 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 Bolt.new: Which Should You Use?
    Bolt.new prototypes in 28 minutes with zero setup. Claude Code takes 90 minutes but ships production-ready code. Here is how to pick the right tool.

Stop configuring. Start building.

SaaS builder templates with AI orchestration.

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.