CLAUDE LABJP
VERSION — v2.1.227 landed on August 10 with no new features, just fixes around plan detection and CI behaviorAUTO — Two days remain until August 14, when auto mode becomes the default in Claude Code for Pro, Max, and TeamCI — Bash commands no longer fail across the board under claude-code-action with allowed_non_write_users on GitHub-hosted runnersBILLING — Sessions started with an expired token could misread your plan and nudge Max users toward usage credits; that is now fixedSUNSET — The legacy Workbench and the experimental prompt tool APIs retire on August 17, five days outPRICE — Sonnet 5 promo pricing at $2/$10 per Mtok runs through August 31, moving to $3/$15 on September 1VERSION — v2.1.227 landed on August 10 with no new features, just fixes around plan detection and CI behaviorAUTO — Two days remain until August 14, when auto mode becomes the default in Claude Code for Pro, Max, and TeamCI — Bash commands no longer fail across the board under claude-code-action with allowed_non_write_users on GitHub-hosted runnersBILLING — Sessions started with an expired token could misread your plan and nudge Max users toward usage credits; that is now fixedSUNSET — The legacy Workbench and the experimental prompt tool APIs retire on August 17, five days outPRICE — Sonnet 5 promo pricing at $2/$10 per Mtok runs through August 31, moving to $3/$15 on September 1
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-code129hooks15automation101ci2reliability17

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-07-14
One Day My Push Had an Extra Destination — Guarding Against /commit-push-pr Pushing to Remotes Beyond origin
The July 14 update made /commit-push-pr push to configured push remotes in addition to origin. Convenient, but if you keep a mirror or backup as a second remote, unintended pushes quietly multiply. Here is how to inventory which remotes you can push to, block anything off the allowlist with a pre-push hook, and keep unattended runs safe — with working code.
Claude Code2026-07-03
Five Minutes of Silence, and Something Retries on Your Behalf — Rethinking Retry Ownership After the Streaming Idle Watchdog Became a Default
Claude Code's streaming idle watchdog is now on by default, quietly adding another retrying layer to your stack. This article inventories the four layers (SDK, wrapper, watchdog, scheduler), computes worst-case attempt amplification, and shows how to collapse retry ownership into a single layer.
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.
📚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 →