Build a Tool-Calling Agent
The Goal
An agent is a program in which the model decides which actions to take, the program performs them, and the model continues with the results until the task is complete. A tool-calling agent makes that concrete: Claude asks for a tool, the application runs it, and the result goes back to Claude, which may ask for another tool or give the final answer. The loop is the agent, and the tools are what it can do.
This project builds a small agent with two tools, a price lookup and an addition, and it runs the loop with the controls the earlier lessons established: a step limit, validated tool inputs, and a log of every action. The model is scripted in the demonstration, so the sequence of steps is known, and the same loop runs against the live API unchanged.
What You Will Build
An run_agent function that:
- sends the question and the tool definitions,
- runs each tool the model requests and returns its result,
- continues the conversation until the model stops asking for tools,
- stops after a fixed number of steps, however the model behaves,
- returns the final answer together with a log of every step.
Project Structure
tool_agent/
├── tools.py # the tools the agent may use, and their definitions
├── agent.py # the loop
└── demo.py # a scripted client that asks for two tools, then answers
Step 1: The Tools
Each tool is a plain function with a fixed behavior, and a definition that tells the model what it does and what inputs it takes. The functions accept only the inputs they declare, so an unexpected argument is an error rather than an action (Lesson 94):
"""tools.py: the tools this agent may use, and the definitions the model sees."""
PRICES = {"django-101": 4999, "sql-basics": 2999}
def lookup_price(course: str) -> int:
if course not in PRICES:
raise ValueError(f"no price for course {course}")
return PRICES[course]
def add(a: int, b: int) -> int:
return a + b
TOOLS = {"lookup_price": lookup_price, "add": add}
TOOL_DEFINITIONS = [
{
"name": "lookup_price",
"description": "Returns the price in rupees of a course, by its slug such as django-101.",
"input_schema": {
"type": "object",
"properties": {"course": {"type": "string"}},
"required": ["course"],
"additionalProperties": False,
},
},
{
"name": "add",
"description": "Adds two whole numbers and returns the sum.",
"input_schema": {
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["a", "b"],
"additionalProperties": False,
},
},
]
The definitions are the only description of the tools the model receives, so they state the inputs and their meaning clearly (Lessons 82 and 93). The additionalProperties setting tells the model, and the schema, that other fields are not accepted.
Step 2: The Loop
The loop follows the pattern from Lesson 88. Each response is appended unchanged, each requested tool is run, its result is returned in one user message, and the loop continues. A step limit ends the loop if the model keeps asking for tools, and an unknown tool name is reported back as an error rather than crashing the agent:
"""agent.py: the loop that runs tools until the model gives a final answer."""
from tools import TOOLS, TOOL_DEFINITIONS
MODEL = "claude-sonnet-5-5"
MAX_STEPS = 5
def text_of(response) -> str:
return "".join(block.text for block in response.content if block.type == "text")
def run_agent(client, question: str) -> tuple[str, list[str]]:
messages = [{"role": "user", "content": question}]
log: list[str] = []
for step in range(1, MAX_STEPS + 1):
response = client.messages.create(
model=MODEL, max_tokens=500, tools=TOOL_DEFINITIONS, messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return text_of(response), log
results = []
for block in response.content:
if block.type != "tool_use":
continue
tool = TOOLS.get(block.name)
try:
output = tool(**block.input) if tool else f"unknown tool {block.name}"
except (ValueError, TypeError) as error:
output = f"error: {error}"
log.append(f"step {step}: {block.name}({block.input}) -> {output}")
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "user", "content": results})
return "[stopped: the agent reached its step limit]", log
How It Works
- Each response is appended before anything else happens, so the thinking and tool-use blocks are preserved as the API requires (Lesson 153).
- Tools run one at a time, in the order requested, and every tool call receives a result with its identifier, as Lesson 87 requires.
- Failures become results. A tool that raises a
ValueErrorreturns an error message to the model, which can then correct itself, following Lesson 90. - The step limit bounds the loop. A model that keeps requesting tools reaches the limit, and the agent returns a message saying so, rather than running without end.
- The log records every action, with its step, its input, and its output, which is the audit trail the security lessons ask for (Lesson 180).
Step 3: A Scripted Demonstration
The demonstration client returns three responses in sequence: a request for a price, a request for a sum, and a final answer. The blocks have the attributes the loop reads, so the agent cannot tell the script from the live model:
"""demo.py: an agent run with a scripted model that asks for two tools and then answers."""
from types import SimpleNamespace
from agent import run_agent
def tool_use(identifier, name, args):
return SimpleNamespace(type="tool_use", id=identifier, name=name, input=args)
SCRIPT = [
SimpleNamespace(stop_reason="tool_use", content=[tool_use("t1", "lookup_price", {"course": "django-101"})]),
SimpleNamespace(stop_reason="tool_use", content=[tool_use("t2", "add", {"a": 4999, "b": 500})]),
SimpleNamespace(stop_reason="end_turn",
content=[SimpleNamespace(type="text",
text="The total with the workshop fee is 5499 rupees.")]),
]
class ScriptedClient:
def __init__(self):
self.messages = self
self.turn = 0
def create(self, **request):
response = SCRIPT[self.turn]
self.turn += 1
return response
if __name__ == "__main__":
answer, log = run_agent(ScriptedClient(), "What is the Django course price plus a 500 workshop fee?")
for line in log:
print(line)
print("final:", answer)
Output:
step 1: lookup_price({'course': 'django-101'}) -> 4999
step 2: add({'a': 4999, 'b': 500}) -> 5499
final: The total with the workshop fee is 5499 rupees.
How It Works
- The first step runs the price lookup, which returns 4999 for the Django course, and the log shows the input and the output.
- The second step adds the workshop fee to that price, using the value the model received from the first step. The model chained the tools by carrying a result forward, which is the core of agent behavior.
- The third response has no tool use, so the loop returns its text as the final answer, along with the log of the two actions.
- The arithmetic is done by a tool, not by the model, which is the reason tools exist for work that must be exact (Lesson 100).
Going Live
The scripted client is replaced by Anthropic(), and run_agent is called in the same way. The live model chooses its own steps, so the log may differ from the script, but the loop, the step limit, and the logging are the same.
Safety for a Live Agent
A live agent can call any tool it is given, so the tools should be chosen with the permission rules of Lesson 176, and their inputs should be validated as Lesson 177 described. A tool that changes data should require confirmation before it runs. The step limit and the log are the controls that let an operator stop and understand a misbehaving agent, and they should be reviewed as part of testing (Lesson 183).