PostToolBatch hookで並列ツール結果をまとめて処理する
PostToolBatchフックは並列ツール呼び出しの全結果が出そろった後に1回だけ発火します。PostToolUseとの違い、バッチ単位の文脈注入、jqスクリプトの落とし穴を動かして確かめます。
PostToolBatchフックは、Claudeが1ターンで呼んだツール呼び出しがすべて解決したあと、次のモデル呼び出しに送る前に1回だけ発火するフックです。PostToolUseはツールごとに発火するため並列実行では同時に何度も走りますが、PostToolBatchはそのターンのバッチ全体に対して1回だけ動きます。個々のツールの結果ではなく「どの組み合わせのツールが動いたか」に応じて文脈を差し込みたいときに使います。
PostToolBatchフックが発火するタイミング
Claudeが複数のツールを並列で呼び出す構成では、PostToolUseは各ツールの完了ごとに独立して(同時並行で)発火します。PostToolBatchはそれらすべてが解決したあとにまとめて1回だけ発火し、次のモデル呼び出しの直前という位置で文脈を注入できます。単一のツールの結果ではなく、バッチとして何が起きたかを判定できるのが利点です。
matcherによるツール名の絞り込みはこのイベントにありません。特定のツールが含まれるバッチだけを対象にしたい場合は、スクリプト側でtool_calls配列を走査して判定します。
PostToolUseとPostToolBatchの違い
どちらもツール実行の後に発火しますが、発火回数・matcherの有無・tool_responseの形・exit code 2の効果が異なります。
同じ並列実行を2つのフックはどう見るか
PostToolUse
ツール1件につき1回、並列時は同時に何度も発火します。matcherでツール名を絞れます。tool_responseはツール固有の構造化Outputオブジェクトで、たとえばWriteなら{filePath: "...", type: "create"}の形です。exit code 2はstderrをClaudeに見せますが、ツールはすでに実行済みなのでブロックにはなりません。
PostToolBatch
バッチ全体で1回だけ発火します。matcherはありません。tool_responseはモデルが実際に見るtool_resultの内容を、シリアライズした文字列かコンテンツブロックの配列で受け取ります。exit code 2は次のモデル呼び出しの前にエージェントループを止めます。
つまずきやすいのはtool_responseの形です。Readの場合、PostToolBatchにはモデルが読む行番号付きテキストがそのまま渡ります。PostToolUseが渡す構造化Outputとは別物なので、同じ解析ロジックを両方に使い回すことはできません。
受け取るJSON
共通の入力フィールドに加えて、tool_callsという配列でバッチ内の全ツール呼び出しを受け取ります。
{
"hook_event_name": "PostToolBatch",
"tool_calls": [
{
"tool_name": "Read",
"tool_input": {"file_path": "/repo/ledger/accounts.py"},
"tool_use_id": "toolu_01...",
"tool_response": "1\tfrom __future__ import annotations\n2\t..."
},
{
"tool_name": "Read",
"tool_input": {"file_path": "/repo/ledger/transactions.py"},
"tool_use_id": "toolu_02...",
"tool_response": "1\tfrom __future__ import annotations\n2\t..."
}
]
}tool_responseは大きくなることがあるため、必要なフィールドだけを取り出して処理します。大きなファイルを何本も読むバッチでは、全文を毎回パースする実装は避けたほうが無難です。
PostToolUseだけでは足りないのはどんなときか
並列ツール呼び出しが増えるほど、PostToolUseは同時に何度も発火します。5つのファイルを並列で読めば5回起動し、それぞれが同じ判定ロジックを重複して回します。「今回はledger配下だけを読んだ」「今回は10個以上のツールが動いた」のように、バッチ全体の組み合わせで判断したい場合は、PostToolBatchで判定を1回に集約できます。
MCPツールが混在するバッチの扱い
tool_callsにはMCPサーバーのツールも他のツールと同じ形式で並び、tool_nameはmcp__<server>__<tool>になります。tool_nameがmcp__で始まるかを調べれば、組み込みツールとMCPツールを振り分けられます。matcherが無いぶん、この振り分けはすべてスクリプト側の責務です。
additionalContextの届き方とハンドラの分け方
additionalContextの文字列はシステムリマインダーに包まれ、バッチ内のツール結果の隣に挿入されます。Claudeは次のモデル呼び出しで読みますが、チャット画面にメッセージとしては出ません。複数のハンドラがadditionalContextを返したときは、すべての値がClaudeに渡ります。
additionalContextによる情報注入とdecision: "block"によるループ停止は、性格の異なる制御です。同じスクリプトに両方を詰め込むと、片方の条件変更がもう片方に影響しないかを毎回確かめることになります。一致するハンドラはすべて並列に実行されるので、2つの役割を別ファイルに分けておけます。
ハンドラを役割で分ける
文脈を注入するハンドラ
バッチの組み合わせを見てadditionalContextを返すだけです。何も返さずにexit 0すれば、そのバッチには介入しません。
バッチサイズを見張るハンドラ
件数などの条件を満たしたときだけdecision: "block"を返します。注入側とは別にテストでき、閾値の変更が注入の条件に波及しません。
同じハンドラを複数の設定ファイルに書いた場合は、1回だけ実行されます。ユーザー設定とプロジェクト設定に同じコマンドを重ねても、二重には動きません。
手元でスクリプトを動かして確かめる
フックのスクリプトは標準入力からJSONを読むだけなので、claudeを起動しなくても、サンプルのJSONを流し込んで出力を確かめられます。次の実装例の出力は、この方法でjq 1.7.1に流して得たものです。
実装例:特定ディレクトリを読んだバッチにだけ注意事項を注入する
ledger/配下のファイルをまとめて読んだバッチにだけ、作業完了前にテストを走らせるよう促す文脈を1回だけ注入する例です。
#!/bin/bash
# .claude/hooks/batch-context.sh
input=$(cat)
paths=$(jq -r '[.tool_calls[] | select(.tool_name=="Read") |
.tool_input.file_path] | join("\n")' <<<"$input")
if grep -q "ledger/" <<<"$paths"; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PostToolBatch",
additionalContext: ("これらのファイルはledgerモジュールの一部です。" +
"作業完了前にpytestを実行してください。")
}
}'
else
exit 0
fi{
"hooks": {
"PostToolBatch": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/batch-context.sh"
}
]
}
]
}
}additionalContextの値は括弧で囲んでいます。jqのオブジェクトの中で文字列を+でつなぐとき、括弧が無いと構文エラーになるためです。ledger配下のReadを含むサンプルJSONを流すと、exit 0で次のJSONが返ります(jq 1.7.1で確認)。
{
"hookSpecificOutput": {
"hookEventName": "PostToolBatch",
"additionalContext": "これらのファイルはledgerモジュールの一部です。作業完了前にpytestを実行してください。"
}
}括弧を付けない書き方で同じ入力を流すと、jqがsyntax error, unexpected '+', expecting '}'を出して終了コード3で終わり、標準出力は空になります。2以外の終了コードで標準出力が空のフックはブロックにならず、トランスクリプトにhook errorの通知が出てバッチは先へ進みます。注意書きが注入されないまま作業が続くので、スクリプトは本番に置く前に一度流して確かめます。
PostToolUse側で同じ注意書きを個々のRead呼び出しに付けると、5ファイル読めば5回同じ文脈が挿入されます。PostToolBatchなら「このターンでledger配下を読んだ」という事実に対して1回で済みます。
実装例:バッチが大きすぎるときにループそのものを止める
decision: "block"(またはcontinue: false)を返すと、エージェントループを次のモデル呼び出しの前に止められます。1バッチに含まれるツール呼び出し数が閾値を超えたら、範囲を絞るよう促して一旦止める例です。
#!/bin/bash
# .claude/hooks/batch-size-guard.sh
input=$(cat)
count=$(jq '.tool_calls | length' <<<"$input")
if (( count > 20 )); then
jq -n --arg n "$count" '{
decision: "block",
reason: ("1バッチに" + $n + "件のツール呼び出しが含まれています。" +
"対象を絞ってから続けてください。")
}'
else
exit 0
fiツール呼び出しを21件並べたサンプルJSONでは次の出力になり、2件のサンプルでは何も出力せずexit 0でした。
{
"decision": "block",
"reason": "1バッチに21件のツール呼び出しが含まれています。対象を絞ってから続けてください。"
}ブロックの理由は警告としてトランスクリプトに出て、会話に残るためClaudeにも見えます。スクリプトがexit 2で止める場合は、stderrの内容が理由になります。PostToolUseやPostToolUseFailureのexit code 2はstderrをClaudeに見せるだけで、すでに終わったツール呼び出しは取り消せません。PostToolBatchのexit code 2は、次のモデル呼び出しの前にループを止められる点が違います。
effortフィールドで実行負荷を記録する
ツール実行の文脈で発火するイベントの入力には、現在のeffortレベルがeffortオブジェクトのlevelとして入ります。値はlow・medium・high・xhigh・maxのいずれかで、同じ値が環境変数CLAUDE_EFFORTにも入ります。バッチの処理コストをレベルごとに集計したり、xhighやmaxのバッチだけログを厚めに残したりする用途に使えます。使っているモデルがeffortに対応していない場合はフィールド自体が付かないため、値が無いケースも想定して実装します。
注入した値はセッション再開後に古くならないか
--continueや--resumeでセッションを再開すると、Claude Codeは過去のターンのPostToolBatchフックを再実行しません。保存されているトランスクリプトから、そのとき注入されたadditionalContextを再生します。タイムスタンプやコミットハッシュのように時間で変わる値を含めると、再開後の会話には実行時点の古い値が残ります。
最新にしたい情報はSessionStartで注入します。SessionStartは再開時にsourceを"resume"として(--fork-sessionを付けたときは"fork"として)もう一度実行されるためです。
タイムアウトとデバッグ
PostToolBatchのcommand・http・mcp_toolハンドラは、既定で600秒のタイムアウトです。一方、UserPromptSubmit・PreModelSwitch・PostModelSwitchは30秒、MessageDisplayは10秒が既定です。promptハンドラの既定は30秒、agentハンドラは60秒です。
タイムアウトに達したハンドラは出力が破棄されるため、そのバッチには注入もブロックも起きません。大きなファイルの読み取りが続くバッチで処理が長引くときは、timeoutを明示的に伸ばします。
デバッグにはclaude --debug-file <path>が便利です。フックの実行結果が指定したファイルに書き出されます。claude --debugだけで起動した場合も、ログは端末ではなく~/.claude/debug/<session-id>.txtに出ます。--debug=mcp,startupのように=でカテゴリを指定して絞ることもできます。
フックが動かないと感じたときは、--bareで起動していないかも見ます。--bareは設定とプラグインのフックを丸ごとスキップします。
ハンドラの出力が大きすぎるとどうなるか
additionalContext・systemMessage・標準出力のテキストには、それぞれ10,000文字の上限があります。超えた場合は全文がセッションのディレクトリ内のファイルに保存され、Claudeにはそのファイルのパスと先頭2,000文字までのプレビューが渡ります。ファイルを読むようにClaudeへ促す処理は入らないので、必ず見せたい内容は上限の内側に収めます。上限は複数のハンドラの合計ではなく、文字列ごとに測られます。
よくある質問
type: promptのフックでもループを止められますか
止められます。ok: falseを返すとそのターンは終了し、理由は警告行として会話に残ります。continueOnBlock: trueで「理由をClaudeに返して続行」へ切り替えられるのはPreToolUse・PostToolUse・TeammateIdleです。PostToolBatchではターンが終了します。
additionalContextとdecision: blockは同時に使えますか
両方を1つのJSONに入れた場合の挙動を示す例はフックの仕様ページにありません。注入と停止は別のハンドラに分けると、それぞれの意図が明確になります。
systemMessageでユーザーに直接警告を出せますか
出せます。systemMessageはdecisionやadditionalContextと独立した汎用フィールドで、ユーザー向けの警告として表示されます。イベントによっては破棄されたり別の場所に出たりします。Claudeの判断には影響させず、人間だけに注意書きを見せたい場合に使います。
まとめ
PostToolBatchが向くのは、複数ツールの組み合わせを見て1回だけ判断したい場面です。個々の結果に反応するならPostToolUseのままで足ります。スクリプトはclaudeを起動せずにサンプルのJSONで動かし、jqの構文エラーで無言のまま通り抜けないことを確かめてから置きます。フック全体の仕様はClaude Code Hooks完全ガイド、コピペで動く他のレシピはHooks実例カタログにあります。並列ツール呼び出しがどう独立して扱われるようになったかの経緯はClaude Code v2.1.161、並列実行時のMCP接続可視化の改善はClaude Code v2.1.128にまとまっています。