# How to Build a Coding Agent Harness From an Empty Loop

> Source: <https://tej.as/blog/how-to-build-a-coding-agent-harness/>
> Published: 2026-11-04 09:00:43+00:00

# How to Build a Coding Agent Harness From an Empty Loop

**You build a coding agent harness by starting from a `while (true)` around one model call and adding one piece at a time, each piece fixing a failure you just watched happen.** That’s the workshop I’m teaching at [AI Engineer Code Summit](https://www.ai.engineer/code/2026) in San Francisco on 10 November 2026: an empty loop at 9:00 and a harness you can serve over HTTP by 10:00, with 9 git branches so nobody in the room gets lost. This post is that hour written down.

If you want the definition first, I wrote a whole post on [what an agent harness is](https://tej.as/blog/what-is-an-agent-harness) after my talk [Harnesses in AI](https://tej.as/talks/ai-engineer-europe-2026). That one drives [GPT-3.5 Turbo](https://platform.openai.com/docs/models/gpt-3.5-turbo) through a browser until it stops lying. This one builds a *coding* agent, in [TypeScript](https://www.typescriptlang.org) with the [Anthropic SDK](https://github.com/anthropics/anthropic-sdk-typescript) as the only dependency, on [Claude Haiku 5.5](https://platform.claude.com/docs/en/about-claude/models/overview) because it’s cheap enough to run live in a workshop.

## Step 0: the model remembers nothing

Here’s the first version in full. One array, one loop, one model call per line you type:

``` js
const messages: Anthropic.MessageParam[] = [];

while (true) {
  const ask = await terminal.question("\nyou> ");
  messages.push({ role: "user", content: ask });
  const response = await client.messages.create({ model: MODEL, max_tokens: 16000, messages });
  messages.push({ role: "assistant", content: response.content });
}
```

Tell it your name, restart the process and ask again: it has no idea who you are. The model is a function that takes messages and returns one message (that’s it!!). The array *is* the memory and the harness owns it.

Look at the last line too: I push all of `response.content` and never just the text. The newest Claude models sign their thinking blocks and a history that gets edited instead of appended to comes back as an error. So the history only ever grows.

## Step 1: one tool and a loop make an agent

Give it `read_file` and a second `while` inside the first one:

``` js
while (true) {
  const response = await client.messages.create({ model: MODEL, max_tokens: 16000, tools, messages });
  messages.push({ role: "assistant", content: response.content });
  if (response.stop_reason !== "tool_use") break;
  // run every tool_use block, then send ONE user message with every tool_result
  messages.push({ role: "user", content: results });
}
```

That inner loop is the difference between a chatbot and an agent. The model never touches your disk. It *asks* to read a file and your code decides whether to. People trip up on the results here: every `tool_use` needs exactly one `tool_result` (all of them in one user message) and a tool that throws goes back as a result with `is_error: true`. The model can recover from an error it can see. It can’t recover from one you swallowed.

## Step 2: the tool list is the domain

Now the single tool becomes a registry: a definition the model reads and a function the harness runs, made by one small helper. I add `list_files`, `write_file`, `edit_file` (which refuses unless the text to replace appears exactly once) and `bash`.

Those 5 tools are what make it a *coding* agent. Swap them and you have a different agent with the same loop, which comes back in step 7. The descriptions matter more than people think because the model knows only what those strings say. They’re prompts.

Ask it to “add `hello.ts` that prints today’s date, then run it” and it writes the file and runs it. This is where I expect the room to get a little nervous!

## Step 3: a gate before anything you can’t undo

Bash with no gate means “clean up” can mean `rm -rf`. So step 3 adds caps and a question for me:

``` js
const MAX_STEPS = 25; // model calls per task
const MAX_TOKENS = 300_000; // input plus output tokens per task
const ASK_FIRST = new Set(["write_file", "edit_file", "bash"]);
```

Before any tool in `ASK_FIRST` runs, the harness asks me y/N in the terminal. When I say no, the model gets an error result telling it to ask me what to do instead (and it does). The caps make a stuck loop pause before it runs up a bill. **The model proposes and the person decides.**

## Step 4: every step resends the conversation

This is the step that surprises people who’ve only used chat apps. Every call sends all of the history again: the system prompt, the tools and every tool result so far. The prompt *is* the bill.

Claude Haiku 5.5 costs [$0.10 per million input tokens, $0.01 per million cached and $0.50 per million out](https://platform.claude.com/docs/en/about-claude/pricing). Past 100,000 tokens in one prompt, the price is 5× for *every token in that request*, which is *insane*… one tool result too many and your next call costs 5 times as much. So step 4 caches, cuts and compacts:

1. Caches the part that never changes, with one `cache_control` field.
2. Cuts any tool result over 12,000 characters where it’s made, with a note telling the model how to ask for less.
3. Starts [server-side compaction](https://platform.claude.com/docs/en/build-with-claude/compaction-threshold) at 90,000 tokens, before the price step and not after it.

``` js
const response = await client.beta.messages.create({
  model: MODEL,
  max_tokens: 16000,
  system: SYSTEM,
  tools,
  messages,
  cache_control: { type: "ephemeral" },
  betas: ["compact-2026-01-12"],
  context_management: { edits: [{ type: "compact_20260112", trigger: { type: "input_tokens", value: 90_000 } }] },
});
```

The harness also prints what every call costs. On a 3-call job the cached count goes from 0 to 1,040 tokens by the second call and the job costs a tenth of a cent in total.

## Step 5: phases beat a bigger model

If each step works 99% of the time, a 122-step job run as one long loop finishes 0.99<sup>122</sup> of the time (about 29%). Run the same job as short phases with one retry each and every phase finishes about 99% of the time.

I forecast exactly this for long browsing jobs at my startup [Daiku](https://daiku.ai): a 104-step job (research across 10 sites, then a purchase) on Haiku 5.5, built from 742 measured task lengths.

Compaction fixes the cost. Only phases fix the success rate!! So in step 5 the loop becomes a function, `runAgent(history, system, tools)`, and the top-level agent gets one more tool:

``` js
const phase = tool("run_phase", "Hand one part of the job to a fresh agent with an empty context.", { brief: "..." }, async ({ brief }) => {
  return await runAgent([{ role: "user", content: brief }], PHASE, registry);
});
```

A phase is a whole agent that starts with nothing but its brief and returns a note of at most 5 lines. When I gave it “find the bug in `math.ts`, then count the lines in `package.json`”, it ran 2 phases, found that `add` returns `a - b` and counted 17 lines, for $0.001 in total.

## Step 6: IBM Granite runs the same harness on my laptop

[Ollama](https://ollama.com) serves an [Anthropic-compatible endpoint](https://docs.ollama.com/api/anthropic-compatibility) so swapping the model is a different `baseURL` on the same SDK:

``` js
const LOCAL = !MODEL.startsWith("claude");
const client = LOCAL ? new Anthropic({ baseURL: "http://localhost:11434", apiKey: "ollama" }) : new Anthropic();
```

A local model gets the plain request because caching and compaction are Claude API features. I ran `MODEL=granite4:tiny-h` with [IBM Granite 4](https://ollama.com/library/granite4) on my Mac, asked how many files were in a folder, and it called `list_files` and answered 3 correctly. At $0.0000 a call! Nothing else in the harness changed. **The harness does more of the work than the model does.**

## Step 7: the same loop in a different domain

Everything so far was a coding harness. In step 7 I delete the file and shell tools and replace them with a browser in the cloud that’s already logged in to my sites. These are Daiku’s tools and each one is an HTTPS call with a script key:

```
daiku("navigate", "Open a URL in the workspace's tab.", { url: { type: "string" } }, ["url", "workspace"]),
daiku("extract", "Read rows from a table or listing as JSON.", { rows: { type: "string" }, fields: { type: "object" } }, ["workspace"]),
```

Same loop, same caps, same phases. I asked it for the top 3 stories on [Hacker News](https://news.ycombinator.com) and it opened a workspace, navigated, pulled the titles with one `extract` and closed the workspace, for $0.0008.

The gate moved too. I took my own y/N gate out because Daiku asks the person on their phone before anything sends, pays or deletes. The gate belongs to whoever owns the risk. Then I added one tool the harness owns: `ask_person`, a checkpoint for choices that change the result (which shop, which size, which date) so the model asks instead of guessing.

## Step 8: the harness as a service

In the last step the agent goes in `agent.ts` as `createAgent({ client, model, io })` and the terminal is just one front end. The other one is a [Cloudflare Worker](https://developers.cloudflare.com/workers/) with a [Durable Object](https://developers.cloudflare.com/durable-objects/) per session:

``` php
POST /sessions                 -> { id }
POST /sessions/:id/events      -> a stream of the agent's events
GET  /sessions/:id/events      -> everything that happened so far
```

The session keeps its history in the Durable Object’s storage so closing the tab or redeploying loses nothing. I ran it locally with [Wrangler](https://developers.cloudflare.com/workers/wrangler/) and it streamed, stored and replayed a whole errand.

If those routes look familiar, it’s because [Claude Managed Agents](https://platform.claude.com/docs/en/managed-agents/overview) works the same way: an agent, an environment, sessions and events, with Anthropic running the loop and the sandbox. [Cloudflare’s Think](https://developers.cloudflare.com/agents/harnesses/think/) gives you the same thing as a class you extend (override `getModel()` and it runs the loop with persistence and resumable streams).

## What I’d buy and what I’d write myself

Having written every step by hand, here’s where I land. I’d happily buy the loop, caching and compaction, sessions that survive a deploy and streams that resume. Those are the same for everyone and both of those products do them well. I’d write my own tool registry and gates and my own phases, checkpoints and budgets. Those are my domain and nobody else can write them for me.

If your team wants to build this by hand on your own agents, that’s [my agentic AI workshop](https://tej.as/workshops).

## Questions

### How do you build a coding agent harness?

You build a coding agent harness by starting from a while loop around one model call and adding one piece at a time: a tool, a registry of tools, guardrails, context management, phases and a way to swap the model. Each piece fixes a failure you can watch happen first. Mine is about 200 lines of TypeScript with one dependency, the Anthropic SDK.

### What is the difference between an agent loop and an agent harness?

The agent loop calls the model, runs the tool it asked for and feeds the result back until it stops asking. The agent harness is everything around that loop: the tool registry, caps on steps and tokens, gates before anything you can't undo, context management, phases and verification.

### Can an agent harness run on a local model?

Yes. Ollama serves an Anthropic-compatible endpoint so the same harness can point the Anthropic SDK at localhost and run IBM Granite on a laptop. Prompt caching and compaction are Claude API features and the harness leaves those fields out of the local request. Granite 4 tiny-h called my file tools and answered correctly at no cost per call.

### What is Claude Managed Agents?

Claude Managed Agents is Anthropic's hosted agent harness, in beta since April 2026. You define an agent (model, system prompt, tools, Model Context Protocol (MCP) servers and skills), an environment it runs in, then start sessions and exchange events with it. Anthropic runs the loop, the sandbox, prompt caching and compaction.

Written by me, Tejas Kumar, an AI Engineer at IBM based in Berlin. Read [everything else I have written](https://tej.as/blog), or go to [Fluent React, my O'Reilly book on how React works inside](https://tej.as/react), [the talks I give at conferences](https://tej.as/speaking), and [ConTejas Code, my podcast](https://tej.as/podcast).
