N.V.J.K KartikKartik
← All writing

I Built an Agent Harness by Hand. The Loop Was Tiny. The Five Bugs Were Not.

· 7 min read · Originally on Medium ↗

An agent harness is a while-loop, a dispatcher, and a growing message list. The loop you can read in one sitting. The bugs in how you hand tool results back are what actually cost me an afternoon.

I built an agent harness by hand before I reached for a framework, because I wanted to see the thing a framework hides. The loop turned out to be smaller than every tutorial makes it look, small enough to read top to bottom and paste into a terminal. What was not small was the handful of ways I broke it on the first try, all of them in the boring plumbing of handing a tool result back to the model.

So this is not really a tutorial on the loop, since the minimal loop is a dozen lines. It is the five bugs that sit underneath it, the ones that make a first harness fail validation, lose context, or spin forever, and the mental model that makes all five obvious in hindsight.

What a minimal harness actually is

Strip everything optional and a harness is three parts. A loop over the model’s stop reason, a dispatcher that maps a tool name to a Python function, and a message list that grows on every turn. That is it. Everything else, the sandboxing, the permission checks, the context compaction, the session state, is a layer wrapped around this core, and none of it is the core.

The shape before any code: you send the model a task and a list of tools. It replies with either a final answer or a request to run a tool. If it asks for a tool, you run it, hand back the result, and ask again. When it stops asking, you return its answer. That loop barely changes between one build and the next, which is why it is worth understanding once rather than importing forever.

The loop, in plain terms

The whole control flow is one condition: keep looping while the model’s stop reason is a tool request. You call the model once with the task and the tool list. As long as it comes back asking for a tool, you run every tool it asked for, append two messages to the history, and call it again. The moment it comes back with a plain end-of-turn instead of a tool request, the loop falls through and you return the text.

The two appends are the heart of it, and their order is the first thing I got wrong. You append the assistant’s message, the one containing the tool request, first. Then you append a single user message carrying the results. Those two, in that order, are what keep the conversation valid on the next call.

Two setup details bite before the loop even runs. On the Anthropic SDK, a max-tokens value is required on every request and has no default, so leaving it out raises before the model ever runs. And rather than hand-writing JSON Schema for each tool, I let Pydantic generate it from a model class, because a generated schema updates itself when I add a field and a hand-written one silently drifts out of sync with the function it describes.

The five bugs that break a first harness

Every one of these cost me time, and every one lives in the plumbing, not the logic.

Bug Symptom Fix Non-string value in the result content The API rejects the request, since result content must be a string or content blocks Serialize non-string returns first, for example as JSON Tool-use ID mismatch The model’s reasoning breaks and the result goes unattributed Copy the ID from the tool-request block into the result’s tool-use ID One result per message on parallel calls An API error when the model returns several tool calls at once Collect every tool request, then send all results in one user message Assistant message not appended Context is lost and the model re-calls the same tool endlessly Append the assistant message before the result message No max-turns ceiling An infinite loop on a bad stop condition Add a turn counter with a configurable ceiling

The pattern across all five: the loop logic was never the hard part. Handing the result back in exactly the shape the API expects was.

Why tool results are where it breaks

Three of the five bugs cluster in one spot, the moment you package a tool result, so it is worth slowing down there.

Order first. The assistant’s tool request has to be in the history before the user message carrying the result. Skip the assistant append and the next call sees a result with no request preceding it, which either fails validation or leaves the model unsure which tool it just ran, so it calls the same one again, and again.

IDs second. Every tool request carries an ID, and the result you send back has to copy that ID into its own tool-use-ID field so the API can pair them. Miss it and the result goes unattributed, which quietly corrupts the model’s reasoning rather than failing loudly.

Serialization third. The result content must be a string or a list of content blocks. A tool that already returns a string passes straight through, but a tool that returns a list or a dict has to be serialized first, usually to JSON. This is the one that passes in testing on your string-returning tool and breaks the first time a tool returns a structure.

Parallel tool calls are one message, not many

One model reply can carry several tool requests at once, when it decides it needs two files or two lookups before it can continue. The instinct is to run the first, send its result, and loop. That is the classic parallel-call bug, because the API expects every result for a given assistant turn to arrive together, in one user message, results first.

The fix is collect-then-send. Iterate every request block in the reply, run each one, gather the results into a list, and send all of them in a single user message before the next call. The payoff beyond correctness is latency: when two tools have no dependency between them, running both in one turn and returning both results is faster than sending the model around the loop once per tool. Handle this correctly once in the loop and you never special-case it again.

The two stop conditions

A harness needs two ways out, a clean finish and a safety valve.

The clean finish is the model’s end-of-turn signal, which means it is done and has a text answer. Because the loop only continues while the stop reason is a tool request, an end-of-turn just falls through and returns the text with no extra check.

The safety valve is a turn counter. A tool that always errors, a bad stop condition, or a model that keeps retrying can all trap a naive loop, so a counter caps the round-trips and raises when it hits the ceiling. It is the cheapest insurance in the whole harness. Frameworks formalize the exact same two exits, an explicit max-turns error and a normal finish on a final text response, so building them by hand means nothing about a framework’s control flow surprises you later.

The one design decision that pays off

Adding a tool should never touch the loop, and if it does, the harness is built wrong. In mine, adding a tool touches three places: define an input schema, add one entry to the tool list the model sees, and register the function in the dispatcher. The loop does not change by a character, because it dispatches through the dispatcher by name and never mentions a specific tool.

That separation between tool definitions and loop logic is the one decision worth making on day one. It is what lets the loop stay a dozen lines while the tool set grows to whatever the task needs, and it is the same separation a framework gives you, except here you can see exactly where it lives.

When to keep the manual loop, and when to leave it

The manual loop is worth keeping when you want zero framework dependency or need to see exactly what happens on every turn, which for me is most of the time early in a build. Reach for a framework when you need multi-agent handoffs, built-in guardrails, or tracing, because reimplementing those by hand stops being educational and starts being a maintenance burden. The reason to build the loop once by hand is not that frameworks are bad, it is that the framework becomes legible afterward: you know what its managed loop, its max-turns error, and its tool registry are doing, because you wrote each one yourself first.

The loop was never the hard part. The plumbing under the tool results was, and now it is a checklist instead of an afternoon.

If you have built a harness by hand, I would like to know which of the five bit you first. For me it was the append order, the model re-calling the same tool in a loop because I had dropped the assistant message and left it with no memory of having already asked.