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. 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 a Stop event. It runs after every response, receives the full session as JSON on stdin, and either exits 0 allow it through or exits 2 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 through 2 — 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 Only fire if BOTH are true 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 offloading 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 : bash /bin/bash Read session JSON from stdin SESSION=$ cat Extract last response text 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 " Check for deference patterns 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 Check for prior tool evidence AND gate 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 Nag-once: compute hash, check if already nag'd 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. Gate: challenge in user message AND capitulation in response AND no verification evidence USER MSG and RESPONSE are pulled from the session transcript the same way hook 1 extracts RESPONSE 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 message 0 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. bash /bin/bash stop combined.sh — ordered gate sequence SESSION=$ cat Gate 1: Decision ownership highest priority RESULT=$ echo "$SESSION" | ~/.claude/hooks/stop decision ownership check.sh 2 &1 if $? -eq 2 ; then echo "$RESULT" &2; exit 2; fi Gate 2: Sycophancy / challenge reverify 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.