Claude Code MessageDisplayフックで応答表示を書き換える
MessageDisplayフックは応答の画面表示だけを書き換えるイベントです。transcriptやClaudeが見るテキストは変わりません。入力フィールドと実装例、他の観測系フックとの違いをまとめます。
MessageDisplayは、Claudeの応答テキストが画面にストリーミング表示されている最中に発火するフックです。役割は狭く、画面に映る文字列を書き換えるだけに限定されています。transcript(会話ログ)に残る内容も、Claude自身が次に参照するテキストも変わりません。
この境界線を誤解すると、「マスクしたのに会話ログには元の文字列が残っていた」「応答を止めたかったのに止まらなかった」という食い違いが起きます。
MessageDisplayでできること・できないこと
Claude Codeのフックには、ツール呼び出しやスラッシュコマンド展開のように処理そのものを止められるものと、記録や表示だけを担う観測系の2系統があります。MessageDisplayは後者で、しかも観測系の中でもさらに狭い「表示専用」という性格を持ちます。
| したいこと | MessageDisplayで実現できるか | 理由 |
|---|---|---|
| 画面表示からMarkdown記法を除く | MessageDisplayで実現できるか◎ | 理由displayContentで表示テキストを差し替えられる |
| ターミナル表示の機密情報をマスクする | MessageDisplayで実現できるか◎ | 理由画面上の漏洩は防げる |
| Claudeの応答内容そのものを訂正する | MessageDisplayで実現できるか× | 理由transcriptとClaudeが見るテキストは変わらない |
| 危険な応答が出たら止める | MessageDisplayで実現できるか× | 理由ブロックという決定制御を持たない |
| ツール呼び出しの結果表示を変える | MessageDisplayで実現できるか× | 理由対象はアシスタントのテキストのみ |
| ハンドラーを終了コード2で失敗させて表示を止める | MessageDisplayで実現できるか× | 理由終了コードに関わらずdisplayContentを返さなければ元のテキストがそのまま表示される |
displayContentが変えるものと変えないもの
置き換わる
ターミナルに描画されるアシスタントのテキストです。マスクした文字列は、ここでだけ置き換わります。
元のまま残る
transcript_pathが指すログ、Claudeが参照するテキスト、verboseモードの表示です。APIキーやホスト名をマスクしても、ログには元の文字列が書き込まれます。
決定制御を持つ他のフックと並べると、この違いがはっきりします。決定制御を持たない側のイベントはSetup/InstructionsLoadedフックの実務設定でも扱っています。
| フック | 介入するタイミング | ブロック・改変 |
|---|---|---|
PreToolUse | 介入するタイミングツール呼び出しの実行前 | ブロック・改変呼び出し自体をブロックできる |
UserPromptExpansion | 介入するタイミングスラッシュコマンドの展開時 | ブロック・改変展開をブロックできる |
PreModelSwitch | 介入するタイミングモデル切り替えの適用前 | ブロック・改変切り替えをブロックできる |
MessageDisplay | 介入するタイミング応答テキストのストリーミング表示中 | ブロック・改変画面表示のみ変更。ブロック・transcript改変は不可 |
モデル切り替えの前後で対になる標準イベントはPreModelSwitch hookとPostModelSwitch hookにまとめています。フック全体の設定構文とイベント一覧はClaude Code Hooks完全ガイドが扱います。
MessageDisplayはmatcherによるフィルタリングにも対応しません。UserPromptSubmitやStopと同じグループに属し、条件を絞らずアシスタントのテキストを含む応答が来るたびに毎回発火します。ツール呼び出しだけで完結した応答のように、テキストを含まない応答では発火しません。Fable系モデルで応答テキストがthinkingブロックに吸収されて消える現象のように、テキストブロックが欠けた応答は発火の対象外で、displayContentでも戻せません。この現象の仕組みはClaude Codeの応答が消える理由 — thinkingに吸収される仕組みにまとめています。
ステップ1 — 入力フィールドとバッチの単位を理解する
MessageDisplayは応答全体が完成してから1回だけ呼ばれるわけではありません。画面に描画できる行がまとまるたびに区切られ、1つの応答に対して複数回呼ばれることがあります。短い応答なら1回で終わることもあります。
コマンドフック共通のsession_id / transcript_path / cwd / hook_event_nameに加えて、MessageDisplay固有の入力フィールドを受け取ります。
| フィールド | 内容 |
|---|---|
turn_id | 内容現在のターンのUUID |
message_id | 内容表示中のアシスタントメッセージのUUID。同じメッセージの全バッチで共通の値になる |
index | 内容メッセージ内でのこのバッチの0始まりの位置 |
final | 内容このメッセージの最後のバッチならtrue |
delta | 内容前回のバッチ以降に新しく確定した行(末尾の改行を含む) |
共通入力フィールドのうちeffort(実効中のeffortレベル)はPreToolUseやStopのようなツール実行文脈のイベント向けで、MessageDisplayの入力には含まれません。他のイベント用に書いたスクリプトをMessageDisplayに流用する際は、この差を見落としやすい点です。
message_idはAPIが払い出すmsg_…形式のIDとは別物です。transcriptに記録されたメッセージIDと突き合わせる用途には使えません。この点を知らずに「message_idでtranscriptの該当行を検索する」処理を書くと、一致しないまま原因を探し続けることになります。
deltaは基本的に行単位で確定した分だけが渡されますが、最後のバッチだけは行の途中で終わることがあります。さらに応答が改行で終わる場合、最後のバッチのdeltaが空文字になることもあります。バッチの区切りを行数で予測するのではなく、finalがtrueかどうかだけを「このメッセージの表示が終わった」合図として扱うのが安全です。
ステップ2 — displayContentで表示を書き換える
MessageDisplayの出力はdisplayContentという1つのフィールドだけを見ます。これを省略すると元のテキストがそのまま表示されます。それ以外のJSON出力フィールド(systemMessageやcontinue)は受け取っても捨てられるため、決定を左右する用途には使えません。
MessageDisplayが受け付けるハンドラー種別はcommand / http / mcp_toolの3つだけです。UserPromptSubmitのようにprompt型やagent型のハンドラーを割り当てられるイベントもありますが、MessageDisplayではその2つは使えません。
MessageDisplayは.claude/settings.jsonのようなsettingsファイルだけでなく、Skillやサブエージェントのfrontmatterでも同じ形式で定義できます。サブエージェントのfrontmatterに書いた場合は、そのサブエージェントが実行されている間だけ発火し、終了すると登録解除されます。プロジェクトのサブエージェントのfrontmatterに書いたフックは、そのフォルダーの信頼ダイアログを承認した後にだけ動きます。-pセッションは承認したことになりません。Skillのfrontmatterに書いた場合は、呼び出し以降セッションが終わるまで動き続けます。初回の成功後に外すonce: trueが働くのは、Skillのfrontmatterに書いたフックだけです。settingsファイルとサブエージェントのfrontmatterでは無視されます。
まず.claude/settings.jsonにハンドラーを登録します。
{
"hooks": {
"MessageDisplay": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/plain-display.sh",
"args": []
}
]
}
]
}
}MessageDisplayはmatcherを評価しないため、hooks配列の直下にハンドラーを並べるだけで足ります。${CLAUDE_PROJECT_DIR}はセッションを開始したプロジェクトのルートに置き換わります。パス置換を含むときはargsを付けると、空白を含むパスでもそのまま1つの引数として渡ります。
スクリプトは.claude/hooks/plain-display.shに保存し、chmod +xで実行権限を付けます。標準入力のJSONからdeltaを受け取り、加工した結果をdisplayContentとして返します。太字マーカーとインラインコードのバッククォートを除去して、平文表示に寄せる例です。
#!/bin/bash
jq '{hookSpecificOutput: {hookEventName: "MessageDisplay", displayContent: (.delta | gsub("\\*\\*"; "") | gsub("`"; ""))}}'Windows(PowerShell)での実装
コマンドハンドラーはtype: "command"のまま、commandにPowerShellを指定します。
{
"hooks": {
"MessageDisplay": [
{
"hooks": [
{
"type": "command",
"command": "powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${CLAUDE_PROJECT_DIR}/.claude/hooks/plain-display.ps1"
]
}
]
}
]
}
}-NoProfileはプロファイル読み込みを省いて起動を速くし、-ExecutionPolicy Bypassはローカルスクリプトの実行を許可します。スクリプト側は同じ加工をPowerShellで行います。
$batch = [Console]::In.ReadToEnd() | ConvertFrom-Json
$text = $batch.delta -replace '\*\*', '' -replace '`', ''
@{
hookSpecificOutput = @{
hookEventName = "MessageDisplay"
displayContent = $text
}
} | ConvertTo-JsonMarkdown記法を含まないバッチはそのまま素通りします。jqが見つからないなど、スクリプトそのものが失敗した場合はエラーで止まらず元のテキストが表示され、失敗はセッション上には出ずデバッグ出力にだけ記録されます。障害時に表示が壊れる心配は小さい一方、失敗に気づきにくいという裏返しでもあります。
claude --debug-file /tmp/claude-messagedisplay.logハンドラーが実際に動いているか、失敗して原文表示にフォールバックしていないかは、この--debug-fileのログで確認します。動作を比べたいときは、claude --bareが使えます。claude --helpでは、--bareは「Minimal mode: skip hooks」と説明され、settingsとインストール済みプラグインのフックを読み込みません。
ログの保存先を指定せずclaude --debugで起動した場合は、~/.claude/debug/<session-id>.txtに書き込まれます(--debugはターミナルへの出力は行いません)。ハンドラーの失敗理由まで細かく追いたいときは、環境変数CLAUDE_CODE_DEBUG_LOG_LEVEL=verboseを設定するとマッチング周りのログ行が増えます。
Claude Codeは各バッチの表示をハンドラーの応答待ちで止めます。処理に時間のかかるスクリプトを書くと、その分だけ応答の描画が遅れて見えます。既定のタイムアウトは10秒で、タイムアウトしたときも元のテキストが表示されます。延ばしたいときは、ハンドラーのエントリにtimeoutフィールド(秒単位)を追加します。外部APIを呼ぶような重い処理は避け、jqやシンプルな文字列置換で完結させるのが基本方針です。
ステップ3 — claudeを起動せずにスクリプトを試す
フックはstdinのJSONを読んで標準出力にJSONを返すだけのコマンドです。パイプで直接動かせるので、登録の前に出力を確かめられます。
1つ目のサンプルです。
echo '{"index":0,"final":true,"delta":"**注意**: `rm -rf` は実行しません\n"}' \
| .claude/hooks/plain-display.sh出力は次のとおりです。太字マーカーとバッククォートが消えています。
{
"hookSpecificOutput": {
"hookEventName": "MessageDisplay",
"displayContent": "注意: rm -rf は実行しません\n"
}
}**が2つの呼び出しに分かれても、index: 0の**複数行にまたがるとindex: 1の強調**ですは、各バッチでそれぞれ複数行にまたがると強調ですに除去されます。マーカーを1個ずつ除くスクリプトなので、バッチの境界を気にせず動きます。
最後のバッチが空文字のときは、displayContentも空文字で返ります。
{"hookSpecificOutput":{"hookEventName":"MessageDisplay","displayContent":""}}jqが無い環境でスクリプトを動かすと、jq: command not foundで終了コード127になります。本番では、このときClaude Codeが元のテキストを表示します。
同じ型でマスキングにも使えます。次はsk-ant-で始まる文字列を伏せる例で、正規表現はスクリプトの書き手が決めるものです。
#!/bin/bash
jq '{hookSpecificOutput: {hookEventName: "MessageDisplay", displayContent: (.delta | gsub("sk-ant-[A-Za-z0-9_-]+"; "[REDACTED]"))}}'キーは sk-ant-api03-abcDEF123_xyz ですという行を渡すと、キーは [REDACTED] ですを返します。各呼び出しが受け取るのは確定した行だけなので、改行をまたいで分かれた文字列は1回の呼び出しの中では見えません。
ステップ4 — Agent SDKとclaude -pでのバッチ挙動を確認する
対話セッションでは複数回呼ばれるMessageDisplayも、Agent SDKのクエリやclaude -pのような非対話実行では性格が変わります。
実行モードごとの呼び出し
対話セッション
行がまとまるたびに呼ばれ、deltaには新しく確定した行だけが入ります。
Agent SDK・claude -p
メッセージの完成後に呼ばれます。indexは0、finalはtrueで、deltaは全文です。
ハンドラー側で毎回のdeltaを連結して蓄積する実装にしておけば、対話・非対話のどちらのモードでも最終的に受け取る合計テキストは同じになります。逆に「複数回呼ばれる前提」でindexごとの累積を仮定した実装を組むと、非対話実行では1回しか呼ばれないために分岐漏れが起きます。Agent SDKでClaude Codeを組み込み、エンドユーザー向けにテキストを加工して見せたい場合は、この非対話側の挙動が実質的な仕様になります。
コマンドハンドラー以外にも、HTTPエンドポイントをハンドラーに指定する構成があります。ネットワーク越しにフックを処理したい場合の実装はClaude CodeのHooksをHTTPエンドポイントで受けるにまとまっています。
よくあるつまずき
- PowerShellのフックに、素の
$CLAUDE_PROJECT_DIRと書いてしまう。PowerShellは未定義のローカル変数として$nullに解決し、スクリプトのパスからプロジェクトルートの接頭辞が消えます。Claude Codeはこの形を書き換えず、デバッグログに警告を出します。${CLAUDE_PROJECT_DIR}か$env:CLAUDE_PROJECT_DIRと書きます - worktreeに入ったセッションで、
${CLAUDE_PROJECT_DIR}がworktreeを指すと考える。この値はセッションを開始したルートのままで、${CLAUDE_PROJECT_DIR}/.claude/hooks/...はメインのチェックアウトのスクリプトを実行します。Claudeが今いるディレクトリは、入力JSONのcwdから読みます - パス置換を含む
commandにargsを付けず、空白を含むパスのプロジェクトで起動に失敗する。"args": []を付けると、パスは1つの引数として渡ります
まとめ
表示の整形だけならMessageDisplay、応答や処理の制御は決定制御を持つPreToolUseなどと役割が分かれます。設定ではパス置換にargsを付け、終了判定はfinalで書くところから始めると、環境差で壊れにくくなります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。