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 flat function tool
  • 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?

Everyday Analogy

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.

Technical Definition — Tool (a.k.a. function calling)

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:

  1. Send the conversation (system prompt + messages so far) to the model, along with your tool list.
  2. Inspect the reply. Did the model ask for a tool? (Anthropic: stop_reason == "tool_use"; Gemini: response.function_calls is non-empty; OpenAI: an output item of type function_call.)
  3. If no tool was requested, the model gave a final answer — return it. Done.
  4. If a tool was requested, run the matching function in your code, then append the model's request and your result to the conversation.
  5. 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:

The Tool-Use Loop — One Full Cycle
1
User asks: "Where's order AC-1042?"you send messages / contents / input
2
Model replies: "Call get_order_status(AC-1042)"tool_use / function_call
3
Your code runs the real functionget_order_status("AC-1042") → {status: "shipped", eta: "Tue"}
4
You append the result as a new turntool_result / functionResponse / function_call_output
↻ loop — send again
5
Model replies with a final answer"Your order shipped and arrives Tuesday!"
Why It Matters

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 };
}
What Just Happened?

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?"));
The Three Round-Trips, Lined Up

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 as tool_result blocks, keyed by tool_use_id. (Yes — tool results are a user turn in Claude.)
  • Gemini: results go back in a role: "tool" Content as functionResponse parts, keyed by the function name.
  • OpenAI: results go back as standalone function_call_output items keyed by call_id (not id — a classic copy-paste bug).

Run It

bash
# 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
Expected output (all three, wording varies)
Good news! Order AC-1042 has shipped via UPS and is expected to arrive
Tuesday. Is there anything else I can help you with?
✅ Checkpoint: Each script should make two round-trips to its model: the first returns a tool request, the second (after you feed back the status) returns the final sentence. If yours returns a made-up tracking number without ever calling the tool, your tool description is too vague — make it explicit that order questions require the tool.

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.
Two Layers of Errors

There are two failure surfaces, and you handle them differently:

  1. 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": True to the tool_result block to mark it. Gemini and OpenAI just receive the error text in the normal result field.
  2. API-level failure (network drop, rate limit, refusal): wrap the create() call in try/except. Every SDK ships typed exceptions (e.g. Anthropic's RateLimitError) and auto-retries transient errors. We build the full production retry wrapper in M07.
Guard the Loop

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

ConceptAnthropicGoogle GeminiOpenAI (Responses)
Declare a tool{name, description, input_schema}FunctionDeclaration in a Tool{type:"function", name, parameters} (flat)
Pass tools intools=config.tools=tools=
"Did it call a tool?"stop_reason == "tool_use"resp.function_calls non-emptyan output item of type function_call
The call's argumentsblock.inputfc.argsjson.loads(call.arguments)
Return the resulttool_result in a user turnfunctionResponse in a tool turnfunction_call_output item
Matches bytool_use_idfunction namecall_id
Read final answercontent[].text blocksresp.textresp.output_text
Why the Differences? (and a shortcut)

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?

AThe model runs your function on the provider's servers
BThe model rewrites your function and executes the new version
CThe model returns a structured request; your code runs the real function and returns the result
DNothing — tools are just documentation
Correct! The model never executes anything. It emits a machine-readable request (name + arguments); you execute and hand back the result. The model is the brain, your tools are its hands.

Q2: In the Anthropic SDK, where do tool results go back?

AIn an assistant message
BIn a user message, as tool_result blocks keyed by tool_use_id
CIn a dedicated tool role message
DIn the system prompt
Correct! Claude threads tool results through the user 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?

ARaise an exception to stop the loop
BAn empty string
CNone, and hope the model notices
DA result like {"error": "No order found"} so the model can apologize and recover
Correct! Return failure as data. A raised exception crashes your loop before the model ever sees the problem; an error dict lets it respond helpfully.

Q4: In OpenAI's Responses API, which field keys a function_call_output back to its request?

Acall_id
Bid
Ctool_use_id
DThe function name
Correct! It's call_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?

ALoops are illegal in Python
BIf the model keeps requesting tools, an uncapped loop spins forever — and every iteration is a paid API call
CA cap makes the model smarter
DThe SDKs require a cap or they error out
Correct! A cap turns a runaway (and expensive) loop into a graceful "let me get a human" fallback. Cost and reliability, both.

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_result in a user turn (Anthropic), functionResponse in a tool turn (Gemini), or a function_call_output item keyed by call_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.