Back to Tutorials

Tutorial

Build a Harness Agent From Scratch

Picture the demo — model, tools, while loop, done. Then someone refreshes the browser and it's gone. This tutorial builds the layer around the loop: sessions, persistence, approvals, and a UI contract — by hand, with the OpenAI SDK.

July 18, 202610 min read
Build a Harness Agent From Scratch

Picture the demo. You wire up a model, give it a couple of tools, drop it in a while loop. You ask it something, it calls a tool, it answers. It looks great. You show it to your team.

Then someone actually uses it.

They refresh the browser mid-task, and the whole thing is gone — no memory of what it was doing. They want to nudge it sideways while it's mid-run, and there's no way to do that short of killing it and starting over. It's about to delete a file, and nothing stops it to ask first.

None of that showed up in the demo. All of it shows up in production.

The pattern has a name by now: an agent loop is not an application. A model calling tools in a loop is a fine proof of concept. The gap between that and a product is sessions, interruption, approval, and persistence — the harness around the loop.

This tutorial builds both layers. First the loop, by hand, so you know exactly what you're wrapping. Then the harness — enough of a session host that a real UI could talk to it.

You'll build

  • A typed agent loop with tool calling (OpenAI SDK)
  • A session object that survives refresh (in-memory → pluggable store)
  • Human-in-the-loop approval before destructive tools run
  • A displayState snapshot your UI can render

You'll need Node 20+, an OPENAI_API_KEY, and TypeScript. Code is fresh against public APIs — not pasted from any internal codebase.

Part 1 — The agent loop

Every harness sits on top of the same four-step cycle:

Perceivemessages inDecidemodel callActrun toolsObservetool results
The agent loop. Every framework implements this ring; few teams can draw it from memory on a whiteboard.

Perceive — assemble the messages the model should see this turn. Decide — call the model; it returns text and/or tool calls. Act — execute each tool call. Observe — append tool results and loop until the model stops calling tools.

That's the whole agent. Everything in Part 2 is what turns this into something a user can depend on.

Step 1: Messages and a single turn

Start with the smallest possible types:

agent/types.ts
export type Role = "system" | "user" | "assistant" | "tool";
 
export type Message =
  | { role: "system" | "user"; content: string }
  | { role: "assistant"; content: string; tool_calls?: ToolCall[] }
  | { role: "tool"; tool_call_id: string; content: string };
 
export type ToolCall = {
  id: string;
  type: "function";
  function: { name: string; arguments: string };
};

One turn = one model call, optionally followed by tool execution:

agent/turn.ts
import OpenAI from "openai";
import type { Message, ToolCall } from "./types";
 
const client = new OpenAI();
 
export async function runModel(
  messages: Message[],
  tools: OpenAI.Chat.ChatCompletionTool[],
) {
  const res = await client.chat.completions.create({
    model: "gpt-4.1-mini",
    messages: messages as OpenAI.Chat.ChatCompletionMessageParam[],
    tools: tools.length ? tools : undefined,
  });
 
  const choice = res.choices[0]?.message;
  if (!choice) throw new Error("Empty model response");
 
  const assistant: Message = {
    role: "assistant",
    content: choice.content ?? "",
    tool_calls: choice.tool_calls as ToolCall[] | undefined,
  };
 
  return assistant;
}

Step 2: Tools with schemas

Tools are just named functions with JSON Schema inputs. Keep them boring and testable:

tools/read-file.ts
import fs from "node:fs/promises";
 
export const readFileTool = {
  definition: {
    type: "function" as const,
    function: {
      name: "readFile",
      description: "Read a UTF-8 text file from disk.",
      parameters: {
        type: "object",
        properties: { path: { type: "string" } },
        required: ["path"],
      },
    },
  },
  async execute(args: { path: string }) {
    return fs.readFile(args.path, "utf8");
  },
};

Register tools in a map so the loop can dispatch by name:

tools/index.ts
import { readFileTool } from "./read-file";
 
export const tools = {
  readFile: readFileTool,
};
 
