Close the loop: build a working agent
Send a tool result back to the model and let it finish the job. Build the plan-act-observe loop by hand and see exactly what every agent framework is doing.
The function calling lesson stopped halfway: the model asked for a tool and you read the request. Now you will run the tool, send the result back, and let the model finish. That round trip, repeated, is what makes an agent, and it takes about fifteen lines.
Conversation as an array
Until now contents has been a string. For a back-and-forth conversation it becomes an array of messages, each with a role and parts. You keep this array yourself. The API is : whatever you send is everything the model can see.
[
{ role: "user", parts: [{ text: "How many copies of Dune do we have?" }] },
{ role: "model", parts: [{ functionCall: { name: "getStock", args: { title: "Dune" } } }] },
{ role: "user", parts: [{ functionResponse: {
name: "getStock",
response: { count: 12 },
} }] },
]- Add the model's own message,
candidates[0].content, exactly as it came back. Without it, the model loses track of what it just decided. - The
functionResponsemust carry the samenameas the call it answers. responsecan be any object. If the tool fails, put the error in here instead of throwing.
Under the hood — Why is the tool result labeled role: "user"?
This one confuses nearly everyone. Your backend ran the function, so labeling the result as coming from the "user" feels wrong.
The reason is that the conversation format only has two roles: model for what the model produced, and user for everything entering the conversation from outside it. A tool result is outside information arriving in the conversation, so it takes the user role even though no human typed it. Read the roles as "generated by the model" versus "supplied to the model" and it stops being strange.
The part that carries the payload — functionResponse — is what marks it as a tool result rather than a person talking.
The loop
Each round, the model decides on a step, your code carries it out, and the model looks at the result before deciding the next one. This is often called the pattern (reason, then act).
for (let step = 0; step < 5; step++) {
const res = await gemini.generateContent({ contents, config: { tools } });
const parts = res.candidates?.[0]?.content?.parts ?? [];
const call = parts.find((p) => p.functionCall)?.functionCall;
if (!call) return gemini.text(res); // model is done: final answer
contents.push(res.candidates[0].content); // remember what it decided
contents.push({
role: "user",
parts: [{ functionResponse: { name: call.name, response: runTool(call) } }],
});
}
throw new Error("Agent did not converge in 5 steps");The bound is the safety feature
Nothing guarantees the model will ever stop asking for tools. A loop without a limit (MAX_STEPS) can keep spending money until someone notices, usually when the bill arrives. Keep the limit low (three to five rounds is plenty for most tasks), and handle hitting it as a normal error in your code.
Your turn
Complete the loop below. The tool is already written for you and always reports 12 copies. Your job is the wiring: detect the call, run it, add both messages to the array, call again, and return the final text.
Key takeaways
- The conversation is an array you own. Each round, add the model's turn and then the tool's result.
- A functionResponse must use the same name as the functionCall it answers.
- Always cap the loop with a step budget, and treat running out as a real error.
Sign in to run the exercise
Reading is free. Writing code here needs an account so we have somewhere to keep your Gemini key and the +40 XP you are about to earn.