I’ve been on a mission lately: go from “I can call an LLM API” to actually understanding how AI agents work under the hood. Not by installing a framework and copy-pasting a quickstart, but by building the raw, minimal version myself first — bugs and all.
Project 1 was the smallest thing I could build that still counts as a real agent: a calculator with one tool, no framework, just the Claude API and an agent loop I wrote by hand. It sounds too simple to write a blog post about. It wasn’t. I broke it, found a genuinely sneaky bug in how I was using tool_choice, and walked away understanding agent loops in a way no tutorial had gotten across to me.
Key takeaways
- An agent loop keeps running until the model decides it’s done — that decision is what makes it an agent, not a script
tool_choice: "auto"can let the model quietly skip a tool it should have used- Forcing a tool call on every turn breaks things a different way — it never lets the model stop
- The fix: force the tool once, on turn 0, then leave it on
"auto"for every turn after- Not every rough edge needs fixing immediately — knowing why you’re leaving something alone is its own skill
In this post: What’s an agent? · How the loop works · The bug · The fix · A real trace · Three tests · What I left unfixed · What I learned · FAQ
What Even Is an “Agent,” Really?
Before writing any code, I had to get honest with myself about this question, because the word “agent” gets thrown around loosely.
The distinction I landed on: a chatbot just talks. A fixed workflow follows a script you wrote. An agent decides for itself, turn by turn, whether it needs to do something — call a tool — or whether it’s ready to just answer.
That “decide for itself” part is the whole game. It’s also exactly where things get interesting to debug.
How the Agent Loop Actually Works
At its core, the loop is just: the model reasons, checks whether it needs a tool, runs the tool if so, feeds the result back in, and repeats until it has a final answer.
Figure 1: the full agent loop — the loop back into “Claude reasons” is what lets a single question take multiple turns.
The One Rule I Built In on Purpose
The tool does exactly one operation per call. No passing in a full expression string like "847 * 39 - 12" and letting the tool do all the math internally — that would’ve been easier to build, but it would’ve taught me nothing. The model would call the tool once, get one number, and be done. No loop, no chaining, no real decision-making.
By forcing one operation per call, a question like “What’s 847 times 39, minus 12?” can’t be answered in one shot. The model has to multiply first, get the intermediate result back, then decide to subtract next — two full trips through the loop.
The Bug That Taught Me More Than Any Tutorial
I set tool_choice to "auto" throughout — meaning the model could freely decide whether to call the tool or just answer in plain text.
Then I asked it: “What is 10 divided by 0?”
It answered in plain text. No tool call. It just knew the answer conceptually and skipped the calculator entirely — which meant it never exercised my Division by zero error-handling branch at all.
That was the moment it clicked: tool_choice: "auto" doesn’t mean “use the tool when it’s needed.” It means “use the tool if the model feels like it.” Those are not the same thing, and the gap between them is exactly the kind of thing you only find by deliberately trying to break your own code.
The Fix (and the Fix I Almost Shipped Instead)
My first instinct was: force the tool call on every turn. Problem solved, right?
Wrong — and I caught it before I even wrote the code. If tool_choice is forced on every turn, the model never gets the option to say “I’m done.” Once it’s genuinely finished, a forced tool call gives it no way to respond in plain text, so it starts inventing meaningless calls like add(0, 0) just to satisfy the requirement — forever, until max_steps cuts it off.
The actual fix was narrower:
tool_choice = {"type": "tool", "name": "calculator"} if step == 0 else {"type": "auto"}
Force the tool only on the first turn — that guarantees it’s exercised at least once, closing the “skips it entirely” bug. From turn two onward, switch back to "auto", so the model can stop cleanly once it actually has the answer.
Figure 2: same question, two different tool_choice strategies — one lets the bug hide, the other forces it into the open.
Watching the Loop Run: A Real Trace
It’s one thing to describe the loop, another to watch it actually take multiple turns. Here’s exactly what happens for “What’s 847 times 39, minus 12?”
Figure 3: three turns through the loop for one question — this repetition is the entire point of forcing one operation per tool call.
Three Tests, Three Confirmations
I didn’t just trust the fix — I ran it against three deliberate cases, predicting the outcome before running each one.
Figure 4: two clean passes and one honest tradeoff — test three is the boundary condition covered in the next section.
The Limitation I Decided Not to Fix Yet
Forcing the tool on turn 0 solves the skipped-tool bug, but it has a cost: any question — even a completely non-arithmetic one like “what’s the capital of France?” — now triggers one throwaway tool call before the model can answer normally.
I could “fix” this. I didn’t, and I think that was the right call for this project specifically. This agent has exactly one job: arithmetic. A single wasted call on an off-topic question is a minor inefficiency, not a real problem.
I’m flagging it explicitly instead of quietly ignoring it — this is a boundary condition, not a bug, and it would not hold up in a broader-purpose agent with multiple tools and varied query types. That’s a problem for a future version, not this one.
What This Actually Taught Me
Stripping away the framework and building the loop by hand forced me to actually understand things I’d only skimmed before:
- The agent loop’s termination condition — the model is done when
stop_reason != "tool_use", and that’s genuinely how you know to stop looping - Tool schemas —
name,description, andinput_schemaaren’t boilerplate, they’re the model’s entire understanding of what a tool does - The
tool_use→tool_resultround trip — how a tool call and its result actually get threaded back into the conversation tool_choiceas a per-call setting — the API doesn’t manage this across turns for you; your loop does, and that responsibility is entirely yours to design
What’s Next
Level 2 is the Personal Research Agent — and it throws away the safety net of “there’s only one tool to pick.” Multiple tools means the model now has to select the right one, a completely different problem than the one this project sidestepped by design.
FAQ: Quick Answers
What is an AI agent loop?
It’s the repeating cycle where a model reasons about a request, optionally calls a tool, receives the result, and reasons again — continuing until it decides it has a final answer rather than another action to take.
What does tool_choice do in the Claude API?
It controls whether the model can call a tool at all on a given turn: "auto" lets it decide freely, {"type": "tool", "name": "..."} forces one specific tool to be called on that turn.
Why not just force tool_choice on every turn?
Because the model would never be allowed to stop. Once it has the real answer, a permanently forced tool_choice leaves it no way to respond in plain text, so it keeps inventing empty tool calls until max_steps cuts it off.
If You’re Building Something Like This Too
The biggest thing I’d pass on: don’t trust that your agent works just because it ran without erroring. Mine ran fine with the bug in it — it just quietly skipped the exact behavior I built the tool to test. Try to break it on purpose. That’s usually where the real understanding is hiding.
On to Level 2.
