AI Agent in Vanilla JS on Node.js

vanilla js agent openai

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:

  1. The model receives the full conversation history plus a list of tool schemas (name, description, expected JSON arguments).
  2. 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"}}).
  3. 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.
  4. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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_MODEL to any model supporting function calling, or point OPENAI_BASE_URL at 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 a save_note tool 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_result content block format instead of tool_calls. The loop stays the same.
  • Persist conversationsJSON.stringify(agent.messages) to a file, reload with JSON.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: true in 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.


Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top