Claude Media
Claude Code modのprompt.submitでプロンプトを書き換え・止める

Claude Code modのprompt.submitでプロンプトを書き換え・止める

modのprompt.submitフックで、送信前のプロンプトを書き換える・Claudeだけが読む文脈を足す・dropで送信を止める。3つの返し方と失敗時の挙動をまとめます。

prompt.submitは送信直前のプロンプトを受け取るフック

prompt.submit は、Claude Codeのmodが登録できるイベントのひとつです。ユーザーがプロンプトを送信すると、ターンが始まる前にフックが呼ばれます。フックは e.text(入力されたとおりの文字列)を読み、書き換え、補足の追加、送信の中止のどれかを選べます。

返し方は3つです。

やりたいこと返すもの
プロンプトを書き換える返すものnext({ ...e, text: newText })
Claudeだけが読む文を後ろに足す返すものnext({ ...e, context: [...(e.context ?? []), extraText] })
送信を止める返すもの{ drop: 'the reason' }

書き換えるとトランスクリプトとプロンプト履歴には新しいテキストが残ります。context に足した文は、画面に出るメッセージを変えません。modの基本形(on(eventName, handler)、$・e・next の3引数)はClaude Codeのmod公式ドキュメントにあり、ここでは prompt.submit に絞って使い方を見ます。

書き換え: nextに変更済みのコピーを渡す

イベント e は深く凍結されていて、e.text = ... と代入すると例外になります。変えたいときは、展開した別のオブジェクトを next に渡します。公式の最小例は、各プロンプトの前後の空白を削るhookです。

on('prompt.submit', async ($, e, next) => {
  // 変更済みのコピーを渡す
  return next({ ...e, text: e.text.trim() })
})

後ろにあるフックとClaude Code本体は、トリム後の文字列だけを受け取ります。元の文字列は見えません。たとえば短縮記法を定型文に展開する用途にも、同じ形で使えます。次は公式の trim の例を応用した書き方の例です。

on('prompt.submit', async ($, e, next) => {
  // 先頭が "!review" のときだけ定型の依頼文に展開する
  if (!e.text.startsWith('!review')) return next(e)
  const rest = e.text.slice('!review'.length).trim()
  return next({ ...e, text: 'Review the staged changes. ' + rest })
})

条件に合わないときは return next(e) で素通しにします。ここを忘れて何も返さないと、結果の形が違うフックとして扱われます(後述の失敗時の挙動を参照)。

文脈追加: Claudeだけが読む行を足す

書き換えはユーザーの目に触れる変更です。履歴にも新しいテキストが残るので、入力した覚えのない文が混ざることになります。画面上のメッセージはそのままで、Claudeにだけ補足を渡したいときは context を使います。

公式の例は、プロンプトがpull requestに触れたときだけ、現在のブランチ名を足すhookです。

on('prompt.submit', async ($, e, next) => {
  if (!/\bPR\b|pull request/i.test(e.text)) return next(e)
  const git = await $.process.run(['git', 'branch', '--show-current'])
  // git リポジトリの外ではコマンドが失敗する
  if (git.exitCode !== 0) return next(e)
  return next({ ...e, context: [...(e.context ?? []), 'Current branch: ' + git.stdout.trim()] })
})

open a PR for this change と送ると、トランスクリプトの表示は入力したとおりです。Claudeは後ろに Current branch: feature/auth のような行を読みます。PRに触れないプロンプトでは git を呼びません。

書くときの注意は2点です。

  • [...(e.context ?? [])] のように、前のフックが足した context を残して追記する。ここで context: [extraText] と書くと、他のmodが足した文を上書きしてしまう
  • 条件を先に判定し、必要なときだけ外部コマンドを呼ぶ。フックの実行時間には10秒の上限があり、超えたフックはスキップされる($.process.run 自体のタイムアウトは既定30秒で別枠)

drop: 送信を止めて入力欄に戻す

next を呼ばずに { drop: 'the reason' } を返すと、プロンプトは送られません。テキストはユーザーの入力欄に戻り、Prompt dropped by a hook: に続けてreasonが表示されます。reasonの読み手はClaudeではなくユーザーなので、次にどう直せばよいかを書きます。

次は書き方の例です。APIキーに見えるトークンが混ざったプロンプトを止めます。

on('prompt.submit', async ($, e, next) => {
  if (/\bsk-[A-Za-z0-9_-]{20,}/.test(e.text)) {
    return { drop: 'APIキーらしき文字列が含まれています。削除してから送信してください。' }
  }
  return next(e)
})