export const toolDefinitions = Object.values(tools).map((t) => t.definition);

Step 3: The loop itself

Tie perceive → decide → act → observe together with a stop condition:

agent/loop.ts
import { runModel } from "./turn";
import { toolDefinitions, tools } from "../tools";
import type { Message } from "./types";
 
const MAX_STEPS = 8;
 
export async function runAgent(initial: Message[]) {
  const messages: Message[] = [...initial];
 
  for (let step = 0; step < MAX_STEPS; step++) {
    const assistant = await runModel(messages, toolDefinitions);
    messages.push(assistant);
 
    const calls = assistant.tool_calls ?? [];
    if (calls.length === 0) break; // model is done
 
    for (const call of calls) {
      const name = call.function.name as keyof typeof tools;
      const tool = tools[name];
      if (!tool) {
        messages.push({
          role: "tool",
          tool_call_id: call.id,
          content: `Unknown tool: ${name}`,
        });
        continue;
      }
 
      const args = JSON.parse(call.function.arguments);
      const result = await tool.execute(args);
      messages.push({
        role: "tool",
        tool_call_id: call.id,
        content: typeof result === "string" ? result : JSON.stringify(result),
      });
    }
  }
 
  return messages;
}

Run it from a script:

scripts/demo-loop.ts
import { runAgent } from "../agent/loop";
 
const out = await runAgent([
  { role: "system", content: "You are a concise assistant with file tools." },
  { role: "user", content: "What's in package.json?" },
]);
 
console.log(out.at(-1));

You now have the demo. Part 2 is why the demo isn't enough.

Before you wrap this in a harness, read Breadcrumbs Over Blobs — large tool outputs belong in a side store, not in messages[] forever.

Part 2 — The harness

The harness is everything around the loop that a UI needs:

UIrender displayState
Session hostthread · approvals · events
Agent loopPart 1
Tools + storefilesystem, DB, APIs
You built the bottom layer in Part 1. The harness adds session state, gates, and a render snapshot.

Step 4: Session state

A session is the live conversation — not the controller, not the model, the running state inside a thread:

harness/session.ts
import type { Message } from "../agent/types";
 
export type DisplayState = {
  status: "idle" | "running" | "awaiting_approval";
  messages: Message[];
  pendingApproval?: { tool: string; args: unknown };
};
 
export class Session {
  threadId: string;
  messages: Message[] = [];
  status: DisplayState["status"] = "idle";
  pendingApproval?: DisplayState["pendingApproval"];
 
  constructor(threadId: string, seed: Message[] = []) {
    this.threadId = threadId;
    this.messages = seed;
  }
 
  getDisplayState(): DisplayState {
    return {
      status: this.status,
      messages: this.messages,
      pendingApproval: this.pendingApproval,
    };
  }
}

The UI reads one snapshotgetDisplayState() — instead of reaching into five different fields.

Step 5: Lifecycle

A turn isn't fire-and-forget. It moves through states:

sendMessagedestructive toolapprovedturn donesavedidlewaitingrunningloop activeapprovalHITL gatepersistsave thread
Session lifecycle for one user message. Approval gates are optional branches on destructive tools.

Step 6: The session host

The host owns sessions, routes messages, and emits events:

harness/host.ts
import { Session } from "./session";
import { runAgentStep } from "./run-step";
import type { Message } from "../agent/types";
 
type Listener = (event: { type: string; session: Session }) => void;
 
export class SessionHost {
  private sessions = new Map<string, Session>();
  private listeners = new Set<Listener>();
 
  subscribe(fn: Listener) {
    this.listeners.add(fn);
    return () => this.listeners.delete(fn);
  }
 
  private emit(type: string, session: Session) {
    for (const fn of this.listeners) fn({ type, session });
  }
 
  getOrCreate(threadId: string, seed: Message[] = []) {
    if (!this.sessions.has(threadId)) {
      this.sessions.set(threadId, new Session(threadId, seed));
    }
    return this.sessions.get(threadId)!;
  }
 
