Integrating LLM APIs · Building Blocks
Tool Calling & Agent Loops
Letting the model call your functions safely.
With tool use you describe functions (name, description, JSON Schema input). The model responds with a tool call; your code validates and executes it, returns a tool result, and calls the model again until it stops calling tools — the agent loop. Keep tools narrow and well-described, validate inputs as untrusted, enforce authorization with the end user's identity inside tools, require human approval for consequential actions, and cap iterations. Return errors as tool results (is_error) so the model can recover.
Tool design guidelines
| Design choice | Prefer | Avoid |
|---|---|---|
| Granularity | Specific tools (get_order, refund_order) | 'run_sql' or 'http_request' with full access |
| Authorization | Tool runs as the end user | Tool runs as a superuser service account |
| Side effects | Approval step for writes/payments | Auto-executing irreversible actions |
| Errors | Return as tool_result with is_error | Crash the loop |
| Loop control | Max iterations + token budget | Unbounded loops |
tools = [{
"name": "get_order_status",
"description": "Look up the status and ETA of one of the current customer's orders.",
"input_schema": {"type": "object", "properties": {"order_id": {"type": "string"}},
"required": ["order_id"], "additionalProperties": False},
}]
messages = [{"role": "user", "content": question}]
for _ in range(MAX_STEPS):
response = client.messages.create(model=MODEL, max_tokens=16000, system=SYSTEM,
tools=tools, messages=messages)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for call in (b for b in response.content if b.type == "tool_use"):
try:
output = run_tool(call.name, call.input, user=current_user) # validates + authorizes
results.append({"type": "tool_result", "tool_use_id": call.id, "content": output})
except ToolError as e:
results.append({"type": "tool_result", "tool_use_id": call.id, "content": str(e), "is_error": True})
messages.append({"role": "user", "content": results}) # all results in one message
for (let step = 0; step < MAX_STEPS; step++) {
const response = await client.messages.create({ model: MODEL, max_tokens: 16000, system: SYSTEM, tools, messages });
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason !== "tool_use") break;
const results: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== "tool_use") continue;
const r = await runTool(block.name, block.input, currentUser).catch((e) => ({ error: String(e) }));
results.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(r), is_error: "error" in r });
}
messages.push({ role: "user", content: results });
}
Common pitfalls
- Tools that bypass the user's permissions.
- Executing writes without confirmation.
- Splitting parallel tool results across multiple messages.
Key points
- Narrow, well-described tools
- Validate + authorize inside every tool
- Approvals and iteration caps
Further reading: Tool use overview
Practice questions
Why should tools execute with the end user's permissions?
Answer
The model can be manipulated (e.g. via prompt injection); if tools run as a superuser, the model can access or modify data the user couldn't.
What should your loop do when a tool fails?
Answer
Return the error as a tool result marked is_error so the model can adjust (retry with different input or explain), rather than crashing the request.
Open in the app to mark lessons complete, take notes and track your progress.