CLAUDE LABJP
BILLING — 1 day to the Jun 15 change: Agent SDK, headless runs, GitHub Actions, and third-party agents move to separate monthly credits ($20/$100/$200) metered at full API rates, no rolloverFABLE5 — Claude Fable 5, a Mythos-class model billed as Anthropic's most capable generally available release, is usable in Claude Code v2.1.170+ (launched Jun 9)SUBAGENTS — Claude Code sub-agents can now spawn their own sub-agents, with smarter model and region handlingENTERPRISE — Custom roles gain admin permissions, letting members reach billing and privacy settings without Owner accessPLUGINS — New plugin search plus better Chrome, VSCode, and terminal workflows; session, memory, and permission bugs fixedUI — New setting disables mouse-wheel scroll acceleration in fullscreen; the /model picker now shows model families correctlyBILLING — 1 day to the Jun 15 change: Agent SDK, headless runs, GitHub Actions, and third-party agents move to separate monthly credits ($20/$100/$200) metered at full API rates, no rolloverFABLE5 — Claude Fable 5, a Mythos-class model billed as Anthropic's most capable generally available release, is usable in Claude Code v2.1.170+ (launched Jun 9)SUBAGENTS — Claude Code sub-agents can now spawn their own sub-agents, with smarter model and region handlingENTERPRISE — Custom roles gain admin permissions, letting members reach billing and privacy settings without Owner accessPLUGINS — New plugin search plus better Chrome, VSCode, and terminal workflows; session, memory, and permission bugs fixedUI — New setting disables mouse-wheel scroll acceleration in fullscreen; the /model picker now shows model families correctly
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-code117hooks11automation62ci2reliability4

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-06-12
A Three-Tier fallbackModel Setup for Claude Code — Keeping Unattended Runs Alive Through Overload Mornings
How I run Claude Code with a three-tier fallbackModel chain so overnight batches survive overload errors: logging which model actually ran, measuring quality drift on fallback days, and pairing it with deny rules.
Claude Code2026-04-25
When Claude Code Hooks Loop Forever — Stopping Self-Triggering PostToolUse Hooks
You wired up Claude Code hooks and now your terminal is a waterfall of tool calls that won't stop. Here's why PostToolUse hooks loop on themselves, and the patterns I use in real projects to make sure they never do again.
Claude Code2026-03-24
Claude Code Hooks— Practical Techniques to Automate Your Dev Workflow
Learn how to automate your development workflow with Claude Code Hooks. From PreToolUse and PostToolUse configuration to automated code review, security checks, and deployment pipeline integration.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →