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.

The Claude agent loopFlowchart: a user message goes to Claude, Claude decides whether a tool is needed, calls the calculator tool if so, feeds the result back in, and loops until it can return a final answer.User sends a messageClaude reasons about the requestDoes it needthe tool?yesCall the calculator toolTool runs, returns a resultfed back as tool_resultnoReturn final answer(stop_reason ≠ tool_use)

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.

tool_choice before and after the fixTwo columns comparing a divide-by-zero question. Before: tool_choice is auto every turn and Claude skips the tool. After: tool_choice is forced only on turn zero, so the tool runs and the error branch is exercised.BEFOREtool_choice: “auto” (every turn)User: “10 divided by 0?”Claude decides not to call itAnswers from memory —error branch never runsBug slips through untestedAFTERforced on turn 0, “auto” afterUser: “10 divided by 0?”Turn 0: tool call is forcedTool runs, hits theDivision by zero branchClaude explains it correctlyError path actually gets tested

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?”

Trace of “847 times 39, minus 12”Three steps: step zero forces a multiply call, step one auto-calls subtract using the previous result, step two the model stops because it has a final answer.STEP 0 · forcedmultiply(847, 39)→ 33,033STEP 1 · autosubtract(33033, 12)→ 33,021STEP 2 · autostop_reason ≠ tool_useno more calls neededFinal: 33,021

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.

Three failure tests and their outcomesTest one, a two-step multiply then subtract chain, passed with the correct answer. Test two, ten divided by zero, was skipped before the fix and handled correctly after. Test three, an unrelated question about the capital of France, still triggered one forced call but the model answered correctly and flagged the call as unnecessary.TEST 1 · chaining“847 × 39, minus 12”two-step tool chain33,021correct, first tryTEST 2 · edge case“10 divided by 0”before: tool skippederror handledafter the fixTEST 3 · boundary“Capital of France?”non-arithmetic question1 wasted callanswer still correct

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 schemasname, description, and input_schema aren’t boilerplate, they’re the model’s entire understanding of what a tool does
  • The tool_usetool_result round trip — how a tool call and its result actually get threaded back into the conversation
  • tool_choice as 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.