Mini agent series: build a basic coding agent from scratch
Motivation
Over the past six months, coding agents like Claude Code and GitHub Copilot have become the main interface I work through during the 9-to-5. These agents can look complicated — even Pi (https://pi.dev), a minimalist coding agent, can feel overwhelming at first glance — but the core of a coding agent is actually fairly simple. In this post, I’ll show you how to build a mini coding agent in about 270 lines of TypeScript.
Step 1: The stateless API call
First, let’s write a function that calls the model API. I’m using OpenRouter, and the call looks like this:
async function callModel(input: string): Promise<string> {
const response = await fetch(
"https://openrouter.ai/api/v1/chat/completions",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "anthropic/claude-sonnet-4-5",
messages: [{ role: "user", content: input }],
}),
},
);
const data = await response.json();
return data.choices[0].message.content;
}
It reads the API key from the OPENROUTER_API_KEY environment variable.
Step 2: REPL
Next, we’ll wrap the model call in a REPL to make it feel like a conversational chatbot. First, let’s define the data structure that holds a conversation session:
type Role = "user" | "assistant";
interface Message {
role: Role;
content: string;
}
const session: Message[] = [];
Then wrap it in a loop:
import * as readline from "readline";
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const ask = (prompt: string) =>
new Promise<string>((resolve) => rl.question(prompt, resolve));
async function repl() {
for (;;) {
const input = await ask("> ");
if (input === "exit") {
rl.close();
break;
}
session.push({ role: "user", content: input });
let modelResponse = await callModel(session);
session.push({ role: "assistant", content: modelResponse });
console.log(modelResponse);
}
}
await repl();
Now you can have a conversation with the model, and it remembers context from earlier turns:
> hi
Hello! How can I help you today?
> what's 12348*7
12348 × 7 = 86,436
> what question did I ask you?
You asked me "what's 12348*7" - a multiplication question asking for the product of 12348 and 7.
> exit
Step 3: Handle tool call
Next, we need to give the model tools so it can interact with the codebase. We send a list of tool definitions along with the model call request, and the model decides when to invoke one, responding with finish_reason === "tool_calls". The agent then parses the tool call’s arguments, dispatches the tool, collects the result, and calls the model again with that result added to the conversation.
sequenceDiagram
participant Agent
participant Model
participant Tool as Tool Dispatcher
participant User
Agent->>Model: tool definitions + conversation
Model-->>Agent: tool_call (name, arguments)
Agent->>User: Request approval
User->>Agent: Approve
Agent->>Tool: invoke tool
Tool-->>Agent: tool result
Agent->>Model: tool definitions + conversation + tool result
Model-->>Agent: final response
3.1 Tool definition
To define a tool, you write a short description of what it does and specify its parameters. Here I define a bash tool that takes a single required parameter, command.
tools = [
{
type: "function",
function: {
name: "bash",
description: "Run a bash command and return its stdout/stderr.",
parameters: {
type: "object",
properties: {
command: {
type: "string",
description: "The bash command to run.",
},
},
required: ["command"],
},
},
},
];
3.2 Parse tool call signature from model response
The model’s response includes a tool call section, which the agent needs to parse to get the tool’s name and arguments.
For example, here the model decides to call the bash tool to do a simple multiplication:
"message": {
"role": "assistant",
"content": null,
"refusal": null,
"reasoning": "**Calculating the Product**\n\nI've begun the calculation: 12348 multiplied by 7. I can handle this directly or use a quick tool to verify. The initial breakdown shows 12000 * 7 is 84000, 300 * 7 is 2100, and 40 * 7 is 280. I'm working on the final parts.\n\n",
"tool_calls": [
{
"type": "function",
"index": 0,
"id": "Z6AYDgRU",
"function": {
"name": "bash",
"arguments": "{\"command\":\"python3 -c \\\"print(12348 * 7)\\\"\"}"
}
}
],
...
3.3 Tool execution
Once the agent has the tool call information, it can execute the requested action — in this case, running the bash command.
You’ll probably want some safety guardrails here, since a bad command could cause real damage. In general, I believe a smarter model plus good sandboxing is the right long-term answer. But for this simple example, I’ll just require explicit user approval before every tool call.
async function askApproval(
name: string,
args: Record<string, string>,
): Promise<boolean> {
console.log(`\n[tool call] ${name}`);
for (const [key, value] of Object.entries(args)) {
console.log(` ${key}: ${value}`);
}
const answer = await ask("Approve? [y/N] ");
return (
answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes"
);
}
const args = JSON.parse(tc.function.arguments);
const approved = await askApproval(tc.function.name, args);
const result = approved
? await dispatchTool(tc.function.name, args)
: "Denied by user.";
3.4 Send tool result back to model
Then I send the tool’s result back to the model as part of the conversation. This means the Message type from step 2 needs two more shapes: the assistant’s tool-call request, and the tool’s result. Update the definition to:
type Message =
| { role: "user" | "assistant"; content: string }
| { role: "assistant"; content: string | null; tool_calls: ToolCall[] }
| { role: "tool"; tool_call_id: string; content: string };
messages.push({ role: "tool", tool_call_id: tc.id, content: result });
Note that content isn’t always null when there’s a tool call — the model can return text alongside it (e.g. “I’ll check that for you.”). It’s easy to accidentally drop that text if you only handle tool_calls and ignore content.
3.5 Coding Agent Toolset
For a minimal coding agent, you only need three tools: bash, read_file, and edit_file.
read_file requires a filename, with optional start_line and end_line. edit_file requires filename and new_content, and runs in one of three modes:
- (Default) Replace whole file with the new content
- When
start_lineandend_lineare provided, replace the section only. - When
old_contentis provided, replace old content with the new content.
Full script
Full script
interface ToolCall {
id: string;
type: "function";
function: { name: string; arguments: string };
}
type Message =
| { role: "user" | "assistant"; content: string }
| { role: "assistant"; content: string | null; tool_calls: ToolCall[] }
| { role: "tool"; tool_call_id: string; content: string };
const session: Message[] = [];
import * as readline from "readline";
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const ask = (prompt: string) =>
new Promise<string>((resolve) => rl.question(prompt, resolve));
import { exec } from "child_process";
import { promisify } from "util";
import { readFile, writeFile } from "fs/promises";
const execAsync = promisify(exec);
const tools = [
{
type: "function",
function: {
name: "bash",
description: "Run a bash command and return its stdout/stderr.",
parameters: {
type: "object",
properties: {
command: {
type: "string",
description: "The bash command to run.",
},
},
required: ["command"],
},
},
},
{
type: "function",
function: {
name: "read_file",
description:
"Read a file's contents, optionally a specific line range. Lines are 1-indexed and inclusive.",
parameters: {
type: "object",
properties: {
filename: {
type: "string",
description: "Path to the file to read.",
},
start_line: {
type: "number",
description:
"First line to read (1-indexed, inclusive). Omit to read from the start.",
},
end_line: {
type: "number",
description:
"Last line to read (1-indexed, inclusive). Omit to read to the end.",
},
},
required: ["filename"],
},
},
},
{
type: "function",
function: {
name: "edit_file",
description:
"Edit a file. Three modes: (1) provide only filename + new_content to replace the whole file; (2) provide start_line and end_line (1-indexed, inclusive) + new_content to replace that line range; (3) provide old_content + new_content to replace the exact occurrence of old_content, which must appear exactly once in the file.",
parameters: {
type: "object",
properties: {
filename: {
type: "string",
description: "Path to the file to edit.",
},
new_content: {
type: "string",
description: "The replacement content.",
},
start_line: {
type: "number",
description:
"First line to replace (1-indexed, inclusive). Used together with end_line.",
},
end_line: {
type: "number",
description:
"Last line to replace (1-indexed, inclusive). Used together with start_line.",
},
old_content: {
type: "string",
description:
"Exact text to find and replace; must appear exactly once in the file.",
},
},
required: ["filename", "new_content"],
},
},
},
];
async function runBash(command: string): Promise<string> {
try {
const { stdout, stderr } = await execAsync(command);
return stdout || stderr || "(no output)";
} catch (err: any) {
return `Error: ${err.message}`;
}
}
async function readFileTool(
filename: string,
startLine?: number,
endLine?: number,
): Promise<string> {
try {
const content = await readFile(filename, "utf-8");
const lines = content.split("\n");
const start = startLine ?? 1;
const end = endLine ?? lines.length;
if (start < 1 || end < start) {
return `Error: invalid line range ${start}-${end}`;
}
return lines
.slice(start - 1, end)
.map((line, i) => `${start + i}\t${line}`)
.join("\n");
} catch (err: any) {
return `Error: ${err.message}`;
}
}
async function editFileTool(args: {
filename: string;
new_content: string;
start_line?: number;
end_line?: number;
old_content?: string;
}): Promise<string> {
const { filename, new_content } = args;
try {
if (args.old_content !== undefined) {
const content = await readFile(filename, "utf-8");
const occurrences = content.split(args.old_content).length - 1;
if (occurrences === 0) {
return `Error: old_content not found in ${filename}`;
}
if (occurrences > 1) {
return `Error: old_content appears ${occurrences} times in ${filename}, must be unique`;
}
const updated = content.replace(args.old_content, new_content);
await writeFile(filename, updated, "utf-8");
return `Replaced content in ${filename}`;
}
if (args.start_line !== undefined) {
const content = await readFile(filename, "utf-8");
const lines = content.split("\n");
const start = args.start_line;
const end = args.end_line ?? start;
if (start < 1 || end < start || end > lines.length) {
return `Error: invalid line range ${start}-${end} (file has ${lines.length} lines)`;
}
const newLines = new_content.split("\n");
lines.splice(start - 1, end - start + 1, ...newLines);
await writeFile(filename, lines.join("\n"), "utf-8");
return `Replaced lines ${start}-${end} in ${filename}`;
}
await writeFile(filename, new_content, "utf-8");
return `Wrote ${filename}`;
} catch (err: any) {
return `Error: ${err.message}`;
}
}
async function dispatchTool(
name: string,
args: Record<string, any>,
): Promise<string> {
if (name === "bash") return runBash(args.command);
if (name === "read_file") {
return readFileTool(args.filename, args.start_line, args.end_line);
}
if (name === "edit_file") return editFileTool(args as any);
return `Unknown tool: ${name}`;
}
async function askApproval(
name: string,
args: Record<string, any>,
): Promise<boolean> {
console.log(`\n[tool call] ${name}`);
for (const [key, value] of Object.entries(args)) {
console.log(` ${key}: ${value}`);
}
const answer = await ask("Approve? [y/N] ");
return (
answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes"
);
}
async function callModel(messages: Message[]): Promise<string> {
for (;;) {
const response = await fetch(
"https://openrouter.ai/api/v1/chat/completions",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "anthropic/claude-sonnet-4-5",
messages,
tools,
}),
},
);
const data = await response.json();
const choice = data.choices[0];
if (choice.finish_reason === "tool_calls") {
const assistantMsg = choice.message;
messages.push(assistantMsg);
// the model can return text alongside a tool call (e.g. "I'll check that for you.")
if (assistantMsg.content) {
console.log(assistantMsg.content);
}
for (const tc of assistantMsg.tool_calls as ToolCall[]) {
const args = JSON.parse(tc.function.arguments);
const approved = await askApproval(tc.function.name, args);
const result = approved
? await dispatchTool(tc.function.name, args)
: "Denied by user.";
messages.push({ role: "tool", tool_call_id: tc.id, content: result });
}
// loop back to call the model again with tool results
} else {
return choice.message.content;
}
}
}
async function repl() {
for (;;) {
const input = await ask("> ");
if (input === "exit") {
rl.close();
break;
}
session.push({ role: "user", content: input });
const modelResponse = await callModel(session);
session.push({ role: "assistant", content: modelResponse });
console.log(modelResponse);
}
}
await repl();
Recap
Putting it all together, we now have a working coding agent in about 270 lines: ~110 lines of core logic and ~160 lines of tool definitions. This mini agent, in fact, helped refine some of the sentences in this blog post.
What’s next
Next, I’ll explore creating sub-agents and handling multi-agent communication.