Claude Media
PostToolBatch hookで並列ツール結果をまとめて処理する

PostToolBatch hookで並列ツール結果をまとめて処理する

PostToolBatchフックは並列ツール呼び出しの全結果が出そろった後に1回だけ発火します。PostToolUseとの違いと、バッチ単位で文脈を注入する実装例をまとめます。

PostToolBatchフックは、Claudeが1ターンで呼んだツール呼び出しがすべて解決したあと、次のモデル呼び出しに送る前に1回だけ発火するフックです。PostToolUseはツールごとに発火するため並列実行では同時に何度も走りますが、PostToolBatchはそのターンのバッチ全体に対して1回だけ動きます。個々のツールの結果ではなく「どの組み合わせのツールが動いたか」に応じて文脈を差し込みたいときに使います。

PostToolBatchフックが発火するタイミング

Claudeが複数のツールを並列で呼び出す構成では、PostToolUseは各ツールの完了ごとに独立して(同時並行で)発火します。PostToolBatchはそれらすべてが解決したあとにまとめて1回だけ発火し、次のモデル呼び出しの直前という位置で文脈を注入できます。この方式には、単一のツールの結果に依存するのではなく「バッチとして何が起きたか」を判定できるという利点があります。

matcherによるツール名の絞り込みはこのイベントには存在しません。特定のツールが含まれるバッチだけを対象にしたい場合は、スクリプト側でtool_calls配列を走査して判定します。

PostToolUseとPostToolBatchの違い

どちらもツール実行の後に発火しますが、発火回数・matcherの有無・exit code 2の効果が異なります。

フック発火回数matchertool_responseの形式exit code 2の効果
PostToolUse発火回数ツールごとに1回(並列時は同時多発)matcherあり(ツール名で絞り込み可)tool_responseの形式ツール固有の構造化Outputオブジェクト(例: Writeなら{filePath, success})exit code 2の効果stderrをClaudeに見せる(ブロックはしない)
PostToolBatch発火回数バッチごとに1回matcherなしtool_responseの形式モデルが実際に見るtool_resultの内容をシリアライズした文字列・コンテンツブロック配列exit code 2の効果エージェントループを次のモデル呼び出し前に停止できる

tool_responseの形式がPostToolUseと異なる点は実装でつまずきやすいポイントです。Readツールの場合、PostToolUseが渡すのは行番号付きテキストではなく構造化Outputですが、PostToolBatch側はモデルが実際に読む行番号付きテキストそのものが渡ります。同じ解析ロジックを両方に使い回すことはできません。

受け取る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\n     2\t..."
    },
    {
      "tool_name": "Read",
      "tool_input": {"file_path": "/repo/ledger/transactions.py"},
      "tool_use_id": "toolu_02...",
      "tool_response": "     1\tfrom __future__ import annotations\n     2\t..."
    }
  ]
}

tool_responseは大きくなることがあるため、必要なフィールドだけを取り出して処理するのが定石です。全文を毎回パースする実装は、大きなファイルを何本も読むバッチで重くなります。

なぜPostToolUseだけでは足りないのか

並列ツール呼び出しが増えるほど、PostToolUseは同時に何度も発火します。5つのファイルを並列で読めば5回同時に起動し、それぞれが同じ判定ロジックを重複して回すことになります。「今回はledger配下だけを読んだ」「今回は10個以上のツールが動いた」のように、個々のツールの結果ではなくバッチ全体の組み合わせに応じた判断をしたい場合、PostToolUseだけで組み立てようとすると、どのハンドラが最後に発火したかに依存する不安定な実装になりがちです。PostToolBatchはこの種の判定を、バッチが完全に解決したあとの1回に集約できます。

MCPツールが混在するバッチの扱い

tool_callsにはMCPサーバーのツールも他のツールと同じ形式(tool_namemcp__<server>__<tool>)で並びます。バッチの中身によって処理を変えたい場合、tool_nameの前方一致でmcp__を判定すれば、組み込みツールとMCPツールを区別して扱えます。matcherによる絞り込みが無いぶん、こうした振り分けはすべてスクリプト側の責務になります。

タイムアウトとデバッグ

PostToolBatchのcommand・http・mcp_toolハンドラは既定で600秒のタイムアウトが設定されています。UserPromptSubmitMessageDisplayのように既定値が短縮されている一部のイベントとは異なるため、バッチに大きなファイルの読み取りが複数含まれ処理に時間がかかる場合はtimeoutを明示的に伸ばします。想定どおりにadditionalContextが注入されているかを確認したいときは、claude --debugで起動してデバッグログにフックの入出力を書き出すのが確実です。

役割ごとにハンドラを分ける

additionalContextでの情報注入とdecision: "block"によるループ停止は、性格の異なる制御です。同じスクリプトに両方の判定を詰め込むと、片方の条件変更がもう片方に影響しないかを毎回確認する必要が出てきます。PostToolBatchの配列には複数のハンドラを登録でき、登録した全ハンドラが並列実行されるため、「文脈を注入するだけのハンドラ」と「バッチサイズを監視して止めるハンドラ」を別ファイルに分けておくと、それぞれを独立にテスト・変更できます。

effortフィールドで実行負荷を記録する

