---
title: "What is a harness?"
subtitle: "A visual guide to the infrastructure that turns language models into agents."
date: "2026-04-22"
tags: ["writing", "harness-design", "fundamentals"]
---

An LLM, on its own, is a function. You hand it text. It hands you back text.

That's not what people mean when they say "AI agent." An agent books flights. It reads files, runs tests, opens pull requests, drives a browser. It does things.

So how do you get from the first to the second? That's what this post is about.

---

## Part 1: building the harness

### The model, alone

Start by handing the task straight to the model.

<!-- component:StepModelOnly -->
**[StepModelOnly component]**

Demo panel labelled **"model only"**. Shows a single inference step with no tools and no loop. The model receives a prompt and replies with text only.

Internal trace shown to the reader:

- *Thinking*: "The user wants me to book flights. I don't have any tools, so I can only reply with text."
- *Final reply*: "I can help you think through it, but I can't actually book flights myself. From Copenhagen (CPH) to Amsterdam (AMS) you'll typically find KLM, SAS, and easyJet operating this route…"

Code shown alongside the trace:

```ts
import { anthropic } from 'ai-sdk';

const messages = [
  { role: 'user', content: userInput },
];

const reply = await anthropic.messages.create({
  model: 'claude-opus-4-7',
  messages,
});

console.log(reply.content);
```
<!-- /component:StepModelOnly -->

The model knows which airlines fly the route and can ballpark prices. What it can't do is find specific flights or book anything. There's no airline API on the other side of the text box. There's just more text coming back. The request arrives, a reply goes out, and nothing in the real world moves.

For that to change, the model needs a way to trigger code outside the text box.

### Giving it tools

To go further, the model needs tools. We describe the actions it can request, such as searching for and booking flights, then pass those descriptions in with the user's message. Each tool has a name, an explanation, and an argument schema. From the model's point of view, they are options it can pick.

There's no function-calling magic here. The model still generates a sequence of tokens. What changes is how the harness interprets them. We tell the model to use an exact format when it wants a tool.

```
<search_flights>
{
  from: "CPH",
  to: "AMS",
  date: "2026-05-24",
}
</search_flights>
```

As the reply streams back, we watch for those tags. The moment we see a `<search_flights>` opening, we stop treating the output as conversation and start treating it as a request. We parse the JSON, call the real `search_flights` function, and carry on. The model wrote text in a particular shape. Our code noticed the shape and did something with it. That's all tool use really is.<!-- component:Footnote -->
**[Footnote component]**

Inline footnote marker.
<!-- /component:Footnote -->

<!-- component:StepWithTools -->
**[StepWithTools component]**

Demo panel labelled **"model + tools"**. Same task as the previous step but the model now has tools (`search_flights`, `book_flight`).

Internal trace shown to the reader:

- *Thinking*: "I should use my search_flights tool to find flights from CPH to AMS on 2026-05-24."
- *Tool call*: `search_flights({ from: "CPH", to: "AMS", date: "2026-05-24" })`
- *Tool result*:
  ```
  KL1124  08:05 → 09:30  €132
  SK1551  11:20 → 12:45  €148
  U21802  14:40 → 16:05  €119
  ```

Code shown as a diff from the previous step:

```ts
import { anthropic } from 'ai-sdk';

const tools = [
  {
    name: 'search_flights',
    description: 'Find flights between two airports',
    input_schema: { /* from, to, date */ },
  },
  { name: 'book_flight', /* ... */ },
];

const messages = [
  { role: 'user', content: userInput },
];

const reply = await anthropic.messages.create({
  model: 'claude-opus-4-7',
  tools,
  messages,
});

if (reply.stop_reason === 'tool_use') {
  const call = reply.content.find(c => c.type === 'tool_use');
  const result = await runTool(call.name, call.input);
  console.log(result);
}
```
<!-- /component:StepWithTools -->

The model picked the right tool, filled in the arguments, and got flight data back. Open the **Code** tab to see the implementation.

