In the previous chapter we saw that an AI agent is made of four elements — brain (LLM), tools, memory, and loop (control). In this chapter we finally get our hands dirty. The goal is simple: build a "working agent" that combines the four elements in their most minimal form, actually run it, and observe how it behaves. We won't add flashy features yet. First, we get the heart of an agent turning by our own hand — "have the LLM think, call a tool, feed the result back, have it think again, and stop when done."
"Assemble and run one minimal agent on your own"
What a minimal agent is
A "minimal agent" is the four elements from the previous chapter assembled in their smallest possible form. No fancy memory, no pile of tools, no multiple agents. As the table below shows, we start from a setup pared down as far as it will go. Once you understand this, all that's left is to fatten up each element.
Just one call to Claude. Write a single line of role into the prompt: "You are an assistant that can use tools."
Just one tool. Search or calculation, either works. Start with a simple function that reliably runs.
A single array of conversation history is enough. Just stack the exchanges in order. Don't worry about persistence yet.
A single while loop. Run "if there's a tool request, execute it; if not, done." Just decide a cap on iterations.
💡 The only difference from "a single API call" is the loop. If you just ask the LLM once and get an answer, that's plain API use. The moment you add the repetition of "execute a tool, feed the result back, and have it think again," it becomes an agent. What you're really adding is this one loop. That's exactly why the shortcut is to understand the loop completely first.
Inside the agent loop
The heart of an agent is a single loop. It runs, in code, the same thing a human does — "investigate, think, act, look at the result, and if not done, repeat." First, let's pin down the flow of one turn with a diagram.
Pass the conversation history so far and the list of tools to the LLM. The LLM decides whether to "answer" or "use a tool."
When the LLM replies "I want to use this tool with these arguments," you actually run that function.
Append the result (the tool's return value) to the conversation history and hand it back to the LLM. Here you return to STEP 1.
When the LLM returns a final answer without using a tool, treat the goal as achieved and break out of the loop.
The key point is that you circle through STEP 1–3, and only break out at STEP 4. The LLM may not finish in one turn. Like "search first, look at the result, then search again," it takes several turns to close in on the goal. Written as pseudocode, it should be surprisingly short.
# Minimal agent loop (pseudocode) messages = [ user("Look up last month's average temperature and summarize it") ] while True: reply = llm.think(messages, tools=[search_tool]) # STEP1 think if reply.wants_tool: # tool requested? result = run_tool(reply.tool_name, reply.args) # STEP2 call messages.append(assistant(reply)) messages.append(tool_result(result)) # STEP3 feed back else: return reply.text # STEP4 stop when done
⚠️ Always add a cap on iterations. The pseudocode above uses while True for explanation, but in real code you must always add a brake of "up to N turns max." An infinite loop where the LLM keeps calling tools forever is a very real accident. We cover this in detail under Where people get stuck.
Giving it a single tool
Next, we give the LLM one tool. The important thing here is that the LLM cannot run code itself. All the LLM can do is request in words — "I want to use this tool with these arguments." Actually running the function and returning the result is always your job (your program's).
To do that, you first pass a tool definition to the LLM. A tool definition is a manual describing "what this tool can do and what arguments it takes." The LLM reads it to decide when and how to use it.
e.g. search_weather — "give it a place and a month, and it returns the average temperature." The description is what the LLM reasons from, so write it carefully.
Specify what arguments it takes and their types via a schema (a JSON-format definition). e.g. city (string) and month (number).
The real code that runs. It receives the arguments the LLM returned and returns a result. This can just be an ordinary function.
The trick is to pass ① and ② to the LLM, and keep ③ on your side. When the LLM requests "I want to use search_weather(city=\"Paris\", month=6)," you receive that name and arguments, call the function in ③, and return its value to the conversation as the tool result. Let's look at it in pseudocode.
# ① and ② … the tool definition passed to the LLM search_tool = { "name": "search_weather", "description": "Given a city and a month, return the average temperature (C)", "input_schema": { "city": "string", "month": "number", }, } # ③ … the actual function kept on your side. Just a function def run_tool(name, args): if name == "search_weather": return weather_db.lookup(args["city"], args["month"]) return "unknown tool"
📊 The description is part of the prompt. A tool's description is the LLM's only clue for deciding "when to use it." If it's vague, the model won't use it, or will use it in odd situations. The trick is to write it as if explaining "what it returns and when it should be used" to a person. Tool design is explored further with MCP in the next chapter.
Implementation sketch — hand-written loop and SDK
Assemble the parts so far and your first agent runs. There are broadly two ways to implement it. To learn how it works, write it by hand; to save effort in production, use an SDK — both are the same loop inside. Let's first sort out the difference.
Run the API calls and tool execution in your own while loop. You see everything happening inside, so it's ideal for learning. Control is entirely yours.
Good when: you want to understand the mechanism / control fine details.
Register your tool definitions and functions with the tool-execution loop provided by an SDK such as Claude's, and let it handle them. You skip the boilerplate and get robustness.
Good when: you want to build fast / offload the boilerplate in production.
A. The hand-written loop is just the parts so far connected straightforwardly. Hold the conversation history, call the LLM, and if there's a tool request, run it and feed the result back; if not, break out — that's all.
# A. Hand-written loop (pseudocode / Claude-message style) messages = [ user(goal) ] for step in range(MAX_STEPS): # <- brake via a cap reply = client.messages.create( model="claude-…", messages=messages, tools=[search_tool], ) log(step, reply) # <- always record, for observation if reply.stop_reason == "tool_use": out = run_tool(reply.tool_name, reply.tool_args) messages += [ assistant(reply), tool_result(out) ] else: break # final answer came out -> finish
B. The SDK's tool runner lets the library take care of that for loop itself. All you write is the "tool definition," the "actual function," and the "initial goal." The loop, history management, and feeding tool results back are handled internally.
# B. Delegate to the SDK's tool runner (pseudocode) runner = ToolRunner( model="claude-…", tools=[search_tool], # definition handlers={"search_weather": run_tool}, # implementation ) answer = runner.run(goal) # the loop turns internally
💡 Get the exact method names from the official docs. The client.messages.create and ToolRunner here are pseudocode to convey the feel. The actual argument names, return shapes, and model IDs differ by SDK and are updated often, so always check the latest in the official docs. Grasp the concept first and fill in the details from the docs — that's the way to avoid detours. For a step-by-step introduction, How to build an AI agent (beginner's guide) is also a good reference.
The recommendation is: "hand-write it once with A, then take it easy with B afterward." Once you've turned the loop yourself, the SDK stops being a black box, and when you get stuck you can picture what's happening inside.
Run it and observe
Once it's assembled, run it. What matters here is not just running it but observing "where it thought what, and where it failed." In agent development, the quality of this observation decides the outcome. So we build in logging from the start.
At a minimum, record the following on each turn. With a hand-written loop, a single line of log(step, reply) is enough.
Whether the LLM decided to "use a tool" that turn or to give a final answer. The branch point of its judgment.
The tool name and arguments. If these are off, the result goes wrong too. This is where the LLM's misunderstandings surface most.
The tool's return value. Too large, empty, an error — the material that throws off the next decision hides here.
Once you actually run it, interesting (and troublesome) behaviors emerge. For instance — the LLM calls a tool when it doesn't need to. Conversely, it fails to call one when it should, answering from memory alone. It gets the city name in the arguments slightly wrong. It repeats the same search over and over when once would do. You only notice these "habits of judgment" by looking at the logs.
✅ Logs aren't a "premium feature" — they're required from the start. Because an agent's insides are hard to see, without logs you have absolutely no idea "why it behaved that way." For now, a single line of print is plenty. Evaluation and observability, which systematize this observation, are covered in earnest in Chapter 5. For now, just build the "habit of looking" here.
Where people get stuck
Even a minimal setup will always hit a few walls when you run it. Each is a classic stumbling block of agent development, and something we'll resolve chapter by chapter from here. Here we pin down "what it really is" and "a stopgap you can apply now," and leave the full treatment to later chapters.
It keeps calling the same tool endlessly and never completes. It spins in place, unable to judge that the goal has been reached.
Stopgap: always set a cap on iterations. Detect and stop consecutive calls with the same arguments.
The tool returns a giant JSON or a full page, and feeding that whole thing back into the conversation blows up both cost and latency.
Stopgap: on the tool side, narrow to just the needed fields / summarize before returning. Don't hand over the full text unprocessed.
The more turns pile up, the more the conversation history accumulates, approaching the limit and becoming slow, expensive, and unstable.
Stopgap: summarize and compress old exchanges / discard unneeded tool results. Full treatment in later chapters.
📊 All three are "context design" problems. Infinite loops, bloated return values, and context bloat all trace back to the context design of "what, and how much, to hand the LLM." This way of thinking is systematized as context engineering, and it's the backbone of agent development. For now, make "don't over-hand, don't over-accumulate" your motto.
- A minimal agent = the four elements assembled at their smallest. The only difference from a single API call is the loop.
- The loop repeats one turn of "think → call a tool → feed the result back → think again → stop when done." A cap on iterations is mandatory.
- For a tool, pass the definition (name, description, arguments) to the LLM, and run the actual function on your side. The LLM only requests.
- There are two implementations — the hand-written loop (for learning) and the SDK's tool runner (for production). The insides are the same.
- Observe with logs from the start, and spot the classic stumbling blocks: infinite loops, bloated return values, and context bloat.
Once you've got one agent running, it's time to seriously expand its tools. In the next chapter, Chapter 3, "MCP and tool integration," we connect tools to external services and data in a standard way (MCP) and dramatically widen the agent's reach. If you want to revisit the big picture from the previous chapter, go back to Chapter 1.