jared hebb ~ %

Claude Code hooks

The MessageDisplay hook in Claude Code

MessageDisplay fires as Claude’s reply is shown and can rewrite what appears on screen without changing what Claude or the transcript recorded.

Every other blocking-capable event in this list can change what actually happens next: a tool call, a turn ending, a config change taking effect. This one cannot. Whatever it returns only ever changes pixels on a screen, never the model's own memory of what it said.

When it fires
While assistant message text is being displayed.
Can it block?
No. The message is shown either way.
Matcher
No. It always fires.

What it receives

The event arrives as JSON on standard input. These are the fields worth reading.

FieldTypeWhat it is
messagestringThe assistant text about to be displayed.
prompt_idstringThe turn being displayed.
session_idstringStable for the session.

Exit codes

The exit code is the decision. Anything the script writes to standard error on a blocking exit is what Claude gets told.

ExitWhat happens
0Success.
2No blocking behavior. The original text is displayed.
otherNon-blocking. The original text is displayed.

What it can return

displayContent replaces what you see on screen and nothing else. The transcript keeps the original, and so does what Claude itself carries forward, which is the point: this is a display filter, not an edit.

{
  "hookSpecificOutput": {
    "hookEventName": "MessageDisplay",
    "displayContent": "string"
  }
}

Keeping secrets off a shared screen

Anything Claude echoes back can end up in a screen recording or a shared window. Masking token-shaped strings on the way to the screen costs nothing and does not alter what the session actually holds.

In settings.json

{
  "hooks": {
    "MessageDisplay": [
      {
        "hooks": [
          { "type": "command", "command": "node ~/.claude/hooks/mask.mjs", "timeout": 5 }
        ]
      }
    ]
  }
}

The script

const input = JSON.parse(require("fs").readFileSync(0, "utf8")) || {};
const masked = (input.message || "").replace(/\b(sk-[A-Za-z0-9]{8,})\b/g, "sk-REDACTED");

console.log(JSON.stringify({
  hookSpecificOutput: { hookEventName: "MessageDisplay", displayContent: masked }
}));
process.exit(0);

Masking the display is not redaction. The real string is still in the transcript on disk.

The catch

The default timeout here is 10 seconds rather than the 600 most events get, and it runs while text is being drawn. Anything slow shows up as the reply stuttering.