Agent & tool foundations
An agent is just a language model placed in a loop where it can call tools, read the results, and decide what to do next — instead of answering once and stopping. This beginner chapter builds that mental model from scratch: what actually separates an agent from a plain chat, what a tool is (a function the model can call, described by a JSON schema), and how the agent loop works step by step. You will hand-build a tiny working agent in plain Python — using a fake, deterministic model so it runs offline — then see exactly how the same shape maps onto a real Anthropic tool-use call. You will finish able to explain what an agent is and reason about when you actually need one.
Learning objectives
- Explain what an agent is — an LLM in a loop with tools — and how it differs from a one-shot chat.
- Say why loops + tools are what give a model agency (the ability to act, not just answer).
- Describe a tool as a function plus a JSON schema (name, description, parameters).
- Trace the agent loop: propose tool call → execute → feed result back → repeat → final answer.
- Implement tool dispatch — map a requested tool name + args to a Python function, and handle unknowns.
- Recognise the shape of a real tool-use call in both Claude and OpenAI, and decide when an agent is worth it.
1 · What an agent actually is
Start with the moment the word “agent” actually earns its keep. You ask a chatbot, “what's 847 × 2,391?” and it confidently prints a number. It looks right. It's wrong — off by a few thousand — because the model didn't calculate anything; it pattern-matched a plausible-looking digit string. Now imagine instead it paused, said “let me run this through a calculator,” got back the exact answer, and used that. Same model, completely different outcome. The difference between those two behaviours is the entire subject of this chapter, and it has a name: agency.
Here's the analogy that makes it click. A one-shot chat is like a brilliant colleague locked in a room with no phone, no computer, and no window — you slide a question under the door and they slide back their best guess from memory. They might be a genius, but they can't check anything: not today's weather, not your database, not even long division. An agent is that same colleague with a desk, a phone, and a to-do list. They can pick up the phone (call a tool), read what comes back, and decide what to do next — and they keep going until the job is actually done, not just until they've said something.
The common beginner mistake is to think an agent is a smarter model. It isn't. The model is identical — the same weights, the same knowledge. What changed is the machinery around it: a loop that keeps calling the model, and tools the model is allowed to invoke. You could wrap a small, cheap model in a good loop with good tools and get more useful behaviour than a giant model answering blind. Agency is an architecture, not a bigger brain. Keep that straight and the rest of this chapter is easy.
A plain chat call is one-shot: you send a prompt, the model writes text, and it stops. It cannot look anything up, run a calculation, or check the current state of the world — it can only produce words from what it already knows. An agent removes that ceiling by putting the model in a loop and giving it tools it can call. Now the model can say “I need to run the calculator” instead of guessing, you run it, and the model continues with the real result in hand.
Read each row left→right. The top row is a plain chat; the bottom row is an agent — notice the only difference is the machinery around the identical purple Model box.
- The top row goes Prompt → Model → Answer and stops. There is no way to check anything — the model answers from memory alone.
- The bottom row adds an amber Tool step: the model can act (compute, search, call an API) and observe what comes back.
- The amber arrow curving back to the Model is the loop — the tool's result is fed in and the model runs again. Only when it has enough does it hand off to the green Answer.
In short: Same model in both rows; the loop-and-tool wrapper on the bottom is what turns "best guess from memory" into "check, then answer".
| One-shot chat | Agent | |
|---|---|---|
| Shape | prompt → answer → stop | prompt → (think → call tool → read result)* → answer |
| Can act? | No — only produces text. | Yes — calls functions and observes their output. |
| Knows current facts? | Only what it was trained on. | Can fetch them via tools at run time. |
| Who runs the loop? | Nobody — there is no loop. | Your code. The model only proposes; your program executes and decides when to stop. |
The word to internalise is agency: the model gains the ability to take actions in the world (search, compute, call an API) and then react to what those actions return. Loops + tools are what turn a text generator into something that can get things done.
2 · Tools = functions the model can call
Here's the mental trap to clear first, because almost everyone starts with it. When people hear “the model uses a tool,” they picture the model reaching out and running the calculator, hitting the weather API, executing your code. It does no such thing — it can't. A language model only produces text. What it produces, when tooling is set up, is a little text message that means “please run calculator with expression='2+3'.” The model is a customer placing an order; your code is the kitchen that actually cooks it and brings back the plate. Nothing runs unless your program chooses to run it.
So how does the model know what it's allowed to order? You hand it a menu. That menu is the tool's schema: a name, a plain-English description, and the list of parameters (with their types). The model reads the menu — it never sees your function's code, only its description — and picks from it. This is why a tool has two completely separate halves that beginners tend to blur together: the schema (what you advertise to the model, so it can choose) and the function (the real Python you run when it does choose). The schema is marketing copy aimed at the model; the function is the actual work.
The description field carries far more weight than it looks like it should, and getting it wrong is the single most common cause of an agent that “ignores” a tool or reaches for the wrong one. The model's only basis for deciding whether this tool fits the current step is the words you wrote in that description — it can't peek at the implementation to see what the function really does. Write the description as if it's a prompt (because it is), and you'll see the payoff in the callout right after the code.
A tool is just a function in your program that you have advertised to the model. The model never runs the function itself — it can't. Instead it emits a structured request that says “call the tool named calculator with these arguments,” and your code runs the real function and hands back the result. To advertise a tool you describe it with a JSON schema: a name, a human-readable description (this is how the model decides when to use it), and the parameters it accepts.
Read it left→right: the purple box is everything the model gets to see; the green box is code that stays entirely on your side.
- The left purple box is the
schema— name, description, and parameters. This is the "menu" the model reads to decide which tool fits the step. - The right green box is the actual
function. The model never sees this code — only its description in the schema. - The two arrows are the round trip: the model picks a tool by name (top arrow), then your code runs the real function and hands the result back (bottom arrow).
In short: Schema = what you advertise to the model; function = what you run. Blurring the two is why beginners expect the model to "run" the tool — it only orders from the menu.
Here are two real tool schemas as plain Python dicts — a calculator and a weather stub:
tool_schemas.pyimport json
# A tool schema tells the model the tool's name, what it's for, and its parameters.
CALCULATOR = {
'name': 'calculator',
'description': 'Evaluate a basic arithmetic expression like "2 + 3 * 4".',
'parameters': {
'type': 'object',
'properties': {
'expression': {'type': 'string', 'description': 'The arithmetic to evaluate.'},
},
'required': ['expression'],
},
}
GET_WEATHER = {
'name': 'get_weather',
'description': 'Get the current weather for a city.',
'parameters': {
'type': 'object',
'properties': {
'city': {'type': 'string', 'description': 'City name, e.g. "Paris".'},
'unit': {'type': 'string', 'enum': ['c', 'f'], 'description': 'Temperature unit.'},
},
'required': ['city'],
},
}
print(json.dumps(CALCULATOR, indent=2))
print('tools advertised:', [CALCULATOR['name'], GET_WEATHER['name']])
{
"name": "calculator",
"description": "Evaluate a basic arithmetic expression like \"2 + 3 * 4\".",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The arithmetic to evaluate."
}
},
"required": [
"expression"
]
}
}
tools advertised: ['calculator', 'get_weather']
2 + 3”) makes the model reach for it at the right moment. Treat tool descriptions as carefully as you treat prompts.3 · The agent loop, from first principles
Everything hinges on one word that trips people up: who runs the loop? The natural assumption — the one that leads to hours of confusion — is that the model runs itself in a loop, calling tools and continuing on its own like a little robot you set loose. It doesn't. The model does exactly one thing per turn: it looks at the conversation and produces one reply, which is either “here's a tool I want you to call” or “here's my final answer.” Then it stops and waits. The loop lives in your code, not the model. You call the model, look at what it said, run any tool it asked for, add the result to the conversation, and call the model again. The model is the advisor; your program is the one turning the crank.
Think of it like a chef and a runner. The chef (the model) can't leave the kitchen — every time they need something, they call out an order and wait. The runner (your loop) fetches the ingredient, sets it on the pass, and tells the chef “here's what you asked for, what next?” The chef looks at the whole spread so far and either asks for one more thing or plates the dish. Round and round until the plate is done. The chef never fetches anything themselves; the runner never decides the recipe. That clean split — model proposes, code executes — is the beating heart of every agent framework you'll ever touch, from ten lines of Python to the biggest production systems.
The reason this matters so much in practice: because you own the loop, you own every safety decision. You decide whether a tool is allowed to run, how many times the loop may repeat before you pull the plug, what to do when a tool fails, and when “done” means done. A model left to loop on its own has none of those guardrails. The diagram below traces one full turn of the crank; read it as a cycle, not a straight line — the arrow from “run tool” quietly loops back to “model” until the model finally answers.
Read the boxes left→right as one turn of the crank — but treat the "Tool call?" step as a fork, not a straight pass-through.
- The blue "Question" box on the far left is the user's request entering the loop; it flows into the purple "Model" box, which proposes the next step — it never runs anything itself.
- The amber "Tool call?" box is the branch: on "yes → execute" the flow moves right into the green "Run tool" box, which runs the real function and — per its "feed result back" label — hands the result back to the model to go again.
- The payoff is the far-right blue "Answer" box: when the model has no tool to call ("no → done"), the loop stops and returns the final answer.
In short: Question → Model → (run a tool and loop back)* → Answer — your code turns the crank; the model only proposes each step.
The loop is mechanical once you see it. The model looks at the conversation so far and either proposes a tool call or gives a final answer. If it proposed a tool call, your code runs the tool, appends the result to the conversation, and loops back to the model. When the model answers instead of calling a tool, you stop.
Here is a complete tiny agent. We replace the LLM with a fake, deterministic decider so it runs offline: on its first turn it decides to call the calculator; on its second turn — now that it can see the result — it writes the final answer. A real LLM replaces this fake decider; the loop around it is identical.
tiny_agent.py# ---- 1. the tool: a real Python function ----
def calculator(expression):
# A safe tiny evaluator for + - * / on numbers (no names, no calls).
return eval(expression, {'__builtins__': {}}, {})
TOOLS = {'calculator': calculator}
# ---- 2. a FAKE model. A real LLM drops into exactly this slot. ----
# It reads the conversation and returns either a tool call or a final answer.
def fake_model(messages):
last = messages[-1]
if last['role'] == 'user':
# First look at the question -> decide to use the calculator.
return {'type': 'tool_call', 'name': 'calculator',
'args': {'expression': '2 + 3 * 4'}}
# We just saw a tool result -> now answer for real.
return {'type': 'final', 'text': f"The answer is {last['content']}."}
# ---- 3. the agent loop (this part never changes when you swap in a real model) ----
def run_agent(question, max_steps=5):
messages = [{'role': 'user', 'content': question}]
for step in range(max_steps):
decision = fake_model(messages)
if decision['type'] == 'final':
return decision['text'], messages
# The model proposed a tool call: run it and feed the result back.
name, args = decision['name'], decision['args']
result = TOOLS[name](**args)
messages.append({'role': 'assistant', 'content': f'call {name}({args})'})
messages.append({'role': 'tool', 'content': result})
return 'stopped: too many steps', messages
answer, transcript = run_agent('what is 2 + 3 * 4?')
print('TRANSCRIPT:')
for m in transcript:
print(f" {m['role']:9} | {m['content']}")
print('\nFINAL:', answer)
TRANSCRIPT:
user | what is 2 + 3 * 4?
assistant | call calculator({'expression': '2 + 3 * 4'})
tool | 14
FINAL: The answer is 14.
That is a real agent. The model didn't compute 14 from memory — it called a tool, your code ran it, and the model finished using the true result. Swap fake_model for a hosted LLM and the loop code stays exactly as written.
max_steps. A real model can get stuck calling tools forever (or ping-ponging between two). Never write an unbounded agent loop — always cap the number of steps so a confused model fails loudly instead of running (and billing) without end.4 · Tool dispatch — matching a request to a function
Picture the gap the model just handed you. What comes back from the model is text — a name ("calculator") and a bag of arguments ({"expression": "10 / 4"}). What you need is a running Python function. Something has to bridge those two, and that bridge is dispatch: the step that takes the requested name and finds the actual function to call. It sounds trivial, and in the happy path it is one line — but this is exactly the seam where real agents crack, so it's worth slowing down for.
The clean way to build the bridge is a registry: a plain dictionary mapping each tool name to its function. Think of it as a switchboard — a name comes in, you look it up, you connect the call. The tempting alternative, a long if name == "calculator": … elif name == "get_weather": … chain, works for two tools and becomes a maintenance swamp at twenty. With a dict, adding a tool is one new entry and dispatch never changes. That's not just tidiness; it's the difference between a codebase you can grow and one you'll be afraid to touch.
Now the part beginners almost always skip, and it's the whole reason this section exists. The model will, sooner or later, ask for a tool that isn't there — a typo (calculater), a tool you renamed last week, or one it simply hallucinated. The instinct is to let the lookup fail and the program crash. Resist it. A crash ends the loop and throws away the model's chance to notice its mistake. Instead, treat a bad request the way a good waiter treats an order for something not on the menu: don't storm off — say “we don't have that, here's what we do have.” Return that as the tool result, feed it back, and the model reads it and corrects itself on the next turn. Turning failures into readable messages the model can act on is the single most important reliability habit in this whole chapter.
In the loop above, TOOLS[name](**args) is doing dispatch: taking the tool name and arguments the model asked for and routing them to the actual Python function. A registry (a dict from name → function) keeps this clean and makes adding a tool a one-line change. The one case beginners forget: the model can ask for a tool that doesn't exist (a typo, or a tool you removed). Handle it — don't crash.
Follow the arrows left→right, then notice the split into two paths at the amber lookup box.
- The blue box is what the model handed you: a tool
nameand a bag ofargs— text, not a function. - The amber box is the registry lookup (
TOOLS.get(name)) — the switchboard that turns a name into a function. - The upper green path is the happy case: the tool exists, you run it and return its result. The lower red path is the safe fallback: an unknown tool or a raised exception becomes an error string you feed back to the model.
In short: Look the name up, don't crash on a miss — return an error the model can read and correct itself with on the next turn.
dispatch.pydef calculator(expression):
return eval(expression, {'__builtins__': {}}, {})
def get_weather(city, unit='c'):
# A stub — a real version would call a weather API here.
return f'18 degrees {unit} and clear in {city}'
TOOLS = {'calculator': calculator, 'get_weather': get_weather}
def dispatch(name, args):
fn = TOOLS.get(name)
if fn is None:
# Return an error string instead of raising — the model can read it and recover.
return f"error: no tool named '{name}'. available: {sorted(TOOLS)}"
try:
return fn(**args)
except Exception as e:
return f'error running {name}: {e}'
print(dispatch('calculator', {'expression': '10 / 4'}))
print(dispatch('get_weather', {'city': 'Paris', 'unit': 'f'}))
print(dispatch('teleport', {'to': 'Mars'})) # unknown tool
print(dispatch('calculator', {'expression': '1/0'})) # tool raises
2.5
18 degrees f and clear in Paris
error: no tool named 'teleport'. available: ['calculator', 'get_weather']
error running calculator: division by zero
Two habits to keep from the start: look tools up in a registry (never a long if/elif chain), and turn failures into result strings the model can read rather than exceptions that kill the loop. Feeding “no tool named 'teleport'” back to the model lets it correct itself on the next turn — which is exactly the kind of self-recovery that makes agents useful.
5 · The real version — a Claude or OpenAI tool-use call
This is the moment the training wheels come off — and the reassuring part is how little changes. Everything so far ran on a fake_model that always made the same scripted choice, so you could watch the loop without an API key or an internet connection. The worry at this point is usually “the real thing must be far more complicated.” It genuinely isn't. Swapping the fake decider for a hosted model changes one box in the diagram — the one that decides the next step — and leaves the loop, the dispatch, and the safety habits exactly as you already wrote them.
What does change is the vocabulary, and it's worth naming the translations so the real code doesn't look foreign. Our fake model returned {'type': 'tool_call', ...}; the Claude API signals the same intent by setting stop_reason == 'tool_use' and emitting tool_use blocks, each carrying a .name, an .input (the arguments), and an .id, and you reply with a tool_result whose tool_use_id matches that .id. The OpenAI Responses API does the same job with different names: a tool request arrives as function_call items in resp.output (each with a name, a JSON-string arguments, and a call_id), there is no stop_reason — you simply loop while any function_call items remain — and you reply with a function_call_output keyed by the matching call_id, reading the final text from resp.output_text. Either way the matching id is how the model knows which answer belongs to which call when several run at once. Same concepts, two sets of field names.
One honest caveat before the code: this example calls a hosted model, so unlike everything above it can't run in the browser sandbox — it needs the SDK for whichever vendor you pick (pip install anthropic + ANTHROPIC_API_KEY, or pip install openai + OPENAI_API_KEY) in your environment. It is complete and correct as written; run it yourself once you have a key. Read it side by side with the tiny loop from §3 and you'll see the identical skeleton wearing production clothing — which is the whole point of having built the fake version first.
Everything above ran with a fake decider so you could see the whole loop. A real agent swaps fake_model for a hosted LLM. The shape is the same as our tiny loop — you send the tools and the conversation, the model replies with either text or a tool-call request, you run the tool, and you send the result back. Here is that same loop against the real API, with Claude and OpenAI versions in the tabs below. It is complete and correct, but it needs your API key to run, so we don't execute it here:
stop_reason=='tool_use', a final answer is any other stop reason, and a tool result becomes a tool_result block keyed by tool_use_id. OpenAI's Responses API maps the same three onto function_call items, "no function_call left," and a function_call_output keyed by call_id — see the OpenAI tab above.
Read it as a translation table, left→right: each blue box (what our fake model returned) maps to the green box beside it (what the real API uses).
- The left blue column is the fake decider's three outputs from §3: a tool call, a final answer, and a tool-result message.
- The right green column is the exact Anthropic equivalent:
stop_reason=='tool_use', any otherstop_reason, and atool_resultblock carrying the matchingtool_use_id. - The arrows are one-to-one — nothing new is added, the names just change. The line at the bottom is the payoff: the loop, dispatch, and step cap you already wrote are untouched.
In short: Going real is a rename, not a rewrite — only the box that decides the next step is swapped for the hosted model.
The real tool-use loop — in Claude or OpenAI
The loop is the same shape on both SDKs — send tools + the conversation, run whatever tool the model asks for, feed the result back. Only the vocabulary differs. In Claude a tool request arrives as stop_reason == 'tool_use' with tool_use blocks, and you reply with a tool_result keyed by tool_use_id. In OpenAI (Responses API) the request arrives as function_call items in resp.output (each with name, JSON-string arguments, and call_id), and you reply with a function_call_output keyed by the matching call_id; you loop while any function_call items remain and read the final text from resp.output_text. Toggle the tab to switch SDK.
real_tool_use.py# The real agent loop. Complete and correct; needs YOUR API key to actually run.
# pip install anthropic ; export ANTHROPIC_API_KEY=sk-...
from anthropic import Anthropic # runs in your own environment, not the sandbox
client = Anthropic()
def calculator(expression):
return eval(expression, {'__builtins__': {}}, {})
TOOLS = {'calculator': calculator}
# Same tool schema as section 2, in the shape the API expects (input_schema).
tools = [{
'name': 'calculator',
'description': 'Evaluate a basic arithmetic expression like "2 + 3 * 4".',
'input_schema': {
'type': 'object',
'properties': {'expression': {'type': 'string'}},
'required': ['expression'],
},
}]
messages = [{'role': 'user', 'content': 'what is 2 + 3 * 4?'}]
while True:
resp = client.messages.create(
model='claude-opus-4-8', max_tokens=1024, tools=tools, messages=messages,
)
if resp.stop_reason != 'tool_use':
# The model gave a final answer -> print the text and stop the loop.
print(next(b.text for b in resp.content if b.type == 'text'))
break
# The model asked for a tool. Run each requested tool and feed results back.
messages.append({'role': 'assistant', 'content': resp.content})
results = []
for block in resp.content:
if block.type == 'tool_use': # block.name / .input / .id
output = TOOLS[block.name](**block.input)
results.append({'type': 'tool_result', 'tool_use_id': block.id,
'content': str(output)})
messages.append({'role': 'user', 'content': results})
real_tool_use.py# The real agent loop. Complete and correct; needs YOUR API key to actually run.
# pip install openai ; export OPENAI_API_KEY=sk-...
import json
from openai import OpenAI # runs in your own environment, not the sandbox
client = OpenAI()
def calculator(expression):
return eval(expression, {'__builtins__': {}}, {})
TOOLS = {'calculator': calculator}
# Same tool, in the flat shape the Responses API expects (parameters schema).
tools = [{
'type': 'function',
'name': 'calculator',
'description': 'Evaluate a basic arithmetic expression like "2 + 3 * 4".',
'parameters': {
'type': 'object',
'properties': {'expression': {'type': 'string'}},
'required': ['expression'],
},
}]
messages = [{'role': 'user', 'content': 'what is 2 + 3 * 4?'}]
while True:
resp = client.responses.create(
model='gpt-5.5', max_output_tokens=1024, tools=tools, input=messages,
)
calls = [b for b in resp.output if b.type == 'function_call']
if not calls:
# No tool requested -> the model gave a final answer. Print it and stop.
print(resp.output_text)
break
# The model asked for a tool. Run each requested tool and feed results back.
messages += resp.output
for block in calls: # block.name / .arguments / .call_id
output = TOOLS[block.name](**json.loads(block.arguments))
messages.append({'type': 'function_call_output', 'call_id': block.call_id,
'output': str(output)})
pip install anthropic + ANTHROPIC_API_KEY, or pip install openai + OPENAI_API_KEY) in the environment — it can't run in the browser sandbox. Notice the shape is our tiny loop with real names: with Claude the model stops with stop_reason == 'tool_use' and emits tool_use blocks you answer with a tool_result; with OpenAI it returns function_call items you answer with a function_call_output (toggle the tab above). The concepts already ran above with the fake model — only the decider changed.6 · When you need an agent — and when you don't
Now that you can build one, here's the harder skill: knowing when not to. Once the agent loop clicks, there's a strong pull to reach for it everywhere — it feels powerful, and it is. But an agent is a race car, not a daily driver. It's the right choice for a specific kind of trip and an expensive, twitchy mistake for the grocery run. The maturity that separates a hobbyist from an engineer is choosing the simplest thing that works, and very often that's a single well-written prompt.
Be concrete about what the loop actually costs, because “it's more powerful” hides a real bill. Every turn is another model call — so a five-step agent is roughly five times the latency and five times the tokens of a one-shot answer. And each turn is a new place to go wrong: the model can pick the wrong tool, pass bad arguments, misread a result, or ping-pong until your step cap saves it. A plain prompt has one call, one predictable cost, and one failure mode. An agent trades that predictability for the ability to act — a great trade when you need action, a bad one when you don't.
So here is the decision rule in one breath: reach for an agent only when the task needs live data or actions the model can't perform itself, or when it takes multiple steps whose order depends on what earlier steps returned. “Summarise this email,” “classify this ticket,” “rewrite this paragraph” — all one-shot; a loop just adds cost and risk. “Check the order status, and if it shipped, draft a delay apology” — that needs to look something up and then branch on the result, which is exactly what the loop is for. When you can fully specify the answer up front, prompt. When the path has to be discovered as you go, that's an agent. The table sharpens both columns.
Agents are powerful, but the loop adds real cost: more model calls (higher latency and bill) and more ways to fail (a bad tool call, an infinite loop, a wrong result fed back). A plain prompt is one call with a predictable cost. Reach for an agent only when the task genuinely needs it:
Follow it left→right: a task arrives, hits one question, and branches into the right tool for the job.
- The blue box is any incoming task. The amber diamond-style box is the single decision: does it need to act, or take steps that depend on each other?
- The upper "no" path lands on a green Plain prompt — one call, predictable cost — for summarise/classify/rewrite work.
- The lower "yes" path lands on the purple Agent — the loop-and-tools machinery — reserved for tasks that must fetch live data or branch on intermediate results.
In short: One question decides it — if you can fully specify the answer up front, prompt; if the path must be discovered as you go, use an agent.
| Use a plain prompt when… | Use an agent when… |
|---|---|
| The answer is already in the model's knowledge or in the prompt you send. | The task needs live data or actions the model can't do itself (search, compute, call an API). |
| It's one step — summarise, classify, rewrite, extract. | It takes multiple steps whose order depends on intermediate results. |
| You want low latency and a predictable, single cost. | The value of getting it done autonomously justifies extra latency, cost, and failure surface. |
| You can fully specify the output up front. | The path can't be fully planned in advance — the model must react as it goes. |
What are the two ingredients that turn a plain LLM into an agent, and what does each add?
Show answer
The model requests a tool named lookup_order, but your registry has no such tool. What should your dispatch code do, and why not just crash?
Show answer
🪜 Practice — from a working toy to real intuition beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
In tiny_agent.py, register a second tool greet(name) that returns f'Hello, {name}!', and add it to TOOLS. You don't need the model to call it yet — just confirm the registry holds both.
Show solution
Definedef greet(name): return f'Hello, {name}!' and set TOOLS = {'calculator': calculator, 'greet': greet}. Print sorted(TOOLS) to see both names. Dispatch is unchanged — the registry does the routing.
Change fake_model so that if the question contains the word “weather” it proposes get_weather, otherwise it proposes calculator. Add a get_weather stub and run both kinds of question.
Show solution
Branch inside theif last['role'] == 'user' block: if 'weather' in last['content'] return a get_weather tool call, else the calculator one. The loop and dispatch don't change — only the decider's choice does.
Print each loop iteration: the step number, whether the model proposed a tool or a final answer, and (for tool calls) the tool name and args. This is the beginning of observability.
Show solution
Insiderun_agent's for step in range(...) loop, print step and decision['type']; for a tool call also print decision['name'] and decision['args']. Seeing the trace is how you debug agents.
Extend the fake model so a question like “weather in Paris, then double the temperature” calls get_weather first, then calculator on the result, then answers. You will need the fake model to look at how many tool results are already in the transcript.
Show solution
Counttool-role messages in messages: 0 → call weather; 1 → call calculator on the returned number; ≥2 → final answer. This is a hand-wired version of what a real model does on its own — multi-step, each step depending on the last.
Make the fake model buggy on purpose — always return a tool call, never a final answer — and confirm your max_steps cap stops it cleanly. Then explain why this matters more with a real model.
Show solution
Withfake_model always returning a tool_call, run_agent hits the max_steps cap and returns “stopped: too many steps”. A real model can loop for real reasons (confusion, a tool that always errors) — the cap converts an infinite, billable loop into a clean, visible failure.
Before calling a tool, check that the model's args match the tool's schema (required keys present, no unexpected keys). Reject bad calls with an error result instead of running the function. Explain why validating at the boundary matters.
Show solution
Read the tool'sparameters schema: verify every name in required is present in args and (optionally) that no extra keys appear; if not, return an error string as the tool result. Validating at the boundary means a malformed model request fails safely and legibly instead of raising deep inside your function — the same discipline real tool frameworks enforce with strict schemas.
Context: A teammate has only ever used one-shot LLM prompts and asks you: “When should I actually build an agent instead of just writing a better prompt?”
Your task: Write a short note (5–8 sentences) that answers them using the mental model from this chapter.
Requirements:
- Define an agent in one line — an LLM in a loop with tools — and contrast it with a one-shot prompt.
- Name the concrete costs an agent adds (more model calls → latency and bill; more failure modes).
- Give the decision rule: reach for an agent only when the task needs to act (tools) or take multiple dependent steps; otherwise a plain prompt wins.
- Mention one safety habit from this chapter (bound the loop with a step cap, or turn tool failures into readable error results).
💡 Hint: You don't need code — this is about communicating the mental model. The next chapter (4.2) covers agent patterns — ReAct, plan-and-execute, reflection — that make the loop smarter.
✓ Checkpoint — you can move on when you can…
- An agent is an LLM in a loop that can call tools and observe results, running until it gives a final answer — unlike a one-shot chat.
- A tool is a function you advertise to the model with a JSON schema (name, description, parameters); the model requests it, your code runs it.
- The agent loop is: model proposes a tool call → you execute → feed the result back → repeat → final answer. You own the loop; the model only proposes.
- Dispatch routes a requested tool name + args to a real function via a registry, and unknown tools and failures return error strings the model can recover from — not crashes.
- A real tool-use call has the same shape in both SDKs: Claude signals
stop_reason == 'tool_use'withtool_use/tool_resultblocks, OpenAI returnsfunction_callitems answered withfunction_call_output— only the decider changed. - Use an agent only when the task needs to act or take multiple dependent steps; otherwise a plain prompt is cheaper and simpler.