Claude Media
Claude Code MessageDisplayフックで応答表示を書き換える

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を返さなければ元のテキストがそのまま表示される

決定制御を持つ他のフックと並べると、この違いがはっきりします。

フック介入するタイミングブロック・改変
PreToolUse介入するタイミングツール呼び出しの実行前ブロック・改変呼び出し自体をブロックできる
UserPromptExpansion介入するタイミングスラッシュコマンドの展開時ブロック・改変展開をブロックできる
PreModelSwitch介入するタイミングモデル切り替えの適用前ブロック・改変切り替えをブロックできる
MessageDisplay介入するタイミング応答テキストのストリーミング表示中ブロック・改変画面表示のみ変更。ブロック・transcript改変は不可

MessageDisplaymatcherによるフィルタリングにも対応しません。UserPromptSubmitStopと同じグループに属し、条件を絞らずアシスタントのテキストを含む応答が来るたびに毎回発火します。ツール呼び出しだけで完結した応答のように、テキストを含まない応答では発火しません。Fable系モデルの応答テキストがthinkingブロックに吸収されて消える現象も、この「テキストを含まない応答」に該当し、MessageDisplayでは検知できません。詳しくは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レベル)はPreToolUseStopのようなツール実行文脈のイベント向けで、MessageDisplayの入力には含まれません。他のイベント用に書いたスクリプトをMessageDisplayに流用する際は、この差を見落としやすい点です。

message_idはAPIが払い出すmsg_…形式のIDとは別物です。transcriptに記録されたメッセージIDと突き合わせる用途には使えません。この点を知らずに「message_idでtranscriptの該当行を検索する」処理を書くと、一致しないまま原因を探し続けることになります。

deltaは基本的に行単位で確定した分だけが渡されますが、最後のバッチだけは行の途中で終わることがあります。さらに応答が改行で終わる場合、最後のバッチのdeltaが空文字になることもあります。バッチの区切りを行数で予測するのではなく、finaltrueかどうかだけを「このメッセージの表示が終わった」合図として扱うのが安全です。

ステップ2 — displayContentで表示を書き換える

MessageDisplayの出力はdisplayContentという1つのフィールドだけを見ます。これを省略すると元のテキストがそのまま表示されます。それ以外のJSON出力フィールド(systemMessagecontinue)は受け取っても捨てられるため、決定を左右する用途には使えません。

MessageDisplayが受け付けるハンドラー種別はcommand / http / mcp_toolの3つだけです。UserPromptSubmitのようにprompt型やagent型のハンドラーを割り当てられるイベントもありますが、MessageDisplayではその2つは使えません。表示のたびに繰り返し呼ばれる高頻度イベントであるため、外部プロセスの起動を最小限に抑える設計になっています。

MessageDisplay.claude/settings.jsonのようなsettingsファイルだけでなく、Skillやサブエージェントのfrontmatterでも同じ形式で定義できます。サブエージェントのfrontmatterに書いた場合は、そのサブエージェントが実行されている間だけ発火し、終了すると登録解除されます。Skillのfrontmatterに書いた場合は、呼び出し以降セッションが終わるまで動き続けます。初回成功後に自動で外したいときは、ハンドラーにonce: trueを追加します。

まず.claude/settings.jsonにハンドラーを登録します。

{
  "hooks": {
    "MessageDisplay": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/plain-display.sh"
          }
        ]
      }
    ]
  }
}

MessageDisplaymatcherを評価しないため、hooks配列の直下にハンドラーを並べるだけで足ります。${CLAUDE_PROJECT_DIR}はリポジトリのルートに展開される環境変数で、プロジェクトを移動してもパスが壊れません。

スクリプト本体は標準入力から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-Json

Markdown記法を含まないバッチはそのまま素通りします。jqが見つからないなど、スクリプトそのものが失敗した場合はエラーで止まらず元のテキストが表示され、失敗はセッション上には出ずデバッグ出力にだけ記録されます。障害時に表示が壊れる心配は小さい一方、失敗に気づきにくいという裏返しでもあります。

claude --debug-file /tmp/claude-messagedisplay.log

