Claudeのツールランナーを使ってtool useのループを楽に実装しよう

Claudeのツールランナーを使ってtool useのループを楽に実装しよう

Claudeのツールランナー(toolRunner)について解説しました。従来のtool useで毎回書いていたループ処理ツールランナーに任せられます。手動ループとの比較に加え、クロージャでユーザーIDを注入して認可をモデルに通さないパターンなど、実アプリで使える書き方についても紹介しています。
2026.08.21

こんにちは、リテールアプリ共創部マッハチームの morimorikochanです。

先日の記事"生成AI(Claude)で作る顧客体験にこだわったLINEチャットボットの設計"で、デモアプリ内のツールの実行にツールランナーという仕組みを使いました。
ただ、記事内ではドキュメントへのリンクを貼っただけだったので、今回はこのツールランナー自体を解説したいと思います。

主に、ツールランナーが何を肩代わりしてくれるのか、実際のアプリでどう使うのか、採用するときに何に気をつければいいのかについて記載しましたので、ClaudeでLLMアプリケーションを作っている・これから作ろうとしているエンジニアにとって参考になれば幸いです。

そもそもツールランナーとは?

ツールランナーは、Claudeを@anthropic-ai/sdk経由で呼び出す際のtool useのエージェントループを自動で回してくれる機能です。
エージェントループは簡単に言うと、モデルを呼び出す→tool_useが返ってくる→ツールを実行する→tool_resultを渡して再度モデルを呼び出す、というモデルとモデル外の処理を繋ぐためのループ処理のことです。このループは基本的にはモデルがツールを要求しなくなるまで繰り返します。

https://platform.claude.com/docs/ja/agents-and-tools/tool-use/tool-runner

ツールランナーは現在ベータ版で、Python、TypeScript、C#、Go、Java、PHP、Rubyの7つのSDKで利用できます。
例えばTypeScriptでは client.beta.messages.toolRunner()、Pythonでは client.beta.messages.tool_runner() として提供されています。

Claudeでアプリを作っていると似たような選択肢が複数あり、"結局どれを使えばいいのか"と迷ってしまうので整理してみました。

選択肢 ループを書くのは ツールの実行場所 向いている場面
Messages APIの手動ループ 自分 自分のプロセス内 人間の承認を挟むなど、ループを細かく制御したい
ツールランナー SDK 自分のプロセス内 自前のツールを呼ぶ一般的なエージェント処理
Claude Agent SDK フレームワーク サンドボックス等も込み ファイル操作やコマンド実行込みのフルエージェント

ツールランナーはあくまでMessages APIの薄いヘルパーで、ツールの実装と実行は自分のプロセス内に置いたまま、ループの部分だけをSDKに任せるものです。
ループは、Claudeがtool_useを含まない応答を返すと終了します。

また、ループ処理をSDKに委任しているため、簡単にループに回数制限をかけることができます。
終了条件がモデル任せにならないよう、max_iterationsで上限を設けておくことをおすすめします。

ツールランナーがあると何が嬉しい?

ツールランナーなしで、Messages APIでtool useを扱う場合は次のようなループを自前で書く必要があります。

const messages: Anthropic.MessageParam[] = [
  { role: 'user', content: 'パリの天気を教えて' },
];

let response = await client.messages.create({
  model: 'claude-sonnet-5',
  max_tokens: 1024,
  tools,
  messages,
});

// stop_reasonがtool_useである限り、ツールを実行して結果を返し続ける
while (response.stop_reason === 'tool_use') {
  const toolResults: Anthropic.ToolResultBlockParam[] = [];
  for (const block of response.content) {
    if (block.type !== 'tool_use') continue;
    try {
      // ツール名→実装の振り分けは自前で書く
      const result = await dispatchTool(block.name, block.input);
      toolResults.push({
        type: 'tool_result',
        tool_use_id: block.id,
        content: result,
      });
    } catch (err) {
      toolResults.push({
        type: 'tool_result',
        tool_use_id: block.id,
        content: String(err),
        is_error: true,
      });
    }
  }
  // 会話履歴に応答とツール結果を積んでから、再度呼び出す
  messages.push({ role: 'assistant', content: response.content });
  messages.push({ role: 'user', content: toolResults });
  response = await client.messages.create({
    model: 'claude-sonnet-5',
    max_tokens: 1024,
    tools,
    messages,
  });
}