Then it stops. The search ran, but nobody passed the result back to the model, so it never got to react to what it found. The return flight never got looked up. Nothing got booked.

### The agent loop

What's missing is a way to keep going. Once a tool runs, the model has to see the result and decide what to do next. And it has to be able to do that as many times as the task takes.<!-- component:Footnote -->
**[Footnote component]**

Inline footnote marker.
<!-- /component:Footnote -->

Wrap the whole thing in a loop. Call the model. If it asks for a tool, run it, append the result to the conversation, and call the model again. Exit when it gives a normal reply instead of requesting another tool.

<!-- component:StepAgentLoop -->
**[StepAgentLoop component]**

Demo panel labelled **"model + tools + loop"**. Same tools as the previous step, but the harness now loops: tool result → model → tool call → tool result, until the model emits a final reply.

Internal trace shown to the reader:

- *Thinking*: "First let me search for the outbound flight from Copenhagen to Amsterdam."
- *Tool call*: `search_flights({ from: "CPH", to: "AMS", date: "2026-05-24" })`
- *Tool result*:
  ```
  KL1124  08:05 → 09:30  €132
  SK1551  11:20 → 12:45  €148
  U21802  14:40 → 16:05  €119
  ```
- *Thinking*: "Good, I have the outbound options. Now I need the return on 2026-05-30."
- *Tool call*: `search_flights({ from: "AMS", to: "CPH", date: "2026-05-30" })`
- *Tool result*:
  ```
  KL1133  17:15 → 18:40  €141
  SK1558  19:55 → 21:20  €156
  ```
- *Thinking*: "I have both legs. I'll suggest the cheapest KLM pairing and ask before booking."
- *Final reply*: "Found two solid options:

  - Outbound: KL1124, 24/05 at 08:05 (€132)
  - Return: KL1133, 30/05 at 17:15 (€141)

  Total €273. Want me to book them?"

Code shown as a diff from the previous step:

```ts
import { anthropic } from 'ai-sdk';

const tools = [
  {
    name: 'search_flights',
    description: 'Find flights between two airports',
    input_schema: { /* from, to, date */ },
  },
  { name: 'book_flight', /* ... */ },
];

const messages = [
  { role: 'user', content: userInput },
];

while (true) {
  const reply = await anthropic.messages.create({
    model: 'claude-opus-4-7',
    tools,
    messages,
  });

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

  if (reply.stop_reason === 'end_turn') break;

  for (const call of reply.content.filter(c => c.type === 'tool_use')) {
    const result = await runTool(call.name, call.input);
    messages.push({
      role: 'user',
      content: [{ type: 'tool_result', tool_use_id: call.id, content: result }],
    });
  }
}
```
<!-- /component:StepAgentLoop -->

Same model, same tools. The only thing we added was a `while` and an append. That loop is the agent.

You could stop here and ship something useful. The rest of this post is about what goes on inside that growing conversation. Once the loop works, most of the hard problems in agent design are about the list of messages you keep handing back to the model.

---

## Part 2: looking inside the harness

### What the model sees on every call

The model is stateless. Each call starts from nothing. If you want it to know what happened on the previous turn, you include that turn in the next call. Same for the turn before, and the one before that. What looks like memory is just us re-sending the whole history every time.

What we've been loosely calling "the conversation" is just a `messages` array we keep in a variable and replay. Below is the full transcript of the flight-booking task. Send a follow-up to see how it grows when the loop keeps going.

<!-- component:ContextStack -->
**[ContextStack component]**

Animated visualization of the model's context window during a flight-booking agent run. Shows the messages stacked vertically (system / user / assistant / tool-result), with a chat input at the bottom that lets the reader send a follow-up.

Initial messages already in context:

