AI Coding Agents Aren’t Magic. I Rebuilt One From Scratch in 3,292 Lines.
I wanted to understand what actually happens between “you” and “the model.” So I threw away every abstraction, wrote the agent loop by hand, and read every line until it made sense.
Let me guess what your day looks like.
- You open your AI editor.
- You describe a task in English.
- The model writes code.
- You read the diff, frown, and fix the same thing by hand.
- You do it again next week, with a slightly different prompt.
Sound familiar?
Here’s the thing nobody tells you: the hard part was never the model. It was always the loop — the boring, invisible machinery that decides what the model can see, what it can touch, and what happens when it breaks.
Most AI coding tools hide that loop inside 50,000 lines of framework. I got tired of not knowing.
So I did something unreasonable: I wrote the entire agent from scratch, in plain TypeScript, with zero runtime dependencies. Then I read every single line until I could explain it in my sleep.
Result: 3,292 lines of source. No dependencies. No build step. One file you can read in a coffee break.
And in the process, I found something that changed how I think about AI agents forever.
The moment that broke my brain 🤯
Here is the entire agent loop. This is src/agent.ts, the heart of the project — with the event bookkeeping (usage tracking, this.emit(...) calls, the try/catch) elided for readability:
for (let step = 1; step <= maxSteps; step++) {
let text = "";
const toolCalls: ToolCall[] = [];
for await (const event of this.currentProvider.chat({
system: this.opts.systemPrompt,
messages: this.messages,
tools: this.opts.tools.specs(),
signal,
})) {
switch (event.type) {
case "text":
text += event.text;
break;
case "tool_call":
toolCalls.push(event.toolCall);
break;
}
}
this.messages.push({ role: "assistant", content: text, toolCalls });
// No tools requested => the model is done.
if (toolCalls.length === 0) {
this.emit({ type: "done", text, steps: step });
return text;
}
// Execute every requested tool and feed the results back into history.
//
// Two details worth noting:
// - approvals are resolved first, one at a time, because the CLI asks
// the user a question and a prompt can't be interleaved.
// - the approved calls then run concurrently, but the results are
// appended in the order the model requested them. Providers expect
// tool results to line up with their tool calls.
const decisions: Array<ToolApproval | undefined> = [];
for (const toolCall of toolCalls) {
decisions.push(await this.approve(toolCall));
}
const outcomes = await Promise.all(
toolCalls.map(async (toolCall, index) => {
const decision = decisions[index];
if (decision && !decision.approved) {
const reason = decision.reason ?? "The user denied this tool call.";
return {
toolCall,
result: { content: reason, isError: true } satisfies ToolResult,
rejected: true,
reason,
};
}
const result = await this.opts.tools.execute(
toolCall.name,
toolCall.arguments,
{ ...this.opts.toolContext, signal },
);
return { toolCall, result, rejected: false, reason: undefined };
}),
);
for (const outcome of outcomes) {
this.messages.push({
role: "tool",
toolCallId: outcome.toolCall.id,
toolName: outcome.toolCall.name,
content: outcome.result.content,
isError: outcome.result.isError,
});
}
}That’s it. That’s the whole thing.
Read it again. It’s four moves:
- Ask the model what it wants to do.
- Append its answer to the conversation history.
- No tools? Done. (the model is finished talking)
- Tools? Run them, append the results, go back to 1.
Everything else in every AI coding tool — the “agent framework,” the “orchestration layer,” the “multi-agent coordinator” — is plumbing wrapped around these four moves.
And here’s the part that made me stop and stare:
The history IS the state.
There is no hidden context object. No separate scratchpad. No “agent memory” database. The entire state of the agent is one array of messages, and it grows by exactly two operations: append the assistant’s turn, append the tool results.
The model doesn’t have memory. The history is its memory. Every tool result becomes part of the conversation it will read next turn.
this.messages.push({
role: "tool",
toolCallId: outcome.toolCall.id,
toolName: outcome.toolCall.name,
content: outcome.result.content,
});
// ↑ this push IS the agent's memory. That's the whole trick.If you understand that one push, you understand agents.
The dirty secret: your AI tool talks to you through a translator
Here’s the hot take that’ll get me yelled at in the comments (please, yell — engagement is engagement):
An AI coding agent doesn’t “use tools.” It asks a model to write a JSON function call, and then runs whatever function that JSON names.
The model doesn’t execute anything. It can’t. It just emits text that looks like a function call:
{
"id": "call_abc123",
"name": "read_file",
"arguments": { "path": "src/agent.ts" }
}That’s a ToolCall — id, name, arguments. Your harness — not the model — is what decides this is allowed, finds the function, runs it, and feeds the output back.
This is why AI agents fail in ways that regular programs don’t. When a model hallucinates a function name, the failure is ours to handle gracefully. When it hallucinates a file path, we have to catch it.
Now here’s the part that scales. OpenAI, Anthropic, Ollama, LM Studio, vLLM, Groq, OpenRouter — they all speak different wire formats. Anthropic sends system as a top-level field. OpenAI puts it in a messages array. Anthropic sends tool results as a user message. OpenAI uses role: "tool".
So every agent framework has to solve the same problem: how do you keep the loop identical while the wire format changes underneath?
The answer I landed on is a normalized message type. One shape. The loop only ever sees this:
export type Message =
| { role: "system"; content: string }
| { role: "user"; content: string }
| { role: "assistant"; content: string; toolCalls: ToolCall[] }
| {
role: "tool";
toolCallId: string;
toolName: string;
content: string;
isError?: boolean;
};Then each adapter translates at the boundary. OpenAI’s adapter:
case "tool":
out.push({
role: "tool",
tool_call_id: message.toolCallId,
content: message.content,
});
break;Anthropic’s adapter — same normalized message, completely different wire format:
// Tool results must be sent as a user message containing tool_result blocks.
// Consecutive results are merged into a single user turn.
const block = {
type: "tool_result",
tool_use_id: message.toolCallId,
content: message.content,
is_error: message.isError ?? false,
};Same input. Same output. Same loop. Two totally different APIs.
That’s the seam. And once you see it, you realize: adding a provider is a translation problem, not an architecture problem. You write two functions — toOpenAIMessages() and toAnthropicMessages() — and the agent doesn't notice.
Trick #1: the tool registry is 77 lines and that’s all it needs
Here’s the entire contract between “what the model can see” and “what the harness can run”:
export interface Tool {
spec: ToolSpec;
run(args: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>;
}Two halves. A spec (what the model sees) and a run (what actually happens). That’s it.
Want to add a tool? Here’s a real one from the project — grep_files. The spec is verbatim; I've elided the body of run (it walks the workspace, greps, and formats path:line: text matches):
export const grepFilesTool: Tool = {
spec: {
name: "grep_files",
description:
"Search file contents with a regular expression. Returns `path:line: text` matches. Useful for finding symbols.",
parameters: {
type: "object",
properties: {
pattern: { type: "string", description: "JavaScript regular expression." },
path: { type: "string", description: "Directory to search from. Default '.'." },
glob: { type: "string", description: "Optional glob to limit files, e.g. '**/*.ts'." },
max_results: { type: "number", description: "Maximum matches. Default 100." },
},
required: ["pattern"],
},
},
async run(args, ctx) {
// ...walks the workspace, greps, returns `path:line: text` matches
return { content: results.join("\n") };
},
};That’s the whole shape of a tool. The model now knows grep_files exists, knows its arguments, and the harness knows how to run it.
Two properties I love about this design:
1. The model never runs code. It requests. You decide. This is the entire security model, and it fits in one sentence.
2. The spec is the documentation. The description and JSON Schema are sent to the model verbatim. There is no separate docstring to drift out of sync, because there is no separate docstring.
And the registry itself? 77 lines, and here’s the entire dispatch:
async execute(
name: string,
args: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult> {
const tool = this.tools.get(name);
if (!tool) {
return {
content: `Unknown tool "${name}". Available tools: ${this.names().join(", ")}`,
isError: true,
};
}
try {
return capOutput(await tool.run(args, ctx));
} catch (err) {
return {
content: `Tool "${name}" failed: ${err instanceof Error ? err.message : String(err)}`,
isError: true,
};
}
}Look at the error handling. A crashing tool does not crash the agent. It becomes a message to the model.
That is not a detail. That is the entire reason agents work at all. A model that calls a failing tool 3 times needs to see the error and adapt. If the exception propagated, you’d get a stack trace and a dead process. Instead the model reads:
Tool "read_file" failed: ENOENT: no such file or directory, open '/abs/path/to/src/nope.ts'and tries something else. Errors are conversation, not exceptions.
Trick #2: one line that stops a cat from eating your context window
Every agent has the same bug: the model runs grep -r "" ., gets back 400,000 characters, and blows the entire context window in one tool call. The conversation is dead. You get an error from the API. The session is ruined.
One guard fixes it:
const MAX_OUTPUT_CHARS = 24_000;
function capOutput(result: ToolResult): ToolResult {
if (result.content.length <= MAX_OUTPUT_CHARS) return result;
const omitted = result.content.length - MAX_OUTPUT_CHARS;
return {
...result,
content: `${result.content.slice(0, MAX_OUTPUT_CHARS)}\n\n[... output truncated, ${omitted} more characters ...]`,
};
}Eight lines. Infinite context-window protection.
And notice the truncation tells the model what happened. It doesn’t silently clip — it says “12,345 more characters were omitted.” The model can then decide to narrow its search. A silent truncation would send the model confidently forward with half a file and no idea it was cut off.
That’s the difference between a cap and a guard: a cap protects the system, a guard informs the model.
Trick #3: the approval gate that isn’t a security boundary (and I know it)
Real coding agents ask before running dangerous commands. I wanted one too, but I also wanted to be honest about what it is.
The entire hook:
export function createApprovalHook(
mode: ApprovalMode,
ask: (question: string) => Promise<string>,
): (toolCall: ToolCall) => Promise<ToolApproval> {
return async (toolCall: ToolCall): Promise<ToolApproval> => {
if (!needsApproval(mode, toolCall.name)) return { approved: true };
const answer = (await ask(` allow ${toolCall.name}(${previewCall(toolCall)})? [y/N] `))
.trim().toLowerCase();
if (answer === "y" || answer === "yes") return { approved: true };
return {
approved: false,
reason: `The user denied the call to \`${toolCall.name}\`. Do not retry it; ask what to do instead.`,
};
};
}But here’s the part that matters more than the code:
When you deny a tool, the denial goes back to the model as a tool result. Look at the reason string — "Do not retry it; ask what to do instead."
That’s a prompt written for the model, not for the user. Without it, the model does what every LLM does when it gets a refusal: it tries the same thing again, slightly rephrased, three times, until you approve it out of exhaustion.
Denials must be addressed to the model, because the model is the one that has to change its mind.
Get Rogerio Amorim’s stories in your inbox
Join Medium for free to get updates from this writer.
The approval prompt is a speed bump, not a security boundary. It stops accidental rm -rf. It does not stop a determined model, because the gate lives inside the same process the tool runs in. The real fix is an OS-level sandbox: separate user, separate container, separate machine.
If you take one thing from this article: know which of your security controls are theater. An approval dialog in-process is theater. A container is not.
Trick #4: swap the model mid-conversation, without losing your thread
This one took me an embarrassingly long time to get right.
You know that feeling. You’re deep in a conversation with Claude, and you want to try the same session on a local Ollama model. Or the OpenAI API is rate-limiting you and you want to flip to Anthropic. Every framework I tried either restarted the conversation or crashed.
The fix is one field and one method:
export class Agent {
private currentProvider: Provider;
/** Swap the model backend without losing conversation history. */
setProvider(provider: Provider): void {
this.currentProvider = provider;
}
}And the loop uses this.currentProvider, not a captured local. That's it.
Why is this free? Because of the normalized Message type. The history is provider-agnostic by construction. It doesn't contain "an OpenAI message" or "an Anthropic content block" — it contains our message shape.
So switching providers is: keep the array, point at a different translator. That’s the whole call — from src/cli.ts:
agent.setProvider(createProvider(config));In your own code, point it anywhere:
agent.setProvider(
new OpenAICompatibleProvider({
model: "qwen2.5-coder",
baseUrl: "http://localhost:11434/v1",
}),
);The conversation survives. The next request goes to a different model, in a different wire format, and the model picks up mid-thought like nothing happened.
This is what the normalized type buys you. Not “provider support.” Interchangeability.
Wait — zero dependencies. How is that even possible?
Here’s package.json:
{
"name": "my-harness",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A tiny, readable LLM agent harness: the loop, the tools, and swappable provider adapters.",
"engines": { "node": ">=23.6" },
"scripts": {
"start": "node src/cli.ts",
"dev": "node --watch src/cli.ts",
"mock": "HARNESS_PROVIDER=mock node src/cli.ts",
"test": "node --test test/",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.10.0",
"typescript": "^5.8.0"
}
}Zero runtime dependencies. Two dev dependencies: TypeScript and its types. That’s it.
Three decisions did all the work:
1. Node 23.6+ runs TypeScript directly. Type-stripping went stable — you literally node src/cli.ts. No build step. No bundler. No dist/. No watch process. The file you edit is the file that runs.
node src/cli.ts "what does this project do?"That’s it. No npm run build. No waiting. No wondering if your editor's version of the code is what you're testing.
2. node:test instead of Vitest/Jest. The test runner is in the runtime:
node --test test/ # 25 tests, 4 suites, ~200msNo config file. No transformer. No vitest.config.ts with 40 lines of plugin soup. And because there's no transform step, the tests run the exact source you ship.
3. node:fs instead of a filesystem library. The built-in modules cover nearly everything: node:fs/promises, node:path, node:child_process, node:crypto, node:readline.
The streaming SSE parser? Hand-rolled, ~65 lines. The JSON Schema? A 4-field interface. The argument parsing? process.argv.
Every dependency you add is a library you now maintain, document, audit, and wait for on every security update. For a teaching project, that cost is pure loss.
The mock provider: watch the loop with no API key
This is my favorite part, and it cost 46 lines.
Most people need an API key to see an agent work. I didn’t want that. So there’s a scripted provider that fakes one round-trip:
export class MockProvider implements Provider {
readonly name = "mock";
readonly model: string;
constructor(model = "mock-1") {
this.model = model;
}
async *chat(req: ChatRequest): AsyncGenerator<LLMEvent> {
const last = req.messages.at(-1);
if (last && last.role === "tool") {
const preview = truncate(last.content, 700);
yield {
type: "text",
text: `Tool \`${last.toolName}\` ${last.isError ? "failed" : "returned"}:\n\n${preview}\n\nThat is the entire loop: model → tool call → harness executes it → result is appended to the history → model sees it again. Point the harness at a real provider to keep going.`,
};
yield { type: "finish", reason: "stop" };
return;
}
yield { type: "text", text: "Let me look at the workspace first." };
yield {
type: "tool_call",
toolCall: {
id: `call_${Date.now()}`,
name: "list_files",
arguments: { path: ".", depth: 1 },
},
};
yield { type: "finish", reason: "tool_use" };
}
}Run it with zero configuration:
node src/cli.ts --mock "list the files here"Real output, from the real v0.1.0:
provider mock · model mock-1
assistant › Let me look at the workspace first.
⚙ list_files({"path":".","depth":1})
✓ list_files · 1ms · 304 chars
assistant › Tool `list_files` returned:
.env.example
.gitignore
README.md
harness.config.example.json
package-lock.json
package.json
src/
src/agent.ts
src/cli.ts
src/config.ts
...
tsconfig.jsonThat is the entire loop: model → tool call → harness executes it → result is appended to the history → model sees it again. Point the harness at a real provider to keep going. context ~1.1k / 32.0k (3.5%)No API key. No network. No account. $0.00.
And this isn’t a toy — it’s the same Provider interface, the same loop, the same registry, the same message format. The only thing that changed is which adapter is plugged in.
That’s the whole architecture, proven by construction: if the mock and a real model are interchangeable, the seam is in the right place.
The three seams (this is the whole map)
┌─────────────────────────────────────────┐
user text → │ append to messages │
│ ↓ │
│ provider.chat(messages, tools) │
│ ↓ │
│ text? print it │
│ tool calls? approve → execute → append ─┼─┐
│ no tool calls? done │ │
└─────────────────────────────────────────┘ │
↑ │
└────────────────────────────────┘Three files own everything:
- Seam: The loop
File: src/agent.ts
Lines: 288
Contract: model → tools → results → repeat, capped at maxSteps - Seam: The provider
File: src/types.ts
Lines: 72
Contract: chat(req): AsyncGenerator<LLMEvent> over a normalized Message - Seam: The tools
File: src/tools/registry.ts
Lines: 77
Contract: { spec, run } — dispatch by name, cap output at 24k chars
288 + 72 + 77 = 437 lines. That’s the entire core. The other ~2,800 lines are the CLI (885), the filesystem tools (418), the provider adapters (561), config, sessions, and the /connect wizard.
If you only read three files, read those three. Everything else is replaceable.
What I deliberately left broken (read before you comment 👇)
I know what you’re typing. “But where’s the compaction? The retries? The sandbox? The parallel subagents?”
Deliberately cut. And here’s why each cut is a lesson:
- No context compaction. History only grows. Real harness: summarize old turns. I left it so you’d feel the wall yourself.
- The context window is display-only. It prints
1.9k / 128.0k (1.5%)and then... lets the request through anyway. Real harness: truncate or reject. I left it visible-but-not-enforced so you'd see that a pretty number isn't a safety mechanism. - Path containment is a string check, not a sandbox.
path.relative()catches../../.env. A symlink does not. Real fix:fs.realpathbefore the check. - Approval is opt-in and off by default. Because a prompt is a speed bump, and I’d rather you learn that by reading the code than by getting burned.
A 3,000-line toy that lists its lies beats a 30,000-line framework that hides them.
Steal this in 5 minutes 🏴☠️
git clone https://github.com/rogeriolaa/my-harness
cd my-harness
npm install
npm test # 25 tests, 4 suites, no dependencies
npm run typecheck # clean
node src/cli.ts --mock "take a look around" # the loop, offline, $0Then do the thing that made me understand it: put a console.log inside the loop and watch the messages array grow. Find this line in src/agent.ts:
this.messages.push({ role: "assistant", content: text, toolCalls });and add one line under it:
this.messages.push({ role: "assistant", content: text, toolCalls });
console.log(this.messages.length, this.messages.at(-1));Run it a few times. Watch the count go 2 → 4 — and watch the roles:
user → assistant → tool → assistantOne user turn in. One assistant turn with a tool call. One tool result. One final assistant turn with no tool call. That means the model is done.
That array IS the agent. Everything else is a detail.
The uncomfortable truth
AI agents aren’t magic, and they aren’t “agents” in the way the marketing suggests. They’re:
- A loop (~40 lines of actual logic; 126 lines in
agent.tsonce you add approvals, usage tracking, and error handling) - A normalized message type (so backends are swappable)
- A registry (so tools are pluggable)
- A system prompt (so the model knows the rules)
That’s it. That’s the whole thing. I wrote it in a handful of evenings, and the code that resulted is 3,292 lines with zero runtime dependencies and no build step.
Every framework you’ve used is this same four things, plus the abstraction you can’t see through.
You don’t have to adopt mine. Steal the pattern: normalize your messages, make the loop provider-agnostic, cap tool output, and — most importantly — know which of your safety controls are real and which are theater.
Now go read those three files. agent.ts is 288 lines — and about 40 of them are the idea. You'll be done before your coffee cools.
If this broke your brain even a little, clap 👏 (it feeds the algorithm gods), drop your angriest “but every agent framework needs X” in the comments.
Repo: github.com/rogeriolaa/my-harness — MIT licensed, 3,292 lines, zero dependencies. Go break it.









