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 Anthropic tool-use call and decide when an agent is worth it.
1 · What an agent actually is
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.
| 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
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.
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
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
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.
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 — an Anthropic tool-use call
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 Anthropic API. It is complete and correct, but it needs your API key to run, so we don't execute it here:
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})
pip install anthropic and your ANTHROPIC_API_KEY in the environment — it can't run in the browser sandbox. Notice the shape is our tiny loop with real names: the model stops with stop_reason == 'tool_use' and emits tool_use blocks (each with a .name, .input, and .id); you run the tool and reply with a tool_result carrying the matching tool_use_id. The concepts already ran above with the fake model — only the decider changed.6 · When you need an agent — and when you don't
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:
| 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 Anthropic tool-use call has the same shape:
stop_reason == 'tool_use'+tool_useblocks in,tool_resultblocks back — 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.