ハンドラーが実際に動いているか、失敗して原文表示にフォールバックしていないかは、この--debug-fileのログで確認します。ログの保存先を指定せずclaude --debugで起動した場合は、~/.claude/debug/<session-id>.txtに書き込まれます(--debugはターミナルへの出力は行いません)。ハンドラーの失敗理由まで細かく追いたいときは、環境変数CLAUDE_CODE_DEBUG_LOG_LEVEL=verboseを設定するとマッチング周りのログ行が増えます。

Claude Codeは各バッチの表示をハンドラーの応答待ちで止めます。処理に時間のかかるスクリプトを書くと、その分だけ応答の描画が遅れて見えます。既定のタイムアウトは10秒で、それ以上の処理時間が必要なら、ハンドラーのエントリにtimeoutフィールドを追加して延長します。外部APIを呼ぶような重い処理は避け、jqやシンプルな文字列置換で完結させるのが基本方針です。

ステップ3 — Agent SDKとclaude -pでのバッチ挙動を確認する

対話セッションでは行がまとまるたびに複数回呼ばれるMessageDisplayも、Agent SDKのクエリやclaude -pのような非対話実行では性格が変わります。メッセージが完成したあとに1回だけ呼ばれ、index0finaltruedeltaにはメッセージ全文が入ります。

実行モード呼び出し回数deltaの中身
対話セッション呼び出し回数行がまとまるたびに複数回deltaの中身新しく確定した行だけ
Agent SDK / claude -p呼び出し回数メッセージ完成後に1回deltaの中身メッセージ全文

ハンドラー側で毎回のdeltaを連結して蓄積する実装にしておけば、対話・非対話のどちらのモードでも最終的に受け取る合計テキストは同じになります。逆に「複数回呼ばれる前提」でindexごとの累積を仮定した実装を組むと、非対話実行では1回しか呼ばれないために分岐漏れが起きます。Agent SDKでClaude Codeを組み込み、エンドユーザー向けにテキストを加工して見せたい場合は、この非対話側の挙動が実質的な仕様になります。

コマンドハンドラー以外にも、HTTPエンドポイントをハンドラーに指定する構成があります。ネットワーク越しにフックを処理したい場合の実装はClaude CodeのHooksをHTTPエンドポイントで受けるにまとまっています。

よくあるつまずき

  • message_idをAPIのmsg_…形式のIDと同じものだと思い込み、transcript側のログとの突き合わせを試みて失敗する。message_idは同一メッセージの全バッチで安定した値ですが、API側のIDとは別系統です
  • displayContentでマスクした文字列が、transcript_pathのログファイルには元のまま残っていることに気づかず、機密情報保護がそこで完結していると誤解する。画面表示とログ保存は別レイヤーです
  • 対話セッションで動作確認したハンドラーを、indexごとの累積処理を前提に組んでしまい、Agent SDKやclaude -pのような1回呼び出しの環境で挙動が変わる
  • 最後のバッチのdeltaが空文字になるケースがあることを知らず、「deltaが空でなければ表示が続いている」という誤った終了判定を書いてしまう。判定にはfinalを使います
  • ハンドラーの処理が重く、既定の10秒に近づいて表示のもたつきが起きる。外部通信を挟む処理は避け、必要ならtimeoutフィールドで明示的に延長します

まとめ

MessageDisplayは、Claudeの応答テキストが画面に表示される瞬間だけに介入できるフックです。displayContentで見た目を変えられますが、transcriptやClaude自身が参照するテキスト、ツール呼び出しの結果には触れません。ブロックのような決定制御も持たないため、応答の中身を制御したい場合はPreToolUseStopのような別のイベントを選びます。

実装では、対話セッションと非対話実行(Agent SDK / claude -p)でバッチの呼ばれ方が根本的に違う点と、ハンドラーの応答待ちがそのまま表示の遅延になる点の2つを押さえておけば、Markdown除去や表示上のマスキングといった典型用途は問題なく組めます。フック全体の設定構文やイベント一覧はClaude Code Hooks完全ガイドに、決定制御を持たない他のイベントとの比較はSetup/InstructionsLoadedフックの実務設定に、モデル切り替え前後で発火する対の標準イベントはPreModelSwitch hookとPostModelSwitch hookにまとめています。

この記事を共有:XはてブLinkedIn