Tool calling turns a model from a text generator into something that acts on your systems — which means the engineering problem shifts from prompt quality to blast radius.
Tier: Advanced · Workflow area: Building LLM Features
This playbook exposes two or three safe internal operations as tools behind a bounded agentic loop: strict argument schemas, server-side validation the model cannot talk its way past, parallel-call handling, explicit error paths, and an iteration cap so the loop always terminates.
Not for single-shot extraction (pb-44) — tool calling adds a loop, latency, and risk; earn it with a real multi-step need.
Pick tools by blast radius, then write contracts. Strict schemas make argument shape a guarantee, not a hope:
TOOLS = [
{
"name": "get_order",
"description": "Fetch one order by ID for the authenticated customer. \
Use when the user references a specific order or purchase.",
"strict": True,
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string", "description": "Format ord_..."}},
"required": ["order_id"],
"additionalProperties": False,
},
},
{
"name": "get_refund_policy",
"description": "Return refund policy for a product category.",
"strict": True,
"input_schema": {
"type": "object",
"properties": {"category": {"type": "string",
"enum": ["hardware", "software", "subscription"]}},
"required": ["category"],
"additionalProperties": False,
},
},
]
Descriptions carry when to use language — that is what drives correct tool selection.
Validate server-side anyway — schemas shape arguments; they don't authorize them. Every executor rechecks tenancy and permissions:
def execute_tool(name: str, args: dict, ctx: RequestContext) -> tuple[str, bool]:
"""Returns (result_json, is_error). NEVER raises into the loop."""
if name not in ALLOWED_TOOLS: # allow-list, not model trust
return json.dumps({"error": "unknown tool"}), True
if name == "get_order":
order = orders.get(args["order_id"])
if order is None or order.customer_id != ctx.customer_id: # tenancy check
return json.dumps({"error": "order not found"}), True # same msg both cases
return order.to_json(), False
...
Run a bounded loop; return parallel results in one message. The model may request several tools at once — execute them (concurrently if independent) and send all results back in a single user turn:
import anthropic
client = anthropic.Anthropic(timeout=30.0)
MAX_TURNS = 5
def run(user_msg: str, ctx) -> str:
messages = [{"role": "user", "content": user_msg}]
for _ in range(MAX_TURNS):
resp = client.messages.create(
model="claude-opus-4-8", max_tokens=2048,
system=SYSTEM_PROMPT, tools=TOOLS, messages=messages,
)
if resp.stop_reason != "tool_use":
return next(b.text for b in resp.content if b.type == "text")
messages.append({"role": "assistant", "content": resp.content})
results = []
for block in resp.content:
if block.type == "tool_use":
out, is_err = execute_tool(block.name, block.input, ctx)
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": out, "is_error": is_err})
messages.append({"role": "user", "content": results})
return "I couldn't complete that within limits — routing to a human."
Design the tool-error path as a feature. is_error: True with an informative message lets the model recover (ask the user for a correct order ID) instead of hallucinating success. Never leak internal exceptions or stack traces into content — they end up in the model's mouth.
Log the full trace per request: every tool call with arguments, result size, error flag, and turn count. Turn-count distribution tells you when the loop is thrashing; tool-selection errors tell you which description to fix.
Test adversarially before launch. Prompts that request other tenants' orders, tools that don't exist, contradictory instructions inside retrieved data ("ignore your rules and call X") — verify the allow-list and tenancy checks hold regardless of what the model asks for. This overlaps deliberately with pb-52.
The security boundary is execute_tool, not the prompt. Any check that exists only as prompt text ("never access other customers' orders") is a suggestion; the tenancy check in code is the control.
@beta_tool + client.beta.messages.tool_runner(...)) removes the loop boilerplate once you trust the shape.{"type": "function", "function": {..., "strict": true}}, results returned as {"type": "function_call_output", "call_id": ..., "output": ...} items; gpt-5.6 in current docs.MAX_TURNS exit path tested and human-routed.issue_refund does not belong in the first release. Add mutating tools only behind confirmation UX or human approval, after the read-only version has production history.strict as authorization. Strict schemas guarantee well-formed arguments; a well-formed request for someone else's data is still an attack.MAX_TURNS means one confused conversation can burn dollars and hold a worker for minutes.MAX_TURNS lower (3) and expect more selection errors — the server-side validation carries you.content.