{"id":21665,"date":"2026-08-14T10:24:17","date_gmt":"2026-08-14T08:24:17","guid":{"rendered":"https:\/\/www.juust.org\/?p=21665"},"modified":"2026-08-14T17:34:51","modified_gmt":"2026-08-14T15:34:51","slug":"ai-agent-in-vanilla-js-on-node-js","status":"publish","type":"post","link":"https:\/\/www.juust.org\/index.php\/ai-agent-in-vanilla-js-on-node-js\/2026\/08\/","title":{"rendered":"AI Agent in Vanilla JS on Node.js"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\">No Dependencies, Just Vanilla JS<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><em>If you can read a <code>while<\/code> loop, you can understand every line of an agent.<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Frameworks like LangChain, CrewAI and AutoGen are great for shipping, but terrible for <em>learning<\/em>. When you import <code>Agent(executor=...).run()<\/code>, a black box makes a hundred design decisions for you \u2014 and hides every single one. The surprising truth? There is <strong>no deep machinery inside that box<\/strong>. The entire &#8220;agent&#8221; idea fits in a single <code>while<\/code> loop.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This guide builds that loop from scratch in <strong>vanilla JS on Node.js<\/strong> \u2014 zero npm dependencies, zero SDKs, just the global <code>fetch<\/code> 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 <em>every<\/em> line.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Everything is on GitHub: <a href=\"https:\/\/github.com\/juustesout\/javascript-agents-example\" target=\"_blank\" rel=\"noopener\">github.com\/juustesout\/javascript-agents-example<\/a><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">1. Installation \u2014 Zero Dependencies<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You only need <strong>Node.js 18+<\/strong> (for the global <code>fetch<\/code>) and an OpenAI API key. No <code>npm install<\/code>, no packages:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>git clone https:\/\/github.com\/juustesout\/javascript-agents-example.git\ncd javascript-agents-example\ncp .env.example .env\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Open <code>.env<\/code> and add your OpenAI key:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>OPENAI_API_KEY=sk-proj-your-key-here\nOPENAI_MODEL=gpt-4o-mini        # optional, default\nAGENT_VERBOSE=1                 # set to 0 to silence trace\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>.gitignore<\/code> already has <code>**\/.env<\/code> \u2014 your key stays local. The agent loads <code>.env<\/code> automatically via its own built-in <code>loadEnv()<\/code> function, so you never have to <code>export<\/code> anything.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And that is it. Run the demo immediately:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>node 01_mini_agent\/demo.js\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">2. Project Structure<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>javascript-agents-example\/\n\u251c\u2500\u2500 01_mini_agent\/\n\u2502   \u251c\u2500\u2500 agent.js          # The core loop + 3 hand-written tools\n\u2502   \u251c\u2500\u2500 demo.js           # Entry point with example prompts\n\u2502   \u2514\u2500\u2500 sample.txt        # A test file for the file reader tool\n\u251c\u2500\u2500 02_agent_vs_agent\/\n\u2502   \u2514\u2500\u2500 game.js           # Two agents negotiate a $100 split\n\u251c\u2500\u2500 package.json          # ESM, zero dependencies\n\u2514\u2500\u2500 .env.example\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>01_mini_agent<\/strong> is a complete agent in two files. <strong>02_agent_vs_agent<\/strong> proves multi-agent is just two copies of the same loop with different system prompts.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">3. The Core Loop \u2014 How Every Agent Works<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the whole trick in four steps:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>The model receives the <strong>full conversation history<\/strong> plus a list of <strong>tool schemas<\/strong> (name, description, expected JSON arguments).<\/li>\n\n\n\n<li>The model replies with either <strong>plain text<\/strong> (it is ready to answer) or one or more <strong>tool calls<\/strong> (JSON like <code>{\"name\": \"calculator\", \"arguments\": {\"expression\": \"2+2\"}}<\/code>).<\/li>\n\n\n\n<li>If it is a tool call, <strong>you<\/strong> run the matching JavaScript function and append the result as a <code>role=\"tool\"<\/code> message. Not a framework, not the model \u2014 your code.<\/li>\n\n\n\n<li>Repeat from step 1 until the model returns plain text. That text is the final answer.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The whole loop: <strong>model \u2192 tool \u2192 result \u2192 model \u2192 \u2026 \u2192 answer<\/strong>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The MiniAgent class<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>export class MiniAgent {\n  constructor(systemPrompt, { model, temperature, maxToolIters, verbose } = {}) {\n    this.messages = &#91;];             \/\/ the only \"memory\" \u2014 an array of plain objects\n    this.tools = {};                \/\/ name -&gt; { schema, fn } registry\n    this.systemPrompt = systemPrompt;\n    this.model = model || DEFAULT_MODEL;\n    this.temperature = temperature ?? 0.7;\n    this.maxToolIters = maxToolIters ?? DEFAULT_MAX_TOOL_ITERS;\n    this.verbose = verbose ?? DEFAULT_VERBOSE;\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The agent has three things: a message list, a tool registry and configuration. That is all the state there is.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The <code>run()<\/code> method \u2014 the heart of every agent<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is where the magic (or rather: the lack of magic) lives:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>async run(userPrompt, { reset = true } = {}) {\n  if (reset || this.messages.length === 0) {\n    this.messages = &#91;\n      { role: \"system\", content: this.systemPrompt },\n      { role: \"user\", content: userPrompt },\n    ];\n  } else {\n    this.messages.push({ role: \"user\", content: userPrompt });\n  }\n\n  for (let iteration = 1; iteration &lt;= this.maxToolIters; iteration++) {\n    this._log(`iteration ${iteration}: calling the model...`, _DIM);\n    const reply = await this._callModel();\n\n    if (reply.tool_calls &amp;&amp; reply.tool_calls.length &gt; 0) {\n      await this._handleToolCalls(reply.tool_calls);\n      continue;                   \/\/ loop back to the model\n    }\n\n    \/\/ No tool calls \u2192 final answer\n    const answer = reply.content || \"\";\n    this.messages.push({ role: \"assistant\", content: answer });\n    this._log(\"final answer received.\", _DIM);\n    return answer;\n  }\n\n  throw new Error(`No answer after ${this.maxToolIters} iterations.`);\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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 \u2014 in real time.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How <code>_callModel()<\/code> works<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the only network call in the whole project. It converts the tool registry into OpenAI&#8217;s JSON schema format and sends the entire conversation alongside it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>async _callModel() {\n  const toolSpecs = Object.values(this.tools).map(t =&gt; ({\n    type: \"function\",\n    function: t.schema,\n  }));\n\n  const body = {\n    model: this.model,\n    messages: this.messages,\n    temperature: this.temperature,\n    tools: toolSpecs.length &gt; 0 ? toolSpecs : undefined,\n    tool_choice: toolSpecs.length &gt; 0 ? \"auto\" : undefined,\n  };\n\n  const res = await fetch(`${this.baseUrl}\/chat\/completions`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application\/json\",\n      Authorization: `Bearer ${this.apiKey}`,\n    },\n    body: JSON.stringify(body),\n  });\n\n  if (!res.ok) {\n    const err = await res.text();\n    throw new Error(`OpenAI API error ${res.status}: ${err}`);\n  }\n\n  const data = await res.json();\n  return data.choices&#91;0].message;\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Key insight:<\/strong> The <code>tools<\/code> array is just JSON describing your functions. The model does <em>not<\/em> run anything \u2014 it only <em>requests<\/em> a call. <strong>You<\/strong> execute the function. That is the line between a &#8220;chatbot&#8221; and an &#8220;agent&#8221;.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How tool results get back into context<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The OpenAI API has a strict protocol: the tool call must be recorded as an assistant message <em>before<\/em> the results, and each result must be tagged with the matching <code>tool_call_id<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>async _handleToolCalls(toolCalls) {\n  \/\/ 1. Record the assistant message with the tool call requests\n  this.messages.push({\n    role: \"assistant\",\n    content: null,\n    tool_calls: toolCalls.map(call =&gt; ({\n      id: call.id,\n      type: \"function\",\n      function: {\n        name: call.function.name,\n        arguments: call.function.arguments,\n      },\n    })),\n  });\n\n  \/\/ 2. Execute each tool and append its result\n  for (const call of toolCalls) {\n    const result = await this._executeTool(\n      call.function.name,\n      call.function.arguments\n    );\n    this.messages.push({\n      role: \"tool\",\n      tool_call_id: call.id,\n      content: result,\n    });\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This is where most home-grown agents fail: the API will reject your next request unless <code>role=\"tool\"<\/code> messages are correctly paired with the <code>tool_call_id<\/code> from the assistant message. If you ever get a 400 error when building your own agent, this is the first place to look.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Executing a tool<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>async _executeTool(name, argumentsJson) {\n  const tool = this.tools&#91;name];\n  if (!tool) return `Error: unknown tool '${name}'.`;\n\n  try {\n    const args = JSON.parse(argumentsJson || \"{}\");\n    this._log(`  -&gt; ${name}(${JSON.stringify(args)})`, _YELLOW);\n    const result = await tool.fn(...Object.values(args));\n    this._log(`  &lt;- result: ${String(result).slice(0, 120)}`, _MAGENTA);\n    return String(result);\n  } catch (exc) {\n    return `Error calling '${name}': ${exc.message}`;\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">4. Tools \u2014 Just Plain Functions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A tool in this system has three parts: a <strong>name<\/strong>, a <strong>JSON schema<\/strong> (telling the model what it does and what arguments it expects) and a <strong>JavaScript function<\/strong> that does the work. Tools are registered like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>addTool(name, description, parameters, fn) {\n  this.tools&#91;name] = {\n    schema: { name, description, parameters },\n    fn,\n  };\n  return this;   \/\/ allows chaining\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That is it. Three lines. Let us look at the three tools that ship with the agent.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Tool 1: Calculator \u2014 hand-written recursive descent parser<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Python version uses <code>ast.parse()<\/code> for safety. JavaScript does not have that, so we write a <strong>recursive descent parser<\/strong> by hand \u2014 which is actually a better demonstration: you can see the exact grammar the model is allowed to use.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The grammar (bottom to top, increasing precedence):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>additive      \u2192 multiplicative ((\"+\" | \"-\") multiplicative)*\nmultiplicative \u2192 unary ((\"*\" | \"\/\" | \"\/\/\" | \"%\") unary)*\nunary         \u2192 (\"+\" | \"-\")* power\npower         \u2192 primary (\"**\" unary)?\nprimary       \u2192 number | constant | \"(\" additive \")\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Each grammar rule becomes a function that parses from the current position, consuming characters and returning a number. The beauty of this approach:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>function parseAdditive() {\n  let value = parseMultiplicative();\n  while (true) {\n    skipWs();\n    if (src&#91;pos] === \"+\") {\n      pos++;\n      value += parseMultiplicative();\n    } else if (src&#91;pos] === \"-\") {\n      pos++;\n      value -= parseMultiplicative();\n    } else { break; }\n  }\n  return value;\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The parser accepts <code>+ - * \/ \/\/ % **<\/code>, parentheses, decimal numbers and the constants <code>pi<\/code> and <code>e<\/code>. Anything else \u2014 function calls, object access, JavaScript keywords \u2014 causes a <code>SyntaxError<\/code>. The model can <em>only<\/em> do arithmetic, no matter how creative it tries to be.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Division by zero is caught at runtime, and <code>\/\/<\/code> implements Python-style floor division with <code>Math.floor()<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>} else if (src&#91;pos] === \"\/\" &amp;&amp; src&#91;pos + 1] === \"\/\") {\n  pos += 2;\n  const divisor = parseUnary();\n  if (divisor === 0) throw new SyntaxError(\"division by zero\");\n  value = Math.floor(value \/ divisor);\n}\n<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>export function calculator(expression) {\n  try {\n    return String(_parseExpression(expression));\n  } catch (exc) {\n    return `Error: ${exc.message ?? exc}`;\n  }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Tool 2: Safe file reader (path traversal defence)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Reading a file seems trivial until the model says <code>\"Read me ..\/..\/etc\/passwd\"<\/code>. The defence is <code>path.relative()<\/code> \u2014 if the resolved path walks &#8220;up&#8221; past the allowed base directory, we refuse:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export function read_local_file(filepath) {\n  try {\n    const base = resolve(_DEFAULT_BASE_DIR);\n    const target = resolve(base, String(filepath));\n\n    const rel = relative(base, target);\n    if (rel.startsWith(\"..\") || isAbsolute(rel)) {\n      return `Error: '${filepath}' escapes the allowed directory. Path traversal blocked.`;\n    }\n    if (!existsSync(target)) {\n      return `Error: file not found.`;\n    }\n    const stat = statSync(target);\n    if (stat.size &gt; _MAX_FILE_BYTES) {\n      return `Error: file too large (limit ${_MAX_FILE_BYTES} bytes).`;\n    }\n    return readFileSync(target, \"utf8\").replace(\/\\s+$\/, \"\");\n  } catch (exc) {\n    return `Error reading file: ${exc.message ?? exc}`;\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Even a path like <code>..\/..\/..\/..\/etc\/passwd<\/code> ends up as an absolute path <em>outside<\/em> the base after <code>resolve()<\/code>, and <code>relative()<\/code> returns <code>\"..\/..\/etc\/passwd\"<\/code> which starts with <code>\"..\"<\/code> \u2014 blocked.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Tool 3: Web search (zero API key, scrapes DuckDuckGo)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Python version uses the <code>ddgs<\/code> package. The JavaScript version does the same with <strong>zero new dependencies<\/strong> \u2014 it fetches the public DuckDuckGo HTML endpoint and uses two regexes to extract titles, URLs and snippets:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export async function web_search(query, max_results = 5) {\n  const url = `https:\/\/html.duckduckgo.com\/html\/?q=${encodeURIComponent(query)}`;\n  try {\n    const res = await fetch(url, {\n      headers: {\n        \"User-Agent\": \"Mozilla\/5.0 (compatible; node-agents-example\/1.0)\",\n      },\n      signal: AbortSignal.timeout(15_000),\n    });\n\n    const html = await res.text();\n    const titleRe = \/class=\"result__a\"&#91;^&gt;]*href=\"(&#91;^\"]+)\"&#91;^&gt;]*&gt;(.*?)&lt;\\\/a&gt;\/gs;\n    const snippetRe = \/class=\"result__snippet\"&#91;^&gt;]*&gt;(.*?)&lt;\\\/a&gt;\/gs;\n\n    const anchors = &#91;...html.matchAll(titleRe)];\n    const snippets = &#91;...html.matchAll(snippetRe)];\n\n    \/\/ Unwrap DuckDuckGo's redirect URL\n    const uddg = href.match(\/uddg=(&#91;^&amp;]+)\/);\n    if (uddg) href = decodeURIComponent(uddg&#91;1]);\n\n    return lines.join(\"\\n\\n\");\n  } catch (exc) {\n    return `Error: web search failed: ${exc.message ?? exc}`;\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>fetch<\/code> API is native in Node 18+. The regex-based scraping is a bit more fragile than the Python <code>ddgs<\/code> library, but it keeps the zero-dependency promise. Swap this for a real search API (Tavily, SerpAPI, Google Custom Search) in minutes \u2014 the tool interface never changes.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Registering tools on the agent<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The convenience function <code>registerBuiltinTools()<\/code> wires all three tools to a MiniAgent instance:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export function registerBuiltinTools(agent) {\n  agent\n    .addTool(\"calculator\",\n      \"Evaluate a mathematical expression...\",\n      {\n        type: \"object\",\n        properties: {\n          expression: { type: \"string\", description: \"The expression to evaluate.\" },\n        },\n        required: &#91;\"expression\"],\n      },\n      calculator\n    )\n    .addTool(\"read_local_file\",\n      \"Read the contents of a text file...\",\n      {\n        type: \"object\",\n        properties: {\n          filepath: { type: \"string\", description: \"Relative path of the file.\" },\n        },\n        required: &#91;\"filepath\"],\n      },\n      read_local_file\n    )\n    .addTool(\"web_search\",\n      \"Search the web using DuckDuckGo...\",\n      {\n        type: \"object\",\n        properties: {\n          query: { type: \"string\", description: \"The search query.\" },\n          max_results: { type: \"integer\", description: \"Max results (default 5).\" },\n        },\n        required: &#91;\"query\"],\n      },\n      web_search\n    );\n  return agent;\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Adding your own tool is always three things: a <strong>function<\/strong>, a <strong>schema<\/strong>, and a <strong>call to <code>addTool()<\/code><\/strong>. That is the entire API.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">5. Running the Agent<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>node 01_mini_agent\/demo.js\n# Or with a custom prompt:\nnode 01_mini_agent\/demo.js \"What is the speed of light times 3600?\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">What you see:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>USER: What is 2 ** 10 + 1000? Use the calculator.\n\n&#91;agent] iteration 1: calling the model...\n&#91;agent]   -&gt; calculator({\"expression\":\"2 ** 10 + 1000\"})\n&#91;agent]   &lt;- result: \"2024\"\n&#91;agent] iteration 2: calling the model...\n&#91;agent] final answer received.\n\nFINAL ANSWER: The result of 2^10 + 1000 is 2024.\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And the multi-step version (web search + calculation):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>USER: Search the web for the speed of light, then calculate\n       how far light travels in one hour. Show your work.\n\n&#91;agent] iteration 1: calling the model...\n&#91;agent]   -&gt; web_search({\"query\":\"speed of light in vacuum\"})\n&#91;agent]   &lt;- result: \"299,792,458 m\/s\"\n&#91;agent] iteration 2: calling the model...\n&#91;agent]   -&gt; calculator({\"expression\":\"299792458 * 3600\"})\n&#91;agent]   &lt;- result: \"1079252848800\"\n&#91;agent] iteration 3: calling the model...\n&#91;agent] final answer received.\n\nFINAL ANSWER: Light travels ~1.08 trillion meters in one hour.\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The coloured trace is the loop. <strong>That transparency is the whole point of building from scratch:<\/strong> no black box, no surprises, no framework mystery. Every model call, every tool execution and every intermediate result is visible in your terminal.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">6. Two Agents, One Conversation \u2014 Multi-Agent Negotiation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If one agent is a <code>while<\/code> loop, multi-agent is <strong>two copies of the same loop<\/strong> talking to each other. The example at <code>02_agent_vs_agent\/game.js<\/code> proves it: two <code>MiniAgent<\/code> instances, one system prompt each, take turns proposing a split of $100.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const alice = new MiniAgent(systemPromptA, { temperature: 0.6, verbose: false });\nconst bob   = new MiniAgent(systemPromptB, { temperature: 0.6, verbose: false });\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The only difference between Alice and Bob is the system prompt. Alice is instructed to be &#8220;The Rational Negotiator&#8221;: fair, calm, evidence-driven. Bob is &#8220;The Bold Trader&#8221;: aggressive, bluffs, maximises his own share. Same code, different text.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The game protocol<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Each agent replies with one of four keywords, extracted by a simple regex:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const _ACTION_RE = \/(OFFER|ACCEPT|COUNTER|REJECT)\/i;\n\nfunction parseMove(text) {\n  const match = _ACTION_RE.exec(text);\n  if (!match) return null;\n  const action = match&#91;1].toUpperCase();\n  if (action === \"ACCEPT\" || action === \"REJECT\") return { action };\n\n  const numbers = text.match(\/\\b(\\d{1,3})\\b\/g);\n  const keep = numbers ? parseInt(numbers&#91;0], 10) : null;\n  return { action, keep };\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>playGame()<\/code> function runs the turn-based loop. On each round, one agent sees the current split and can <strong>accept<\/strong> (deal!), <strong>reject<\/strong> (both get $0) or <strong>counter<\/strong> (roles swap):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>for (let turn = 0; turn &lt; MAX_TURNS; turn++) {\n  const instruction =\n    `${responderName}, ${proposerName} put this split on the table: ` +\n    `you get $${proposal.give}, ${proposerName} gets $${proposal.keep}. ` +\n    `Reply with exactly one of: ACCEPT \/ COUNTER &lt;keep&gt; &lt;give&gt; \/ REJECT.`;\n\n  const reply = await safeAsk(responder, instruction);\n  const move = parseMove(reply);\n\n  if (!move || move.action === \"REJECT\") { \/* impasse *\/ return null; }\n  if (move.action === \"ACCEPT\") { \/* deal *\/ return proposal; }\n  \/\/ COUNTER: swap roles, update proposal\n  &#91;proposer, responder] = &#91;responder, proposer];\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Running the game<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>node 02_agent_vs_agent\/game.js           # verbose \u2014 show raw model text\nnode 02_agent_vs_agent\/game.js --quiet   # only parsed moves\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Example session:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Alice opens the negotiation...\nAlice -&gt; Alice keeps $50, gives $50       (fair start)\nBob   -&gt; Bob keeps $70, gives $30         (aggressive counter)\nAlice -&gt; Alice keeps $40, gives $60       (concedes)\nBob   -&gt; Bob keeps $75, gives $25         (presses harder)\nAlice rejects the deal.\n\nImpasse: no deal reached. Both agents walk away with $0.\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The lesson is simple but profound: <strong>multi-agent &#8220;collaboration&#8221; is not a framework feature.<\/strong> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">7. Putting It All Together<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the complete checklist to build your own agent from scratch:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>One API client<\/strong> \u2014 a <code>fetch()<\/code> call to <code>\/v1\/chat\/completions<\/code>. 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.<\/li>\n\n\n\n<li><strong>A message list<\/strong> \u2014 system prompt + user messages + assistant replies + tool results. That array <em>is<\/em> the agent&#8217;s memory. Want long-term memory? Save it to a JSON file and reload it.<\/li>\n\n\n\n<li><strong>A tool registry<\/strong> \u2014 a plain object mapping names to <code>{ schema, fn }<\/code> tuples. Each function is described by a JSON Schema the model reads. Three lines per tool.<\/li>\n\n\n\n<li><strong>A while-loop<\/strong> \u2014 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.<\/li>\n\n\n\n<li><strong>Multi-agent<\/strong> \u2014 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.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>That is everything.<\/strong> Frameworks add caching, streaming, middleware, GUIs and marketing \u2014 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).<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Going Further<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Swap the model<\/strong> \u2014 change <code>OPENAI_MODEL<\/code> to any model supporting function calling, or point <code>OPENAI_BASE_URL<\/code> at OpenRouter or a local Ollama instance.<\/li>\n\n\n\n<li><strong>Add your own tools<\/strong> \u2014 write a function, write a JSON Schema, call <code>agent.addTool()<\/code>. That is the entire API. Try adding a <code>save_note<\/code> tool that writes a timestamped line to a local file.<\/li>\n\n\n\n<li><strong>Use Anthropic<\/strong> \u2014 the same loop works with Anthropic&#8217;s API. The only difference is that Claude uses a <code>tool_use<\/code>\/<code>tool_result<\/code> content block format instead of <code>tool_calls<\/code>. The loop stays the same.<\/li>\n\n\n\n<li><strong>Persist conversations<\/strong> \u2014 <code>JSON.stringify(agent.messages)<\/code> to a file, reload with <code>JSON.parse()<\/code>. That is all &#8220;memory&#8221; is.<\/li>\n\n\n\n<li><strong>Extend the negotiation<\/strong> \u2014 three players, different rules, a marketplace with prices. You own the game logic because you wrote it.<\/li>\n\n\n\n<li><strong>Stream the output<\/strong> \u2014 use <code>stream: true<\/code> in the API call and process chunks as they arrive. The loop stays the same; only the I\/O pattern changes.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The full source is at <a href=\"https:\/\/github.com\/juustesout\/javascript-agents-example\" target=\"_blank\" rel=\"noopener\">github.com\/juustesout\/javascript-agents-example<\/a>. Clone it, open <code>agent.js<\/code> and read the whole thing in one sitting \u2014 you already know enough.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>This guide builds an Agent loop from scratch in vanilla JS on Node.js<\/p>\n","protected":false},"author":5796,"featured_media":21666,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_sitemap_exclude":false,"_sitemap_priority":"","_sitemap_frequency":"","site-sidebar-layout":"default","site-content-layout":"","ast-site-content-layout":"default","site-content-style":"default","site-sidebar-style":"default","ast-global-header-display":"","ast-banner-title-visibility":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"","ast-breadcrumbs-content":"","ast-featured-img":"","footer-sml-layout":"","ast-disable-related-posts":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","astra-migrate-meta-layouts":"set","ast-page-background-enabled":"default","ast-page-background-meta":{"desktop":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"ast-content-background-meta":{"desktop":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"footnotes":""},"categories":[543,484,479,11,26],"tags":[483],"class_list":["post-21665","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-agents","category-ai","category-javascript","category-seo-tool","category-trends","tag-ai"],"_links":{"self":[{"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/posts\/21665","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/users\/5796"}],"replies":[{"embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/comments?post=21665"}],"version-history":[{"count":2,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/posts\/21665\/revisions"}],"predecessor-version":[{"id":21677,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/posts\/21665\/revisions\/21677"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/media\/21666"}],"wp:attachment":[{"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/media?parent=21665"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/categories?post=21665"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/tags?post=21665"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}