Tool Calling
Describe a function so the model can request it, dispatch the request through an allow-list, and send the result back.
client.messages.create(..., tools=[{"name": ..., "description": ..., "input_schema": {...}}])Tool calling lets a model ask your program to do something it cannot: read a database, call an internal service, do arithmetic that must be exact. You describe the functions, the model decides when one is needed, and your code does every bit of the executing.
Describing a tool
A tool is a dict with name, description and input_schema, the last being JSON Schema with required and additionalProperties: False. The description is the part that decides whether the tool gets used correctly, so write it for a stranger: what it does, when to reach for it, what the arguments mean. Adding "strict": True to the tool makes the arguments schema-valid on arrival.
The loop
Call with tools=[...]. If response.stop_reason == "tool_use", pull the tool_use blocks out of response.content, run each one, then send back a user message whose content is a list of tool_result blocks, each carrying the tool_use_id it answers. Append the assistant turn before the results; the history must contain both halves. One assistant turn can request several tools, and all their results go back in a single user message. Repeat until stop_reason is end_turn. The SDK's beta tool runner is shorthand for exactly this loop.
Never execute what you did not define
The tool name and its arguments are model output, which is to say untrusted input. Dispatch through a dict of name to function, return an error result for anything else, and never use getattr, eval or an import based on that name. Validate arguments again on your side even with a schema, and re-check authorisation inside each tool: the model has no idea who the user is.
Example
When to use it
- Letting an assistant look up live order status instead of guessing
- Giving a model exact arithmetic by handing it a calculator function
- Turning a natural-language request into a parameterised internal API call
- Gating a destructive action behind a hard limit in your own code
More examples
The manual loop, in full
The loop every tool integration is built on. Two mistakes to avoid: splitting the results of one assistant turn across several user messages, which teaches the model to stop requesting tools in parallel, and dropping a failed tool instead of returning a tool_result with is_error set, which leaves the history missing a half the API requires.
messages = [{"role": "user", "content": "Where is order A-1?"}]
while True:
reply = client.messages.create(
model="claude-opus-5-5", max_tokens=2048,
tools=TOOLS, messages=messages,
)
if reply.stop_reason != "tool_use":
break
# The assistant turn must go back verbatim, tool_use blocks included.
messages.append({"role": "assistant", "content": reply.content})
results = []
for block in reply.content:
if block.type == "tool_use":
results.append(dispatch(
{"id": block.id, "name": block.name, "input": block.input}))
# Every tool_result for one assistant turn goes in ONE user message.
messages.append({"role": "user", "content": results})
print("".join(b.text for b in reply.content if b.type == "text"))Authorisation lives in the tool, not the prompt
Closing over the authenticated user is what keeps tool calling safe in a multi-tenant application: the model can ask for any order id it likes and still only ever see the caller's own. The second tool shows the other half of the rule, a hard cap on blast radius inside the function, because no system prompt reliably prevents a large refund.
def make_handlers(current_user):
"""Bind the caller's identity into the tools; the model never sees it."""
def get_order(order_id: str):
order = db.orders.get(order_id)
if order is None:
return {"error": "not found"}
if order.customer_id != current_user.id and not current_user.is_staff:
# A prompt instruction is not an access control. This is.
return {"error": "not authorised"}
return {"order_id": order.id, "status": order.status}
def refund(order_id: str, amount_cents: int):
if amount_cents > 5_000:
return {"error": "refunds over 50.00 need a human"}
return billing.refund(order_id, amount_cents, actor=current_user.id)
return {"get_order": get_order, "refund": refund}The SDK tool runner as shorthand
The same loop with the bookkeeping removed: the decorator builds the input_schema from the type hints and the docstring (the Args section becomes the per-argument descriptions), and until_done runs request, execute and resend until the model stops asking. Iterating the runner instead yields each turn. It is a beta helper on client.beta.messages, so the manual loop above remains the shape to understand and to fall back to.
import anthropic
from anthropic import beta_tool
client = anthropic.Anthropic()
@beta_tool
def get_order(order_id: str) -> str:
"""Look up one order by id. Use for order status questions.
Args:
order_id: The order reference, for example A-1.
"""
return json.dumps({"order_id": order_id, "status": "shipped"})
runner = client.beta.messages.tool_runner(
model="claude-opus-5-5", max_tokens=2048,
tools=[get_order],
messages=[{"role": "user", "content": "Where is order A-1?"}],
)
final = runner.until_done()
print("".join(b.text for b in final.content if b.type == "text"))
Discussion