Stop hook decision:block is rendered as 'Stop hook error', making intentional instructional blocks look like failures

Status Fixed / completed
Maintainer reply ✓ Yes — bcherny
Activity 4 comments · opened Aug 6, 2026 · closed Aug 17, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Description

A Stop hook that returns the documented blocking contract —

{"decision": "block", "reason": "<instructions for the model>"}

— is rendered in the transcript UI as:

Ran 2 stop hooks
  ⎿  Stop hook error: <reason text>

The red "Stop hook error" label appears even though nothing failed: the hook exited 0, produced valid JSON, and used the documented mechanism for asking the model to do something before finishing. Blocking-with-reason is the only way a Stop hook can get the model to act (additionalContext does not re-invoke the model), so any hook-based product that legitimately needs an end-of-turn action — recording metadata, running a checklist, filing a summary — has its routine, healthy operation displayed as an error banner on every stop.

Why it matters

Users read "error" as "the tool is broken." We ship a hook-based provenance tool whose Stop hook asks the model to record a one-sentence intent for captured file changes; the feature works exactly as designed, and users still report it as a malfunction because the banner says error. The framing cost is high enough that it pushes hook authors toward NOT using the documented blocking contract, or toward suppressing useful prompts.

Expected

Distinguish the two cases visually:

  • Hook failure (non-zero unexpected exit, invalid JSON, timeout) → "Stop hook error", red — current rendering is correct here.
  • Hook blocked with a reason (valid contract usage) → a neutral label, e.g. "Stop hook feedback" or "Stop hook request", non-error styling — the same tone as "Stop hook additional context", which already renders neutrally.

Alternatively (or additionally), allow the hook to pass a display hint (e.g. "severity": "info" | "warn" | "error" alongside decision/reason) so authors can label genuine policy violations as errors while requests render as requests.

Environment

  • Claude Code CLI, macOS (darwin 25.3.0)
  • Stop hook configured via project settings, plain command hook returning the JSON contract above

Repro

  1. Configure a Stop hook that exits 0 and prints {"decision": "block", "reason": "Please summarize this turn, then finish."}
  2. Send any message; let the turn end.
  3. Transcript shows "Stop hook error: Please summarize this turn, then finish." — an instruction rendered as an error.

View original on GitHub ↗

4 Comments

ym259 · 25 days ago

One clarification, preempting the strongest counterargument to this request.

A blocking Stop hook overrides the model's decision to finish, and I assume the conspicuous rendering is partly deliberate: the user should always see that a hook intervened and redirected the model. That property is worth keeping, and this issue is not asking to make interventions quieter or hideable.

The ask is narrower: visible and alarming are different axes. A distinct, still-prominent label — "Stop hook blocked: <reason>" or "Stop hook request:" — keeps the intervention just as auditable while no longer asserting that something failed. The current label makes two different claims ("a hook intervened" — true, keep it loud; "something went wrong" — false) with one word.

On the severity-hint alternative in the original report: on reflection that half is the weaker proposal, for exactly the visibility reason above — the hook author is not the right party to choose how noticeable their own intervention is, and an "info" level would be attractive to precisely the hooks a user most needs to notice. A single harness-chosen non-error label for all valid blocks avoids that failure mode entirely, so please treat the relabeling as the primary request and the severity hint as withdrawn.

ym259 · 25 days ago

Prior art, for triage context: this has been reported at least twice before and neither report was resolved on the merits — both were closed by the stale bot ("inactive for too long", NOT_PLANNED), whose own closing message directs still-affected users to file a new issue referencing the old one:

  • #12667 — [UX] Stop hook displays 'error' for intentional blocking behavior (same decision: block case as this issue)
  • #34600 — Stop hook with exit code 2 displays as 'Stop hook error' instead of feedback (the exit-code-2 variant of the same contract)

So this is the third independent report of the same rendering, filed per that guidance. The behavior reproduces on current builds.

kyzzen · 17 days ago

The double rendering this issue describes also has a wire cost, beyond the label
problem.

The blocking feedback is persisted to the transcript twice: a plain-string user
record and a hook_blocking_error attachment holding the identical payload. The
live conversation only ever contains the string copy. When the conversation is
rebuilt from records (session resume), the attachment expands as well, and the
record pre-orderer glues the duplicate on with a different position and a
trailing-newline difference. The bytes of an earlier message change, so the prompt
cache is invalid from that message onward and the entire tail re-caches. With
large blocking payloads this is an expensive, recurring break. It fires on exactly
the hooks this issue describes: healthy blocks that follow the documented
contract.

The rendering layer already knows these are not errors: the TUI switch for this
attachment type returns null for Stop/SubagentStop. Eight of the nine transcript
consumers filter the attachment out; the Stop path feeding the model-facing
assembly is the one that does not. Guarding that single expansion site removes
both the duplicate model-facing text and the cache break, independent of the
label fix requested here.

bcherny collaborator · 15 days ago

Thanks for the detailed report. I reproduced the rendering on 2.1.233: a Stop hook that exits 0 and prints {"decision": "block", "reason": "..."} shows Ran 2 stop hooks / ⎿ Stop hook error: <reason> and Claude continues.

This is the intended display for decision: "block", but the neutral channel you're asking for already exists: since 2.1.163, Stop and SubagentStop hooks can return hookSpecificOutput.additionalContext instead. I tested it on 2.1.233 with the same script returning {"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": "Please summarize this turn, then finish."}} — the transcript shows Stop hook feedback: ... (no error styling, no error notification) and Claude does continue and act on it, with the same stop_hook_active input and 8-continuation cap as a block. So the premise that additionalContext doesn't re-invoke the model isn't correct on current versions.

Docs: https://code.claude.com/docs/en/hooks#stop-decision-control — "Use additionalContext when the hook is working as designed and giving Claude guidance ... the transcript labels it Stop hook feedback and no hook error notification is shown." decision: "block" is meant for the "the hook overrode Claude's decision to stop" case, hence the louder label.

If additionalContext doesn't cover your use case (e.g. you need it for the exit-code-2 path), let us know what's missing.

🤖 Generated with Claude Code