No Dependencies, Just Vanilla JS
If you can read a while loop, you can understand every line of an agent.
Frameworks like LangChain, CrewAI and AutoGen are great for shipping, but terrible for learning. When you import Agent(executor=...).run(), a black box makes a hundred design decisions for you — and hides every single one. The surprising truth? There is no deep machinery inside that box. The entire “agent” idea fits in a single while loop.
This guide builds that loop from scratch in vanilla JS on Node.js — zero npm dependencies, zero SDKs, just the global fetch API and plain functions. You will end up with a real, working AI agent that can calculate, read local files and search the web, and you will understand every line.
Everything is on GitHub: github.com/juustesout/javascript-agents-example
1. Installation — Zero Dependencies
You only need Node.js 18+ (for the global fetch) and an OpenAI API key. No npm install, no packages:
git clone https://github.com/juustesout/javascript-agents-example.git
cd javascript-agents-example
cp .env.example .env
Open .env and add your OpenAI key:
OPENAI_API_KEY=sk-proj-your-key-here
OPENAI_MODEL=gpt-4o-mini # optional, default
AGENT_VERBOSE=1 # set to 0 to silence trace
The .gitignore already has **/.env — your key stays local. The agent loads .env automatically via its own built-in loadEnv() function, so you never have to export anything.
And that is it. Run the demo immediately:
node 01_mini_agent/demo.js
2. Project Structure
javascript-agents-example/
├── 01_mini_agent/
│ ├── agent.js # The core loop + 3 hand-written tools
│ ├── demo.js # Entry point with example prompts
│ └── sample.txt # A test file for the file reader tool
├── 02_agent_vs_agent/
│ └── game.js # Two agents negotiate a $100 split
├── package.json # ESM, zero dependencies
└── .env.example
01_mini_agent is a complete agent in two files. 02_agent_vs_agent proves multi-agent is just two copies of the same loop with different system prompts.
3. The Core Loop — How Every Agent Works
Here is the whole trick in four steps:
- The model receives the full conversation history plus a list of tool schemas (name, description, expected JSON arguments).
- The model replies with either plain text (it is ready to answer) or one or more tool calls (JSON like
{"name": "calculator", "arguments": {"expression": "2+2"}}). - If it is a tool call, you run the matching JavaScript function and append the result as a
role="tool"message. Not a framework, not the model — your code. - Repeat from step 1 until the model returns plain text. That text is the final answer.
The whole loop: model → tool → result → model → … → answer.
The MiniAgent class
export class MiniAgent {
constructor(systemPrompt, { model, temperature, maxToolIters, verbose } = {}) {
this.messages = []; // the only "memory" — an array of plain objects
this.tools = {}; // name -> { schema, fn } registry
this.systemPrompt = systemPrompt;
this.model = model || DEFAULT_MODEL;
this.temperature = temperature ?? 0.7;
this.maxToolIters = maxToolIters ?? DEFAULT_MAX_TOOL_ITERS;
this.verbose = verbose ?? DEFAULT_VERBOSE;
}
}
The agent has three things: a message list, a tool registry and configuration. That is all the state there is.
The run() method — the heart of every agent
This is where the magic (or rather: the lack of magic) lives:
async run(userPrompt, { reset = true } = {}) {
if (reset || this.messages.length === 0) {
this.messages = [
{ role: "system", content: this.systemPrompt },
{ role: "user", content: userPrompt },
];
} else {
this.messages.push({ role: "user", content: userPrompt });
}
for (let iteration = 1; iteration <= this.maxToolIters; iteration++) {
this._log(`iteration ${iteration}: calling the model...`, _DIM);
const reply = await this._callModel();
if (reply.tool_calls && reply.tool_calls.length > 0) {
await this._handleToolCalls(reply.tool_calls);
continue; // loop back to the model
}
// No tool calls → final answer
const answer = reply.content || "";
this.messages.push({ role: "assistant", content: answer });
this._log("final answer received.", _DIM);
return answer;
}
throw new Error(`No answer after ${this.maxToolIters} iterations.`);
}
Every iteration prints a color trace so you can watch the agent think, call a tool, read the result, and decide what to do next — in real time.
How _callModel() works
This is the only network call in the whole project. It converts the tool registry into OpenAI’s JSON schema format and sends the entire conversation alongside it:
async _callModel() {
const toolSpecs = Object.values(this.tools).map(t => ({
type: "function",
function: t.schema,
}));
const body = {
model: this.model,
messages: this.messages,
temperature: this.temperature,
tools: toolSpecs.length > 0 ? toolSpecs : undefined,
tool_choice: toolSpecs.length > 0 ? "auto" : undefined,
};
const res = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI API error ${res.status}: ${err}`);
}
const data = await res.json();
return data.choices[0].message;
}
Key insight: The tools array is just JSON describing your functions. The model does not run anything — it only requests a call. You execute the function. That is the line between a “chatbot” and an “agent”.
How tool results get back into context
The OpenAI API has a strict protocol: the tool call must be recorded as an assistant message before the results, and each result must be tagged with the matching tool_call_id:
async _handleToolCalls(toolCalls) {
// 1. Record the assistant message with the tool call requests
this.messages.push({
role: "assistant",
content: null,
tool_calls: toolCalls.map(call => ({
id: call.id,
type: "function",
function: {
name: call.function.name,
arguments: call.function.arguments,
},
})),
});
// 2. Execute each tool and append its result
for (const call of toolCalls) {
const result = await this._executeTool(
call.function.name,
call.function.arguments
);
this.messages.push({
role: "tool",
tool_call_id: call.id,
content: result,
});
}
}
This is where most home-grown agents fail: the API will reject your next request unless role="tool" messages are correctly paired with the tool_call_id from the assistant message. If you ever get a 400 error when building your own agent, this is the first place to look.
Executing a tool
async _executeTool(name, argumentsJson) {
const tool = this.tools[name];
if (!tool) return `Error: unknown tool '${name}'.`;
try {
const args = JSON.parse(argumentsJson || "{}");
this._log(` -> ${name}(${JSON.stringify(args)})`, _YELLOW);
const result = await tool.fn(...Object.values(args));
this._log(` <- result: ${String(result).slice(0, 120)}`, _MAGENTA);
return String(result);
} catch (exc) {
return `Error calling '${name}': ${exc.message}`;
}
}
Yes, that is the entire tool execution engine. A function lookup, a JSON parse and a function call. All the complexity of frameworks lives in 15 lines of real code.
4. Tools — Just Plain Functions
A tool in this system has three parts: a name, a JSON schema (telling the model what it does and what arguments it expects) and a JavaScript function that does the work. Tools are registered like this:
addTool(name, description, parameters, fn) {
this.tools[name] = {
schema: { name, description, parameters },
fn,
};
return this; // allows chaining
}
That is it. Three lines. Let us look at the three tools that ship with the agent.
Tool 1: Calculator — hand-written recursive descent parser
The Python version uses ast.parse() for safety. JavaScript does not have that, so we write a recursive descent parser by hand — which is actually a better demonstration: you can see the exact grammar the model is allowed to use.
The grammar (bottom to top, increasing precedence):
additive → multiplicative (("+" | "-") multiplicative)*
multiplicative → unary (("*" | "/" | "//" | "%") unary)*
unary → ("+" | "-")* power
power → primary ("**" unary)?
primary → number | constant | "(" additive ")"
Each grammar rule becomes a function that parses from the current position, consuming characters and returning a number. The beauty of this approach:
function parseAdditive() {
let value = parseMultiplicative();
while (true) {
skipWs();
if (src[pos] === "+") {
pos++;
value += parseMultiplicative();
} else if (src[pos] === "-") {
pos++;
value -= parseMultiplicative();
} else { break; }
}
return value;
}
The parser accepts + - * / // % **, parentheses, decimal numbers and the constants pi and e. Anything else — function calls, object access, JavaScript keywords — causes a SyntaxError. The model can only do arithmetic, no matter how creative it tries to be.
Division by zero is caught at runtime, and // implements Python-style floor division with Math.floor():
} else if (src[pos] === "/" && src[pos + 1] === "/") {
pos += 2;
const divisor = parseUnary();
if (divisor === 0) throw new SyntaxError("division by zero");
value = Math.floor(value / divisor);
}
export function calculator(expression) {
try {
return String(_parseExpression(expression));
} catch (exc) {
return `Error: ${exc.message ?? exc}`;
}
}
Tool 2: Safe file reader (path traversal defence)
Reading a file seems trivial until the model says "Read me ../../etc/passwd". The defence is path.relative() — if the resolved path walks “up” past the allowed base directory, we refuse:
export function read_local_file(filepath) {
try {
const base = resolve(_DEFAULT_BASE_DIR);
const target = resolve(base, String(filepath));
const rel = relative(base, target);
if (rel.startsWith("..") || isAbsolute(rel)) {
return `Error: '${filepath}' escapes the allowed directory. Path traversal blocked.`;
}
if (!existsSync(target)) {
return `Error: file not found.`;
}
const stat = statSync(target);
if (stat.size > _MAX_FILE_BYTES) {
return `Error: file too large (limit ${_MAX_FILE_BYTES} bytes).`;
}
return readFileSync(target, "utf8").replace(/\s+$/, "");
} catch (exc) {
return `Error reading file: ${exc.message ?? exc}`;
}
}
Even a path like ../../../../etc/passwd ends up as an absolute path outside the base after resolve(), and relative() returns "../../etc/passwd" which starts with ".." — blocked.
Tool 3: Web search (zero API key, scrapes DuckDuckGo)
The Python version uses the ddgs package. The JavaScript version does the same with zero new dependencies — it fetches the public DuckDuckGo HTML endpoint and uses two regexes to extract titles, URLs and snippets:
export async function web_search(query, max_results = 5) {
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
try {
const res = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; node-agents-example/1.0)",
},
signal: AbortSignal.timeout(15_000),
});
const html = await res.text();
const titleRe = /class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/gs;
const snippetRe = /class="result__snippet"[^>]*>(.*?)<\/a>/gs;
const anchors = [...html.matchAll(titleRe)];
const snippets = [...html.matchAll(snippetRe)];
// Unwrap DuckDuckGo's redirect URL
const uddg = href.match(/uddg=([^&]+)/);
if (uddg) href = decodeURIComponent(uddg[1]);
return lines.join("\n\n");
} catch (exc) {
return `Error: web search failed: ${exc.message ?? exc}`;
}
}
The fetch API is native in Node 18+. The regex-based scraping is a bit more fragile than the Python ddgs library, but it keeps the zero-dependency promise. Swap this for a real search API (Tavily, SerpAPI, Google Custom Search) in minutes — the tool interface never changes.
Registering tools on the agent
The convenience function registerBuiltinTools() wires all three tools to a MiniAgent instance:
export function registerBuiltinTools(agent) {
agent
.addTool("calculator",
"Evaluate a mathematical expression...",
{
type: "object",
properties: {
expression: { type: "string", description: "The expression to evaluate." },
},
required: ["expression"],
},
calculator
)
.addTool("read_local_file",
"Read the contents of a text file...",
{
type: "object",
properties: {
filepath: { type: "string", description: "Relative path of the file." },
},
required: ["filepath"],
},
read_local_file
)
.addTool("web_search",
"Search the web using DuckDuckGo...",
{
type: "object",
properties: {
query: { type: "string", description: "The search query." },
max_results: { type: "integer", description: "Max results (default 5)." },
},
required: ["query"],
},
web_search
);
return agent;
}
Adding your own tool is always three things: a function, a schema, and a call to addTool(). That is the entire API.
5. Running the Agent
node 01_mini_agent/demo.js
# Or with a custom prompt:
node 01_mini_agent/demo.js "What is the speed of light times 3600?"
What you see:
USER: What is 2 ** 10 + 1000? Use the calculator.
[agent] iteration 1: calling the model...
[agent] -> calculator({"expression":"2 ** 10 + 1000"})
[agent] <- result: "2024"
[agent] iteration 2: calling the model...
[agent] final answer received.
FINAL ANSWER: The result of 2^10 + 1000 is 2024.
And the multi-step version (web search + calculation):
USER: Search the web for the speed of light, then calculate
how far light travels in one hour. Show your work.
[agent] iteration 1: calling the model...
[agent] -> web_search({"query":"speed of light in vacuum"})
[agent] <- result: "299,792,458 m/s"
[agent] iteration 2: calling the model...
[agent] -> calculator({"expression":"299792458 * 3600"})
[agent] <- result: "1079252848800"
[agent] iteration 3: calling the model...
[agent] final answer received.
FINAL ANSWER: Light travels ~1.08 trillion meters in one hour.
The coloured trace is the loop. That transparency is the whole point of building from scratch: no black box, no surprises, no framework mystery. Every model call, every tool execution and every intermediate result is visible in your terminal.
6. Two Agents, One Conversation — Multi-Agent Negotiation
If one agent is a while loop, multi-agent is two copies of the same loop talking to each other. The example at 02_agent_vs_agent/game.js proves it: two MiniAgent instances, one system prompt each, take turns proposing a split of $100.
const alice = new MiniAgent(systemPromptA, { temperature: 0.6, verbose: false });
const bob = new MiniAgent(systemPromptB, { temperature: 0.6, verbose: false });
The only difference between Alice and Bob is the system prompt. Alice is instructed to be “The Rational Negotiator”: fair, calm, evidence-driven. Bob is “The Bold Trader”: aggressive, bluffs, maximises his own share. Same code, different text.
The game protocol
Each agent replies with one of four keywords, extracted by a simple regex:
const _ACTION_RE = /(OFFER|ACCEPT|COUNTER|REJECT)/i;
function parseMove(text) {
const match = _ACTION_RE.exec(text);
if (!match) return null;
const action = match[1].toUpperCase();
if (action === "ACCEPT" || action === "REJECT") return { action };
const numbers = text.match(/\b(\d{1,3})\b/g);
const keep = numbers ? parseInt(numbers[0], 10) : null;
return { action, keep };
}
The playGame() function runs the turn-based loop. On each round, one agent sees the current split and can accept (deal!), reject (both get $0) or counter (roles swap):
for (let turn = 0; turn < MAX_TURNS; turn++) {
const instruction =
`${responderName}, ${proposerName} put this split on the table: ` +
`you get $${proposal.give}, ${proposerName} gets $${proposal.keep}. ` +
`Reply with exactly one of: ACCEPT / COUNTER <keep> <give> / REJECT.`;
const reply = await safeAsk(responder, instruction);
const move = parseMove(reply);
if (!move || move.action === "REJECT") { /* impasse */ return null; }
if (move.action === "ACCEPT") { /* deal */ return proposal; }
// COUNTER: swap roles, update proposal
[proposer, responder] = [responder, proposer];
}
Running the game
node 02_agent_vs_agent/game.js # verbose — show raw model text
node 02_agent_vs_agent/game.js --quiet # only parsed moves
Example session:
Alice opens the negotiation...
Alice -> Alice keeps $50, gives $50 (fair start)
Bob -> Bob keeps $70, gives $30 (aggressive counter)
Alice -> Alice keeps $40, gives $60 (concedes)
Bob -> Bob keeps $75, gives $25 (presses harder)
Alice rejects the deal.
Impasse: no deal reached. Both agents walk away with $0.
The lesson is simple but profound: multi-agent “collaboration” is not a framework feature. It is two single-agent loops passing messages through a shared state. The protocol (OFFER/ACCEPT/COUNTER/REJECT) is just text matching. You can build any multi-agent system on the same pattern: turn-based, free-form text, a parser that extracts structure.
7. Putting It All Together
Here is the complete checklist to build your own agent from scratch:
- One API client — a
fetch()call to/v1/chat/completions. The endpoint, headers and response format are standard across OpenAI, OpenRouter, and any compatible provider. Swap the base URL and key, your agent works with a different model. - A message list — system prompt + user messages + assistant replies + tool results. That array is the agent’s memory. Want long-term memory? Save it to a JSON file and reload it.
- A tool registry — a plain object mapping names to
{ schema, fn }tuples. Each function is described by a JSON Schema the model reads. Three lines per tool. - A while-loop — send the message list + tool schemas to the model. If the model requests a tool call, execute it, append the result, loop. If it returns text, stop and return it. That loop is ~30 lines.
- Multi-agent — run two copies of the same loop with different system prompts. Define a message protocol (OFFER/ACCEPT/COUNTER/REJECT or whatever you need). Let them talk.
That is everything. Frameworks add caching, streaming, middleware, GUIs and marketing — but underneath every single one is this same three-step loop. Once you have written it by hand, every framework becomes obvious (and often visibly over-engineered).
Going Further
- Swap the model — change
OPENAI_MODELto any model supporting function calling, or pointOPENAI_BASE_URLat OpenRouter or a local Ollama instance. - Add your own tools — write a function, write a JSON Schema, call
agent.addTool(). That is the entire API. Try adding asave_notetool that writes a timestamped line to a local file. - Use Anthropic — the same loop works with Anthropic’s API. The only difference is that Claude uses a
tool_use/tool_resultcontent block format instead oftool_calls. The loop stays the same. - Persist conversations —
JSON.stringify(agent.messages)to a file, reload withJSON.parse(). That is all “memory” is. - Extend the negotiation — three players, different rules, a marketplace with prices. You own the game logic because you wrote it.
- Stream the output — use
stream: truein the API call and process chunks as they arrive. The loop stays the same; only the I/O pattern changes.
The full source is at github.com/juustesout/javascript-agents-example. Clone it, open agent.js and read the whole thing in one sitting — you already know enough.