◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/Claude Code
⟐ Claude Code/2026-06-14Advanced

Running Claude Code Hooks as a Quality Gate Without Breaking Your Pipeline

An implementation note on running Claude Code Hooks as a safety valve for automation: when to block with exit code 2 versus JSON output, how to keep formatters from looping or over-blocking, and how to log every hook firing so misfires are traceable.

claude-code132hooks22automation109cireliability18

✦ Premium Article

As an indie developer running several sites unattended, the first time my Claude Code setup ground to a halt, the culprit was a hook I had written myself. I had put a formatter on PostToolUse meaning to "tidy up every file after it's written," and the formatting rewrote the file, which then got treated as more work, which triggered the formatter again — close to a back-and-forth that never settled.

Hooks don't ask the model to do something; they guarantee that something will happen. That power is exactly why a poorly designed hook can take down your whole automation. What follows is a set of hard-won notes — grounded in the documented behavior but shaped by actually running an automated publishing pipeline — on how to wield that power without breaking things.

Start with the contract: exit codes and stdout

Before writing any clever script, nail down the contract: what does a hook return to Claude Code? Get this wrong and you'll have gates that wave through what you meant to block, or block what you meant to allow.

A hook reports its result in two ways: the exit code, and JSON written to stdout.

The exit codes break down into three cases:

  • exit 0 — success. How stdout is treated depends on the hook type (more below).
  • exit 2 — a blocking error. stderr is fed back to Claude and the operation is stopped.
  • anything else (e.g. exit 1) — a non-blocking error. The user sees a warning, but execution continues.

For automation, exit 2 is the one that matters most. Every gate — "stop a dangerous command," "reject an edit that violates a rule" — is built from exit 2 plus a message on stderr. The corollary: if you mean to block but return exit 1, you get a warning and the operation still goes through, so it isn't a gate at all.

#!/usr/bin/env bash
# block-force-push.sh — stop a dangerous push from PreToolUse(Bash)
input=$(cat)                      # hooks receive JSON on stdin
cmd=$(echo "$input" | jq -r '.tool_input.command // empty')
 
if echo "$cmd" | grep -qiE 'git +push.*(--force|-f)\b'; then
  # write the reason to stderr and exit 2 -> fed back to Claude, operation stops
  echo "Force pushes are disabled on this repo. Consider --force-with-lease." >&2
  exit 2
fi
exit 0

Three things matter here. Hook input arrives on stdin as JSON, not as arguments; the block reason goes to stderr, not stdout; and you must read the right field (tool_input.command). My first version wrote the reason to stdout, which produced a confusing state: the block happened, but no feedback ever reached Claude.

Use JSON output to make "stop or continue" explicit

Exit-code control is simple and robust, but limited in expressiveness. When you want finer control — "block, but for this reason" or "continue, but inject extra context" — emitting JSON on stdout is the better fit.

For PreToolUse, returning JSON like this lets you express allow/deny without relying on the exit code:

#!/usr/bin/env bash
# guard-writes.sh — deny writes to protected paths from PreToolUse(Write|Edit)
input=$(cat)
path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
 
case "$path" in
  *.env|*/secrets/*|*/.git/*)
    cat <<JSON
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Protected path ($path). Make this change by hand if it's intentional."
  }
}
JSON
    exit 0
    ;;
esac
exit 0

The advantage of the JSON approach is that you express "deny" while keeping the exit code at 0. That separates a hook error (a script bug exiting non-zero) from a business-logic denial. In practice this distinction pays off later: your logs can tell whether the hook crashed or the rule rejected something.

Stop hooks expose a decision field. Returning "decision": "block" overrides Claude's attempt to stop and keeps it working per the reason you provide. A "don't stop until the tests are green" gate looks like this:

#!/usr/bin/env bash
# require-green-tests.sh — force continuation from a Stop hook if tests fail
input=$(cat)
# loop guard: if this hook already blocked once, don't block again
if [ "$(echo "$input" | jq -r '.stop_hook_active // false')" = "true" ]; then
  exit 0
fi
 
if ! npm test --silent >/tmp/test.log 2>&1; then
  cat <<JSON
{"decision": "block", "reason": "Tests are failing. Check /tmp/test.log, fix them, then finish."}
JSON
  exit 0
fi
exit 0

The single most important line is the stop_hook_active check. Without it, the Stop hook blocks -> works -> stops again -> blocks again, an endless round trip. As I'll get to below, most unattended-run incidents come from leaving this loop entrance open.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦When to block with exit code 2 versus JSON decision output, broken down per PreToolUse and Stop
✦How to put formatters and linters on hooks without triggering loops or false blocks
✦A JSONL observability wrapper that records every hook's firing, duration, and block count
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⟐ Claude Code2026-09-02
Two hooks that record every model switch and stop the ones you never agreed to
Build a PostModelSwitch hook that logs every model change and a PreModelSwitch hook that blocks unapproved switches during unattended runs. Complete scripts, measured timings, and the reason the two jobs must stay separate.
⟐ Claude Code2026-08-28
Don't let your verification script's full output flow back through a hook
The check was working the whole time. The conversation was what ran out of room. Measured side by side: 58 bytes from one scan of 833 files, 42KB from another. Which paths actually reach the model, and how to enforce a budget on them.
⟐ Claude Code2026-04-30
Composing Claude Code Skills — Building Automation a Single Skill Can't Reach
A practical guide to going beyond one-skill-at-a-time and composing multiple Claude Code Skills into real workflows. Pipeline, branching, and meta-skill patterns with full implementation examples and the failure modes you should design against.