  async sendMessage(threadId: string, content: string) {
    const session = this.getOrCreate(threadId);
    session.messages.push({ role: "user", content });
    session.status = "running";
    this.emit("display_state_changed", session);
 
    await runAgentStep(session, {
      onApprovalRequired: (tool, args) => {
        session.status = "awaiting_approval";
        session.pendingApproval = { tool, args };
        this.emit("approval_required", session);
      },
    });
 
    session.status = "idle";
    session.pendingApproval = undefined;
    this.emit("display_state_changed", session);
  }
 
  approve(threadId: string) {
    const session = this.getOrCreate(threadId);
    session.pendingApproval = undefined;
    session.status = "running";
    this.emit("display_state_changed", session);
    return runAgentStep(session, { resumeAfterApproval: true });
  }
}

Step 7: Approval before destructive tools

Human-in-the-loop is a gate in the Act step, not a separate app:

harness/run-step.ts
import { runModel } from "../agent/turn";
import { toolDefinitions, tools } from "../tools";
import type { Session } from "./session";
 
const DESTRUCTIVE = new Set(["deleteFile"]);
 
export async function runAgentStep(
  session: Session,
  opts: {
    onApprovalRequired?: (tool: string, args: unknown) => void;
    resumeAfterApproval?: boolean;
  } = {},
) {
  const assistant = await runModel(session.messages, toolDefinitions);
  session.messages.push(assistant);
 
  for (const call of assistant.tool_calls ?? []) {
    const name = call.function.name;
    const args = JSON.parse(call.function.arguments);
 
    if (DESTRUCTIVE.has(name) && !opts.resumeAfterApproval) {
      opts.onApprovalRequired?.(name, args);
      return; // pause the loop — UI shows approval card
    }
 
    const tool = tools[name as keyof typeof tools];
    const result = await tool.execute(args);
    session.messages.push({
      role: "tool",
      tool_call_id: call.id,
      content: String(result),
    });
  }
}
UISessionHostAgent loopsendMessage()runModel + toolsdeleteFile → gateapproval_requiredapprove()resume Act stepdisplay_state_changed
One turn with an approval gate. The UI subscribes once; events drive re-renders.

Step 8: Persistence (pluggable store)

In-memory sessions die on restart. Swap the map for an interface:

harness/store.ts
import type { Message } from "../agent/types";
 
export interface ThreadStore {
  load(threadId: string): Promise<Message[] | null>;
  save(threadId: string, messages: Message[]): Promise<void>;
}
 
export class MemoryStore implements ThreadStore {
  private data = new Map<string, Message[]>();
  async load(id: string) {
    return this.data.get(id) ?? null;
  }
  async save(id: string, messages: Message[]) {
    this.data.set(id, messages);
  }
}
 
// Production: PostgresThreadStore, RedisThreadStore — same interface

Hydrate on getOrCreate, persist after each turn. The harness doesn't care which database you pick — only that load/save are explicit.

Part 3 — What you have now

Browsersend / approve / render
SessionHostthreads + events
Sessionmessages + status
Agent loopPart 1
End state: UI talks to the host; the host owns sessions; the loop stays dumb and testable.

You can now:

  • Refresh the page — reload threadId, hydrate from the store, render getDisplayState().
  • Pause mid-run — approval gate stops the loop without losing messages.
  • Test the loop alonerunAgent() has no UI dependencies; the harness is a thin wrapper.

This is the same shape every production agent platform ends up at — whether you build it by hand or reach for a framework.

Where to go next

If you want the framework version of what you just built — modes, subagents, streaming, durable runs — the Mastra series walks through it in seven parts. Part 3 (The Harness) maps directly onto this tutorial's SessionHost, with AgentController instead of hand-rolled glue.

For the comparison angle — interactive session host vs autonomous graph — read Two Agent Harnesses.

And before you ship anything with big tool outputs, wire Breadcrumbs Over Blobs into your tool layer. The harness keeps the conversation alive; breadcrumbs keep each turn small enough to think.