Text arrives in batches
You see Claude’s reply appear line by line, but the hook does not wait for the whole message. In an interactive session the reply streams, and MessageDisplay fires for each batch of lines as it becomes ready to render. In a non-interactive run it fires once per completed assistant message. There is no matcher. The hooks reference gives this event a 10-second command timeout, down from the 600-second default.
The payload carries turn_id, message_id, a zero-based index, final, and delta, the text of this batch. index and final tell a hook where the batch sits in the message and whether more are coming.
The screen changes; the transcript does not
This is the hidden layer: what you see and what Claude remembers are two different things. Exit 0 with a valid hookSpecificOutput.displayContent, under hookEventName: "MessageDisplay", replaces what appears on screen for that batch. Without it, the original delta displays. systemMessage and continue are discarded, and there is no decision field. On every exit code the transcript, and the context Claude reasons over, keep the original text.
Failure shows the original
When the hook breaks, you get the unmodified text and nothing else changes. Exit 1 displays the original and records the failure in the debug log. Exit 2 displays the original too. It cannot hold the message back. A timeout does the same. So a broken MessageDisplay hook degrades to no hook.
You write a hook that redacts anything shaped like an API key from the screen, test it, and see the key replaced with asterisks. Done, you think.
The key is still in the transcript file and still in Claude’s context. displayContent changes the rendering of one batch and nothing else. You have hidden it from the one person who was already allowed to see it.
To keep the key out of context, stop it upstream: a PreToolUse hook that denies the Read, which module 09 builds as block_secrets.