Multi-Agent Orchestration in Node.js (No Framework)

1 views

“Multi-agent” sounds like it needs a framework: LangGraph, CrewAI, an orchestration platform. Under the hood, a multi-agent system is a handful of functions with one job each, passing messages through plain code that decides who goes next. This tutorial builds one in Node.js 22 with zero npm packages and a small local model (Ollama with qwen2.5:3b): a planner, a researcher, a writer and a reviewer, coordinated by a JavaScript orchestrator, answering support questions from a small help center.

The interesting part isn’t the happy path. It’s what happens when the small model writes bad maths, when the reviewer is wrong, and when the step limit crashes the run. All of that happens on screen in the video, and the fixes are below.

⚡ TL;DR – One job per agent; the orchestrator is ordinary code with shared state and a message log. – Not every agent needs an LLM. The researcher just runs a search tool. – Validate every model output (JSON shape, required keys) and retry with backoff on failure. – Review in two layers: deterministic code checks (citations, numbers must appear in sources), then an LLM PASS/FAIL. – Hard limits: 2 revisions and 12 messages. “Not approved” is an honest, useful result.

Before you start

  • Node.js 22+ with "type": "module" in package.json; no dependencies.
  • Ollama with qwen2.5:3b pulled.
  • A docs/ folder of short Markdown articles, each with a title header. The video uses seven help articles for a made-up notes app.
  • Helpful background: the “AI Agent in JavaScript” and “RAG Chatbot” tutorials on this channel.

Step 1: Draw the workflow before writing code

text
question -> Planner (1-3 search queries)
         -> Researcher (runs search_docs, collects notes with sources)
         -> Writer (answer with [n] citations, max 90 words)
         -> Reviewer -> PASS: done
                     -> FAIL: feedback back to Writer (max 2 revisions)

Each arrow is a message. Writing it down first tells you how many messages a worst-case run needs, which is how you size the step limit (see the failures section).

Step 2: One LLM helper with a JSON flag

js
// llm.js
export async function chat(system, user, { json = false } = {}) {
  const r = await fetch('http://localhost:11434/api/chat', {
    method: 'POST',
    body: JSON.stringify({
      model: 'qwen2.5:3b', stream: false, options: { temperature: 0 },
      ...(json && { format: 'json' }),
      messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
    }),
  });
  if (!r.ok) throw new Error(`Ollama HTTP ${r.status}`);
  return (await r.json()).message.content;
}

format: "json" makes Ollama constrain output to valid JSON. Valid JSON is not the same as the right JSON, so you still validate.

Step 3: A real tool for the researcher

js
// tools.js
const docs = await loadDocs();                    // [{ file, title, body }]
export function searchDocs(query) {
  const words = query.toLowerCase().split(/\W+/).filter(w => w.length > 2);
  return docs
    .map(d => ({ d, score: words.filter(w => d.body.toLowerCase().includes(w)).length }))
    .filter(x => x.score >= 2)
    .sort((a, b) => b.score - a.score)
    .slice(0, 2)
    .map(x => ({ source: x.d.file, text: x.d.body }));
}

It’s deliberately simple keyword overlap, not vector search. The point is that the researcher calls a real tool with real results. Swap in hybrid search later.

Step 4: Retries and JSON validation

js
export async function withRetry(name, fn, tries = 3) {
  for (let i = 1; i <= tries; i++) {
    try { return await fn(); }
    catch (e) {
      console.warn(`${name} failed (try ${i}/${tries}): ${e.message}`);
      if (i === tries) throw e;
      await new Promise(r => setTimeout(r, 500 * i));   // back off a little more each time
    }
  }
}
export function parseJson(text, keys) {
  const obj = JSON.parse(text);                        // throws on bad JSON
  for (const k of keys) if (!(k in obj)) throw new Error(`missing key ${k}`);
  return obj;
}

Treat a bad shape exactly like a network error: it’s a failure, so retry.

Step 5: Write the four agents

Planner: “Split the customer question into 1 to 3 short search queries. Reply as JSON with a queries array.” Validate that it’s a non-empty array, then slice to 3 in code, because models ignore limits written in prompts.

Researcher: no model call at all. It runs searchDocs for each query and turns hits into notes with a source file name. If plain code can do a job reliably, let plain code do it.

Writer: numbers the notes as sources and is told to answer only from them, cite every sentence, stay under 90 words, and say it doesn’t know if the sources don’t answer. When the reviewer rejects a draft, its problems are appended: “Rewrite the answer and fix: …”. That’s the handoff back.

Reviewer, in two layers:

