Build your first AI agent harness in Node.js
A hands-on build of the smallest real agent harness — read, run, edit, permission gate — in plain Node.js, watched fixing a real bug end to end.
The last essay drew the anatomy of an AI agent harness on paper: a model is a brain in a jar, and the harness is everything built around it — hands to act with, a spine to keep going, reflexes to keep it in check. This one builds it. Not a diagram — a script you can write on your own laptop tonight, in plain Node.js, with nothing hiding the moving parts, that reads a real file, runs real commands, and fixes a real bug while you watch the terminal.
We’ll build it the slow way, on purpose. Four short sections, each one bolting a new body part onto the same growing script — nothing thrown away, nothing rewritten from scratch. By the time we get to the real example, you won’t be reading new code for the first time. You’ll be watching code you already understand, doing the thing it was always going to do.
What you need: Node.js 18 or later, an Anthropic API key, and about fifteen minutes.
Hello, model
Before any anatomy, the floor everyone starts from. Install the SDK, and make one call:
$ npm install @anthropic-ai/sdk dotenv
// 01-hello-model.mjs
import { config } from "dotenv";
import Anthropic from "@anthropic-ai/sdk";
config({ quiet: true });
const client = new Anthropic();
const response = await client.messages.create({
model: "claude-opus-5",
max_tokens: 1024,
messages: [{ role: "user", content: "In one sentence, what is 100°F in Celsius?" }],
});
for (const block of response.content) {
if (block.type === "text") console.log(block.text);
}
$ node 01-hello-model.mjs
100°F is approximately 37.8°C (calculated as (100 − 32) × 5/9).
That’s it — that’s the whole script. No tools, no loop, no memory of this exchange the moment the process exits. This is deliberately not a harness. It’s the brain in a jar from the last essay, made runnable: ask a question, get an answer, nothing more.
It can tell you the Celsius equivalent of any Fahrenheit temperature in one sentence, correctly, every time. What it cannot do is open a file on your disk, notice the formula in it is wrong, and fix it — not because it isn’t smart enough, but because right now it has no hands. That’s what the rest of this article gives it, one piece at a time.
Give it hands (tools)
Every model that supports tool calling does the same dance underneath, regardless of vendor. You describe a set of tools as JSON schemas — a name, a description, and the shape of the arguments. The model never runs anything itself; it returns a message that says, in effect, “call this tool with these arguments.” Your code executes the tool locally and sends the result back as the next message. Describe, request, execute, return — that round trip is identical whether you’re talking to Claude, GPT, or Gemini. Only the exact JSON shape differs. What follows is Claude’s shape; the concept ports directly to whatever model you’re using. (There’s also a standard for sharing one set of tools across every harness — MCP — and it’s built on exactly this round trip.)
Here’s the file we’ll spend the rest of this article working on — a small Fahrenheit-to-Celsius converter. Don’t worry yet about whether it’s correct; that’s what the harness is for.
// convert.js
function fahrenheitToCelsius(f) {
return (f * 5) / 9 - 32;
}
console.log(fahrenheitToCelsius(100));
Now give the harness one hand — the ability to read a file — and one round trip: ask, tool call, execute, answer.
// 02-give-it-hands.mjs
import { config } from "dotenv";
import Anthropic from "@anthropic-ai/sdk";
import { readFile } from "fs/promises";
import path from "path";
config({ quiet: true });
const client = new Anthropic();
const ROOT = process.cwd();
// One hand: it can read a file. Confined to this project folder -
// the model's `path` argument is untrusted input, even for a read.
async function readFileTool(input) {
const target = path.resolve(ROOT, input.path);
if (!target.startsWith(ROOT)) return "error: path escapes the project folder";
return await readFile(target, "utf-8");
}
const tools = [
{
name: "read_file",
description: "Read the contents of a file in the project folder.",
input_schema: {
type: "object",
properties: { path: { type: "string", description: "Relative file path" } },
required: ["path"],
},
},
];
const messages = [
{ role: "user", content: "Read convert.js. Does the Fahrenheit-to-Celsius formula look correct?" },
];
// First call - the model asks to use its one hand
const first = await client.messages.create({
model: "claude-opus-5",
max_tokens: 1024,
tools,
messages,
});
const toolUse = first.content.find((b) => b.type === "tool_use");
console.log(`Claude wants to: ${toolUse.name}(${JSON.stringify(toolUse.input)})`);
const result = await readFileTool(toolUse.input);
// Second call - hand back what the file contained, get the real answer
messages.push({ role: "assistant", content: first.content });
messages.push({
role: "user",
content: [{ type: "tool_result", tool_use_id: toolUse.id, content: result }],
});
const second = await client.messages.create({
model: "claude-opus-5",
max_tokens: 1024,
tools,
messages,
});
for (const block of second.content) {
if (block.type === "text") console.log(block.text);
}
$ node 02-give-it-hands.mjs Claude wants to: read_file({"path":"convert.js"}) No — the formula is incorrect. The subtraction and the scaling are in the wrong order.Current code: return (f * 5) / 9 - 32; This scales first and subtracts 32 afterward.
Correct formula: return ((f - 32) * 5) / 9; You must subtract the 32°F offset before scaling by 5/9, since the two scales have different zero points as well as different degree sizes.
Checking against known values: 32°F → current: −14.22 correct: 0°C 212°F → current: 85.78 correct: 100°C 100°F → current: 23.56 correct: 37.78°C
Fixed version: function fahrenheitToCelsius(f) { return ((f - 32) * 5) / 9; }
Real diagnosis, right down to a little sanity-check table of known reference points. But go check convert.js on disk — it’s untouched, still printing 23.555.... It can see the problem. It cannot act on it. A read_file-only harness has exactly one hand, and reading isn’t the hand this bug needs.
Give it a spine (the loop)
Two things turn this from a single round trip into an actual agent: a second hand — run_command, so it can execute the script and see the real output, not just reason about the source — and a loop, so it can chain tool calls on its own instead of you hand-orchestrating every exchange.
// 03-give-it-a-spine.mjs
import { config } from "dotenv";
import Anthropic from "@anthropic-ai/sdk";
import { readFile } from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
config({ quiet: true });
const client = new Anthropic();
const ROOT = process.cwd();
const execAsync = promisify(exec);
async function readFileTool(input) {
const target = path.resolve(ROOT, input.path);
if (!target.startsWith(ROOT)) return "error: path escapes the project folder";
return await readFile(target, "utf-8");
}
// A second hand: it can run a shell command and see what happens.
async function runCommandTool(input) {
try {
const { stdout, stderr } = await execAsync(input.command, { cwd: ROOT, timeout: 10_000 });
return stdout + stderr || "(no output)";
} catch (err) {
return `error: ${err.message}`;
}
}
const tools = [
{
name: "read_file",
description: "Read the contents of a file in the project folder.",
input_schema: {
type: "object",
properties: { path: { type: "string", description: "Relative file path" } },
required: ["path"],
},
},
{
name: "run_command",
description: "Run a shell command in the project folder and see its output.",
input_schema: {
type: "object",
properties: { command: { type: "string", description: "Shell command to run" } },
required: ["command"],
},
},
];
async function executeTool(name, input) {
if (name === "read_file") return readFileTool(input);
if (name === "run_command") return runCommandTool(input);
return `error: unknown tool ${name}`;
}
const messages = [
{
role: "user",
content:
"Read convert.js and run it with `node convert.js`. It should print the Celsius " +
"equivalent of 100°F, which is 37.8. Compare the real output to that and explain " +
"exactly what's wrong. You don't have a way to edit files yet, so don't try.",
},
];
// The spine: keep going until the model has nothing left to do.
while (true) {
const response = await client.messages.create({
model: "claude-opus-5",
max_tokens: 1024,
tools,
messages,
});
for (const block of response.content) {
if (block.type === "text" && block.text) console.log(block.text);
}
if (response.stop_reason !== "tool_use") break;
messages.push({ role: "assistant", content: response.content });
const toolResults = [];
for (const block of response.content) {
if (block.type !== "tool_use") continue;
console.log(`Claude wants to: ${block.name}(${JSON.stringify(block.input)})`);
const result = await executeTool(block.name, block.input);
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: result });
}
messages.push({ role: "user", content: toolResults });
}
$ node 03-give-it-a-spine.mjs I'll read the file and run it. Claude wants to: read_file({"path":"convert.js"}) Claude wants to: run_command({"command":"node convert.js"})Expected: 37.8 Actual: 23.555555555555557
The subtraction happens in the wrong place. The correct formula is (f − 32) × 5/9 — subtract the 32 offset first, then scale. The code instead scales first and subtracts afterward:
return (f * 5) / 9 - 32; // ((100 * 5) / 9) - 32 = 23.5555…
Worth noting: even once the formula is fixed, the function would return 37.77777777777778, not exactly 37.8 — your stated expectation implies rounding to one decimal place, which nothing in the current code does.
Nobody told it to call read_file and then run_command — it chained both on its own, chose the order, and stopped calling tools the moment it had nothing left to do. That autonomy is the spine’s whole job. It’s also, as a bonus, a sharper diagnosis than section 2’s: it caught a second, independent issue (the missing rounding) that only shows up once you actually run the code instead of just reading it.
convert.js is still broken, on purpose — this harness still has no edit hand. But now there’s an uncomfortable question sitting under this loop: what stops it if it decides to try something risky, or just never calls end_turn? Right now — nothing. That’s next.
Give it reflexes (guardrails)
Two new pieces. An edit_file hand — the one that can actually change the file — and a reflex gating it: nothing that touches or executes anything runs without a real y/n from you first. Plus a hard iteration cap, so a loop that never settles can’t run forever.
// 04-give-it-reflexes.mjs
import { config } from "dotenv";
import Anthropic from "@anthropic-ai/sdk";
import { readFile, writeFile } from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import readline from "readline/promises";
config({ quiet: true });
const client = new Anthropic();
const ROOT = process.cwd();
const execAsync = promisify(exec);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
async function readFileTool(input) {
const target = path.resolve(ROOT, input.path);
if (!target.startsWith(ROOT)) return "error: path escapes the project folder";
return await readFile(target, "utf-8");
}
async function runCommandTool(input) {
try {
const { stdout, stderr } = await execAsync(input.command, { cwd: ROOT, timeout: 10_000 });
return stdout + stderr || "(no output)";
} catch (err) {
return `error: ${err.message}`;
}
}
// A third hand: it can edit a file - by replacing one exact snippet with another,
// the same way a real text-editor tool works. This is the risky one.
async function editFileTool(input) {
const target = path.resolve(ROOT, input.path);
if (!target.startsWith(ROOT)) return "error: path escapes the project folder";
const contents = await readFile(target, "utf-8");
const occurrences = contents.split(input.old_str).length - 1;
if (occurrences === 0) return "error: old_str not found in file";
if (occurrences > 1) return `error: old_str appears ${occurrences} times, must be unique`;
await writeFile(target, contents.replace(input.old_str, input.new_str), "utf-8");
return "file updated";
}
const tools = [
{
name: "read_file",
description: "Read the contents of a file in the project folder.",
input_schema: {
type: "object",
properties: { path: { type: "string", description: "Relative file path" } },
required: ["path"],
},
},
{
name: "run_command",
description: "Run a shell command in the project folder and see its output.",
input_schema: {
type: "object",
properties: { command: { type: "string", description: "Shell command to run" } },
required: ["command"],
},
},
{
name: "edit_file",
description: "Replace one exact snippet of text in a file with new text. old_str must appear exactly once.",
input_schema: {
type: "object",
properties: {
path: { type: "string" },
old_str: { type: "string", description: "Exact text to find" },
new_str: { type: "string", description: "Text to replace it with" },
},
required: ["path", "old_str", "new_str"],
},
},
];
// The reflex: these two hands touch the real world, so they don't run
// until a human says yes. read_file is read-only - no gate needed.
const RISKY_TOOLS = new Set(["run_command", "edit_file"]);
async function confirm(question) {
const answer = await rl.question(` -> ${question} allow? (y/n) `);
return answer.trim().toLowerCase() === "y";
}
async function executeTool(name, input) {
if (RISKY_TOOLS.has(name)) {
const label = name === "run_command" ? `run \`${input.command}\`` : `edit ${input.path}`;
const allowed = await confirm(`Claude wants to ${label}.`);
if (!allowed) return "error: user declined this action";
}
if (name === "read_file") return readFileTool(input);
if (name === "run_command") return runCommandTool(input);
if (name === "edit_file") return editFileTool(input);
return `error: unknown tool ${name}`;
}
const MAX_ITERATIONS = 8;
const messages = [
{
role: "user",
content:
"Read convert.js and run it with `node convert.js`. It should print the Celsius " +
"equivalent of 100°F, which is about 37.8. If it's wrong, fix convert.js and rerun " +
"it to confirm the fix worked.",
},
];
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
const response = await client.messages.create({
model: "claude-opus-5",
max_tokens: 2048,
tools,
messages,
});
for (const block of response.content) {
if (block.type === "text" && block.text) console.log(block.text);
}
if (response.stop_reason !== "tool_use") {
rl.close();
process.exit(0);
}
messages.push({ role: "assistant", content: response.content });
const toolResults = [];
for (const block of response.content) {
if (block.type !== "tool_use") continue;
console.log(`Claude wants to: ${block.name}(${JSON.stringify(block.input)})`);
const result = await executeTool(block.name, block.input);
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: result });
}
messages.push({ role: "user", content: toolResults });
}
console.log(`Stopped after ${MAX_ITERATIONS} iterations without finishing.`);
rl.close();
Run it, and the terminal now pauses before anything real happens:
Claude wants to: edit_file({"path":"convert.js", ...})
-> Claude wants to edit convert.js. allow? (y/n) _
It waits there. Say no and it backs off gracefully — the tool result is just "error: user declined this action", and the model adapts instead of crashing. Say yes, and for the first time in this article, the file actually changes. What that whole run looks like, start to finish, is the next section.
Watch it happen
Same script as section 4. convert.js reset to its original buggy state. This is the complete run, real output, lightly reformatted for readability — the terminal interleaves the model’s text with tool prompts on the same line; what follows is the same content on separate lines, with y shown where I typed it.
$ node 04-give-it-reflexes.mjs I'll start by reading the file and running it to see what happens.Claude wants to: read_file({“path”:“convert.js”}) Claude wants to: run_command({“command”:“node convert.js”}) -> Claude wants to run
node convert.js. allow? (y/n) yThe output is 23.56, which is wrong. The bug is in the order of operations: the code subtracts 32 after scaling by 5/9, but the correct formula subtracts 32 from the Fahrenheit value first, then scales.
Claude wants to: edit_file({“path”:“convert.js”,“old_str”:” return (f * 5) / 9 - 32;”,“new_str”:” return ((f - 32) * 5) / 9;”}) -> Claude wants to edit convert.js. allow? (y/n) y
Claude wants to: run_command({“command”:“node convert.js”}) -> Claude wants to run
node convert.js. allow? (y/n) yFixed and confirmed.
What was wrong: convert.js computed (f * 5) / 9 - 32, applying the ratio before the offset. For 100°F that gave 55.56 - 32 = 23.56.
The fix: changed the formula to ((f - 32) * 5) / 9, so the 32° offset is removed before scaling by 5/9.
Result: the script now prints 37.77777777777778, matching the expected ~37.8. As a sanity check, the corrected formula also behaves properly at the known anchor points (32°F → 0°C, 212°F → 100°C).
Check the file on disk, and it’s real:
- return (f * 5) / 9 - 32;
+ return ((f - 32) * 5) / 9;
$ node convert.js
37.77777777777778
Every body part from the last three sections is doing exactly its job in that transcript: the hand reads and runs the file; the spine chains three tool calls without you touching the keyboard between them; the reflex stops it dead twice, in front of the two actions that actually matter, until you say go.
One honest note: rerun this yourself and you may see a fourth tool call — a quick spot-check against known reference points, the same kind of self-verification section 3’s version did unprompted. That variance is real and worth expecting. A harness built on a reasoning model isn’t a deterministic script; it’s closer to delegating to a careful colleague who sometimes double-checks their own work before calling it done. Ninety percent of the time that’s a feature, not a bug — which is exactly why the reflex from section 4 exists for the ten percent it isn’t.
The five-minute version
Everything above was written by hand on purpose — so you’d see the loop, the tool-result plumbing, and the permission check as real code, not a library doing something invisible for you. In production, you generally wouldn’t write that loop yourself. The Anthropic SDK ships a Tool Runner that drives it for you: you describe tools with a run function attached, hand the whole set to toolRunner(), and it handles the request/execute/loop cycle internally — including, if you gate inside each tool’s run function, the exact same permission check.
// 06-tool-runner.mjs
import { config } from "dotenv";
import Anthropic from "@anthropic-ai/sdk";
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";
import { readFile, writeFile } from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import readline from "readline/promises";
config({ quiet: true });
const client = new Anthropic();
const ROOT = process.cwd();
const execAsync = promisify(exec);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
async function confirm(question) {
const answer = await rl.question(` -> ${question} allow? (y/n) `);
return answer.trim().toLowerCase() === "y";
}
const readFileTool = betaZodTool({
name: "read_file",
description: "Read the contents of a file in the project folder.",
inputSchema: z.object({ path: z.string() }),
run: async ({ path: p }) => {
const target = path.resolve(ROOT, p);
if (!target.startsWith(ROOT)) return "error: path escapes the project folder";
return readFile(target, "utf-8");
},
});
const runCommandTool = betaZodTool({
name: "run_command",
description: "Run a shell command in the project folder and see its output.",
inputSchema: z.object({ command: z.string() }),
run: async ({ command }) => {
if (!(await confirm(`Claude wants to run \`${command}\`.`))) return "error: user declined this action";
try {
const { stdout, stderr } = await execAsync(command, { cwd: ROOT, timeout: 10_000 });
return stdout + stderr || "(no output)";
} catch (err) {
return `error: ${err.message}`;
}
},
});
const editFileTool = betaZodTool({
name: "edit_file",
description: "Replace one exact snippet of text in a file with new text. old_str must appear exactly once.",
inputSchema: z.object({ path: z.string(), old_str: z.string(), new_str: z.string() }),
run: async ({ path: p, old_str, new_str }) => {
if (!(await confirm(`Claude wants to edit ${p}.`))) return "error: user declined this action";
const target = path.resolve(ROOT, p);
if (!target.startsWith(ROOT)) return "error: path escapes the project folder";
const contents = await readFile(target, "utf-8");
const occurrences = contents.split(old_str).length - 1;
if (occurrences !== 1) return `error: old_str matched ${occurrences} times, must be exactly 1`;
await writeFile(target, contents.replace(old_str, new_str), "utf-8");
return "file updated";
},
});
// This is the entire harness: hands, spine, and reflexes, in well
// under half the lines of the hand-written version.
const finalMessage = await client.beta.messages.toolRunner({
model: "claude-opus-5",
max_tokens: 2048,
tools: [readFileTool, runCommandTool, editFileTool],
messages: [
{
role: "user",
content:
"Read convert.js and run it with `node convert.js`. It should print the Celsius " +
"equivalent of 100°F, which is about 37.8. If it's wrong, fix convert.js and rerun " +
"it to confirm the fix worked.",
},
],
});
for (const block of finalMessage.content) {
if (block.type === "text" && block.text) console.log(block.text);
}
rl.close();
Run against the same reset, broken convert.js, it does the identical job — read, run, diagnose, ask, edit, ask, rerun, confirm — in 84 lines against section 4’s 139. The permission gate didn’t get simpler to write (it’s the same confirm() call, just moved inside each tool’s run function instead of a shared dispatcher), but the loop itself — the entire while block, the stop_reason check, the message-pushing — disappears into the library. Now that you’ve written that loop by hand once, you know exactly what disappeared.
Anthropic’s own docs recommend the Tool Runner as the default for most custom-tool agents, and for anything beyond a teaching example, that’s the right call — less code to maintain, and streaming, retries, and compaction come built in. We built the manual version first for the same reason you’d want to understand an engine before you drive an automatic.
If you want to run this fully local
Everything above calls the real Claude API, and that’s a deliberate choice, not a shortcut: reliable tool calling is what makes the whole demo work. But the harness itself — the tools, the loop, the permission gate — has nothing Claude-specific in it, and you can point the same shape at a model running on your own machine with Ollama, which now speaks an OpenAI-compatible tool-calling API for models built for it (Llama 3.1 and later, Qwen2.5, and others). Swap the Anthropic client for an OpenAI-compatible one pointed at http://localhost:11434/v1, translate the tool schemas — nearly identical shape — and the loop, the gate, and the file operations don’t change at all, because none of that logic was ever about Claude.
Temper your expectations going in, though. I haven’t wired this variant up myself, and the honest evidence from people who have isn’t encouraging: one detailed writeup of a very similar local build — same idea, small local model, real filesystem tools — ran into a model that simply wasn’t reliable enough at tool calling to finish the job, title included as fair warning. A fully local harness is a genuinely good weekend project and a real privacy and cost win. It is not, today, a drop-in replacement for a frontier model’s tool-calling reliability — that gap is exactly what you’re trading for running on your own hardware.
Four sections, one script, and by the end it fixed a real bug without you writing a single line of the fix. That’s the whole point of a harness, made concrete: the model supplied the intelligence, but the reading, the running, the looping, and the asking-first were all code you wrote and understand. Next in this series: context engineering — what actually belongs in the context you hand the model in the first place, the harness’s senses, and the part everyone gets wrong first.