stop_reasonの判定、ツール名から実装への振り分け、tool_use_idの対応付け、例外をis_error: trueに変換する処理、履歴への追加を、正しい形式で書き切る必要があります。
tool_resultの形式をひとつ間違えるだけでAPIエラーになるので、地味に面倒です。

ツールランナーを使うと、この辺りの処理が自動的に行われ、ループが終わったあとの最終メッセージだけ返ってきます。前述のコードと比べると大きく減っていることがわかると思います

const finalMessage = await client.beta.messages.toolRunner({
  model: 'claude-sonnet-5',
  max_tokens: 1024,
  max_iterations: 5,
  tools: [getWeatherTool], // run関数付きのツール定義(後述)
  messages: [{ role: 'user', content: 'パリの天気を教えて' }],
});

コードが減る以外にも以下のメリットがあります。特に並列化は待ち時間が減るので嬉しいですね

  • zodスキーマからrun関数の入力の型が推論され、ランタイムバリデーションも効く
  • run内で例外が投げられると、自動でis_error: truetool_resultに変換されてClaudeに渡り、Claudeがリカバリを試みる
  • 1回の応答に複数のtool_useが含まれる場合、自動で並列にツールコールが処理される

使い方

前回の記事で作った、ホームセンターのLINE公式アカウントのチャットボットを例に説明します。
FAQ検索、会員情報の照会、注文履歴の照会、有人対応へのエスカレーションなど8個のツールを持つボットで、構成はHono + Anthropic SDK + Amazon Bedrock(Claude Sonnet 5)です。Anthropic APIだけではなくAmazon Bedrockでも利用可能な点は嬉しいですね。

ツールランナーを使って出力した例1 例2
1_ポイントから注文まで 5_過去の会話引き継ぎ

ツールを定義する

TypeScriptではzodでスキーマを書くbetaZodToolを使います(zod 3.25.0以上)。
デモアプリのFAQ検索ツールの定義はこうなっています。

import { betaZodTool } from '@anthropic-ai/sdk/helpers/beta/zod';
import { z } from 'zod';

const searchFaq = betaZodTool({
  name: 'search_faq',
  description:
    'FAQをキーワードで検索する。回答の根拠づけに必ず使う。ヒットしない場合は言い換え(例: クーポン→割引券)で再検索すること。',
  inputSchema: z.object({
    query: z.string().min(1).describe('検索キーワード(スペース区切りで複数可)'),
  }),
  run: async (input) => {
    // inputは { query: string } に型推論されている
    const hits = scoreSearch([...store.faqs.values()], input.query).slice(0, 5);
    if (hits.length === 0) return '該当するFAQは見つかりませんでした。';
    return JSON.stringify(
      hits.map(({ item }) => ({ id: item.id, title: item.title, body: item.body })),
    );
  },
});

runは文字列を返却する関数なので、検索結果のような構造化データは、JSON.stringifyして文字列にしてから返します。

ツールランナー特有ではないですが、エラーのハンドリングは注意しなければなりません。

if (hits.length === 0) return '該当するFAQは見つかりませんでした。';

"FAQが見つからない"ような正常な結果は、例外を投げるのではなく次に何をすべきかを含んだ文字列で返すようにしなければなりません。
もし、例外を投げてしまうとis_errorフラグがtrueとしてClaudeに扱われてしまい、ユーザーへの提示やリトライを行なってしまうためです。

また、ツールが増えてきたら、descriptionに「いつ使わないか」も書いておくと誤選択が減ります。
例えばFAQ検索ツールなら"会員個別のポイントや注文には使わない"といった感じです。

ちなみに、Pythonでは@beta_toolデコレータを関数に付けるだけで、型ヒントとdocstringからスキーマが自動生成されます。

ループを回す

ツールを定義できたら、あとはtoolRunnerに渡してawaitするだけです。
実際のアプリのコードがこちらです(一部省略しています)。

const finalMessage = await client.beta.messages.toolRunner({
  model: config.bedrockModelId,
  max_tokens: 2048,
  max_iterations: 5,
  system: systemPrompt(),
  messages: [...getHistory(lineUserId), { role: 'user', content }],
  tools: buildTools(ctx),
});

