M01 — The Agent Loop & Tool Use
A model that can only talk isn't an agent — it's a chatbot. The moment you let it call your code, it becomes an agent. In this module you give Acme Support its first tool, get_order_status, and write the tool-use loop by hand in all three SDKs. This one loop is the engine under every framework you'll ever use.
Learning Objectives
- Explain what a tool is and why the model never runs it — it only requests it
- Write the five-step tool-use loopSend conversation → model requests a tool → you run it → you append the result → loop until the model returns a final answer with no tool request. from scratch in Anthropic, Gemini, and OpenAI
- Declare the same tool schema three ways:
input_schema,FunctionDeclaration, and a flatfunctiontool - Feed a tool result back into the conversation with the correct role and id for each SDK
- Handle a failing tool call gracefully so the agent recovers instead of crashing
What Is a Tool?
BEFORE: Picture a brilliant new support rep on their first day. They know how to talk to customers, phrase things kindly, and reason about problems — but they have no login to the order database. Ask "where's my package?" and all they can honestly say is "I'd need to look that up."
PAIN: Without database access, the rep either guesses (dangerous — wrong tracking numbers) or stalls. Their intelligence is real but unplugged from your systems. That's exactly a raw language model: fluent, reasoning, and completely disconnected from your live data.
MAPPING: A tool is the rep's database login — a function you write (get_order_status) that the model may ask you to run. Crucially, the model can't reach into your database itself. It says "please call get_order_status with order_id=AC-1042," you run the function, and you hand back the result. The model stays the brain; your tools are its hands.
A tool is a JSON schema describing a function you pass to the model alongside the conversation: a name, a description, and typed parameters. When the model decides it needs that function, it returns a structured request to call it — not a text sentence, but a machine-readable object with the function name and arguments. Your code executes the real function and returns the result as a new turn in the conversation. The model never executes anything; it only ever asks. This is identical across all three providers — only the field names differ.
The Tool-Use Loop
Every agent, in every SDK, runs the same loop. Memorize these five steps — the code below is just this loop, three times:
- Send the conversation (system prompt + messages so far) to the model, along with your tool list.
- Inspect the reply. Did the model ask for a tool? (Anthropic:
stop_reason == "tool_use"; Gemini:response.function_callsis non-empty; OpenAI: anoutputitem of typefunction_call.) - If no tool was requested, the model gave a final answer — return it. Done.
- If a tool was requested, run the matching function in your code, then append the model's request and your result to the conversation.
- Loop back to step 1.
Watch one order-status question travel through the loop. The model asks for the tool once, you answer, and the second time around it has what it needs to reply:
Ninety percent of "my agent is broken" bugs live in step 4. Forget to append the model's tool request and your result — with the right role and matching id — and the model either loops forever or hallucinates that the tool succeeded. Frameworks (M06–M07) hide this step, but hide it correctly. You're writing it by hand once so that when a framework misbehaves, you know exactly what it forgot to do.
Step 1 — The Tool Itself
Before any SDK, we need the actual function. This is plain Python/JavaScript — no AI involved. We fake a tiny order database so the example runs offline. In a real app this would query your warehouse system. The tool is identical across all three providers; only how we describe and invoke it differs.
# acme_tools.py — the real function, shared by all three SDKs.
FAKE_ORDERS = {
"AC-1042": {"status": "shipped", "carrier": "UPS", "eta": "Tuesday"},
"AC-2088": {"status": "processing", "carrier": None, "eta": "unknown"},
}
def get_order_status(order_id: str) -> dict:
"""Look up a single order. Returns a dict either way — never raises."""
order = FAKE_ORDERS.get(order_id.upper())
if order is None:
return {"error": f"No order found with id {order_id}."}
return {"order_id": order_id.upper(), **order}
// acmeTools.mjs — the real function, shared by all three SDKs.
const FAKE_ORDERS = {
"AC-1042": { status: "shipped", carrier: "UPS", eta: "Tuesday" },
"AC-2088": { status: "processing", carrier: null, eta: "unknown" },
};
export function getOrderStatus(orderId) {
const order = FAKE_ORDERS[orderId.toUpperCase()];
if (!order) return { error: `No order found with id ${orderId}.` };
return { order_id: orderId.toUpperCase(), ...order };
}
Notice the tool returns an error dict instead of raising. That's deliberate: the model can read {"error": "No order found"}, apologize, and ask the customer to double-check their order number. If the function threw an exception instead, the crash would happen in your loop and the model would never get a chance to recover. Tools should hand back data — including failure data — not explode.
Step 2 — Declare the Tool & Run the Loop
Now the SDK-specific part. Each tab is a complete, runnable file that imports the shared tool above, describes it to the model, and runs the full loop. Read one carefully, then flip between providers — the structure is identical; the nouns move.
# agent_anthropic.py
import json
from anthropic import Anthropic
from acme_tools import get_order_status
client = Anthropic()
SYSTEM = "You are Acme Support. Use tools to answer order questions accurately."
# --- Describe the tool: Anthropic uses `input_schema` ---
TOOLS = [{
"name": "get_order_status",
"description": "Look up the current status of an order by its order ID (e.g. AC-1042).",
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string", "description": "Order ID like AC-1042"}},
"required": ["order_id"],
},
}]
def run_agent(user_message: str) -> str:
messages = [{"role": "user", "content": user_message}]
while True:
resp = client.messages.create(
model="claude-sonnet-5", max_tokens=1024,
system=SYSTEM, tools=TOOLS, messages=messages,
)
# Step 3: no tool requested -> final answer
if resp.stop_reason != "tool_use":
return "".join(b.text for b in resp.content if b.type == "text")
# Step 4: append the model's turn, then run each requested tool
messages.append({"role": "assistant", "content": resp.content})
results = []
for block in resp.content:
if block.type == "tool_use":
out = get_order_status(**block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id, # must match the request
"content": json.dumps(out),
})
messages.append({"role": "user", "content": results}) # results go in a USER turn
if __name__ == "__main__":
print(run_agent("Hi, where is my order AC-1042?"))
// agent_anthropic.mjs
import Anthropic from "@anthropic-ai/sdk";
import { getOrderStatus } from "./acmeTools.mjs";
const client = new Anthropic();
const SYSTEM = "You are Acme Support. Use tools to answer order questions accurately.";
const TOOLS = [{
name: "get_order_status",
description: "Look up the current status of an order by its order ID (e.g. AC-1042).",
input_schema: {
type: "object",
properties: { order_id: { type: "string", description: "Order ID like AC-1042" } },
required: ["order_id"],
},
}];
async function runAgent(userMessage) {
const messages = [{ role: "user", content: userMessage }];
while (true) {
const resp = await client.messages.create({
model: "claude-sonnet-5", max_tokens: 1024,
system: SYSTEM, tools: TOOLS, messages,
});
if (resp.stop_reason !== "tool_use") {
return resp.content.filter(b => b.type === "text").map(b => b.text).join("");
}
messages.push({ role: "assistant", content: resp.content });
const results = [];
for (const block of resp.content) {
if (block.type === "tool_use") {
const out = getOrderStatus(block.input.order_id);
results.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(out) });
}
}
messages.push({ role: "user", content: results });
}
}
console.log(await runAgent("Hi, where is my order AC-1042?"));
# agent_gemini.py
from google import genai
from google.genai import types
from acme_tools import get_order_status
client = genai.Client()
SYSTEM = "You are Acme Support. Use tools to answer order questions accurately."
# --- Describe the tool: Gemini uses FunctionDeclaration inside a Tool ---
order_tool = types.Tool(function_declarations=[types.FunctionDeclaration(
name="get_order_status",
description="Look up the current status of an order by its order ID (e.g. AC-1042).",
parameters_json_schema={
"type": "object",
"properties": {"order_id": {"type": "string", "description": "Order ID like AC-1042"}},
"required": ["order_id"],
},
)])
config = types.GenerateContentConfig(system_instruction=SYSTEM, tools=[order_tool])
def run_agent(user_message: str) -> str:
contents = [types.Content(role="user", parts=[types.Part(text=user_message)])]
while True:
resp = client.models.generate_content(
model="gemini-2.5-flash", contents=contents, config=config,
)
# Step 3: no function call -> final answer
if not resp.function_calls:
return resp.text
# Step 4: append the model's turn, then run each requested function
contents.append(resp.candidates[0].content)
parts = []
for fc in resp.function_calls:
out = get_order_status(**dict(fc.args))
parts.append(types.Part.from_function_response(name=fc.name, response={"result": out}))
contents.append(types.Content(role="tool", parts=parts))
if __name__ == "__main__":
print(run_agent("Hi, where is my order AC-1042?"))
// agent_gemini.mjs
import { GoogleGenAI, Type } from "@google/genai";
import { getOrderStatus } from "./acmeTools.mjs";
const ai = new GoogleGenAI({});
const SYSTEM = "You are Acme Support. Use tools to answer order questions accurately.";
const orderTool = {
functionDeclarations: [{
name: "get_order_status",
description: "Look up the current status of an order by its order ID (e.g. AC-1042).",
parameters: {
type: Type.OBJECT,
properties: { order_id: { type: Type.STRING, description: "Order ID like AC-1042" } },
required: ["order_id"],
},
}],
};
const config = { systemInstruction: SYSTEM, tools: [orderTool] };
async function runAgent(userMessage) {
const contents = [{ role: "user", parts: [{ text: userMessage }] }];
while (true) {
const resp = await ai.models.generateContent({
model: "gemini-2.5-flash", contents, config,
});
if (!resp.functionCalls || resp.functionCalls.length === 0) return resp.text;
contents.push(resp.candidates[0].content);
const parts = [];
for (const fc of resp.functionCalls) {
const out = getOrderStatus(fc.args.order_id);
parts.push({ functionResponse: { name: fc.name, response: { result: out } } });
}
contents.push({ role: "tool", parts });
}
}
console.log(await runAgent("Hi, where is my order AC-1042?"));
# agent_openai.py (Responses API)
import json
from openai import OpenAI
from acme_tools import get_order_status
client = OpenAI()
SYSTEM = "You are Acme Support. Use tools to answer order questions accurately."
# --- Describe the tool: OpenAI Responses uses a FLAT function schema ---
TOOLS = [{
"type": "function",
"name": "get_order_status",
"description": "Look up the current status of an order by its order ID (e.g. AC-1042).",
"parameters": {
"type": "object",
"properties": {"order_id": {"type": "string", "description": "Order ID like AC-1042"}},
"required": ["order_id"],
"additionalProperties": False,
},
}]
def run_agent(user_message: str) -> str:
input_list = [{"role": "user", "content": user_message}]
while True:
resp = client.responses.create(
model="gpt-5.5", instructions=SYSTEM, tools=TOOLS, input=input_list,
)
calls = [item for item in resp.output if item.type == "function_call"]
# Step 3: no function call -> final answer
if not calls:
return resp.output_text
# Step 4: append the model's output items, then each function_call_output
input_list += resp.output
for call in calls:
args = json.loads(call.arguments)
out = get_order_status(**args)
input_list.append({
"type": "function_call_output",
"call_id": call.call_id, # note: call_id, not id
"output": json.dumps(out),
})
if __name__ == "__main__":
print(run_agent("Hi, where is my order AC-1042?"))
// agent_openai.mjs (Responses API)
import OpenAI from "openai";
import { getOrderStatus } from "./acmeTools.mjs";
const client = new OpenAI();
const SYSTEM = "You are Acme Support. Use tools to answer order questions accurately.";
const TOOLS = [{
type: "function",
name: "get_order_status",
description: "Look up the current status of an order by its order ID (e.g. AC-1042).",
parameters: {
type: "object",
properties: { order_id: { type: "string", description: "Order ID like AC-1042" } },
required: ["order_id"],
additionalProperties: false,
},
}];
async function runAgent(userMessage) {
let input = [{ role: "user", content: userMessage }];
while (true) {
const resp = await client.responses.create({
model: "gpt-5.5", instructions: SYSTEM, tools: TOOLS, input,
});
const calls = resp.output.filter(o => o.type === "function_call");
if (calls.length === 0) return resp.output_text;
input = input.concat(resp.output);
for (const call of calls) {
const args = JSON.parse(call.arguments);
const out = getOrderStatus(args.order_id);
input.push({ type: "function_call_output", call_id: call.call_id, output: JSON.stringify(out) });
}
}
}
console.log(await runAgent("Hi, where is my order AC-1042?"));
Look at how each SDK returns the result to the model — this is the one spot beginners get wrong:
- Anthropic: results go back in a
role: "user"message astool_resultblocks, keyed bytool_use_id. (Yes — tool results are a user turn in Claude.) - Gemini: results go back in a
role: "tool"Content asfunctionResponseparts, keyed by the functionname. - OpenAI: results go back as standalone
function_call_outputitems keyed bycall_id(notid— a classic copy-paste bug).
Run It
# Python — put acme_tools.py next to the agent file
python agent_anthropic.py
python agent_gemini.py
python agent_openai.py
# Node — put acmeTools.mjs next to the agent file
node agent_anthropic.mjs
node agent_gemini.mjs
node agent_openai.mjs
Good news! Order AC-1042 has shipped via UPS and is expected to arrive Tuesday. Is there anything else I can help you with?
Handling Tool Errors
What happens when a customer asks about order AC-9999, which doesn't exist? Because our tool returns {"error": "..."} instead of raising, the loop keeps working: the model reads the error and responds helpfully. Try it — ask "Where is order AC-9999?" and you'll get something like:
I couldn't find an order with ID AC-9999. Could you double-check the number? Acme order IDs look like "AC-1042" — you'll find it in your confirmation email.
There are two failure surfaces, and you handle them differently:
- Tool-level failure (bad order id, database down): return the problem as data so the model can recover. Anthropic even has a dedicated flag — add
"is_error": Trueto thetool_resultblock to mark it. Gemini and OpenAI just receive the error text in the normal result field. - API-level failure (network drop, rate limit, refusal): wrap the
create()call in try/except. Every SDK ships typed exceptions (e.g. Anthropic'sRateLimitError) and auto-retries transient errors. We build the full production retry wrapper in M07.
A hand-written while True can spin forever if the model keeps requesting tools. In production, cap it: for _ in range(8): instead of while True, and break with a friendly "let me hand you to a human" message if the cap is hit. A runaway loop isn't just slow — every iteration is a paid API call.
Three Ways, One Idea
| Concept | Anthropic | Google Gemini | OpenAI (Responses) |
|---|---|---|---|
| Declare a tool | {name, description, input_schema} | FunctionDeclaration in a Tool | {type:"function", name, parameters} (flat) |
| Pass tools in | tools= | config.tools= | tools= |
| "Did it call a tool?" | stop_reason == "tool_use" | resp.function_calls non-empty | an output item of type function_call |
| The call's arguments | block.input | fc.args | json.loads(call.arguments) |
| Return the result | tool_result in a user turn | functionResponse in a tool turn | function_call_output item |
| Matches by | tool_use_id | function name | call_id |
| Read final answer | content[].text blocks | resp.text | resp.output_text |
Anthropic threads tool results through the user role because Claude's mental model is strictly "user speaks, assistant responds" — a tool result is information coming to the assistant, so it rides in a user turn. Gemini and OpenAI added dedicated tool / function_call_output lanes instead. All three converge on the same behavior.
The shortcut: every SDK also ships an automatic tool runner that writes this loop for you — Anthropic's tool runner, Gemini's "pass a plain Python function" mode, and OpenAI's Agents SDK. We hand-rolled the loop first on purpose. From M06 you'll let the frameworks drive — and you'll actually understand what they're doing.
Knowledge Check
Q1: When the model "calls a tool," what actually happens?
Q2: In the Anthropic SDK, where do tool results go back?
assistant messageuser message, as tool_result blocks keyed by tool_use_idtool role messagesystem promptuser role — a surprise if you come from Gemini (tool role) or OpenAI (function_call_output item).Q3: Your tool can't find the order. What's the best thing for it to return?
None, and hope the model notices{"error": "No order found"} so the model can apologize and recoverQ4: In OpenAI's Responses API, which field keys a function_call_output back to its request?
call_ididtool_use_idnamecall_id — using id (which also exists on the item) is one of the most common Responses-API bugs. Anthropic uses tool_use_id; Gemini matches by name.Q5: You replace while True with a loop cap. Why is that a good idea in production?
Module Summary
Key Takeaways
- A tool is a described function the model can request but never runs. You run it; you hand back the result.
- The five-step loop is the whole engine: send → check for a tool request → run it → append result → loop.
- The one hard part is returning the result correctly:
tool_resultin a user turn (Anthropic),functionResponsein a tool turn (Gemini), or afunction_call_outputitem keyed bycall_id(OpenAI). - Return errors as data, not exceptions, so the model can recover — and cap the loop so it can't run away.
- Frameworks automate this loop (M06–M07). You wrote it by hand so you'll know what they're doing.
Next: M02 — Structured Output
Acme can look up an order — but it answers in prose. When a UI, a database, or another service consumes the answer, you need guaranteed JSON, not a friendly paragraph. In M02 you'll force each SDK to return a typed OrderStatus object that validates against a schema — every time.