js
function checkDraft(draft, question, sources) {
  const problems = [];
  if (!/\[\d+\]/.test(draft)) problems.push('no citation');
  const haystack = question + ' ' + sources.map(s => s.text).join(' ');
  for (const n of draft.match(/\d+(\.\d+)?/g) ?? [])
    if (!haystack.includes(n)) problems.push(`number ${n} is not in the sources`);
  return problems;
}

If the draft is an honest “I don’t know”, approve it straight away. If code checks find problems, reject without calling the model. Otherwise ask the model for {"verdict": "PASS" | "FAIL", "reason": "..."} and throw (and retry) on any other verdict.

Step 6: The orchestrator and message bus

js
const MAX_REVISIONS = 2, MAX_STEPS = 12;
export async function runWorkflow(task) {
  const state = { task, queries: [], notes: [], draft: '', log: [] };
  const send = (from, to, content) => {
    if (state.log.length >= MAX_STEPS) throw new Error('step limit reached');
    state.log.push({ from, to, content });
    console.log(`${from} -> ${to}: ${String(content).slice(0, 80)}`);
  };

  send('orchestrator', 'planner', task);
  state.queries = await withRetry('planner', () => planner(task));
  for (const q of state.queries) {
    send('planner', 'researcher', q);
    for (const n of researcher(q))
      if (!state.notes.some(x => x.source === n.source)) state.notes.push(n);
  }
  send('researcher', 'writer', `${state.notes.length} notes`);

  let feedback = '';
  for (let rev = 0; rev <= MAX_REVISIONS; rev++) {
    state.draft = await withRetry('writer', () => writer(task, state.notes, feedback));
    send('writer', 'reviewer', state.draft);
    const review = await withRetry('reviewer', () => reviewer(state.draft, task, state.notes));
    if (review.ok) { send('reviewer', 'orchestrator', 'APPROVED'); return { ...state, approved: true }; }
    feedback = review.problems.join('; ');
    send('reviewer', 'writer', feedback);
  }
  return { ...state, approved: false };   // never pretend a draft passed
}

One function owns logging and the step limit. That’s the whole message bus.

Step 7: Run it and read the log

Real runs from the video:

  • “What happens if I hit the API rate limit?” Three queries, two docs, answer cites status 429 with a Retry-After header. Approved in seven messages in about 5 seconds.
  • “Refund on my annual team plan after 3 weeks?” Correctly “no, only within 14 days”, approved. But an unrelated export doc came first in the notes, keyword noise worth fixing.
  • “Does Nimbus have dark mode?” The docs don’t say. The writer says it doesn’t know, and the reviewer approves immediately.
  • “How much is Team on annual billing?” The LLM reviewer wrongly rejected a correct first draft. Draft two invented an “effectively $18 a month” figure (ten months out of twelve is about 16.67), which the code check caught because 18 isn’t in the sources. After the revision limit, the result was honestly reported as not approved.

Same question, temperature zero, and runs still vary. Don’t trust one good run.

Troubleshooting: real failures and fixes

  • Step limit crash. The first limit was 10. A question with two rejections needed more, and the run crashed with “step limit reached” before the reviewer answered. Fix: count your worst case (planning, three drafts, three reviews) and set the limit from that, in this case 12. Keep it as a safety net.
  • Over-strict reviewer. A prompt that said “be strict, reject if any fact isn’t in the sources, reply approved true or false” made the small model reject almost everything. Fix: move fact checks into code and ask the model a narrower PASS/FAIL question with a one-line reason.
  • Ollama HTTP 500. Intermittent server errors under load. Fix: throw on non-OK status and let withRetry back off.

Production checklist

  • Every agent has one job and a validated output shape.
  • Deterministic checks run before any LLM judge.
  • Revision limit and global step limit, sized from the workflow.
  • Full message log saved per run for debugging.
  • Not-approved answers go to a human, not a customer.

FAQ

When do I actually need a framework?

When you need persistence, resumable runs, parallel branches, or tracing UIs. The logic is the same; frameworks add infrastructure around it.

Why not let one agent do everything?

You can, but separating roles makes each prompt short and testable, and lets you replace an LLM step (like research) with plain code.

Is the LLM reviewer worth it if it makes mistakes?

As a second layer, yes. It catches unsupported claims that code can’t. Just don’t let it be the only check.

Official documentation

Key takeaways

  • A multi-agent system is plain code plus a message log and shared state.
  • Validate and retry every model output.
  • Code checks catch made-up numbers for free; LLM reviewers can be wrong in both directions.
  • Size limits from the workflow, and treat “not approved” as a feature.

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

PDF for this post is being prepared. Subscribe now and we’ll unlock downloads sitewide as packs go live.

Subscribe free →


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.