{"slug": "building-a-multi-agent-system-in-typescript", "title": "Building a Multi-Agent System in TypeScript", "summary": "A developer detailed the construction of a multi-agent system in TypeScript, highlighting two patterns: Orchestrator/Subagent and Pipeline. The system decomposes tasks into parallel subtasks to overcome context window limits and improve efficiency, as demonstrated with a competitor pricing analysis example.", "body_md": "Single agents hit real limits in production. Long tasks exceed context windows. Complex goals need different tools at different stages. Sequential reasoning is slow when subtasks are independent.\n\nMulti-agent systems solve these problems by decomposing work across specialized agents that can run in parallel. This article walks through two patterns — Orchestrator/Subagent and Pipeline — implemented entirely in TypeScript.\n\nThe case for multi-agent systems isn't abstract. Consider a research task: \"Analyze our competitors' pricing pages and summarize the key differences.\"\n\nA single agent working sequentially has to: fetch page 1, process it, fetch page 2, process it, fetch page 3, process it, then write the analysis. Each step burns context window. Total time is the sum of all steps.\n\nAn orchestrator-based approach: spawn three agents in parallel, one per competitor. Each fetches and processes its page independently. Total time is roughly the time of the slowest agent, not the sum.\n\n```\nSingle agent (serial):\n  Task → [fetch A → fetch B → fetch C → analyze] → Result\n  Time: T(A) + T(B) + T(C) + T(analyze)\n\nMulti-agent (parallel):\n                    ┌→ [Agent A: fetch + process] ─┐\n  Task → [Orchestrator] → [Agent B: fetch + process] → [Synthesize] → Result\n                    └→ [Agent C: fetch + process] ─┘\n  Time: max(T(A), T(B), T(C)) + T(synthesize)\n```\n\nThe orchestrator pattern has three phases: decompose the goal into subtasks, execute subtasks (in parallel where possible), then synthesize results.\n\nFirst, define the data structures that flow between orchestrator and subagents:\n\n```\n// src/lib/agent/multi/types.ts\n\nexport interface SubTask {\n  id: string;\n  title: string;\n  description: string;\n  toolSet: 'browser' | 'file' | 'code' | 'rag' | 'general';\n  dependsOn?: string[];   // IDs of tasks that must complete first\n  priority: 'high' | 'medium' | 'low';\n  timeoutMs?: number;\n}\n\nexport interface SubTaskResult {\n  taskId: string;\n  status: 'success' | 'failed' | 'timeout' | 'skipped';\n  output: string;\n  durationMs: number;\n}\n\nexport interface OrchestratorPlan {\n  goal: string;\n  tasks: SubTask[];\n  estimatedParallelGroups: string[][];  // Which tasks can run concurrently\n}\n```\n\n`dependsOn`\n\nis what makes the dependency graph work — a task that needs another task's output lists its ID here. `estimatedParallelGroups`\n\nis the LLM's suggestion for which tasks can run at the same time.\n\nThe orchestrator calls the LLM once to convert a natural-language goal into a structured plan:\n\n```\nasync decompose(goal: string): Promise<OrchestratorPlan> {\n  const { text } = await callLLM([{\n    role: 'user',\n    content: `Decompose this goal into subtasks. Output JSON only.\n\nGoal: ${goal}\n\nFormat:\n{\n  \"goal\": \"...\",\n  \"tasks\": [\n    {\n      \"id\": \"task_1\",\n      \"title\": \"...\",\n      \"description\": \"detailed enough for another AI to complete independently\",\n      \"toolSet\": \"browser|file|code|rag|general\",\n      \"dependsOn\": [],\n      \"priority\": \"high|medium|low\",\n      \"timeoutMs\": 60000\n    }\n  ],\n  \"estimatedParallelGroups\": [[\"task_1\", \"task_2\"], [\"task_3\"]]\n}`,\n  }], { system: ORCHESTRATOR_SYSTEM, temperature: 0 });\n\n  return JSON.parse(text.replace(/```\n{% endraw %}\njson\\n?|\\n?\n{% raw %}\n```/g, '').trim());\n}\n```\n\nUsing `temperature: 0`\n\nfor planning — you want deterministic, structured output here, not creativity.\n\nThe execution phase respects both the dependency graph and a concurrency limit:\n\n```\nasync executeParallel(\n  plan: OrchestratorPlan,\n  maxConcurrency = 3,\n): Promise<Map<string, SubTaskResult>> {\n  const results = new Map<string, SubTaskResult>();\n  const completed = new Set<string>();\n  const failed = new Set<string>();\n\n  for (const group of plan.estimatedParallelGroups) {\n    // Only run tasks whose dependencies have completed successfully\n    const executable = group.filter(taskId => {\n      const task = plan.tasks.find(t => t.id === taskId);\n      if (!task) return false;\n      return (task.dependsOn ?? []).every(\n        dep => completed.has(dep) && !failed.has(dep)\n      );\n    });\n\n    if (executable.length === 0) continue;\n\n    const batchResults = await this.executeBatch(\n      executable.map(id => plan.tasks.find(t => t.id === id)!),\n      results,\n      maxConcurrency,\n    );\n\n    for (const [id, result] of batchResults) {\n      results.set(id, result);\n      result.status === 'success' ? completed.add(id) : failed.add(id);\n    }\n  }\n\n  return results;\n}\n\nprivate async executeBatch(\n  tasks: SubTask[],\n  previousResults: Map<string, SubTaskResult>,\n  maxConcurrency: number,\n): Promise<Map<string, SubTaskResult>> {\n  const results = new Map<string, SubTaskResult>();\n  const active: Promise<void>[] = [];\n\n  for (const task of tasks) {\n    const promise: Promise<void> = this.executeTask(task, previousResults)\n      .then(result => { results.set(task.id, result); })\n      .catch(err => {\n        results.set(task.id, {\n          taskId: task.id,\n          status: 'failed',\n          output: err instanceof Error ? err.message : String(err),\n          durationMs: 0,\n        });\n      })\n      .finally(() => { active.splice(active.indexOf(promise), 1); });\n\n    active.push(promise);\n\n    if (active.length >= maxConcurrency) {\n      await Promise.race(active);  // Wait for one slot to free up\n    }\n  }\n\n  await Promise.allSettled(active);\n  return results;\n}\n```\n\nThe concurrency control pattern is worth understanding: `Promise.race(active)`\n\nwaits for the first active task to finish, then removes it from the pool and allows the next task to start. The number of concurrently running tasks never exceeds `maxConcurrency`\n\n.\n\nEach subtask runs in its own ReAct agent instance with tools appropriate for its `toolSet`\n\n. Upstream results are injected as context:\n\n```\nprivate async executeTask(\n  task: SubTask,\n  previousResults: Map<string, SubTaskResult>,\n): Promise<SubTaskResult> {\n  const start = Date.now();\n\n  // Inject upstream results as context\n  const context = (task.dependsOn ?? [])\n    .map(depId => {\n      const dep = previousResults.get(depId);\n      return dep ? `[${depId}] ${dep.output.slice(0, 1000)}` : '';\n    })\n    .filter(Boolean)\n    .join('\\n\\n');\n\n  const prompt = context\n    ? `Background (from upstream tasks):\\n${context}\\n\\nCurrent task: ${task.description}`\n    : task.description;\n\n  const agent = new ReActAgent({\n    tools: this.getToolsForTaskType(task.toolSet),\n    maxSteps: 8,\n  });\n\n  try {\n    const timeout = task.timeoutMs ?? 120_000;\n    const result = await Promise.race([\n      agent.run(prompt),\n      new Promise<never>((_, reject) =>\n        setTimeout(() => reject(new Error('TIMEOUT')), timeout)\n      ),\n    ]);\n\n    return {\n      taskId: task.id,\n      status: result.stopped === 'error' ? 'failed' : 'success',\n      output: result.answer,\n      durationMs: Date.now() - start,\n    };\n  } catch (error) {\n    const isTimeout = error instanceof Error && error.message === 'TIMEOUT';\n    return {\n      taskId: task.id,\n      status: isTimeout ? 'timeout' : 'failed',\n      output: isTimeout ? `Timed out after ${task.timeoutMs ?? 120_000}ms` : String(error),\n      durationMs: Date.now() - start,\n    };\n  }\n}\n```\n\nNote: the timeout is implemented with `Promise.race`\n\n. This stops waiting for the agent, but it doesn't kill the underlying work — the agent's API calls may still be running. For production, you'd want proper cancellation via `AbortController`\n\n.\n\nAfter all subtasks complete, one final LLM call produces the answer:\n\n```\nasync synthesize(\n  goal: string,\n  plan: OrchestratorPlan,\n  results: Map<string, SubTaskResult>,\n): Promise<string> {\n  const summaries = plan.tasks\n    .map(task => {\n      const result = results.get(task.id);\n      return `[${task.title}]\\nStatus: ${result?.status ?? 'skipped'}\\n${result?.output ?? 'No output'}`;\n    })\n    .join('\\n\\n---\\n\\n');\n\n  const { text } = await callLLM([{\n    role: 'user',\n    content: `Based on all subtask results, answer the original goal.\n\nOriginal goal: ${goal}\n\nSubtask results:\n${summaries}\n\nProvide a complete, well-structured final answer. If some subtasks failed, explain the impact.`,\n  }], { temperature: 0.5, maxTokens: 3000 });\n\n  return text;\n}\n```\n\nNot all multi-agent work fits the orchestrator model. Some tasks are inherently sequential — each stage transforms the previous stage's output. For these, a pipeline is cleaner.\n\n```\n// src/lib/agent/multi/pipeline.ts\n\nexport interface PipelineStage {\n  name: string;\n  description: \"string;\"\n  tools: Tool[];\n  maxSteps?: number;\n  buildPrompt: (previousOutput: string, originalInput: string) => string;\n  validate?: (output: string) => { valid: boolean; reason?: string };\n}\n\nexport class AgentPipeline {\n  constructor(\n    private stages: PipelineStage[],\n    private maxRetries = 2,\n  ) {}\n\n  async run(initialInput: string): Promise<{ finalOutput: string; success: boolean }> {\n    let currentOutput = initialInput;\n\n    for (const stage of this.stages) {\n      let retries = 0;\n      let success = false;\n\n      while (retries <= this.maxRetries && !success) {\n        const agent = new ReActAgent({\n          tools: stage.tools,\n          maxSteps: stage.maxSteps ?? 6,\n          systemPrompt: stage.description,\n        });\n\n        const result = await agent.run(\n          stage.buildPrompt(currentOutput, initialInput)\n        );\n\n        if (stage.validate) {\n          const validation = stage.validate(result.answer);\n          if (!validation.valid) {\n            retries++;\n            continue;\n          }\n        }\n\n        currentOutput = result.answer;\n        success = true;\n      }\n\n      if (!success) {\n        throw new Error(`Pipeline stage \"${stage.name}\" failed after ${this.maxRetries} retries`);\n      }\n    }\n\n    return { finalOutput: currentOutput, success: true };\n  }\n}\n```\n\nThe `validate`\n\nfunction per stage is the key design decision here. Rather than hoping each agent produces usable output, you define what \"valid\" means and retry automatically when it isn't. This makes pipelines significantly more reliable than single-stage agents.\n\nThree stages: gather sources, extract insights, write the report.\n\n``` js\nconst researchPipeline = new AgentPipeline([\n  {\n    name: 'Information Gathering',\n    description: \"'You are a researcher. Collect information from the web.',\"\n    tools: [fetchWebpageTool],\n    maxSteps: 6,\n    buildPrompt: (_, originalInput) =>\n      `Gather key information on this topic, including recent developments and data:\\n\\n${originalInput}`,\n    validate: output => ({\n      valid: output.length > 200,\n      reason: 'Insufficient information gathered',\n    }),\n  },\n  {\n    name: 'Analysis',\n    description: \"'You are an analyst. Extract insights from raw research.',\"\n    tools: [],\n    buildPrompt: (previousOutput, originalInput) => `\nOriginal topic: ${originalInput}\n\nRaw research:\n${previousOutput}\n\nExtract: key findings, main trends, supporting data points, open questions.`,\n    validate: output => ({\n      valid: output.includes('finding') || output.includes('trend'),\n      reason: 'Analysis must include findings or trends',\n    }),\n  },\n  {\n    name: 'Report Writing',\n    description: \"'You are a technical writer. Produce a structured report.',\"\n    tools: [writeFileTool],\n    buildPrompt: (previousOutput, originalInput) => `\nTopic: ${originalInput}\n\nAnalysis:\n${previousOutput}\n\nWrite a structured report with: Executive Summary, Key Findings, Analysis, Conclusion.`,\n  },\n]);\n\nconst result = await researchPipeline.run(\n  'Current state of TypeScript adoption in backend development'\n);\n```\n\nMulti-agent systems fail in opaque ways. An agent in the middle of a pipeline produces bad output; the next agent silently works with it; the final result is wrong with no obvious signal as to why.\n\nAdd progress callbacks to the orchestrator and structured logging to each stage:\n\n``` js\nconst orchestrator = new Orchestrator({\n  onProgress: (event) => {\n    console.error(`[${event.type}] ${event.message}`);\n    // In production: emit SSE event to frontend, write to tracing system\n  },\n});\n```\n\nThe `onProgress`\n\ncallback fires at every key milestone: planning complete, group start, task start, task done (with status), synthesis start, done. With these events you can show real-time progress in a UI, and you have a complete audit trail when something fails.\n\n**Token costs compound.** Each subagent runs its own ReAct loop. A 6-task orchestration job with 5 steps per agent is 30 LLM calls before synthesis. Budget accordingly, and set `maxSteps`\n\naggressively.\n\n**The plan is a suggestion, not a guarantee.** The LLM might put tasks in `estimatedParallelGroups`\n\nthat actually have implicit dependencies. Always validate that the dependency graph is consistent before execution.\n\n**Failure modes are different.** In a single agent, failure is obvious — no output. In a multi-agent system, one task can fail silently while others succeed, and the synthesis step might paper over the gap with plausible-sounding output. Explicit status tracking and output validation per stage are not optional.\n\n** Promise.race for timeouts doesn't cancel.** The agent's underlying API calls keep running after the timeout fires. For production use, pass\n\n`AbortController`\n\nsignals through to each LLM call.*This article is adapted from Chapter 16 of AI Engineering with TypeScript — A Comprehensive Guide to Building AI Agents at Leanpub or Amazon*", "url": "https://wpnews.pro/news/building-a-multi-agent-system-in-typescript", "canonical_source": "https://dev.to/kristinz/building-a-multi-agent-system-in-typescript-58ki", "published_at": "2026-08-16 01:16:45+00:00", "updated_at": "2026-08-16 01:41:06.603686+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-agents", "developer-tools"], "entities": ["TypeScript"], "alternates": {"html": "https://wpnews.pro/news/building-a-multi-agent-system-in-typescript", "markdown": "https://wpnews.pro/news/building-a-multi-agent-system-in-typescript.md", "text": "https://wpnews.pro/news/building-a-multi-agent-system-in-typescript.txt", "jsonld": "https://wpnews.pro/news/building-a-multi-agent-system-in-typescript.jsonld"}}