DEV Community

MUHAMMAD ZAIN UL ABIDIN
MUHAMMAD ZAIN UL ABIDIN

Posted on Originally published at zainvibes.com

Your AI Agent Needs No Framework: A 200-Line TypeScript Harness


I have watched too many agent projects start with a framework install and end with a debugging session inside someone else's abstraction. The agent itself is not complicated. It is a loop: send messages to a model, run the tools it asks for, send the results back, and stop when it stops asking. I built that loop in plain TypeScript, ran it against a stub model to prove the limits work, and kept it. This post is that loop, the reasons I trust it, and the honest list of cases where I would still pick a framework.

The short answer

Your agent does not need a framework. It needs a loop, a tool registry, three limits, and one approval gate. That is just over 200 lines of TypeScript, and every line is code you can read, test, and change when the agent misbehaves. A framework earns its place later, when you need durable runs, multi-agent routing, or observability you do not want to build. It does not earn its place on day one, when you have one agent and five tools and the framework is the biggest thing in the repo.

This is not a new claim. InfoQ's October 26, 2026 piece on agentic harnesses traces the same path: linear prompt chains first, then graph workflows with branching and retries, and now code-executing harnesses that verify outputs by running them. The advice in that piece matches what I found in practice: validate model outputs, and pick the least complex design that meets your reliability, speed, and cost needs. Anthropic made a related point the same month, arguing for folding subagents into one main loop with progressively disclosed tools, because fewer lossy handoffs mean better quality at lower cost and latency. Both point the same way: the loop is the product, and the framework is optional packaging around it.

What a harness actually is

Strip the marketing away and a harness has six parts. Thoughtworks' harness guide lists them as instructions and context, tools, environment, state, feedback from tests and linters, and boundaries with human approval for high-risk actions. I agree with that list, and my loop covers the first five directly while the approval gate covers the sixth:

  1. The loop. One while loop that owns the message history and decides when to stop.
  2. The tool registry. A map from tool name to a description, a schema, and a handler. Nothing else in the system knows how tools work.
  3. The limits. Max turns, max cost, and a repeated-call guard. These are the whole safety story for a first agent.
  4. The model interface. One method, complete(), so swapping models means swapping an adapter, not rewriting the agent.
  5. The verifier. A check that runs on the final answer before the run counts as done. Mine is a function you pass in, not a second agent.
  6. The approval gate. Write actions and money actions wait for a human yes. Reads run free.

Notice what is missing: no graph DSL, no agent class hierarchy, no plugin system, no state machine framework. Those are fine tools, but each one is a bet that your agent will grow into needing it. My bet is the opposite one: most agents never grow past ten tools, and the ones that do are easier to migrate once you know exactly which part hurt.

The loop, in full

Here is the core. It is provider-agnostic, it has no dependencies, and I ran it against a stub model before writing this post to confirm the turn limit, the cost budget, the repeated-call guard, and the approval gate all fire as described.

// harness.ts - the loop, the registry, and the limits. No dependencies.
export type Message = {
  role: "user" | "assistant" | "tool";
  content: string;
  toolCallId?: string;
  toolCalls?: ToolCall[];
};
export type ToolCall = {
  id: string;
  name: string;
  args: unknown;
};
export type ModelResponse = {
  text: string;
  toolCalls: ToolCall[];
  costUsd: number;
};
export interface ModelClient {
  complete(messages: Message[], tools: ToolDef[]): Promise<ModelResponse>;
}
export type ToolDef = {
  name: string;
  description: string;
  parameters: Record<string, unknown>;
  needsApproval: boolean;
  run: (args: unknown) => Promise<unknown>;
};
export type Limits = {
  maxTurns: number;
  maxCostUsd: number;
  approve?: (toolName: string, args: unknown) => Promise<boolean>;
  verify?: (answer: string) => boolean;
};
export type AgentResult =
  | { status: "done"; answer: string; turns: number; costUsd: number }
  | { status: "turn_limit" | "cost_limit" | "repeated_call" | "blocked" | "failed_verification"; turns: number; costUsd: number };
