cd /news/ai-agents/stop-hooks-as-hard-constraints-enfor… · home topics ai-agents article
[ARTICLE · art-57100] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Stop Hooks as Hard Constraints: Enforcing Claude Code Behavior Outside the Model

A developer building a fleet of Claude Code agents replaced soft prompt constraints with stop hooks—shell scripts that run after every model response and can force a revision. The hooks act as hard constraints, blocking outputs that defer decisions to the user when the model already has sufficient data. The approach uses two independent conditions—a behavioral anti-pattern regex and evidence of prior tool calls—to avoid false positives.

read6 min views1 publishedJul 13, 2026

Originally published on hexisteme notes.

I run a fleet of Claude Code agents, and for a long time I tried to enforce behavioral rules the obvious way: write them into the system prompt and hope they hold. They mostly did — until pressure showed up. A plausible-sounding challenge from me, a long task, a shortcut that saves a turn, and the model would quietly drop a rule it had followed all session. Prompt instructions are soft constraints. They shape behavior probabilistically, and when they conflict with RLHF-trained patterns — deference to the user, hedged language, listing options instead of committing to one — the RLHF training usually wins, especially under pressure.

Stop hooks are what I actually rely on now. They're hard constraints: shell scripts that run after Claude Code generates a response and before I see it, and they can force a revision the model can't talk its way around.

A stop hook is a shell script registered in

settings.json

under aStop

event. It runs after every response, receives the full session as JSON on stdin, and either exits0

(allow it through) or exits2

with a message (block it and force a revision Claude must address). Because it runs outside the model, it's enforced, not suggested.

The registration itself is small:

// ~/.claude/settings.json
{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [{
          "type": "command",
          "command": "~/.claude/hooks/stop_combined.sh"
        }]
      }
    ]
  }
}

The script receives the full session context as JSON on stdin and can:

0

— allow the response through2

— block it and inject a correction message Claude must addressThe correction message comes back as feedback, and Claude generates a new response. I only ever see the final, corrected output — the hook's intervention is invisible unless I go looking for it in the transcript.

A hook that fires on a regex match alone has an obvious failure mode: false positives. A rule like "block responses that ask the user to decide" will also block a legitimate clarifying question like "what's your timeline for this?" That's not a failure mode I can tolerate in something that blocks output on every turn.

The fix is to require two independent conditions before firing:

CONDITION_1: Response text matches behavioral anti-pattern regex
     AND
CONDITION_2: Session transcript shows prior data-collection tool calls

if [[ $RESPONSE_MATCHES == "1" && $HAD_TOOL_CALLS == "1" ]]; then
    block_with_message
fi

The tool-call evidence half is what makes this work. If Claude asks a clarifying question without having done any analysis first, that's a legitimate question — let it through. If it's deferring back to me after running searches and reading files, it already has what it needs and is off work it should be finishing itself.

The failure mode here is specific: RLHF trains models to hand judgment back to the user. "Which approach would you prefer?" after already analyzing both options. "It depends on your priorities" after having every piece of information needed to just recommend one. That isn't politeness — it's transferring cognitive labor that was the whole point of asking in the first place.

The hook, stop_decision_ownership_check.sh

, works like this (condensed from the production script):

#!/bin/bash
SESSION=$(cat)

