No turn, no tokens, no assistant message in the transcript. Type ,api at an idle prompt and it runs. This is a UserPromptSubmit hook, it is about a hundred lines, and it has nothing to do with terminals.
The hook returns this and exits 2:
{ "decision": "block", "reason": "text shown to you, verbatim",
"hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "suppressOriginalPrompt": true } }
Four things about that are not obvious.
- Exit 2 is required, not optional. Exit 0 lands in a branch that reports the outcome as success and appends a spurious "completed" line. Exit 2 skips it, and decision "block" has already set the blocking error, so your text is never replaced by a wrapped stderr dump.
- A bare exit 2 cannot suppress the echo. Without suppressOriginalPrompt the blocked line is appended back into the transcript as "Original prompt: ,api". That is why the hook emits JSON instead of just exiting.
- An async hook silently cannot gate anything. The async flag's own schema says the hook runs in the background without blocking, so its result arrives after the prompt already reached the model. Nothing warns you.
- Fail open, always. Anything that is not a comma line exits 0 untouched, and so does every failure path. This must never eat a real prompt.
The limit: the gate only fires on a prompt submitted at an idle input box. Type ,api while a turn is already running and the text is delivered to the model mid-turn, never reaches UserPromptSubmit, lands in the transcript as an ordinary prompt, and costs a turn. That is a property of the hook, not of this script.
Measured against Claude Code 2.1.224 on 2026-08-07. Claude Code ships roughly one release a day, so check it against your build before you rely on it. MIT.