Manual Implementation

Intermediate6 min

The agent loop in forty lines, why a failing tool returns its error as a result, and what a framework is doing on top.

#implementation

Workflow or agent, and why the distinction decides the code

Before writing a loop, check whether you need one.

Anthropic's distinction is the clearest available. Workflows are "systems where LLMs and tools are orchestrated through predefined code paths." Agents are "systems where LLMs dynamically direct their own processes and tool usage, maintaining control over how they accomplish tasks."

The test is who decides the order. If you know the steps (classify, then retrieve, then summarize) that is a workflow, and it should be three function calls in sequence. You get to debug it, test it, and reason about its cost. An agent is for "open-ended problems where it's difficult or impossible to predict the required number of steps, and where you can't hardcode a fixed path", at a price the same source names: "higher costs, and the potential for compounding errors."

Most features that get built as agents were workflows. The loop below is short enough that writing it is cheap; deciding you need it is the expensive part.

The agent loop, in about forty lines

An agent is a while loop around a model call. That is not a simplification. It is the whole structure.

Three things below are yours to supply: client, a provider SDK instance; tools, your tool declarations in that provider's format; and runTool, the function that dispatches a name and arguments to your own code.

type Message = { role: 'user' | 'assistant'; content: unknown }

async function runAgent(task: string, maxTurns = 10) {
  const messages: Message[] = [{ role: 'user', content: task }]

  for (let turn = 0; turn < maxTurns; turn++) {
    const response = await client.messages.create({
      model: 'claude-sonnet-5',
      max_tokens: 4096,
      tools,
      messages,
    })

    messages.push({ role: 'assistant', content: response.content })

    if (response.stop_reason !== 'tool_use') {
      return response
    }

    const results = []
    for (const block of response.content) {
      if (block.type !== 'tool_use') continue
      try {
        const output = await runTool(block.name, block.input)
        results.push({ type: 'tool_result', tool_use_id: block.id, content: output })
      } catch (error) {
        results.push({
          type: 'tool_result',
          tool_use_id: block.id,
          content: `Error: ${error instanceof Error ? error.message : String(error)}`,
          is_error: true,
        })
      }
    }

    messages.push({ role: 'user', content: results })
  }

  throw new Error(`Agent did not finish within ${maxTurns} turns`)
}

That is it. The model decides what to call, your code calls it, the result goes back, and the model decides again. Anthropic describes the pattern as "LLMs using tools based on environmental feedback in a loop", where the model gets "ground truth from the environment at each step."

Function calling covers the request and response shapes this is built on: how tools are declared, and what a tool-use block contains. Everything new here happens on the second pass through the loop.

The message list is the entire state

There is no other state. No session object, no memory store, no hidden context.

Each turn appends to the array: the assistant's response, then the results of whatever it asked for. Next turn sends the whole array. The model "remembers" earlier steps because those steps are in the request, verbatim.

Three consequences follow, and they explain most of what surprises people.

Cost grows with the square of the conversation, roughly. Every turn re-sends everything before it, so a ten-turn agent does not cost ten model calls' worth of input. It costs the sum of a growing prefix. Prompt caching exists for this.

Long runs degrade. The array grows, the useful signal thins out, and long context processing describes what happens to recall. A failed attempt from turn three is still in the prompt at turn nine, still competing for attention.

And the debugging story is better than you would expect. When an agent does something baffling, print the message array. The complete input the model saw is right there.

Stopping the loop: three conditions, one of them a cap

A loop that only exits when the model stops asking for tools is a loop with one exit, and it is not one you control.

The natural exit is the model returning text instead of a tool call. In this API, stop_reason being anything other than tool_use. Take it.

The second is a turn cap, and it is not optional. A model that misreads a tool's output can call the same tool with the same arguments indefinitely, and each iteration bills you. The cap is what turns an unbounded failure into a bounded one. Ten is a reasonable start and the right value is whatever your slowest legitimate task needs plus a margin.

The third is a budget, which becomes worth adding as soon as real money is involved. Sum the token usage the API returns each turn and stop when it crosses a threshold. This catches the case a turn cap misses: five turns, each with an enormous tool result.

A tool that fails is a result, not an exception

Look at the catch block again, because it is the part most first implementations get wrong.

A failing tool returns its error to the model as a tool result. It does not throw out of the loop. This feels wrong the first time, an error is an error, but consider what the model can do with each version. Thrown, the run ends and the user gets nothing. Returned, the model sees Error: no such file: /tmp/reprot.txt and can try /tmp/report.txt.

The environmental feedback the loop runs on includes negative feedback. A tool error is information about the world, which is what the model needs to pick a next step.

There is one class of exception worth letting through: failures that recovery cannot help. A missing API key, a revoked credential, a database that is down. Retrying those spends turns to arrive at the same place.

What a framework adds on top

Having written the loop, the build-or-adopt question gets easier to answer, because you can name what you are missing rather than guessing.

You are missing conversation persistence across processes, retries with backoff around a flaky API, streaming so a user sees progress, tracing of the kind tracing and logging describes, concurrent tool execution, subagents with their own context, and the machinery for approving an action before it runs.

None of that is hard individually. Together it is real work, and it is why the Claude Agent SDK and its equivalents exist.

The argument for writing the loop once anyway is not that frameworks are bad. It is the one the same source makes: "Incorrect assumptions about what's under the hood are a common source of customer error." Forty lines of your own is the cheapest way to stop guessing about the other ten thousand.

Further reading

Knowledge check

Question 1 of 4

Your feature classifies an incoming email, retrieves matching policies, then drafts a reply. The three steps never vary. What should you build?

Sign in to save your progress and pick up where you left off.