You will build a Claude-Code-style coding agent from an empty file: the loop, the tools, real workspace containment, error feedback, and a raw-HTTP adapter for a live model — no SDK, no dependencies. At the end you'll have the exact code shipped in this directory, and you'll know why every piece is shaped the way it is.
The thesis: a coding agent is not magic. It is one loop — ask the model what to do, run the tools it asks for, feed the results back, repeat until it says done. Everything else (files, shell, a real LLM) plugs into that shape.
Requirements: Node 18+. An API key only for the final step, and even that step is optional.
Create agent.mjs. Before writing tools or touching a model, write the thing they plug into:
export async function runAgent({ task, model, tools, maxSteps = 10, log = () => {} }) {
const messages = [{ role: 'user', content: task }];
for (let step = 1; step <= maxSteps; step++) {
const out = await model({ messages, tools });
if (out.done) {
log(`step ${step}: done`);
return { text: out.text, steps: step, messages };
}
for (const call of out.toolCalls) {
const tool = tools[call.name];
let result;
try {
result = tool
? String(tool.run(call.args || {}))
: `error: unknown tool "${call.name}"`;
} catch (e) {
result = `error: ${e.message}`;
}
log(`step ${step}: ${call.name}(${JSON.stringify(call.args ?? {})}) -> ${result.slice(0, 70)}`);
messages.push({ role: 'assistant', toolCall: call });
messages.push({ role: 'tool', name: call.name, content: result });
}
}
return { text: '(max steps reached without finishing)', steps: maxSteps, messages };
}(The shipped agent.mjs is this exact loop plus two cosmetic log-formatting helpers, truncate and compact.)
Four load-bearing decisions, one per clause:
- The model is a parameter. It's any function of
{ messages, tools }that returns{ toolCalls: [...] }or{ done: true, text }. This one signature is why the rest of the tutorial can swap a scripted mock for a live LLM without touching the loop. maxStepscaps the loop. An agent without a step budget is an infinite loop with a credit card.- Tool failures are data, not crashes. The
try/catchturns a thrown error into a result string the model sees on the next turn — so it can react: retry, fix the path, change approach. MCP encodes the same idea asisError. Without this, oneENOENTkills the whole run. - The transcript is the state. Every call and every result is appended to
messages. The model is stateless; the transcript is the agent's memory.
Create tools.mjs. Each tool is a plain function plus a JSON Schema — the schema is how the model knows how to call it:
import fs from 'node:fs';
import path from 'node:path';
export function makeTools(cwd) {
const resolve = (p) => path.resolve(cwd, p); // ⚠ not done — see Step 3
return {
write_file: {
description: 'Write text to a file (creates or overwrites).',
inputSchema: {
type: 'object',
properties: { path: { type: 'string' }, content: { type: 'string' } },
required: ['path', 'content'],
},
run: ({ path: p, content }) => {
fs.mkdirSync(path.dirname(resolve(p)), { recursive: true });
fs.writeFileSync(resolve(p), content);
return `wrote ${p} (${content.length} bytes)`;
},
},
read_file: {
description: 'Read a file as text.',
inputSchema: {
type: 'object',
properties: { path: { type: 'string' } },
required: ['path'],
},
run: ({ path: p }) => fs.readFileSync(resolve(p), 'utf8'),
},
list_dir: {
description: 'List entries in a directory.',
inputSchema: { type: 'object', properties: { path: { type: 'string' } } },
run: ({ path: p = '.' }) => fs.readdirSync(resolve(p)).join('\n') || '(empty)',
},
};
}Tools return strings describing what happened (wrote greet.txt (15 bytes)) — the model can't see your filesystem; the return string is its perception.
The resolve above has a hole you should see for yourself. path.resolve(cwd, '../escape.txt') happily returns a path outside cwd — and so does any absolute path. An agent given those tools can write anywhere the process can.
Real containment is: resolve, then verify the result is still inside the workspace before touching the filesystem:
const resolve = (p) => {
const full = path.resolve(cwd, p);
const rel = path.relative(cwd, full);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`path escapes the workspace: ${p}`);
}
return full;
};path.relative(cwd, full) answers "how do I get from the workspace to this path?" — if the answer starts with .. (or is absolute, which happens on Windows across drives), the path left the workspace. The throw lands in the loop's try/catch from Step 1 and comes back to the model as error: path escapes the workspace: ... — containment and error-feedback working together.
(A hardened agent also needs a realpath check against symlink escapes, and a shell tool needs an entirely different answer — approval gates or an OS-level sandbox. That's §10 of the main index.)
Checkpoint — try to escape:
// node --input-type=module -e "..."
import { makeTools } from './tools.mjs';
const t = makeTools(process.cwd());
t.write_file.run({ path: '../escaped.txt', content: 'x' }); // must throwAdd to the returned object in makeTools:
run_shell: {
description: 'Run a shell command in the workspace. UNSAFE without approval.',
inputSchema: {
type: 'object',
properties: { command: { type: 'string' } },
required: ['command'],
},
run: ({ command }) => {
try {
return execSync(command, { cwd, encoding: 'utf8' }).trim() || '(no output)';
} catch (e) {
return `error: ${e.message}`;
}
},
},(plus import { execSync } from 'node:child_process'; at the top). Note what this tool does not have: containment. cwd sets the working directory, nothing more — rm -rf / runs. The danger is left visible on purpose: a real agent must put a sandbox and/or a human approval step in front of shell access, and pretending a working-directory setting is a security boundary is exactly the kind of false claim this tutorial wants you to recognize.
Create mock-model.mjs. It honors the model contract from Step 1 and reacts to the transcript — deciding its next move from what already happened, exactly the way a real model does, just deterministically:
export function mockModel({ target = 'greet.txt', content = 'hello from byoa' } = {}) {
return async function model({ messages }) {
const wrote = messages.some(
(m) => m.role === 'assistant' && m.toolCall?.name === 'write_file' && m.toolCall?.args?.path === target,
);
const readMsg = messages.find((m) => m.role === 'tool' && m.name === 'read_file');
if (!wrote) return { toolCalls: [{ name: 'write_file', args: { path: target, content } }] };
if (!readMsg) return { toolCalls: [{ name: 'read_file', args: { path: target } }] };
const ok = readMsg.content === content;
return {
done: true,
text: ok
? `Created ${target} and verified its contents: "${readMsg.content}"`
: `Created ${target} but contents did not match (got "${readMsg.content}")`,
};
};
}Be clear-eyed about what this is: a scripted stand-in, not intelligence. Its value is that the loop mechanics — transcript, tool dispatch, result feedback, termination — are inspectable end-to-end without an API key, and the run is reproducible in CI.
Checkpoint — wire it up in example.mjs (throwaway temp dir as workspace) and run:
step 1: write_file({"path":"greet.txt","content":"hello from byoa"}) -> wrote greet.txt (15 bytes)
step 2: read_file({"path":"greet.txt"}) -> hello from byoa
step 3: done
final: Created greet.txt and verified its contents: "hello from byoa"
Create real-model.mjs — the same contract, backed by a live LLM. No SDK, so the wire format stays visible. Every provider adapter ever written is these two translations:
Translation 1: your transcript → the provider's message format. The Anthropic Messages API represents a tool call as a tool_use content block in an assistant message, and its result as a tool_result block in a user message, tied together by id:
function toApiMessages(messages) {
const out = [];
let lastToolUseId;
for (const m of messages) {
if (m.role === 'user') {
out.push({ role: 'user', content: m.content });
} else if (m.role === 'assistant' && m.toolCall) {
lastToolUseId = m.toolCall.id;
out.push({
role: 'assistant',
content: [{ type: 'tool_use', id: m.toolCall.id, name: m.toolCall.name, input: m.toolCall.args || {} }],
});
} else if (m.role === 'tool') {
out.push({
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: lastToolUseId,
content: m.content,
is_error: m.content.startsWith('error:') || undefined,
}],
});
}
}
return out;
}Translation 2: the provider's response → the loop's contract. stop_reason: "tool_use" means "run these and come back"; anything else means done:
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-opus-4-8',
max_tokens: 8192,
tools: Object.entries(tools).map(([name, t]) => ({
name, description: t.description, input_schema: t.inputSchema,
})),
messages: toApiMessages(messages),
}),
});
const msg = await res.json();
if (msg.stop_reason === 'tool_use') {
return {
toolCalls: msg.content
.filter((b) => b.type === 'tool_use')
.map((b) => ({ id: b.id, name: b.name, args: b.input })),
};
}
return { done: true, text: msg.content.filter((b) => b.type === 'text').map((b) => b.text).join('\n') };Two subtleties that bite everyone the first time: the API's field is input_schema (snake_case) while yours is inputSchema, and the id on each tool_use block must come back on the matching tool_result — which is why Step 1's loop stores the whole call object in the transcript: the id rides along for free.
Checkpoint — the same task, a real model, and agent.mjs unchanged:
ANTHROPIC_API_KEY=sk-... node example.mjs --realA complete coding agent: a model-agnostic loop with a step budget, schema-described tools, workspace containment that actually contains, failures that inform the model instead of killing the run, and a provider adapter you can retarget by swapping a URL and three field names. What Claude Code and Cursor add on top — better tools (search, precise edits), permission gates, context management, sub-agents — are refinements of exactly this skeleton.
Part of build-your-own-agent — the index of the strongest from-scratch resource for each component of the agent stack.