Write Down the Decision, Not Just the Fact
An agent I use every day started answering twice. Same response rendered top to bottom, then a second copy with small edits, like it had reconsidered mid-sentence and started over in public.
That bug got fixed four times, with a correct diagnosis every time, and it survived all four.
What's interesting is why four correct diagnoses produced four fixes that didn't hold.
What was actually happening
Claude Code fires a Stop hook when the assistant finishes a turn. A hook there can return JSON, and one of the fields it can return is hookSpecificOutput.additionalContext.
The field has its own row in the Stop decision-control table, verbatim from the hooks reference.
Non-error feedback for Claude. The conversation continues so Claude can act on it, but unlike
decision: "block"it is shown in the transcript as hook feedback rather than a hook error.
And one paragraph down.
It keeps the conversation going through the same loop protections as
decision: "block", namely thestop_hook_activeinput and the 8-consecutive-continuation cap.
Read that against where the hook fires. Continuing the conversation at Stop means re-invoking the model on a response the user has already read on screen. The model gets new context, so it does the reasonable thing with new context. It writes the answer again, slightly differently. Two copies.
I measured it by parsing one session transcript. Four duplicated responses, 10,662 of 37,708 assistant characters, so 28% of that session's output was a second draft of something already delivered.
The trap that caught four passes in a row
Everyone who looked at this, me included, assumed decision: "block" was the culprit. Block is the loud one, and it's named like a control, so each fix made the hook less blocking.
additionalContext reads like a passive annotation, and in most places it is one. At SessionStart and PostToolUse the field attaches to an existing turn and Claude reads it on the next model request. Nothing is forced. Same field name, same JSON shape, genuinely passive.
At Stop the lifecycle inverts it. There is no next turn to attach to, so the runtime makes one. That is the whole gap between "annotation" and "another model call", and the field name gives you no warning at all.
Dropping the block made the hook quieter without making it passive.
The four attempts
| Attempt | Change | Result |
|---|---|---|
| 1 | block to advisory | still double-rendered |
| 2 | add a stop_hook_active guard | capped it at exactly 2 copies, cause untouched |
| 3 | shrink the injected payload | cheaper duplicate, still a duplicate |
| 4 | stop emitting at Stop entirely | fixed |
Attempt 2 is the one worth staring at. The guard works. It reads the stop_hook_active input, sees the runtime is already continuing because of a stop hook, and refuses to emit a second time. The duplicate count goes from unbounded to exactly two, which looks a lot like a fix if you are watching the symptom.
It caps a loop that should not have started.
The tell that was sitting in the codebase the whole time
Every Stop hook in that project had independently grown the same stop_hook_active guard. Five hooks, written at different times for different jobs, all converging on the same three lines of defensive code.
Nobody copied anyone. Five separate passes each hit the same behavior and each wrote their own small defense against it.
That convergence was the diagnosis. When independent authors write the same workaround, they aren't each being careful. They're each routing around one shared upstream defect, and how many times the workaround got duplicated tells you how long the real bug has been live.
I now treat repeated defensive code as a first-class signal, the same way I treat a flaky test. One guard is somebody being careful. Five identical guards written independently is a report about the system.
The cheap version of that check, run before writing guard number six.
# how many places independently defend against the same thing
grep -rln 'stop_hook_active' .claude/hooks/ | wc -lIf that number is greater than one, the next commit is a design decision, not another guard.
Three honest options at Stop
Once the mechanism was clear, a Stop hook turned out to have exactly three real choices, and the cost of each is visible up front.
1. Force another turn, and accept the re-render. Return decision: "block" with a reason. The docs are explicit that this prevents Claude from stopping and tells it why to continue. This is the right call when the turn genuinely is not finished. It is the wrong call when you just want to say something.
{
"decision": "block",
"reason": "Required when Claude is blocked from stopping"
}2. Talk to the user. systemMessage is a common output field, documented as the warning message shown to the user, and it is not one of the two Stop outputs that continue the conversation. If the message was always for the human, this is the whole answer and nothing gets re-invoked.
3. Talk to the model, at a moment where talking is free. Write the text to a queue at Stop, then drain the queue at the next UserPromptSubmit, where additionalContext attaches to the prompt that is already on its way in.
# at Stop, do not emit, just record
printf '%s\n' "$FEEDBACK" >> "$QUEUE"
# at UserPromptSubmit, drain into the turn that is already happening
[ -s "$QUEUE" ] && jq -Rs '{
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: .
}
}' < "$QUEUE" && : > "$QUEUE"Option 3 was the one I wanted for correctness reasons, and it turned out to be better on its own terms. Feedback delivered at Stop is feedback on a finished answer, so the only thing it can do is cause a rewrite of something you already read. The same feedback delivered at UserPromptSubmit lands before the next response is written, which is the only moment it can actually change one.
The bug had been pointing at the better design the whole time, and I kept reading it as a rendering glitch.
The regression guard, and why it has to be source-level
Runtime can't see this failure. The hook exits 0, the JSON is valid, and nothing gets logged or thrown, so no latency alarm fires. The hook still "works" by every check a hook normally gets.
The only symptom is that the answer renders twice, which requires a human to notice and bother reporting.
So the guard is a source-level assertion instead. Walk the hook configuration, resolve every script wired to Stop, and fail if any of them can emit that field.
# fails the suite if any Stop-wired script emits additionalContext
grep -rn 'additionalContext' "${STOP_HOOK_SCRIPTS[@]}" && exit 1That is a blunt test and it is fine that it is blunt. It encodes a decision that a reader can act on without re-deriving anything, which is more than four correct diagnoses managed.
The actual lesson
Every one of those four sessions left an accurate note behind. One of them states outright that the field re-invokes the model, and then adds a guard instead of removing the emit.
The fact was recorded. The decision never was.
A fact answers "what is true." A decision answers "why this option beat the others, and what we are not doing anymore." With the fact captured and the decision missing, each pass re-derived a fresh mitigation from the same correct diagnosis and stopped right there, because nothing on record said the mitigation class itself was already ruled out.
So the rule I use now is that when a bug shows up a second time and the diagnosis is right again, what I owe the codebase is a decision, not another note.
Three questions before closing it.
- Has this exact thing been diagnosed before? If yes, my job this pass is not to diagnose, it is to decide.
- Does this change remove the cause or cap the symptom? Both are legitimate. Saying which one out loud is what makes the next person's search productive.
- What is now ruled out? Write that sentence down. "Stop hooks do not emit context, they queue it" is worth more than five paragraphs of accurate mechanism.
The same shape, twice more, the same day
The same day I finally fixed this one, I found two more instances of the shape in unrelated code.
A JSON parser in a production pipeline had been hardened to tolerate malformed blocks inside a payload, and still hard-required the top-level wrapper. The fact ("the model drifts on output shape") was recorded and acted on. The decision ("tolerate drift at every level, or fail loudly at exactly one, and never partially") was not, so the next drift was still fatal at the level nobody had gotten to yet.
A known bad interaction between two libraries was documented in April and bit a different caller in July. The fact was in the file, correct and findable. The decision, meaning which callers must never do that and what they do instead, was not, so the document described the trap without closing it.
Same shape all three times. Good notes, missing decisions, and a bug that gets solved over and over without ever being fixed.
Once the decision existed, the code change was small. Getting to the decision took four sessions, which is the part I would like back.
If you are building agent tooling and running into the same class of thing, I am happy to compare notes.