Back to Notes
ArticleTechnologySep 14, 2026

Agent Harness 101

Event
The Innovation Lab
Overview

Every coding agent is a short loop: ask the llm, run the tools it asks for, and stop when it asks for nothing. The harness is everything around that loop that keeps it safe and durable, and each of its primitives (session, thread, turn, tool, permission and agent) is written here as a real schema. Ends with a scripted demo that follows one message from the moment you press enter to the final answer.

Technologies
AIAgentsArchitectureEffectTypeScript
Agent Harness 101 preview
Introduction

What happens when you press enter

An agent is an llm in a loop that can use tools. On its own, an llm only turns text into more text. Give it a list of tools it may ask for, run the ones it asks for, and hand it back the results, and it can read files, run tests and check its own work.

The loop is a few lines. Send the conversation to the llm. If it asks for a tool, run the tool, add the result to the conversation, and send it again. Stop when it stops asking.

The harness is the program around that loop. It takes your message, runs the loop, lets the llm act through tools, asks you before it does anything risky, and streams the answer back to your screen as it is written.

This note names each piece of a harness and shows its shape as a small schema: a short description of which fields a piece of data has and what goes in each. Then it runs a scripted mock of the whole thing that you can step through. Nothing real is called. The llm’s replies are written in advance and the tools are fakes.

The core

The loop

Two words first. A turn is one request to the llm and its full reply. The request carries the conversation so far. The reply is some text, and sometimes a few tool calls.

A tool call is the llm asking the harness to run a tool for it, with some input: read this file, run these tests. The llm cannot run anything itself. It can only ask, and it asks by putting a tool call in its reply.

Here is the whole loop, as a sketch.

async function run(session: Session, agent: Agent) {
  while (true) {
    const request: LlmRequest = {
      instructions: agent.instructions,
      thread: session.thread,
      tools: agent.tools.map(describeTool),
    };
    const reply = await streamLlm(request);
    session.thread.push(reply);
    if (reply.toolCalls.length === 0) return;
    for (const call of reply.toolCalls) {
      const result = await runTool(call);
      session.thread.push({ _tag: "tool", result });
    }
  }
}

There is no step that decides the work is done. The llm decides that, by not asking for anything. A reply with no tool calls means its text is the answer, so the loop ends. A reply with tool calls means it wants something first, so the harness runs those tools, adds what they returned to the thread, and takes another turn.

Everything else in a harness exists to run this loop safely, for more than one client, without losing anything along the way.

The llm does the heavy lifting

The harness is plumbing. It moves messages, runs tools and asks you questions, but it never decides anything about the task. The llm reads the thread, decides whether it needs a tool, picks which one and with what input, reads what came back, and writes the answer.

Why the thread only ever grows

An llm reads text as tokens. For every token it reads, it builds a piece of internal working memory called the key-value cache, or KV cache, and building it is the expensive part. The llm keeps nothing between requests, so every turn sends the whole thread again.

Prompt caching is how that stays affordable. When a new request starts with exactly the same tokens as a recent one, the provider reuses the KV cache for that part and only reads what is new. So a harness keeps the instructions and the tool list at the front, never edits an old message, and only appends. By the second turn, almost all of the request comes from the cache. The demo below counts it for you.

The nouns

The pieces, as data

These are the real schemas the demo at the bottom of this page runs on, with their comments trimmed for reading.

Session

A session is one ongoing conversation with an agent. Everything else hangs off its id, and it records which agent it talks to by name. Its status is idle while it waits for you and running while the loop is going.

import { Schema } from "effect";
import { Thread } from "./thread";

export const Session = Schema.Struct({
	id: Schema.String,
	agent: Schema.String,
	status: Schema.Literals(["idle", "running"]),
	thread: Thread,
});

Thread

The thread is the ordered list of messages, oldest first. A user message holds what you said. An assistant message holds what the llm said and the toolCalls it asked for, and a tool message holds what one of those tools returned.

The whole thread is sent to the llm every turn. The llm keeps no memory between requests, so this list is all it knows.

import { Schema } from "effect";
import { ToolCall, ToolResult } from "./tool";

export const UserMessage = Schema.TaggedStruct("user", { text: Schema.String });

export const AssistantMessage = Schema.TaggedStruct("assistant", {
	text: Schema.String,
	toolCalls: Schema.Array(ToolCall),
});

export const ToolMessage = Schema.TaggedStruct("tool", { result: ToolResult });

export const Message = Schema.Union([
	UserMessage,
	AssistantMessage,
	ToolMessage,
]);

export const Thread = Schema.Array(Message);

Turn