押さえておく条件は次のとおりです。

  • reasonは4,096文字までで、それより長いと末尾が切られる(切られてもdropは有効)。切り詰めはv2.1.292以降で、それ以前の版ではフックが失敗扱いになる
  • next(e) を呼んだあとに drop を返しても、プロンプトは通る。ターンは走り、フックは a drop after its next() was answered を含むメッセージで失敗する。dropは next を呼ぶ前に決める

matcherで対象のプロンプトだけに絞る

on の第2引数にフィルタ(Claude Codeではmatcherと呼ぶ)を渡すと、イベントのフィールドと比べて、全部が合ったときだけフックが動きます。値・配列・正規表現のどれでも書けます。説明は tool.call の { tool: 'Bash' } を例にしたもので、prompt.submit 専用の例はありません。ただし e.text はイベントのフィールドなので、次のように書く形が考えられます。

// 書き方の例: 先頭が "!review" のプロンプトだけを対象にする
on('prompt.submit', { text: /^!review/ }, async ($, e, next) => {
  const rest = e.text.slice('!review'.length).trim()
  return next({ ...e, text: 'Review the staged changes. ' + rest })
})

matcherに合わないプロンプトではフックが呼ばれないため、前節のような if (...) return next(e) の素通し分岐が要りません。この書き方が自分のバージョンで通るかは、後述のテストで確かめられます。

もうひとつ、登録の数え方があります。同じイベントはmatcherごとに1回しか登録できません。eventsページは session.start で説明していて、matcherなしで2回呼ぶと on("session.start") is registered twice without a matcher でモジュールの読み込みが失敗します。この規則は「Register each event once per matcher」と一般形で書かれているので、matcherなしの prompt.submit フックを1つのmodの中に2つ置く構成は避け、書き換えと検査を同じ関数にまとめるか、matcherで分けます。

フックが失敗したときは通ってしまう

フックが例外を出す、時間切れになる、結果の形が間違っている。そのとき next を呼ぶ前に失敗したフックは、スキップされて次のハンドラが動きます。drop で守る構成では、フックが壊れた瞬間にチェックが外れる点に注意が必要です。

失敗したときの扱いは、next を呼んだかどうかで分かれます。

  • next の前に失敗した: そのフックはスキップされ、次のハンドラが代わりに動く
  • next が返ったあとに失敗した: その結果が有効のまま残り、同じフックは二度動かない

どちらも1行のメッセージが残ります。形は my-mod: tool.call hook skipped: threw Error: boom のように、mod名・イベント・理由が並びます。prompt.submit のdropが通らなかったとき、まずここを見ると原因が分かります。

止める側に倒したいなら、.catch を付けます。

on('prompt.submit', guard).catch(async ($, e, next) => {
  // guard が next を呼んだ後に失敗したなら、通った結果をそのまま返す
  if (next.called) return next(e)
  return { drop: 'プロンプトの検査に失敗したため送信を止めました: ' + next.error.kind }
})

next.called が false のときは、prompt.submit の拒否形である { drop: 'the reason' } を返します。.catch ハンドラ自身の実行時間は1秒が上限で、そこで例外や時間切れが起きるとフックごとスキップされます。この場合も、guard が next を呼んでいなければプロンプトは通ります。

設定ファイルのUserPromptSubmitフックとの違い

settings.jsonに書くコマンド型などのhookにも UserPromptSubmit があります。同じ入口に見えますが、できることが違います。

観点modの prompt.submit設定hookの UserPromptSubmit
プロンプト本文の置き換えmodの prompt.submitできる(text を差し替える)設定hookの UserPromptSubmitできない
追記modの prompt.submitcontext に足す設定hookの UserPromptSubmitadditionalContext で足す
送信の中止modの prompt.submit{ drop: reason }設定hookの UserPromptSubmitdecision: "block" か終了コード2
記述する場所modの prompt.submitプラグイン内のJavaScriptモジュール設定hookの UserPromptSubmitsettingsのhook定義

設定hook側は、プロンプトを置き換えられず additionalContext を添えられるだけです。本文の整形まで要るならmodを選ぶ理由になります。逆に、ブロックされたプロンプトの本文はトランスクリプトに残る点は設定hookの仕様で、suppressOriginalPrompt で消せます。一方、modのeventsページの prompt.submit の節は、dropしたテキストがトランスクリプトに残るかどうかを定めていません。秘密情報を止める用途では、止めたあとのテキストの残り方を手元の環境で試してから運用に載せるのが確実です。設定側の全イベントはClaude Code Hooks完全ガイドにまとめています。

