UserPromptSubmit: add a 'handled' decision (show output, skip agent, no 'blocked' framing)
Problem
UserPromptSubmit hooks can short-circuit a prompt via decision: "block", but the
UI renders this as "operation blocked by hook" — framing that reads as an error or
denial. There's no way for a hook to say "I handled this successfully, here's the
output" and have it appear as ordinary transcript text.
This blocks a useful pattern: programmatic fast-path with agent fallback. A hook
intercepts a command, runs a binary, and:
- On success → show the result to the user, no agent needed
- On failure → fall through to the agent for judgment
Today this works via block + exit-code fallback, but a successful run still looks
like an error.
The existing options don't fit:
additionalContext— the agent still runs (latency + token cost for nothing)continue: false— halts the whole session!shell— no agent fallback; the user must know the exact command- user-defined
/commands— prompt templates that always invoke the agent
Proposed solution
A new decision (e.g. "handled") whose output renders as normal transcript text —
no "blocked" label — and which does not invoke the agent:
{
"decision": "handled",
"output": "✓ Done — 3 files updated"
}
Ideally the handled command's stdout/stderr is also surfaced to the agent (as !command
does today), so the agent stays aware the action occurred on subsequent turns.
Prior discussion
Originally raised in #42178 (closed as stale). Re-filing per the stale bot's request,
since it's still relevant and had +1s from multiple users hitting the same wall —
including a VS Code case where block reason text isn't rendered in the chat window at all.
Use case
A team uses a custom CLI to handle routine workflow commands. A UserPromptSubmit hook
intercepts those commands and runs the binary directly. On success we want clean output
shown to the user with no agent round-trip; when the command needs judgment (e.g. exit
code 2) we fall through to the agent via additionalContext. The programmatic-first,
agent-fallback pattern works well — except a successful run is framed as "blocked by hook."
3 Comments
+1 — with some implementation-level findings from inspecting the v2.1.217 binary that confirm there is no workaround today, plus a second concrete use case.
Use case. We hit the same wall building a local provider switcher for Claude Code: a
UserPromptSubmithook intercepts/switch <model>, flips a local routing daemon, and blocks the prompt — zero model turns by design, because switching has to keep working while the current model is rate-limited, so "fall through to the agent" is not an option. Functionally perfect; the only problem is that every successful switch renders as an error-styled block.What the binary shows (2.1.217). Since the rendering isn't specified in the docs, I inspected the bundled JS to map what a hook can actually influence:
``
jsUserPromptSubmit operation blocked by hook:\n${e.blockingError}function K2s(e){return
}
``suppressOriginalPromptis set:``
js${O}\n\nOriginal prompt: ${H}let O=K2s(F.blockingError),
U=F.suppressOriginalPrompt?O:
;
``return{messages:[wl(U,"warning",void 0,!0)],shouldQuery:!1,resultText:U}
hookSpecificOutput.suppressOriginalPromptis parsed for UserPromptSubmit — it appears to be undocumented, and it's very welcome:``
js
``case"UserPromptSubmit":
u.additionalContext=e.hookSpecificOutput.additionalContext,
u.sessionTitle=e.hookSpecificOutput.sessionTitle,
u.suppressOriginalPrompt=e.hookSpecificOutput.suppressOriginalPrompt;break;
continue: false, is framed too — `Operation stopped by hook: ${stopReason}` — and additionally pushes that text into history as a meta message.So the best reachable rendering today is banner line +
reason(echo suppressed): one unavoidable line of "blocked" framing over what is actually a success.Minimal version of this request. Given
suppressOriginalPromptalready exists on this exact path, honoring one more boolean — e.g.hookSpecificOutput.suppressBanner: true(ordisplay: "plain") — would unblock both use cases with no new decision semantics. The proposeddecision: "handled"is cleaner still, since "blocked as refusal" and "handled locally" are genuinely different outcomes and could eventually render differently (e.g. like!commandcells do).Happy to test a build.
The
blockworkaround does work today, and getting it right took me long enough that it seems worth writing down the exact shape plus the details that are not documented. It gets you most of what you are asking for, which also makes the remaining gap precise.All of this is read out of the 2.1.223 bundle, so treat it as reverse-engineered rather than contractual.
1.
suppressOriginalPromptis what stops the echo. Without it, the intercepted prompt is appended back underneath your output:So your command reappears as
Original prompt: ,apibelow the result. A bareexit 2with a message on stderr cannot set this flag. It is in the schema (When decision is "block", omit the original prompt from the block message) but it is easy to miss, and it is the difference between something that reads as a command and something that reads as a rejected message.**2. Emit the JSON and exit 2.** Two separate reasons, and one of them is not what I originally assumed.
The wrapper you are avoiding:
Since
decision: "block"has already populatedblockingError, that never fires, so yourreasonis used verbatim rather than rendered as[<command>]: <stderr>.With exit 0 you land in an earlier branch instead:
I had assumed this dropped the blocking decision. It does not: it spreads the parsed output, so
blockingErrorsurvives. What exit 0 actually costs you is a spurious<hook name> completedline plus an outcome reported as success. Possibly useful for #81818:suppressOutputdoes gate this particular branch, even though it does not touch the block notice itself.3. The hook must be synchronous, and this failure is silent. The config flag's own schema says it outright:
An
async: truehook therefore returns after the prompt has already reached the model, so it cannot gate anything. Nothing warns you. The hook simply stops having any effect while continuing to look correctly configured. That cost me about 90 minutes, and someone else mentioned unprompted that it cost them about the same, so it may deserve a line in the hooks docs independently of what happens to this request.What none of this fixes is the part the title is about. It is still a block, so whatever notice the client renders around blocks still renders (#81818), and on Desktop and the VS Code extension the reason text is dropped entirely (#73525, #75534). The workaround gets the content right and cannot touch the framing.
Four open issues circling the same missing primitive seems like a better argument for
handledthan for four separate rendering fixes.Working implementation if it is useful to anyone: prompt-hook.sh, about 100 lines, MIT. It uses this to run a terminal command from the input box with no turn and no tokens spent.
Thanks for re-filing with the clear use case. One piece exists today: a
UserPromptSubmithook that blocks can sethookSpecificOutput.suppressOriginalPrompt: trueso the original prompt isn't echoed back in the message (https://code.claude.com/docs/en/hooks#userpromptsubmit-decision-control). The output is still shown as a blocked-by-hook warning, there's no neutral "handled" rendering, and the model doesn't see the result on later turns, so leaving this open for that.🤖 Generated with Claude Code