RESPONSE=$(echo "$SESSION" | python3 -c "
import json,sys
d=json.load(sys.stdin)
msgs = d.get('messages', [])
for m in reversed(msgs):
    if m.get('role') == 'assistant':
        content = m.get('content', '')
        if isinstance(content, list):
            print(' '.join(b.get('text','') for b in content if b.get('type')=='text'))
        else:
            print(content)
        break
")

DEFER_REGEX='(어느.*(좋아|나을|선택)|당신이.*(정하|결정)|뭐가.*좋을|what.*prefer|which.*would you|up to you|your call|depends on your)'
if ! echo "$RESPONSE" | grep -qiE "$DEFER_REGEX"; then
    exit 0  # No match, allow
fi

TOOL_CALLS=$(echo "$SESSION" | python3 -c "
import json,sys
d=json.load(sys.stdin)
count = sum(1 for m in d.get('messages',[])
            for b in (m.get('content',[]) if isinstance(m.get('content'), list) else [])
            if isinstance(b,dict) and b.get('type') in ('tool_use','tool_result'))
print(count)
" 2>/dev/null || echo "0")

if [[ "$TOOL_CALLS" -lt 2 ]]; then
    exit 0  # No tool evidence, likely a legitimate question
fi

HASH=$(echo "$RESPONSE" | shasum -a 256 | cut -c1-16)
NAG_FILE="/tmp/.hook_nag_ownership_$HASH"
if [[ -f "$NAG_FILE" ]]; then
    exit 0  # Already nag'd this exact response
fi
touch "$NAG_FILE"

echo "DECISION_OWNERSHIP: You collected data then deferred the decision back. Commit to a single recommendation with one-line rationale and one falsifiable condition under which you'd be wrong." >&2
exit 2

The regex mixes Korean and English deference markers on purpose — I work across both languages, and the pattern has to catch either.

The second failure mode is worse, because it's invisible in the output. When challenged, Claude reverses its position at a 98% rate — that's from Anthropic's own published sycophancy research (arXiv 2310.13548). Not because the challenge supplied new evidence, but because RLHF rewarded agreement. Claude doesn't announce the capitulation ("you're right, I was wrong") — it just quietly shifts.

stop_challenge_reverify.sh

catches the pattern with a three-part gate: challenge, capitulation, and the absence of any actual verification.


CHALLENGE_REGEX='(아닌데|그건 아니|틀렸|잘못|다시 생각|actually|that'\''s wrong|not quite|disagree|incorrect)'
CAPITULATE_REGEX='(맞네요|맞습니다|수정|맞는 말씀|사실|좋은 지적|you'\''re right|good point|I was wrong|actually yes|I misread)'
VERIFY_REGEX='(검증|계산|확인|출처|논문|측정|verified|calculated|source|measured|according to)'

if echo "$USER_MSG" | grep -qiE "$CHALLENGE_REGEX"; then
    if echo "$RESPONSE" | grep -qiE "$CAPITULATE_REGEX"; then
        if ! echo "$RESPONSE" | grep -qiE "$VERIFY_REGEX"; then
            echo "CHALLENGE_REVERIFY: Position changed without new evidence. Choose:
HOLD — restate original position with grounds
CHANGE — cite specific new evidence or calculation that overrides original" >&2
            exit 2
        fi
    fi
fi

This hook has a deliberate blind spot: it can't catch silent capitulation, where the tone shifts without any explicit reversal language. I've accepted that trade-off — the priority is keeping false positives low on legitimate corrections, not catching every possible way a model can cave.

Blocking indefinitely just creates a new failure: Claude keeps generating blocked responses and I get no output at all. Nag-once breaks that loop:

/tmp/.hook_nag_{hash}

, block with a message0

and let it throughThis surfaces the issue exactly once and lets the conversation keep moving. I can still push back on the hook's correction if I disagree with it; that's fine — the hook's job is to force the question onto the table, not to win it.

Hooks operate on the final response text. They can't see the model's reasoning before it outputs anything, can't inject into the middle of a generation, and can't stop the model from starting down a bad path — only from completing it. Deeper behavioral constraints still have to live at the system-prompt and instruction level; hooks are the last line of enforcement, not the only one.

I run several gates through one stop_combined.sh

that calls each in sequence and returns on the first block — I don't want two hooks piling correction messages on top of each other. Ordering matters: the highest-priority gates run first.

#!/bin/bash
SESSION=$(cat)

RESULT=$(echo "$SESSION" | ~/.claude/hooks/stop_decision_ownership_check.sh 2>&1)
if [[ $? -eq 2 ]]; then echo "$RESULT" >&2; exit 2; fi

RESULT=$(echo "$SESSION" | ~/.claude/hooks/stop_challenge_reverify.sh 2>&1)
if [[ $? -eq 2 ]]; then echo "$RESULT" >&2; exit 2; fi

exit 0

None of these hooks make the model smarter. What they do is close the gap between the behavior I actually want and the behavior RLHF defaults to under pressure, by moving enforcement outside the place where that pressure operates.

More notes at hexisteme.github.io/notes.

── more in #ai-agents 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/stop-hooks-as-hard-c…] indexed:0 read:6min 2026-07-13 ·