const text = finalMessage.content
  .filter((block) => block.type === 'text')
  .map((block) => block.text)
  .join('\n')
  .trim();

「ポイント残高を確認して、失効日も添えて答える」といった複数ツールをまたぐやりとりも、この1回のawaitの裏でループが回って解決されます。
ちなみにこのデモアプリでは、Amazon BedrockのAnthropic互換エンドポイント(Mantle)に接続するAnthropicBedrockMantleを利用しています。

import { AnthropicBedrockMantle } from '@anthropic-ai/bedrock-sdk';

const client = new AnthropicBedrockMantle({ awsRegion: 'us-east-1' });

インターフェースはAnthropic SDKと同じなので、client.beta.messages.toolRunnerもこのままBedrock経由で使えました。
詳しくはBedrock の Anthropic 互換エンドポイント(Mantle)で、LangChain から Opus 4.7 を呼んでみたを参照してください。

便利パターン1: クロージャでコンテキストを注入する

さきほどのコードに出てきたbuildTools(ctx)は実はアプリケーションの認可にとってとても重要な実装です。
公式ドキュメントのサンプルはどれもツールをモジュールのトップレベルで静的に定義していますが、現実のアプリでは"誰からのメッセージか"というリクエストごとの情報もツールに必要になります。
そこでツール定義をファクトリ関数にして、LINEのWebhookで届くユーザーIDをクロージャでツールに閉じ込めています。

export function buildTools(lineUserId: string) {
  const getMemberProfile = betaZodTool({
    name: 'get_member_profile',
    description:
      'ID連携済み会員の情報(氏名・保有ポイント・失効予定ポイント)を取得する。ポイントや会員情報の質問で必ず使う。',
    inputSchema: z.object({}), // 入力は空。誰の情報かはツール側が知っている
    run: async () => {
      const member = findMemberByLineUserId(ctx.lineUserId); // クロージャ経由で参照
      if (!member) return 'ID連携された会員が見つかりませんでした。';
      return JSON.stringify({
        会員ID: member.id,
        氏名: member.name,
        保有ポイント: member.points,
        失効予定: `${member.expiringPoints.amount}ptが${member.expiringPoints.expiresOn}に失効`,
      });
    },
  });
  // ...他のツールも同様にctxを参照する
  return [getMemberProfile /* , ... */];
}

const finalMessage = await client.beta.messages.toolRunner({
  // ユーザーIDが固定された状態でツール呼び出しできる
  tools: buildTools(ctx.lineUserId),
  // ...
});

ポイントは、ユーザーIDをプロンプトに載せず、ツールの入力スキーマにも含めていないことです。
もしユーザーIDをモデル経由で渡す設計にすると、Claudeが入力を組み立てる過程が挟まるため、別のIDを出力し、なりすましや権限昇格が発生してしまう可能性があります。
これをクロージャで注入することで認可の判断がモデルを通らずコードの中で完結します。

このパターンは、Claudeのアプリケーションを開発する際には必須だと考えています。

便利パターン2: ツールの副作用で応答を組み立てる

ツールの返り値はClaudeに渡るテキストですが、ツールの中でできることはそれに限りません。
このボットでは、電話番号の案内にLINEのFlexメッセージ(ボタン付きのカード)を使いたかったので、ツールがコンテキストにメッセージを積む作りにしました。

const sendContactCard = betaZodTool({
  name: 'send_contact_card',
  description: '電話番号などの問い合わせ先を、コピー用ボタン付きのカードで送る。',
  inputSchema: z.object({
    title: z.string().describe('カードの見出し(例: お客様サービスセンター)'),
    phone: z.string().describe('電話番号(例: 0120-000-000)'),
  }),
  run: async (input) => {
    ctx.extraMessages.push(contactCardFlex(input)); // 副作用: 送信キューに積む
    return 'カードを送信キューに積みました。本文では番号を繰り返さず簡潔に案内してください。';
  },
});

ループが終わったら、最終メッセージのテキストとctx.extraMessagesをまとめてLINEに返信します。
"Claudeへの返答"と"アプリとしての出力"をツールの中で分けられるので、出力がClaudeによって書き換えられる心配がありません。