function sameCall(a: ToolCall, b: ToolCall): boolean {
  return a.name === b.name && JSON.stringify(a.args) === JSON.stringify(b.args);
}
Enter fullscreen mode Exit fullscreen mode

The types are the contract. Message is the only memory the agent has. ToolDef is the only place a tool is defined. Limits is the only place stopping rules live. When something goes wrong in production, one of these three places is where you look, and that is the whole point of keeping the harness small.

export async function runAgent(
  task: string,
  model: ModelClient,
  tools: ToolDef[],
  limits: Limits,
): Promise<AgentResult> {
  const messages: Message[] = [{ role: "user", content: task }];
  const registry = new Map(tools.map((t) => [t.name, t]));
  let turns = 0;
  let costUsd = 0;
  let lastCall: ToolCall | null = null;
  while (true) {
    if (turns >= limits.maxTurns) {
      return { status: "turn_limit", turns, costUsd };
    }
    if (costUsd >= limits.maxCostUsd) {
      return { status: "cost_limit", turns, costUsd };
    }
    const res = await model.complete(messages, tools);
    turns += 1;
    costUsd += res.costUsd;
    messages.push({ role: "assistant", content: res.text, toolCalls: res.toolCalls });
    if (res.toolCalls.length === 0) {
      if (limits.verify && !limits.verify(res.text)) {
        return { status: "failed_verification", turns, costUsd };
      }
      return { status: "done", answer: res.text, turns, costUsd };
    }
    for (const call of res.toolCalls) {
      if (lastCall && sameCall(lastCall, call)) {
        return { status: "repeated_call", turns, costUsd };
      }
      lastCall = call;
      const tool = registry.get(call.name);
      if (!tool) {
        messages.push({ role: "tool", toolCallId: call.id, content: `Unknown tool: ${call.name}` });
        continue;
      }
      if (tool.needsApproval) {
        const ok = limits.approve ? await limits.approve(call.name, call.args) : false;
        if (!ok) {
          return { status: "blocked", turns, costUsd };
        }
      }
      try {
        const result = await tool.run(call.args);
        messages.push({ role: "tool", toolCallId: call.id, content: JSON.stringify(result) });
      } catch (err) {
        messages.push({ role: "tool", toolCallId: call.id, content: `Tool error: ${(err as Error).message}` });
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Read the failure paths, because they are the harness. An unknown tool becomes a tool result, not a crash, so the model can correct itself. A tool error becomes a tool result for the same reason. A repeated call stops the run instead of burning your budget in a circle. A write tool with no approver defaults to blocked, not to allowed, which is the safe default and the one I would defend in a code review. The verifier runs only on a final answer, so a bad answer fails loudly instead of shipping quietly.

One judgment call deserves a direct note: I run tool calls in sequence, not in parallel. Parallel calls are faster, and a framework will offer them by default. I keep them sequential in a first harness because tool results can depend on each other, error messages stay in order, and the message history stays readable when you debug a bad run. Add parallelism later, behind the registry, when a measured slow run justifies it.

Swapping the model without touching the loop

The loop never imports a provider SDK. It talks to ModelClient, and the adapter translates. This is the OpenAI-compatible adapter I use, built on fetch so it works with any provider that speaks the chat completions shape:

// openai.ts - one adapter. Change baseUrl and key to change providers.
import type { Message, ModelClient, ToolDef } from "./harness";
type OpenAIToolCall = {
  id: string;
  function: { name: string; arguments: string };
};
export function openAIClient(opts: {
  apiKey: string;
  model: string;
  baseUrl?: string;
}): ModelClient {
  const baseUrl = opts.baseUrl ?? "https://api.openai.com/v1";
  return {
    async complete(messages, tools: ToolDef[]) {
      const res = await fetch(`${baseUrl}/chat/completions`, {
        method: "POST",
        headers: {
          "content-type": "application/json",
          authorization: `Bearer ${opts.apiKey}`,
        },
        body: JSON.stringify({
          model: opts.model,
          messages: messages.map((m) => ({
            role: m.role === "tool" ? "tool" : m.role,
            content: m.content,
            tool_call_id: m.toolCallId,
            tool_calls: m.toolCalls?.map((c) => ({
              id: c.id,
              type: "function",
              function: { name: c.name, arguments: JSON.stringify(c.args) },
            })),
          })),
          tools: tools.map((t) => ({
            type: "function",
            function: { name: t.name, description: t.description, parameters: t.parameters },
          })),
        }),
      });
      if (!res.ok) {
        throw new Error(`Model request failed: ${res.status}`);
      }
      const data = await res.json();
      const choice = data.choices[0];
      const calls: OpenAIToolCall[] = choice.message.tool_calls ?? [];
      return {
        text: choice.message.content ?? "",
        toolCalls: calls.map((c) => ({
          id: c.id,
          name: c.function.name,
          args: JSON.parse(c.function.arguments || "{}"),
        })),
        costUsd: 0,
      };
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Two honest gaps in this adapter, stated plainly. First, costUsd is 0 here because cost needs the usage block and a price table per model, and I did not want to fake a price in sample code. In my own runs I compute cost from data.usage with a small price map and pass it through, because a cost limit that never moves is decoration. Second, tool arguments are parsed with JSON.parse and not yet schema-validated in this snippet. Validate them inside each tool's run function, or wrap the registry with a schema check, before you point this at real data. My Zod-validated output post covers the schema pattern I use for that step, and it ports directly: the schema lives on the tool definition, the handler receives only parsed args.

A worked example: refund status, with a gate

Here is the smallest example that still shows the interesting parts: one read tool, one write tool behind approval, and a verifier that refuses an answer without an order id in it.

// main.ts - wire the loop to real tools. Reads run free, writes wait.
import { runAgent, type ToolDef } from "./harness";
import { openAIClient } from "./openai";
const orders: Record<string, string> = {
  ord_a1b2c3d4e5: "refunded",
  ord_f6g7h8i9j0: "processing",
};
const tools: ToolDef[] = [
  {
    name: "get_order_status",
    description: "Look up an order by id and return its status.",
    parameters: {
      type: "object",
      properties: { orderId: { type: "string" } },
      required: ["orderId"],
    },
    needsApproval: false,
    run: async (args) => {
      const { orderId } = args as { orderId: string };
      const status = orders[orderId];
      if (!status) return { error: `No order ${orderId}` };
      return { orderId, status };
    },
  },
  {
    name: "issue_refund",
    description: "Refund an order. Only call after confirming the status.",
    parameters: {
      type: "object",
      properties: { orderId: { type: "string" } },
      required: ["orderId"],
    },
    needsApproval: true,
    run: async (args) => {
      const { orderId } = args as { orderId: string };
      orders[orderId] = "refunded";
      return { orderId, status: "refunded" };
    },
  },
];
const result = await runAgent(
  "Check order ord_f6g7h8i9j0 and refund it if it is still processing.",
  openAIClient({ apiKey: process.env.OPENAI_API_KEY!, model: "gpt-5-mini" }),
  tools,
  {
    maxTurns: 12,
    maxCostUsd: 0.5,
    approve: async (name, args) => {
      console.log(`Approve ${name}?`, args);
      return process.env.APPROVE_WRITES === "yes";
    },
    verify: (answer) => answer.includes("ord_"),
  },
);
console.log(result);
Enter fullscreen mode Exit fullscreen mode

The approval callback is deliberately boring: it reads an environment flag in this example, and in a real app it would ask a human in a UI or a chat message. The shape is what matters. The loop never decides whether a write is safe. It asks, and it stops on a no. That separation is the difference between an agent you can demo and an agent you can leave running.

What I tested, and what I did not

I did not test this against a live model with real money for this post, and I will not pretend otherwise. What I did test, with a stub model that returns scripted tool calls, is the harness logic itself: the turn limit fires, the cost budget fires, the repeated-call guard fires on the second identical call, an unapproved write returns blocked, and a final answer without an order id fails verification. Those are the behaviors that decide whether a bad run costs you cents or dollars, and they are testable without any API key. Test them in your own copy before you add a real model, because a loop you have never seen fail is a loop you do not understand yet.

The other test that matters is a cost test with a real model on a small task, with maxCostUsd set low on purpose. Run ten tasks, record turns and cost per task, and look at the spread. If one task costs ten times the median, read its message history before you raise the budget. The budget is not there to be raised. It is there to tell you which task shape is misbehaving.

When I would still pick a framework

A 200-line loop is a starting position, not a religion. I would pick a framework, or at least a managed runtime, in four cases:

Situation Why the loop stops being enough
Runs must survive restarts A plain loop holds state in memory. A durable runtime checkpoints messages and tool results and resumes after a crash.
Several agents coordinate Shared state, handoffs, and routing across agents are a second system, not a second loop.
Runs last days, not minutes Retries, backoff, and human approval queues across days need persistence and an operations story.
Your team already knows the framework A deadline and a team that knows LangGraph beats a clean loop nobody else can maintain.

The test I apply is simple: if the next feature you need is a feature the framework already ships, and you can name the week it will hurt not to have it, adopt it then. If you cannot name that week, the framework is premature, and premature is just another word for a debugging session you scheduled for yourself.

My verdict: what I would pick, and why

If you are building your first agent with a handful of tools, I would build the loop in this post and skip the framework. You will understand every failure mode, your cost limits will be real numbers instead of framework settings you never read, and you will know exactly what to keep when you eventually adopt a framework for durability or scale. The framework decision gets easier, not harder, after you have run your own loop, because you will be choosing it for a named gap instead of a vague fear of building it wrong.

That verdict flips in one condition: when durability or multi-agent coordination is a requirement on day one, not a someday. Then I would pick the framework first, accept its abstraction cost, and keep my tools behind the same registry shape so the migration stays a rewrite of the loop, not of everything.

If you build this loop, start with reads only, add one write tool behind approval, and set the cost budget low enough that a bad run embarrasses you instead of billing you. That is the whole method. The model is the least interesting part of the system. The harness is the agent.

FAQ

What is an AI agent harness?

The harness is everything around the model that makes an agent reliable: the loop that calls the model, the tools it can call, the limits that stop runaway runs, the approval gates for risky actions, and the checks that verify the result. The model only proposes actions. The harness decides what runs, what the model sees next, and when the work is done.

Do I need a framework like LangChain or LangGraph to build an agent?

No, not for a single agent with a handful of tools. A tool-calling loop is a while loop over messages, a tool registry, and a few limits. You need a framework when you need durable runs that survive restarts, complex multi-agent routing, built-in observability, or a large tool ecosystem you do not want to maintain yourself.

How does the harness stop an agent from looping forever?

Three limits do that job together: a maximum turn count, a maximum cost budget, and a repeated-call guard that stops the run when the model requests the exact same tool call twice in a row. A turn cap alone is not enough, because turns vary wildly in cost, so the cost budget is the binding limit.

How do I swap models without rewriting the agent?

Keep the model behind one small interface with a single complete() method. The loop only talks to that interface. An OpenAI-compatible adapter built on fetch can point at OpenAI, or at any provider with a compatible endpoint, by changing the base URL and the key. The loop, the tools, and the limits never change.

Should tool arguments be validated?

Yes, always, before the handler runs. A model can return malformed JSON, a wrong type, or an argument that looks plausible and is wrong. Validate every call against a schema, and return the validation error to the model as a tool result so it can retry with fixed arguments instead of crashing your loop.

When should I NOT build my own harness?

When the run must survive a server restart, when several agents must coordinate with shared state, when you need retries and checkpoints across days rather than minutes, or when your team already knows a framework well and the project timeline is tight. In those cases the framework buys you durability and operations, and building your own loop buys you nothing.

Tools I used

Tool What it is for Official page
OpenAI API The model behind the adapter in this post https://platform.openai.com/docs
Zod Schema validation for tool arguments (see my Zod output post) https://zod.dev
TypeScript The language for the whole harness https://www.typescriptlang.org

{/* MONETIZATION NOTE: swap the tool links above for affiliate links when programs exist; never claim an affiliate relationship before it exists. */}

Related reading

If you build agents for clients, or you want one built for your product, get in touch. I build production TypeScript systems, and an agent loop like this one is usually the first week of that work, not the whole project.

Top comments (0)