兄弟イベント: prompt.section・prompt.context・skill.prompt

ユーザーが打つ文だけがClaudeの読むものではありません。リファレンスは、それ以外の入口を別のイベントとして並べています。

イベント発火のタイミング返せるもの
prompt.section発火のタイミングシステムプロンプトの名前付きセクションごとに1回。e.name が prompt.compose の id返せるもの{ text }、または { text: null } でセクションを省く
prompt.context発火のタイミング会話ごとに1回、最初のメッセージと一緒に送る文脈返せるもの{ blocks }
skill.prompt発火のタイミングスキルの本文がClaudeに展開されるとき返せるもの{ text }

注意として、これらのフックが返す文がリクエストのたびに変わると、プロンプトキャッシュが無効になります。タイムスタンプや乱数を prompt.section に混ぜる書き方が典型です。固定の文なら影響はありません。どのイベントでも、差し込む文は同じセッション内で変えないほうが安全です。なお、prompt.submit 自体については、キャッシュとの関係は記載されていません。

そのほか、プロンプト欄に入るテキストを扱う prompt.fill・prompt.suggest、入力欄の編集に反応する prompt.edit、@ メンションで読むファイルを差し替える prompt.mention(v2.1.290以降)があります。prompt.edit だけは実行時間の上限が50ミリ秒と短く、重い処理を置けません。

複数のmodがあるときの順番

同じイベントに複数のmodがフックを付けると、ひとつのチェーンになります。最初のmodが最も外側で、イベントを最初に見ます。後ろのmodが前のmodを止めることはできません。

順序は次のとおりです。

  1. 組織の prependPlugins に入れたmodと、組み込みのガード
  2. 自分で入れたmod
  3. 組織の appendPlugins のmod
  4. その他の組み込みmod

自分で入れたmodの間では、manifestの dependencies に別のmodを挙げている側が先に動き、挙げられた側はその後ろに回ります。ひとつのモジュールの中では、register が on を呼んだ順にフックが走ります。たとえば書き換えのフックを検査のフックより前に on で登録すれば、検査は書き換え後の e.text を見ます。

自分で入れた prompt.submit フックの前に、組織側が drop を返すことがあります。その場合、自分のフックは呼ばれません。導入そのものを組織で絞る設定はallowManagedHooksOnlyの連鎖的な影響が参考になります。mod側の管理設定は別のキーなので、混同しないでください。

動作を確かめる

作ったフックは、セッションもサインインも無しに claude plugin test で確かめられます。テストのキットと、イベントごとのスタブの返し方はmodのtestを書くにまとめてあります。prompt.submit のスタブは、フックが next(e) で渡した先でClaude Codeの代わりに答えるもので、($, e) => ({ text: e.text }) の形です。

次は、前掲の trim のフックを確かめるテストの書き方の例です。テストキットの $ はClaude Codeの役を演じ、$.prompt.submit のように同名のイベントを発火します(($, on) の形も含め、書き方は上のリンク先と同じです)。ここではスタブを on で登録し、発火した結果の text を比べます。

import { expect, test } from 'claude-code/testing'
 
test('prompt.submit trims the prompt', async ($, on) => {
  // Claude Code の代わりに、渡されたテキストをそのまま受け取る
  on('prompt.submit', ($, e) => ({ text: e.text }))
 
  const sent = await $.prompt.submit({ text: '  hello  ' })
  expect(sent.text).toBe('hello')
})

期待する確認は3点です。

  • 条件に合わない入力が、return next(e) でそのまま通る
  • 条件に合う入力で、トランスクリプトの表示とClaudeが読む内容が意図どおりに分かれる(text を変えたか、context を足したか)
  • 例外を投げたとき、.catch の drop が返る

drop を確かめるテストも同じ形で、APIキーらしき文字列を入れて、フックが返したオブジェクトにreasonが入っているかを見ます。返り値の形は手元のバージョンで一度出力して確かめてください。

tool.call 側の同じ書き方はtool.callを観測・書き換え・代理応答するにあります。prompt.submit と違い、拒否の形が { deny } になる点が異なります。

まとめ

prompt.submit は、見える変更(text)と見えない補足(context)と中止(drop)を分けて返せるフックです。見える側を変えるほど履歴に残る文が実際の入力とずれるため、補足で足りるなら context に寄せるほうが副作用は小さくなります。止める用途では、フックが失敗すると素通しになる点が最大の落とし穴で、.catch の next.called 判定まで書いて初めて閉じた構成になります。

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