1. **System prompt**.
2. **User**: the original flight-booking request.
3. **Assistant** thinking: "The user wants flights in both directions. Let me start with the outbound from Copenhagen to Amsterdam on May 24th." This is followed by tool call `search_flights({ from: "CPH", to: "AMS", date: "2026-05-24" })`.
4. **Tool result**:
   ```
   3 flights found
   KL1124   KLM       08:05 → 09:30   €132   direct
   SK1551   SAS       11:20 → 12:45   €148   direct
   U21802   easyJet   14:40 → 16:05   €119   direct
   ```
5. **Assistant** thinking: "Got the outbound options. Now the return from Amsterdam on May 30th." This is followed by tool call `search_flights({ from: "AMS", to: "CPH", date: "2026-05-30" })`.
6. **Tool result**:
   ```
   2 flights found
   KL1133   KLM   17:15 → 18:40   €141   direct
   SK1558   SAS   19:55 → 21:20   €156   direct
   ```
7. **Assistant**: "Found two solid options. Outbound: KL1124. Return: KL1133. Total €273. Want me to book them?"

When the reader clicks send, follow-up messages stream in:

- **User**: "Yes, book them both"
- **Assistant** thinking: "Confirmed. Booking the outbound leg first." This is followed by tool call `book_flight({ flight_id: "KL1124", date: "2026-05-24" })`, etc.

The whole point of the visualization: every previous message stays in context. Nothing is dropped.
<!-- /component:ContextStack -->

The system prompt is the first message. It tells the model what it's for, which tools it has, and how to behave. The model treats it as higher-priority instruction than user messages, although that does not make it a security boundary. It stays at the top of every call. Below it, user messages, assistant replies, tool calls, and tool results pile up unless the harness removes or compresses them.

That pile is the `messages` array from the loop we wrote in Part 1. It's the only memory the agent has. Whatever is in there is what the model can reason over on the next call.

### The cost of remembering

Because the full transcript goes out on every call, the input the model has to read grows with every turn.

<!-- component:ContextGrowthChart -->
**[ContextGrowthChart component]**

Bar chart titled **"context size per LLM call (tokens)"** showing how context grows across a 6-call agent run:

| Call    | Tokens |
| ------- | ------ |
| Call 1  | 180    |
| Call 2  | 520    |
| Call 3  | 880    |
| Call 4  | 1,640  |
| Call 5  | 2,900  |
| Call 6  | 5,100  |

Footnote shown below the chart: "Every tool result sticks around. Every assistant reply sticks around. By the sixth call, the model is re-reading everything it has already done. That is why context management becomes the engineering problem."
<!-- /component:ContextGrowthChart -->

Every noisy tool call keeps costing you. A test runner might dump 200 lines. A file read might return 4,000 lines. That output stays in the transcript and gets read again on every later call. One careless tool call at step 2 can double the input at step 10.

That's where most of the design work happens. Once the loop works, you have to decide what enters the transcript and what stays out. Compaction, sub-agents, skills, and memory hierarchies all answer the same question: **what belongs in the context?**

---

## Where this leads

That's the whole thing. An agent is:

- A model
- A set of tools the model can call
- A loop that keeps calling the model with the growing transcript

Everything else refines one of those pieces. That includes system prompts, sub-agents, context compaction, skills, permission gates, verification loops, and memory hierarchies.

Tool design starts with which tools you give the agent and how well each one works. Too few and the agent can't do the job. Too many and it gets lost picking between them. One tool can wreck a run by dumping too much output, requiring too many arguments, or doing the wrong thing. The rest of the harness can't save it.

Changing the loop usually means adding a step between model calls, such as verification, compaction, or a handoff to a sub-agent.

When you read about a new agent framework or prompt technique, ask which part it changes: the tools, the loop, or the transcript?

That's the map. Most of what follows in this series is just working out the details.

---

*This is the first in a series on harness design. Next, I'll test progressive disclosure: how agents navigate large contexts and how that context should be structured. Reach out at [noah@schenktechnology.com](mailto:noah@schenktechnology.com) if you're building agents and want to compare notes.*
