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.jsonunder aStopevent. It runs after every response, receives the full session as JSON on stdin, and either exits0(allow it through) or exits2with a message (block it and force a revision Claude must address). Because it runs outside the model, it's enforced, not suggested.
How stop hooks actually work
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:
- Exit
0— allow the response through - Exit
2— block it and inject a correction message Claude must address
The 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.
The AND-gate pattern
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.
Production hook 1: decision-ownership enforcement
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
# 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.
Production hook 2: sycophancy prevention
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.
The nag-once pattern
Blocking indefinitely just creates a new failure: Claude keeps generating blocked responses and I get no output at all. Nag-once breaks that loop:
- First violation — compute a sha256 of the response, write it to
/tmp/.hook_nag_{hash}, block with a message - Second attempt with the identical response — the hash file already exists, so exit
0and let it through - A genuinely different response — new hash, new nag file, block again if it's still violating
This 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.
What hooks can't do
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.
Combining hooks
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
# 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.
Top comments (6)
The distinction between soft constraints and hard constraints is the right frame — prompt instructions are probabilistic and degrade under pressure in ways that are hard to characterize without running ablation tests across session lengths. The stop hook as the enforcement layer is architecturally clean because it sits outside the context window and cannot be talked out of; the model cannot reason about what it has not seen. The pattern mirrors what Unix systems do with capabilities-dropping: trust the model for everything within its sandbox, but use a non-bypassable gate for the invariants that actually matter. Are you storing the correction injection messages anywhere, or running blind on how often the hook fires in practice?
Running blind, mostly — and you've put your finger on the same hole another commenter found in a different post of mine this week, which is a bad sign for me and a good sign for the question.
What exists today is only the nag-once file: on a block I sha256 the response, touch
/tmp/.hook_nag_{hash}, and skip re-blocking an identical response. That's deduplication state, not telemetry. It answers "have I already nagged about this exact text" and nothing else. There's no time series, so I can't tell you the fire rate, the false-positive rate, or — worse — whether a hook has silently stopped firing at all.That last one is the real argument for logging it. A stop hook disabled by a config change fails open, and open failure with no counter is invisible by construction. The enforcement layer needs to prove it's still enforcing, and right now mine can't. That isn't analytics; it's liveness.
Fixing it with an append-only line per fire — timestamp, hook id, verdict, response hash — which gives both the rate and a dead-hook alarm (no fires in N days on a hook that used to fire weekly is a signal, not silence). Cheap, and I should have had it from the start.
Your capability-dropping analogy is the one I keep returning to, and it extends further than you framed it: the reason capability systems are auditable is that the kernel logs the denial. I built the gate and skipped the audit log.
Thanks for asking the question that exposed it — I'd have kept running blind otherwise, and "the hook is there" would have kept feeling like "the hook is working."
This is the right mental model.
Prompting alone is soft guidance. Stop hooks / policy layers are actual guardrails.
Once agents can edit files and run commands, behavior has to be enforced outside the model. Otherwise you are just hoping the next token is responsible.
Curious how you handle false positives when a legitimate refactor trips a hard constraint.
Two mechanisms, and the first one is the one that matters.
Never block on a regex alone — AND-gate it with tool evidence. A regex is context-blind: "what's your timeline?" and "you decide, I can't call it" look similar to a pattern matcher but are opposite behaviors. So the decision-ownership hook fires only when the deflection pattern matches and the preceding turn shows data-gathering tool calls. The reasoning: punting before doing any work is a legitimate question; punting after running the analysis is handing the cognitive labor back to the user. Same words, different verdict — and the tool history is a usable proxy for the context the regex can't see. That single conjunct killed most of my false positives, because pure-value questions ("do you want to take this risk?") have no tool evidence behind them and sail straight through.
Second: nag-once is an escape hatch, not just loop protection. The block keys on a hash of the response. Block the same text twice and the second one passes. So a genuine false positive costs one wasted turn, not an infinite wall — the model reasserts and gets through. That asymmetry is deliberate: this thing runs on every turn, so a false positive is a tax on all correct behavior, while a false negative is a tax on one incorrect one.
The honest cost is that both mechanisms buy low FP by accepting FN. My sycophancy hook can't catch silent capitulation — a tone shift with no explicit reversal language sails right past it. I took that trade knowingly. A gate you route around because it cries wolf enforces nothing at all.
Thanks for the question — the legitimate-refactor case is exactly the one that decides whether a hard constraint is usable in practice or just a good idea on a slide. It's the question I'd have asked too.
"The enforcement layer needs to prove it's still enforcing" is the exact frame I was missing when I first built enforcement gates too. The append-only fire log closes a specific failure mode you described well: a dead hook is indistinguishable from a compliant model unless you have a counter. One extension worth adding to the schema: a block_reason field alongside the timestamp and verdict, so you can distinguish rule-specific fire rates rather than just aggregate gate activity, which matters when you have multiple hooks competing for the same response and need to know which rule is doing work versus which is silent noise.
The
block_reasonfield is a clean upgrade — aggregate gate activity hides which rule is actually firing versus which is dead weight, and that distinction matters when hooks compete. I'd been treating the log as a binary heartbeat, but tagging each veto with its rule lets you surface silent noise and tune per-rule thresholds instead of guessing. Thanks for the schema extension; it closes the observability gap I left open.