(in around 130 lines of vanilla JS)
Press enter or click to view image in full size
When I was a young PHP developer, we had a running joke: every PHP developer had to write their own CMS.
You start as a junior, building plain PHP sites. At some point, the customer asks for an admin panel, so you reach for Joomla or Drupal or whatever’s hot that month. Then one day you start feeling like you could do this better yourself — better UX, better API, better ORM, pick your axis. So you sit down and build your own CMS.
And it teaches you a lot. How the whole thing actually works under the hood. How the pieces connect. How you handle auth and sessions. How a template engine plugs in. How to keep it from being a security disaster.
I was one of those developers.
Now, in 2026, I have the same itch — but for agents. I use existing agentic frameworks all the time. LangChain in Python. Vercel’s AI SDK in JS. They’re fine. But sometimes they do weird things. Sometimes the API feels counterintuitive. Sometimes I just want more control than they give me.
So maybe it’s time to build one from scratch.
Not a real framework — a small one. Just enough to understand. Vanilla JS. Node.js (so we have “fetch” built in). No npm install. ESM modules. By the end, you’ll have around 130 lines of code in a single “agent.mjs” file, and you’ll know what every single line does.
A note before we start. The code below uses the OpenAI-compatible Chat Completions API. I’ll read the base URL, the API key, and the model name from environment variables, so the same code works against OpenAI’s endpoint, Moonshot’s Kimi endpoint (which I happen to use because it’s cheaper), or anyone else who exposes a compatible API. If you ever need to plug in a provider that isn’t OpenAI-compatible — Anthropic’s native API, for example — you’d add an adapter layer that translates between provider shapes. We’re not doing that today, but keep it in mind. That’s exactly the abstraction layer LangChain spends a lot of code on.
1. The provider call
Step one — the only piece you actually can’t skip. Talk to the model.
Create “agent.mjs” and start with this:
const BASE_URL = process.env.OPENAI_BASE_URL || 'https://api.moonshot.ai/v1';
const API_KEY = process.env.OPENAI_API_KEY;
const MODEL = process.env.MODEL || 'kimi-k2.6';async function chatComplete({messages, tools, tool_choice, response_format}) {
const body = {
model: MODEL,
messages,
};
if (tools) {
body.tools = tools;
}
if (tool_choice) {
body.tool_choice = tool_choice;
}
if (response_format) {
body.response_format = response_format;
}
const resp = await fetch(`${BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify(body),
});
if (!resp.ok) {
throw new Error(`API error ${resp.status}: ${await resp.text()}`);
}
return resp.json();
}
That’s it. The whole “provider layer.” Nothing here you don’t already know — POST JSON, parse JSON back.
If you ever want to support a provider that doesn’t speak the OpenAI shape (Anthropic’s native API, Google Gemini, etc.), this function is the natural place for a switch — and that switch is what people call the adapter layer. For now, we don’t need one. One file, one provider format. You can find detailed OpenAI API documentation here.
2. System prompt and user prompt
The Chat Completions API takes a list of messages, each with a “role” and “content”. The roles you care about:
- “system”— your instructions to the model. Set the persona, the rules, and the expectations.
- “user” — what the user actually said.
- “assistant” — what the model already said in this conversation. You’ll append these as you go.
- “tool” — the response from a tool call (more about it in a second).
Smallest possible test — drop this at the bottom of “agent.mjs”:
const resp = await chatComplete({
messages: [
{ role: 'system', content: 'You are a helpful assistant. Be concise.' },
{ role: 'user', content: 'What is the capital of France?' },
]
});
console.log(resp.choices[0].message.content);Run “OPENAI_API_KEY=sk-… node agent.mjs”. You get “Paris” or similar.
Press enter or click to view image in full size
That’s it. That’s the foundation. Everything else in this article is just layers on top of “chatComplete”.
3. Tools — basically just RPC
Here’s the part that confused me the first time I saw it: tool calling sounds magical, but it’s actually just RPC. The model says, “I want to call function X with these arguments.” Your code says, “OK, here’s the result.” Same protocol as any client/server RPC, except the client is an LLM.
There are two pieces:
- The schema — a JSON-Schema-ish description of what tools exist, so the model knows what’s available.
- The implementation — actual JS functions that execute when the model asks.
Replace the test snippet from section 2 with this — we’ll set up the tools first:
// Tool schemas — what the model sees
const tools = [
{
type: 'function',
function: {
name: 'calculator',
description: 'Evaluate a math expression and return the result.',
parameters: {
type: 'object',
properties: {
expression: {
type: 'string',
description: 'A JS-syntax math expression, e.g. "2 + 2 * 3"',
},
},
required: ['expression'],
},
},
},
{
type: 'function',
function: {
name: 'fetch_url',
description: 'Fetch a URL and return up to 2000 characters of its text content.',
parameters: {
type: 'object',
properties: {
url: {type: 'string', description: 'A full http(s) URL'},
},
required: ['url'],
},
},
},
];// Tool implementations — what actually runs
const toolImpls = {
calculator({ expression }) {
console.log(`Evaluating ${expression}`);
// Whitelist before evaluation: only digits, operators, parentheses, dots, and whitespace.
// Blocks identifiers, semicolons, quotes — anything that could escape the math context.
if (!/^[\d+\-*/().\s]+$/.test(expression)) {
throw new Error('Invalid expression: only digits and + - * / ( ) . are allowed');
}
return String(Function(`"use strict"; return (${expression})`)());
},
async fetch_url({ url }) {
console.log(`Fetching ${url}`);
const resp = await fetch(url);
const text = await resp.text();
return text.slice(0, 2000);
},
};
Two things to notice. First, the schema and the implementation are completely separate. The model only ever sees the schema. The implementation runs entirely on your side. That’s important when you start thinking about what the model should and shouldn’t be able to do.
Second, when you pass “tools” to “chatComplete”, the model can respond in one of two ways:
- Plain answer: “choices[0].message.content” has text, “tool_calls” is empty or missing.
- Tool call: “choices[0].message.tool_calls” is an array. Each entry has an “id”, a “function.name”, and “function.arguments” (a JSON-encoded string of the args — not parsed for you).
When the model returns a tool call, your code has to:
- Look up the implementation by name.
- “JSON.parse” the arguments.
- Call it.
- Append the result back into “messages” as a “role: ‘tool’” message, with the matching “tool_call_id”.
- Call the model again so it can read the result.
That last bit is the whole reason agents need a loop.
4. The agent loop
This is the actual “agent” part. Everything before this is just plumbing.
async function runAgent({system, user, tools, maxSteps = 10}) {
const messages = [
{role: 'system', content: system},
{role: 'user', content: user},
]; for (let step = 0; step < maxSteps; step++) {
const resp = await chatComplete({messages, tools});
const msg = resp.choices[0].message;
messages.push(msg);
// No tool calls? The model is done — return its answer.
if (!msg.tool_calls?.length) {
return {answer: msg.content, messages, steps: step + 1};
}
// Otherwise: execute each tool call and feed results back.
for (const call of msg.tool_calls) {
const name = call.function.name;
const args = JSON.parse(call.function.arguments);
let result;
try {
result = await toolImpls[name](args);
} catch (err) {
result = `Error: ${err.message}`;
}
messages.push({
role: 'tool',
tool_call_id: call.id,
content: typeof result === 'string' ? result : JSON.stringify(result),
});
}
}
throw new Error(`Agent did not finish within ${maxSteps} steps`);
}
That’s the whole loop around 40 lines. Read it twice, and you’ve understood the core of every agent framework in the wild.
A quick driver to actually run it:
const result = await runAgent({
system: 'You are a helpful assistant. Use tools when the answer requires computation or live data.',
user: 'What is 17 * 23, and what does https://example.com say?',
tools,
});
console.log(result.answer);Press enter or click to view image in full size
In the screenshot, the model calls “calculator” and “fetch_url” at the same step. It gets “391” from the “calculator” call, and the example.com text from the “fetch_url” call. Then it writes a final answer that uses both. Two tool calls, two model turns total.
5. Tokens — counting and the I/O difference
Every response from the API includes a “usage” object:
{
prompt_tokens: 245, // tokens you sent IN (input)
completion_tokens: 87, // tokens the model generated OUT (output)
total_tokens: 332
}Input and output are priced differently — usually output costs 3–5× more per token than input. Worth tracking both separately if you care about cost. Let’s add that to the loop:
async function runAgent({system, user, tools, maxSteps = 10}) {
const messages = [
{role: 'system', content: system},
{role: 'user', content: user},
]; /* ADDED */
let tokensIn = 0;
let tokensOut = 0;
///////////
for (let step = 0; step < maxSteps; step++) {
const resp = await chatComplete({messages, tools});
const msg = resp.choices[0].message;
/* ADDED */
tokensIn += resp.usage?.prompt_tokens || 0;
tokensOut += resp.usage?.completion_tokens || 0;
///////////
messages.push(msg);
// No tool calls? The model is done — return its answer.
if (!msg.tool_calls?.length) {
/* UPDATED*/
return { answer: msg.content, messages, steps: step + 1, tokensIn, tokensOut };
///////////
}
// Otherwise: execute each tool call and feed results back.
for (const call of msg.tool_calls) {
const name = call.function.name;
const args = JSON.parse(call.function.arguments);
let result;
try {
result = await toolImpls[name](args);
} catch (err) {
result = `Error: ${err.message}`;
}
messages.push({
role: 'tool',
tool_call_id: call.id,
content: typeof result === 'string' ? result : JSON.stringify(result),
});
}
}
throw new Error(`Agent did not finish within ${maxSteps} steps`);
}
Press enter or click to view image in full size
One nuance worth knowing: at each step of the loop, the whole conversation so far (system prompt + user message + every model response + every tool result) gets sent back to the API. That means “prompt_tokens” grows on every iteration. A 5-step agent isn’t 5x the tokens of one call — it’s more, because every step the input is bigger than the last.
About client-side counting. The API tells you usage after the call — fine for billing, useless for planning (“will this conversation fit in 128k context?”). For that, reach for a tokenizer library like “tiktoken”. They give you a count function you can run locally before making the call. The math isn’t 1 token per word — closer to about 4 characters per token on average for English, but it varies by model and by language. Just know the option exists, and add it later if you need it.
6. When does the loop stop?
We’ve already got two stop conditions:
- The model returns a message with no “tool_calls” -> we treat the “content” as the final answer.
- “maxSteps” is hit -> we bail.
That’s the happy path for most agents. Three more cases worth knowing.
Force structured output
Sometimes you don’t want free-form text. You want JSON with a specific shape. The API has a “response_format” parameter:
const resp = await chatComplete({
messages: [
{ role: 'system', content: 'Always respond as JSON: { "answer": string, "confidence": number }' },
{ role: 'user', content: 'Is Paris the capital of France?' },
],
response_format: { type: 'json_object' },
});
const parsed = JSON.parse(resp.choices[0].message.content);
console.log(parsed.answer, parsed.confidence);Better, if the model supports it: “json_schema” mode, which validates the output shape against a real JSON Schema, and the model refuses to return anything off-shape. The exact syntax varies between providers — check your API docs.
Force a tool call
If you want the model to call a specific tool no matter what (useful when it keeps trying to “think out loud” instead of using the tool you gave it):
// Force a specific tool
const resp = await chatComplete({
messages,
tools,
tool_choice: { type: 'function', function: { name: 'calculator' } },
});// Or force ANY tool call, but let the model pick which one:
const resp2 = await chatComplete({ messages, tools, tool_choice: 'required' });
When the model gets “stuck”
Sometimes the model keeps calling tools in a circle, or keeps re-asking the same thing in slightly different words. That’s where you start needing meta-strategies:
- A hard “maxSteps” cap (we already have it).
- Detect repeated identical tool calls in “messages” and inject a “system” nudge: ”You already called `X` with these args and got `Y`. Try a different approach.”
- Detect responses with empty content and no tool call (rare but possible) and retry with “tool_choice: ‘required’” to force the model to commit.
This is where you start spending real engineering hours. By far the messiest part of agent frameworks.
So… it’s just this?
Yes. Look at what we built:
- A function that talks to an OpenAI-compatible API.
- A list of tools, half-schemas, half-JS-functions.
- A loop that swaps model turns and tool results until the model stops calling tools.
- Token counting from the API response.
- Some extra control over output shape and forced tool calls.
That’s it. That’s every agent framework you’ve ever used. LangChain has more abstractions (memory, retrievers, chains, callbacks). Vercel’s AI SDK has nicer ergonomics for streaming and React. Claude Code and Codex layer on careful prompts and curated tool sets. But the bones are identical. No magic. Just RPC over HTTP with a model on the other end, and a loop wrapped around it.
When my code does something weird because LangChain decided to retry-with-backoff seven times silently — now I know exactly where to look.