Claude APIのtool useエージェントループを5段階で自作する — is_errorまで
カレンダー操作ツールで、単発のtool_useからstop_reasonのwhileループ、複数ツール、is_error、Tool Runnerへの置き換えまで5段階で組みます。
Claude APIのツール使用エージェントは、stop_reason が "tool_use" である間だけ回る while ループです。ツールを実行して結果を会話履歴に積み、もう一度APIを呼ぶ。この繰り返しが、エージェントの正体です。
ここでは create_calendar_event というカレンダー操作ツールを題材に、ループを5段階(Ring 1〜5)で育てます。各段は単体で動くプログラムで、1段ごとに足す概念は1つだけです。最後にSDKのTool Runnerへ置き換えるところまで進みます。
5段階で足していくもの
Ring 1からRing 5までの流れ
- 1
Ring 1: 単発の往復
tool_useを1回受け、tool_resultを1回返してend_turnを得ます。 - 2
Ring 2: whileループと履歴
stop_reasonがtool_useの間、実行と再送を繰り返します。履歴は1つのリストに積みます。 - 3
Ring 3: 複数ツールと並列呼び出し
1つの応答に複数の
tool_useが入る前提に直し、結果を1つのuserメッセージにまとめて返します。 - 4
Ring 4: is_errorで失敗を返す
例外を握りつぶさず、
is_error: true付きのtool_resultにします。 - 5
Ring 5: Tool Runnerへの置き換え
ループ・結果の整形・履歴管理をSDKに任せます。
準備はSDKとAPIキーだけです。以下はPython版で、コードはモデルに claude-opus-5-5 を指定します。
pip install anthropic
export ANTHROPIC_API_KEY="sk-ant-..."Ring 1: tool_useを受けてtool_resultを返す
最小構成は、ツール1つ、userメッセージ1つ、往復1回です。リクエストの tools にツール定義を渡し、Claudeがツールを使うと判断すると、応答は stop_reason: "tool_use" と tool_use ブロックを返します。ブロックにはツール名、一意の id、構造化された input が入ります。
題材のツールは、ネストしたオブジェクト(recurrence)、配列(attendees)、省略可能な項目を持ちます。平らな文字列1つの引数より、実際のツールに近い形です。
tools = [
{
"name": "create_calendar_event",
"description": "Create a calendar event with attendees and optional recurrence.",
"input_schema": {
"type": "object",
"properties": {
"title": {"type": "string"},
"start": {"type": "string", "format": "date-time"},
"end": {"type": "string", "format": "date-time"},
"attendees": {
"type": "array",
"items": {"type": "string", "format": "email"},
},
"recurrence": {
"type": "object",
"properties": {
"frequency": {"enum": ["daily", "weekly", "monthly"]},
"count": {"type": "integer", "minimum": 1},
},
},
},
"required": ["title", "start", "end"],
},
}
]1回目のリクエストを送り、応答から tool_use ブロックを探します。テキストブロックが先頭に来ることがあるので、位置を決め打ちせず content を走査します。
response = client.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
tools=tools,
tool_choice={"type": "auto", "disable_parallel_tool_use": True},
messages=[{"role": "user", "content": "Schedule a 30-minute sync with ..."}],
)
tool_use = next(b for b in response.content if b.type == "tool_use")
result = {"event_id": "evt_123", "status": "created"} # 実際はカレンダーAPIを呼ぶ結果を返すときは、直前のassistantの応答をそのまま履歴に含め、次のuserメッセージに tool_result を入れます。tool_use_id は tool_use の id と一致させます。
followup = client.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
tools=tools,
tool_choice={"type": "auto", "disable_parallel_tool_use": True},
messages=[
{"role": "user", "content": "Schedule a 30-minute sync with ..."},
{"role": "assistant", "content": response.content},
{"role": "user", "content": [{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": json.dumps(result),
}]},
],
)チュートリアルが示す出力例は次のとおりです(動かした実測値ではなく、掲載されているサンプルです)。
stop_reason: tool_use
Tool: create_calendar_event
Input: {'title': 'Sync', 'start': '2026-03-30T10:00:00', 'end': '2026-03-30T10:30:00', 'attendees': ['alice@example.com', 'bob@example.com']}
stop_reason: end_turn
I've scheduled your 30-minute sync with Alice and Bob for Monday, March 30 at 10am.1回目の stop_reason が tool_use なのは、Claudeがカレンダーの結果を待っているからです。結果を返した2回目は end_turn になり、中身は利用者向けの自然文です。
tool_resultの並べ方には決まりがある
ツール結果を返すメッセージには、守らないと400になる並べ方があります。
tool_resultは、対応するtool_useを含むassistantメッセージの直後に置く。間に別のメッセージを挟まない- 同じuserメッセージ内では、
tool_resultを先頭に並べ、テキストはその後ろに置く
Ring 1〜4のコードはどちらも満たしています。結果と一緒に補足テキストを送りたくなったら、テキストは必ず tool_result の後ろへ回します。
Ring 2: stop_reasonで回すwhileループ
Ring 1は、Claudeがツールを1回だけ呼ぶ前提でした。実際の依頼は複数回の呼び出しを要することがあります。イベントを作り、確認結果を読み、また次を作る、といった流れです。そこで条件を stop_reason == "tool_use" にした while ループにします。
もう1つの変更は履歴です。リクエストごとに messages を組み立て直さず、1つのリストに追記し続けます。毎回のリクエストが過去の文脈をすべて含む形になります。
def run_tool(name, tool_input):
if name == "create_calendar_event":
return {"event_id": "evt_123", "status": "created", "title": tool_input["title"]}
return {"error": f"Unknown tool: {name}"}
messages = [{"role": "user", "content": "Schedule a weekly team standup every Monday ..."}]
response = client.messages.create(
model="claude-opus-5-5", max_tokens=1024, tools=tools,
tool_choice={"type": "auto", "disable_parallel_tool_use": True},
messages=messages,
)
while response.stop_reason == "tool_use":
tool_use = next(b for b in response.content if b.type == "tool_use")
result = run_tool(tool_use.name, tool_use.input)
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": [{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": json.dumps(result),
}]})
response = client.messages.create(
model="claude-opus-5-5", max_tokens=1024, tools=tools,
tool_choice={"type": "auto", "disable_parallel_tool_use": True},
messages=messages,
)ループが何周するかはClaudeの分解の仕方しだいで、コードは事前に知る必要がありません。サンプルの出力は、4週分の定例を設定した旨の1文です。
ループが抜ける条件はtool_use以外すべて
while の条件が偽になるのは、stop_reason が tool_use 以外のときです。end_turn のほか、max_tokens、stop_sequence、refusal でも抜けます。最後の3つは「最終回答が出た」とは限りません。
Ring 2〜4のコードは、抜けた直後に next(block for block in response.content if block.type == "text") でテキストを取り出します。テキストブロックが無い応答では、この next() が StopIteration を投げます。このコードは最小構成なので、本番に持ち込むなら抜けた理由を先に見ます。
if response.stop_reason != "end_turn":
# max_tokens なら出力が途中で切れている。refusal なら拒否の扱いを決める
raise RuntimeError(f"unexpected stop_reason: {response.stop_reason}")refusal を受けた側の設計はClaude Haiku 5.5のstop_reason refusalは呼び出し側で受ける、エラーと stop_reason の切り分けはstop_reasonとエラーの違いで扱っています。
このコードには周回数の上限もありません。モデルがツールを呼び続ける限り while は回るので、実運用では上限を自分で足す設計が考えられます。上のコードの条件を while response.stop_reason == "tool_use" and turns < 20: のようにし、turns += 1 を各周で数える形です。20は例の数字で、依頼の性質に合わせて決めます。
Ring 3: 複数ツールと、1つのuserメッセージへの全結果
エージェントは1つの能力だけでは足りません。2つ目のツール list_calendar_events(指定日の予定一覧)を足し、「予定を確認してから衝突しない時間に会議を入れる」という依頼を投げます。
Ring 2とRing 3のコードで、見落としやすい差が2つあります。
tool_choiceのdisable_parallel_tool_use: Trueが消えているnext(...)で最初の1個だけ取っていた処理が、for block in response.contentの全件走査に変わっている
Ring 2が next() で足りていたのは、並列呼び出しを禁止していたからです。禁止を外した状態でRing 2のループを使うと、2個目以降の tool_use に tool_result が返りません。Claudeが複数の独立した呼び出しを1つの応答に詰めてくる場合に備え、全件を処理して全結果を1つのuserメッセージに入れます。
while response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = run_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
response = client.messages.create(
model="claude-opus-5-5", max_tokens=1024, tools=tools, messages=messages,
)サンプル出力は、14時から15時の既存予定を見つけ、10時から11時に計画セッションを入れたという内容です。ここでは list_calendar_events が固定の予定1件を返す仮実装になっています。
並列呼び出しそのものを抑えたいときの挙動はdisable_parallel_tool_useの効果はtool_choiceで変わる、並列呼び出しを増やす書き方は並列ツール呼び出しを増やすシステムプロンプトの書き方にあります。
Ring 4: 失敗をis_errorで返す
ツールは失敗します。カレンダーAPIが参加者数の多すぎるイベントを拒否することも、日付が壊れていることもあります。例外でプログラムを落とさず、エラー文を is_error: true 付きの tool_result で返すと、Claudeはそれを読んで入力を直して再試行するか、利用者に確認するか、制約を説明します。
Ring 4では run_tool が、参加者が11人以上なら例外を投げる仕様になっています。未知のツール名も、辞書を返す代わりに例外にします。呼び出し側で try / except を足すだけです。
for block in response.content:
if block.type == "tool_use":
try:
result = run_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
except Exception as exc:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(exc),
"is_error": True,
})成功の結果との差は is_error のフラグだけです。サンプルの依頼は15人宛ての全体会議で、Claudeの最終応答は「1イベント10人までなので、2回に分けるか、優先する10人を教えてほしい」という趣旨になります。
Ring 3の run_tool は未知のツール名に {"error": ...} という辞書を返していました。これは成功の tool_result として送られ、Claudeには失敗のフラグが届きません。Ring 4で例外に変えたのは、その差を is_error に載せるためです。
エラー文はClaudeが次の行動を選べる形で書く
エラー文は、何が起きたかと次に何を試すかを入れると回復しやすくなります。"failed" だけの文字列より、"Rate limit exceeded. Retry after 60 seconds." のように、原因と待ち時間まで書くほうが回復しやすくなります。Ring 4の Too many attendees (max 10) は、原因と上限値が入っているので、Claudeが参加者を絞る提案に進めました。
必須パラメータが欠けたツール呼び出しも、同じ要領で is_error: true の tool_result を返すと、Claudeが欠けた値を補って再試行します。入力不備なら、Claudeは2〜3回直して再試行し、それでも通らなければ謝罪に回ります。スキーマに合う入力を保証したいなら strict: true の厳密なツール使用もあります。is_error の文面設計はtool_resultのis_errorでエラーを正しく伝える書き方、並列呼び出しで一部だけ失敗した場合は並列ツール呼び出しが一部失敗したときのis_errorの返し方で扱っています。
サーバーツール(Web検索など)のエラーは別です。Claudeが透過的に処理するので、クライアントツールのように is_error を自分で返す必要はありません。
Ring 5: Tool Runnerに置き換える
Ring 2〜4で手で書いたのは、APIを呼び、stop_reason を見て、ツールを実行し、結果を積み、繰り返す、という同じ処理でした。Tool Runnerはこれを肩代わりします。ツールを関数として定義して client.beta.messages.tool_runner() に渡し、ループが終わったら最終メッセージを受け取ります。エラーの包み込み、結果の整形、会話の管理は内部で行われます。
Pythonでは @beta_tool デコレーターが、関数のシグネチャから入力スキーマを組み立てます。スキーマの定義が実装のすぐ隣に置かれます。
from anthropic import beta_tool
@beta_tool
def create_calendar_event(
title: str,
start: str,
end: str,
attendees: list[str] | None = None,
recurrence: dict | None = None,
) -> str:
"""Create a calendar event with attendees and optional recurrence.
Args:
title: Event title.
start: Start time in ISO 8601 format.
end: End time in ISO 8601 format.
attendees: Email addresses to invite.
recurrence: Dict with 'frequency' (daily, weekly, monthly) and 'count'.
"""
if attendees and len(attendees) > 10:
raise ValueError("Too many attendees (max 10)")
return json.dumps({"event_id": "evt_123", "status": "created", "title": title})
final_message = client.beta.messages.tool_runner(
model="claude-opus-5-5",
max_tokens=1024,
tools=[create_calendar_event, list_calendar_events],
messages=[{"role": "user", "content": "Check what I have on Monday, ..."}],
).until_done()出力はRing 3と同じです。違いはコードの量で、行数はRing 3の約半分になり、手動ループが消えて、スキーマが実装の隣に置かれます。名前空間が beta である点は忘れずに確認します。
言語が変わるとメソッド名も変わります。TypeScriptは client.beta.messages.toolRunner()、Goは client.Beta.Messages.NewToolRunner() です。Tool Runner自体の使い方、ToolErrorの扱い、Agent SDKとの使い分けはTool RunnerでAnthropic APIのツール呼び出しループを自動化するにまとめています。
手動ループに戻る場面
Tool Runnerを選べば、Ring 4の try / except も書かずに済みます。ただし、実行の前に人の承認を挟みたい、ログを1呼び出しごとに取りたい、条件しだいで途中で止めたい、といった要件は手動ループのほうが自然です。Ring 2〜4のコードは、そのための土台として残せます。pause_turn が絡むサーバーツール混在のループは、tool_useとpause_turnの違いが判定の整理になります。
自作ループを本番に近づける前の確認点
ここまでのコードは、仮実装のツールを呼ぶ最小形です。自分のツールを載せる前に、次の点を見直しておくと事故が減ります。
stop_reasonがtool_useとend_turn以外のときの扱い(max_tokensの打ち切り、refusal)を決める- 周回数の上限を設ける
- 全結果を返す前に、
tool_use_idが欠けていないか数える - 外部へ書き込むツール(イベント作成など)は、再試行で二重に実行されても困らないかを考える
最後の点は見落としやすい部分です。Ring 4のようにClaudeが入力を変えて再試行する以上、イベント作成のような書き込み系ツールは、同じ呼び出しが2回届いても予定が2件できない作りにしておく必要があります。
まとめ
エージェントループの核は、stop_reason を見て、全 tool_use に結果を返し、履歴を積み続ける3点です。Ring 2の next() が並列禁止に依存していたように、前提が外れる境目を知っておくと、手動ループの不具合を切り分けやすくなります。細かい制御が不要になった時点でTool Runnerへ移し、承認やログが必要な局面だけ手動ループに戻す。この順で進めるのが無理のない道筋です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。