PostToolBatchは共通入力フィールドのeffortも受け取ります。現在の実行に使われているeffortレベルがlowからmaxのいずれかで入るため、バッチの処理コストをeffortレベルごとに集計したり、xhighmaxで動いているバッチだけログを厚めに残したりする用途に使えます。effortをサポートしないモデルではこのフィールド自体が付かないため、値が無いケースも想定して実装します。

セッション再開時の挙動

--continue--resumeでセッションを再開すると、Claude Codeは過去のターンについてPostToolBatchフックを再実行するのではなく、そのとき注入されたadditionalContextをトランスクリプトから読み直して再現します。タイムスタンプやコミットハッシュのように時間とともに変わる値をadditionalContextに含めていると、再開後の会話には実行時点の古い値がそのまま残ります。再開のたびに最新化したい情報はPostToolBatchではなく、source: "resume"で再実行されるSessionStart側で改めて注入する設計にします。

実装例:特定ディレクトリを読んだバッチにだけ注意事項を注入する

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"
          }
        ]
      }
    ]
  }
}

PostToolUse側で同じ注意書きを個々のRead呼び出しに付けると、5ファイル読めば5回同じ文脈が挿入されます。PostToolBatchなら「このターンでledger配下を読んだ」という事実に対して1回で済み、コンテキストの消費を抑えられます。

実装例:バッチが大きすぎるときにループそのものを止める

additionalContextによる情報注入とは別に、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

このブロックメッセージは警告として会話に残り、会話が続く限りClaudeにも見える状態で保持されます。PostToolUsePostToolUseFailureexit code 2が「stderrをClaudeに見せるだけ」で呼び出し自体は取り消せないのに対し、PostToolBatchexit code 2は次のモデル呼び出し前にループそのものを止められる点が異なります。

落とし穴

matcherで絞り込めない

設定側にはツール名フィルタが無いため、特定のツールだけを対象にしたい場合は必ずスクリプト内でtool_callsを走査してから判定します。無条件に発火させたままファイルI/Oや外部通信を行うと、無関係なバッチでも毎回コストがかかります。

tool_responseの形式をPostToolUse用のコードで使い回さない

同じツール名でもPostToolUseは構造化Output、PostToolBatchはモデルが見るシリアライズ済みコンテンツという別形式です。パーサーを共用すると片方だけ壊れます。

大きなレスポンスは必要な部分だけ読む

バッチに大きなファイルの読み取りが複数含まれるとtool_response全体が肥大化します。正規表現やjqで必要なフィールドだけ抜き出す設計にしておきます。

よくある質問

ツール呼び出しが1件だけのターンでも発火しますか

PostToolBatchは「そのターンのツール呼び出しのバッチ」が解決するたびに1回発火する設計のため、バッチに含まれるツールが1件でも同じ扱いになると考えられます。

type: promptのフックでもループを止められますか

止められます。ok: falseを返すとそのターンは終了し、理由は警告行として会話に残ります。ただしPostToolUseのようにcontinueOnBlock: trueで「理由をClaudeに返して続行」へ切り替えることはできず、PostToolBatchではcontinueOnBlockの指定に関わらずターンが終了します。コマンドフックのdecision: "block"も同じくターンを終了させるため、停止の効果自体は変わりません。

PostToolBatchはPostToolUseの代わりになりますか

代わりにはなりません。個々のツール結果に応じた即時の文脈(「このファイルは自動生成です」等)はPostToolUseが担い、バッチ全体の組み合わせに応じた文脈はPostToolBatchが担う、という補完関係です。

additionalContextとdecision: blockは同時に使えますか

同じJSONオブジェクト内で両方のフィールドを返すことは技術的には可能ですが、decision: "block"を返すとその時点でループが止まるため、additionalContextは次にモデルが動くタイミングで初めて意味を持ちます。用途を分けて実装するほうが意図を追いやすくなります。

systemMessageでユーザーに直接警告を出せますか

出せます。systemMessagedecisionadditionalContextと独立した汎用フィールドで、ほとんどのイベントで警告としてユーザーに表示されます。Claudeの判断には影響させず、人間だけに注意書きを見せたい場合はこちらを使います。

複数のPostToolBatchフックを登録したらどうなりますか

登録した全ハンドラが並列実行され、それぞれのadditionalContextはすべてClaudeに渡されます。1つのハンドラが大量のテキストを返すと10,000文字を超えた分がファイルに退避されプレビューだけが渡る点は他のイベントと共通です。

まとめ

PostToolBatchは、並列ツール実行の結果を「個々のツール」ではなく「そのターンのバッチ」という単位でまとめて扱える唯一のフックです。役割の異なる判定は別々のハンドラに分けておくと、あとから条件を調整するときに見通しが良くなります。matcherが無いぶんスクリプト側での絞り込みが必須になりますが、その代わりにバッチ全体の組み合わせを見た判断ができ、exit code 2でエージェントループそのものを止める強い制御も持っています。同じツール名でもPostToolUseとはレスポンスの形が違う点だけ押さえておけば、大きな実装の落とし穴は避けられます。フック全体の仕様はClaude Code Hooks完全ガイド、コピペで動く他のレシピはHooks実例カタログを参照してください。並列ツール呼び出しがどう独立して扱われるようになったかの経緯はClaude Code v2.1.161、並列実行時のMCP接続可視化の改善はClaude Code v2.1.128にまとまっています。

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