A turn pairs a request with the events that come back. The request holds the agent’s instructions, the thread so far, and the tools on offer. The reply does not arrive as one block. It arrives as a stream of small events: some text, a tool-call, and last of all finish.

import { Schema } from "effect";
import { Thread } from "./thread";
import { Tool, ToolCall } from "./tool";

export const LlmRequest = Schema.Struct({
	instructions: Schema.String,
	thread: Thread,
	tools: Schema.Array(Tool),
});

export const LlmEvent = Schema.Union([
	Schema.TaggedStruct("text", { text: Schema.String }),
	Schema.TaggedStruct("tool-call", { call: ToolCall }),
	Schema.TaggedStruct("finish", {}),
]);

export const Turn = Schema.Struct({
	number: Schema.Int,
	request: LlmRequest,
	events: Schema.Array(LlmEvent),
});

Tool

A tool is a name and a description. The llm never sees the tool’s code. It reads the description to decide when to ask for it. This schema keeps only those two fields; real harnesses also describe the shape of the input a tool expects.

A tool call is the llm asking for one tool with an input, and it gets its own id. A tool result is the output that came back, matched to its call by callId.

import { Schema } from "effect";

export const Tool = Schema.Struct({
	name: Schema.String,
	description: Schema.String,
});

export const ToolCall = Schema.Struct({
	id: Schema.String,
	tool: Schema.String,
	input: Schema.String,
});

export const ToolResult = Schema.Struct({
	callId: Schema.String,
	output: Schema.String,
});

Permission

A rule gives one tool a decision. allow runs it straight away, deny blocks it, and ask makes the harness pause and ask you. A tool with no rule at all is treated as ask.

That pause is a permission request, which holds the tool call waiting on you. You reply once for just this call, or always, which saves an allow rule so that tool stops asking.

import { Schema } from "effect";
import { ToolCall } from "./tool";

export const Decision = Schema.Literals(["allow", "ask", "deny"]);

export const Rule = Schema.Struct({ tool: Schema.String, decision: Decision });

export const PermissionRequest = Schema.Struct({
	id: Schema.String,
	call: ToolCall,
});

export const Reply = Schema.Literals(["once", "always"]);

Agent

An agent is a named setup: its instructions, the tools it may use, and its rules. It is configuration, not a separate program. The same loop runs every agent, and the demo uses one called coder.

import { Schema } from "effect";
import { Rule } from "./permission";

export const Agent = Schema.Struct({
	name: Schema.String,
	instructions: Schema.String,
	tools: Schema.Array(Schema.String),
	rules: Schema.Array(Rule),
});
The verbs

The parts that move

Those are the nouns. Three parts move them around. A client is whatever you type into, like a terminal or a web page.

Session API

Every request from a client goes through the session API. Calling prompt saves your message and returns accepted right away, without waiting for the llm. Updates, like streaming text or a permission question, come back to the client over a stream it keeps open, which is why those arrows in the demo go straight to the client.

Coordinator

The coordinator makes sure one session only ever has one run going. A run is every turn of the loop, from your message to the final answer. If two clients send a message to the same session at once, it still starts only one loop.

Runner

The runner is the loop itself. It takes turns until the llm replies with no tool calls, then tells the coordinator it is done, and the session goes back to idle.

The demo below puts all of these on one screen. Pick a prompt and step through every message that passes between them.

All of it at once

From enter to answer

On the left is the chat you would see. On the right is what runs underneath it, one step at a time. Pick a question under the chat, then press play or step through it.

What you see

c

coder

idle

Pick a question below. You will see the chat here, and what the harness does about it on the right.

What is an agent harness?

What runs

The harness as a racetrackrequest goes outreply comes backpit laneturnappapicoordinatorrunnerllmtoolspermission gate
1 / 14

You type a message into the app. Nothing has been sent yet, so the thread is still empty.

The data behind this step

Same shapes as the schemas above, updating as you step. Token counts are estimates, including a typical system prompt.

llm

Waiting for its first request.

context

everything the llm reads this turn, in order

2,438 tok

session_1, agent coder, idle

turn

no turn yet

agent

name: coder
tools: read_file, run_tests
rules:
  read_file → allow
  run_tests → ask

permission

nothing waiting

The first prompt takes one turn. The llm answers without asking for a tool, so the runner stops. The second takes two turns and never asks you, because a rule already allows read_file. The third asks first, because a rule says run_tests needs your okay.

Press Always allow in the chat on the third, then run it again. The ask is gone. Your answer saved a new rule, and the harness checks its rules before it asks. Press reset and the saved rule is gone, so the ask comes back.

Questions?

Got a question about something I wrote? Send me a note.