そもそもrunが多重実行されないかは気になりますが、公式ドキュメントに明確な記載はないものの、このボットでは1つのtool_useにつきrunは1回だけ実行され、今のところうまく動いています。

中間メッセージを見る方法

awaitすると最終メッセージしか受け取れませんが、ツールランナーは反復処理プロトコルにもなっていて、for awaitで各ターンのメッセージを順に取得できます。

const runner = client.beta.messages.toolRunner({
  model: 'claude-sonnet-5',
  max_tokens: 1024,
  max_iterations: 5,
  tools: buildTools(ctx),
  messages: [{ role: 'user', content: 'いま何ポイント持ってる?あと直近の注文いつ届く?' }],
});

let turn = 0;
for await (const message of runner) {
  turn += 1;
  console.log(`--- ターン${turn} (stop_reason: ${message.stop_reason}) ---`);
  for (const block of message.content) {
    if (block.type === 'tool_use') {
      console.log(`[tool_use] ${block.name} ${JSON.stringify(block.input)}`);
    }
    if (block.type === 'text') {
      console.log(`[text] ${block.text}`);
    }
  }
}

実行すると、Claudeがどの順でツールを呼んだのかがそのまま見えます。

--- ターン1 (stop_reason: tool_use) ---
[tool_use] get_member_profile {}
[tool_use] get_orders {}
--- ターン2 (stop_reason: end_turn) ---
[text] 山田様、ご確認しました。

【保有ポイント】
現在 **1,250pt** お持ちです。
※このうち500ptは2026-08-31に失効予定ですので、お早めのご利用がおすすめです。

【直近の注文】
一番新しいご注文は以下の2件が「配送準備中」です。

- **ORD-1034**(2026-08-10注文/珪藻土バスマット Lサイズ×2、防カビ剤お風呂用×1)
  → 配送予定日:**2026-08-16**
- **ORD-1031**(2026-08-08注文/ガーデンテーブル3点セット×1)
  → 配送予定日:**2026-08-15**

(参考:ORD-1023「電動ドライバー MKD-350」は2026-08-03に配達済みです)

配送日の変更などご希望があれば教えてください。

進捗表示やログ収集のほか、途中でbreakすればループを打ち切ることもできるので、本番運用で「何ターン目に何をしたか」を記録したいときにも使えます。

注意点

  • 2026年8月現在ではベータ版です。beta名前空間で提供されている機能なので、今後APIが変わる可能性があります。
  • betaToolは入力をバリデーションしません。zod版のbetaZodToolは入力を実行時に検証しますが、生のJSON Schemaで定義するbetaToolは、Claudeが生成した入力を実行時に検証しません。betaToolを使う場合は、run関数の中で別途バリデーションを実装しましょう。

まとめ

  • ツールランナーは、tool useのエージェントループ(stop_reason判定、ツールの振り分け、tool_resultの組み立て、履歴管理、再呼び出し)をSDKに任せられるベータ機能
  • ツールはbetaZodToolで定義と実装をセットで書け、zodによる型推論とランタイムバリデーションが効く
  • クロージャでコンテキストを注入すれば、ユーザーIDをモデルに渡さずに認可をコード側で完結できる
  • for awaitで中間メッセージに介入できる
  • 2026年8月現在ではまだベータ版である点、betaTool(JSON Schema版)は入力を実行時検証しない点に注意が必要です。

ツールランナーで消えるのはあくまでループの定型コードで、descriptionや返り値でモデルの次の行動を誘導するツール設計そのものは、引き続き開発者に委ねられています。
また、どんな場合でも有用な訳ではなく、高度にハンドリングする必要がある場合は使わない方が良いです。要件に応じて使い分けてください。

参考リンク

https://dev.classmethod.jp/articles/customer-experience-line-chatbot-claude/


Claudeならクラスメソッドにお任せください

クラスメソッドは、Anthropic社とリセラー契約を締結しています。各種製品ガイドから、業種別の活用法、フェーズごとのお悩み解決などサービス支援ページにまとめております。まずはご覧いただき、お気軽にご相談ください。

サービス詳細を見る

この記事をシェアする

AI白書

関連記事