Think back to what happened when you said "roll me three twenty-sided dice".
Your server rolled the dice. That is all it did. Something else read your sentence, realised a tool was needed, picked the right one out of four, invented the arguments, made the call, read the result, and turned it into a friendly sentence.
That something else is called a host, and it did all the interesting work. Project #2 builds one.
Part 1 Β· Four steps, and that is the whole thing
"AI agent" sounds like a thing you buy. It isn't. It is four steps you write yourself.
| # | What happens |
|---|---|
| 1 | Send the conversation to the model, along with the list of tools. |
| 2 | The model replies. Its reply either is the answer, or it is a request: "please run roll_dice with these arguments." |
| 3 | If it's a request: run the tool, paste the result into the conversation as if the user had said it, and go back to step 1. |
| 4 | Otherwise, you're done. |
There was never anything else in the box.
In actual code
Stripped of bookkeeping, the loop is about twenty lines:
let messages = [{ role: "user", content: userText }];
for (let iteration = 1; iteration <= MAX_ITERATIONS; iteration++) {
// 1 Β· ask the model, giving it the conversation AND the tool list
const response = await anthropic.messages.create({
model: "claude-sonnet-5",
max_tokens: 4096,
tools, // built once, outside the loop
messages,
});
// 2 Β· append the WHOLE reply β see rule 1 on the next page
messages.push({ role: "assistant", content: response.content });
// 3 Β· if it didn't ask for a tool, it answered. Stop.
if (response.stop_reason !== "tool_use") break;
// 4 Β· run every tool it asked for, in parallel
const toolUses = response.content.filter((b) => b.type === "tool_use");
const results = await Promise.all(
toolUses.map(async (use) => ({
type: "tool_result",
tool_use_id: use.id,
content: await toolbox.dispatch(use.name, use.input),
})),
);
// 5 Β· paste them ALL back as ONE user message, then loop
messages.push({ role: "user", content: results });
}Read step 5 again. The tool results go back into the conversation in the
user role β as though you had typed them. The model has no separate channel
for tool output. It is all just conversation.
What stop_reason is telling you
The model tells you why it stopped talking, and branching on it properly is the difference between a loop and a spin.
stop_reason | Means | What the loop does |
|---|---|---|
end_turn | It finished its answer. | Stop. This is success. |
tool_use | It wants a tool run. | Run it, paste, loop. |
max_tokens | It was cut off mid-sentence. | Stop and say so β the answer is truncated, not wrong. |
refusal | It declined. | Stop and surface it. |
Part 2 Β· The demo that proves it
One sentence, chosen because it cannot be faked:
Roll 3d20, then put that many cookies in the jar.
Why this sentence? Because the second half depends on the first half. The
model cannot fill in count until it has seen the dice total β and the dice
total does not exist until a real server on the real internet has rolled real
random numbers.
If you see the right number in the second call, the chaining is genuine. There is no other explanation available.
What actually happened
--- iteration 1 --------------------------------------------------
-> CALL cookiejar__roll_dice {"sides":20,"times":3}
<- RESULT Rolled 3d20 -> [10, 4, 16] Total: 30 (265ms)
--- iteration 2 --------------------------------------------------
The rolls were 10, 4, and 16 β a total of 30. Now I'll add 30 cookies to the jar.
-> CALL cookiejar__cookie_jar {"action":"add","count":30}
<- RESULT Added 30. The jar now has 42 cookie(s). (275ms)
--- iteration 3 --------------------------------------------------
Done! π² I rolled 3d20 and got 10, 4, 16 for a total of 30.
I added 30 cookies to the jar, which now has 42 cookies in it.
done β end_turn
3 iteration(s), 2 tool call(s), 697 uncached in / 287 out
cache: 2281 written, 4562 read at ~10% priceWalking the three iterations
Iteration 1. messages contains one thing: your sentence. The model sees
it plus eight tool descriptions, and returns stop_reason: "tool_use" asking
for roll_dice. The loop runs it and gets back the string
"Rolled 3d20 -> [10, 4, 16] Total: 30".
Now messages has three entries: your sentence, the assistant's request, and a
user message carrying the result.
Iteration 2. The model is called again with all three. It reads its own
previous request and the result underneath it β and asks for
cookie_jar { action: "add", count: 30 }.
Iteration 3. Called again with all five messages. Nothing left to do, so
stop_reason: "end_turn" and a summary.
The thing to actually notice
Look at count: 30.
Part 3 Β· Server, client, host
Worth nailing down now, because the rest of the course leans on it.
| Talks to | Owns | |
|---|---|---|
| server | whoever calls it | its own tools and data |
| client | exactly one server | the transport |
| host | the model, and several clients | the conversation, the loop, and the money |
Every AI product you admire β Claude Code, Cursor, ChatGPT with plugins β is a variation on that third row.
Part 4 Β· The state is an array, and that turns out to matter
One more observation, easy to skip, that two later projects are built on.
Look again at what the loop actually holds between iterations. It is
messages β an array. That is the whole of the agent's state. There is no
hidden memory, no session on Anthropic's side, no server-side conversation id.
What you now know
- An agent is four steps: send, read the reply, run any tools, paste the results back, repeat.
- Tool results go back in the
userrole. There is no separate channel. - Branch on every
stop_reason, not justtool_use. - Chaining works because the loop pastes output into the conversation β no code connects one tool to the next.
- A host owns the model conversation, the loop and the key; servers only propose.
- The agent's entire state is the
messagesarray, which is why it can be frozen.
Next: the three rules that will save you, each of which cost real time to learn.