Skip to content

Building your first agent

An agent is a model in a loop with tools. That sentence is the whole architecture, and in this lesson we make it concrete. We define one tool, write the loop, and watch a model use the tool to answer a question it couldn’t answer alone.

Every example below runs against a fixture: a fake model that behaves deterministically, so you can predict what happens and check yourself. The complete program is site/examples/building-agents/agent-loop/agent.py in the repository. Swapping in a real model changes one function.

The model can’t run code. What it can do is emit text that says “call this function with these arguments”. So a tool has two halves: the function you run, and the description the model reads to decide when to ask for it.

def get_weather(city: str) -> str:
data = {"Amsterdam": "14°C, rain", "Lisbon": "27°C, sun"}
if city not in data:
raise LookupError(f"no data for {city}")
return data[city]
TOOLS = {
"get_weather": {
"fn": get_weather,
"description": "Current weather for a city. Args: city (str).",
},
}

Predict before you run.

Checkpoint · predict

What does this print?

print(TOOLS["get_weather"]["fn"]("Lisbon"))

Output verified in CI from site/examples/building-agents/agent-loop/tool_call.py.

The description matters as much as the code. The model chooses tools by reading descriptions, so a vague one (“weather stuff”) gets called at the wrong times and with the wrong arguments.

Now the fixture model. It looks at the last message and either asks for a tool or gives a final answer.

def fake_model(messages):
last = messages[-1]
if last["role"] == "user" and "weather" in last["content"]:
city = last["content"].split(" in ")[-1].rstrip("?")
return {"tool": "get_weather", "args": {"city": city}}
if last["role"] == "tool":
if last["content"].startswith("error:"):
return {"answer": f"I could not check. The tool said: {last['content']}"}
return {"answer": f"It is {last['content']} there."}
return {"answer": "I can only help with weather."}

And the loop itself. Read it slowly. Every agent framework you ever use is this with more error handling.

def run(question, model=fake_model, max_steps=5):
messages = [{"role": "user", "content": question}]
for _ in range(max_steps):
reply = model(messages)
if "answer" in reply:
return reply["answer"]
tool = TOOLS.get(reply["tool"])
if tool is None:
result = f"error: unknown tool {reply['tool']}"
else:
try:
result = tool["fn"](**reply.get("args", {}))
except Exception as exc:
result = f"error: {exc}" if str(exc) else f"error: {type(exc).__name__}"
messages.append({"role": "assistant", "content": str(reply)})
messages.append({"role": "tool", "content": str(result)})
return "stopped: step limit"

This reuses TOOLS and fake_model from above.

Checkpoint · predict

What does this print?

print(run("What is the weather in Amsterdam?"))

Output verified in CI from site/examples/building-agents/agent-loop/loop.py.

A real model API returns the same two cases in a different format. The reply has a stop reason field that says whether the model finished or wants a tool, and each tool result names the id of the request it answers, so the model can match them up when it asked for more than one tool at once [1]. With a stateless model API, the usual kind, every round sends the whole message list and the tool definitions again, because the model doesn’t keep state between calls [2] [3]. The answer key in fake_model plays the role of the stop reason, and translating that field is most of the work of swapping in a real model.

Notice max_steps. Without it, a model that keeps asking for tools runs forever and spends your money. A real loop has a budget, and when the budget runs out it returns a string that names the limit (stopped: step limit) rather than an empty answer. The lesson Stopping the loop on purpose replaces the bare string with a dict that has a stop reason field.

The loop also keeps going when a tool fails. A tool name that isn’t in TOOLS, or a function that raises, becomes a tool result that starts with error: and goes back to the model like any other result. The model can then retry with better arguments or tell the user what went wrong. A tool error never escapes the loop, so the run always ends with something to show. The same path handles a reply that names a tool but has no args. The loop calls the function with no arguments. Because get_weather has a required parameter, Python reports the missing argument, and that report goes back to the model as a tool error it can repair. An exception with no message is named by its type. The model then reads error: ValueError rather than a bare error:. The loop stores every tool result as text. Text is what the model reads.

Checkpoint · predict

What does this print?

print(run("What is the weather in Oslo?"))

Output verified in CI from site/examples/building-agents/agent-loop/tool_error.py.

A vendor SDK (software development kit) can run this loop for you. The Claude API SDKs call it the tool runner. You hand it your functions and it returns the final answer. In Python a decorator builds each tool definition from the function’s arguments and docstring, and the runner runs the request and result rounds [4] [5]. That saves the code above and hides the same decisions: how many steps it takes, what it does on a tool error, when it stops, and what it puts in the message list. Having written the loop once, you know which of those to check in the SDK’s version.

Checkpoint · order

Put the steps of one iteration of the agent loop in order.

  1. Send the message list to the model
  2. Check whether the reply is a final answer; if so, return it
  3. Look up the requested tool by name
  4. Run the tool function with the model's arguments
  5. Append the tool call and the tool result to the messages

Exercise

Copy agent.py from the repository. Add a second tool, get_time(city), returning a fixed string per city. Extend fake_model so a question containing “time” asks for it. Run the loop on “What is the time in Lisbon?” and check the answer.

A good result: the new tool has a description written for the caller, the loop itself is unchanged, and the question returns the fixed string for Lisbon in one tool round.

Stretch: Replace fake_model with a call to a real model API that supports tool use, keeping run unchanged. The only new work is translating the API's tool-call format into the shape the loop already understands.

Recap

  1. A tool is a function plus a description written for the model [6].
  2. The loop is: ask the model, run the tool it asks for, append the result, repeat until it answers or the budget runs out.
  3. The model never runs anything; your loop does. That is where safety controls go.

You can now

  • Defines a tool with a schema the model uses correctly
  • Implements the loop with error handling and a stop condition

  1. Anthropic. Handle tool calls. Claude Platform documentation. Reference. Claude docs handle-tool-calls
  2. Anthropic. Building with the Claude API. Claude Academy. Course. Academy building-with-the-claude-api
  3. Anthropic. Messages. Claude Platform documentation. Reference. Claude docs messages
  4. Anthropic. Claude Platform 101. Claude Academy. Course. Academy claude-platform-101
  5. Anthropic. Tool runner (SDK). Claude Platform documentation. Reference. Claude docs tool-runner
  6. Addy Osmani, Ivar Soares Urdalen, Leo Simons. Building your first agent: the loop from scratch, then with an SDK. Agent Engineer Course. Course. AEC-13