{"slug": "beyond-the-agentic-loop-in-typescript-building-a-shopping-agent-with-the-pattern", "title": "Beyond the Agentic Loop, in TypeScript: building a shopping agent with the Orchestrator pattern", "summary": "A developer built a shopping agent in TypeScript using the Orchestrator pattern, which separates decision-making from execution with exactly two LLM calls per request. The system routes queries to single-purpose agents via a registry, avoiding the latency and unpredictability of agentic loops.", "body_md": "This post is a TypeScript implementation of the pattern described in [ \"Beyond the Agentic Loop: The Orchestrator Pattern for Multi-Agent Systems\"](https://stackademic.com/blog/beyond-the-agentic-loop-the-orchestrator-pattern-for-multi-agent-systems) by\n\nBefore the pattern, the scene. The demo is a small storefront assistant backed by a\n\nfew single-purpose agents:\n\nA customer request might need just one of these, several of them at once, or a few in a\n\nstrict order — and deciding *which* of those shapes a request calls for is exactly what\n\nthe orchestrator is for.\n\n`while`\n\nloop\nThe default way to build a multi-agent system is the **agentic loop**: you hand the\n\nmodel a bag of tools and let it drive.\n\n```\nthink → call a tool → observe the result → think again → call another tool → …\n```\n\nThe LLM is both the brain *and* the control flow. That's wonderfully flexible, and\n\nit's the right tool when the task is open-ended and you genuinely don't know the\n\nsteps in advance. But in production it has three nasty properties:\n\nIf you already know which agents exist and what they do, an open-ended reasoning loop\n\non every request is more freedom than the job needs.\n\nThe orchestrator's move is to **separate the decision from the execution**. Instead\n\nof letting the model loop, you make exactly **two** LLM calls with plain,\n\ndeterministic code in between:\n\n```\nquery ──▶ [ROUTE: LLM #1] ──▶ [EXECUTE: agents, no LLM] ──▶ [SYNTHESIZE: LLM #2] ──▶ answer\n```\n\nTwo calls, every time, no matter how many agents run. That fixed shape is the whole\n\npoint: a plan you can inspect before anything happens, latency that doesn't depend on\n\nthe model's mood, and independent work you can fan out. (It's cheaper too — the article\n\nputs the same query at ~2 calls instead of ~7 — but the cost isn't the headline; the\n\n*outcomes* are.)\n\nAn agent is a name, a description (for the router), a JSON-Schema for its arguments,\n\nand an `execute`\n\nfunction. Nothing more.\n\n```\n// src/server/orchestrator/types.ts\nexport type ExecuteFn = (args: AgentArgs, context: AgentContext) => Promise<AgentResult>;\n\nexport interface AgentDefinition {\n  agent: string;        // human name, e.g. \"Catalog Agent\"\n  description: string;  // shown to the router LLM so it can choose this tool\n  parameters: Record<string, unknown>; // JSON Schema for the args\n  execute: ExecuteFn;\n}\n```\n\nThe \"registry\" is a plain in-process object — agents are **registered by hand**.\n\nThere's deliberately no Redis, no database, no HTTP self-registration. That keeps the\n\nwhole thing runnable and testable with zero infrastructure.\n\n``` js\n// src/server/orchestrator/registry.ts\nexport const REGISTRY: Record<string, AgentDefinition> = {\n  catalog_agent__list_categories: catalogCategoriesAgent,\n  catalog_agent__search_products: catalogAgent,\n  inventory_agent__check_stock: inventoryAgent,\n  pricing_agent__get_deals: pricingAgent,\n  reviews_agent__get_reviews: reviewsAgent,\n  order_agent__place_order: orderAgent,\n};\n```\n\n`toolDefinitions()`\n\nprojects that map into the OpenAI tool format the router sees —\n\neach agent becomes one function tool, plus one **meta-tool** we'll meet shortly.\n\nThe router is given a blunt system prompt: *pick tools, do not answer.*\n\n``` js\n// src/server/orchestrator/router.ts\nconst SYSTEM_PROMPT = `You are a query router. Your ONLY job is to decide which tool(s) to call.\nRules:\n- If the query needs ONE agent, call that one tool.\n- If the query needs MULTIPLE INDEPENDENT agents, call all of them.\n- If the query needs steps IN ORDER (a later step depends on an earlier one), call plan_execution and provide the ordered steps.\nDo NOT answer the user's question — just pick tools.`;\n```\n\nWe call the model at `temperature: 0`\n\nwith `tool_choice: \"auto\"`\n\n, then read its tool\n\ncalls back out. The shape of that tool-call list *is* the execution plan — we never\n\nask the model to \"answer,\" only to choose:\n\n```\n// src/server/orchestrator/router.ts\nexport async function route(query: string): Promise<RouteDecision> {\n  const response = await getOpenAIClient().chat.completions.create({\n    model: getConfig().ROUTER_MODEL,\n    temperature: 0,\n    tools: toolDefinitions(),\n    tool_choice: \"auto\",\n    messages: [\n      { role: \"system\", content: SYSTEM_PROMPT },\n      { role: \"user\", content: query },\n    ],\n  });\n\n  const toolCalls = response.choices[0]?.message.tool_calls ?? [];\n\n  // plan_execution present -> sequential. Take its ordered steps.\n  const planCall = toolCalls.find((c) => c.function.name === PLAN_EXECUTION_TOOL);\n  if (planCall) {\n    const parsed = safeParseArgs(planCall.function.arguments) as {\n      steps?: Array<{ tool: string; args?: AgentArgs; reason?: string }>;\n    };\n    const steps = (parsed.steps ?? []).map((s) => ({ tool: s.tool, args: s.args ?? {}, reason: s.reason }));\n    return { mode: \"sequential\", steps };\n  }\n\n  const steps = toolCalls.map((c) => ({ tool: c.function.name, args: safeParseArgs(c.function.arguments) }));\n  return { mode: steps.length > 1 ? \"parallel\" : \"single\", steps };\n}\n```\n\nSo the router collapses to three outcomes:\n\n`single`\n\n`parallel`\n\n`plan_execution`\n\n`sequential`\n\nThis is where parallel and sequential actually diverge — and it's pure TypeScript,\n\nno model involved.\n\n```\n// src/server/orchestrator/executor.ts\nexport async function* executeStream(mode: Mode, steps: PlanStep[]): AsyncGenerator<ExecEvent, AgentContext> {\n  const results: AgentContext = {};\n\n  if (mode === \"parallel\") {\n    for (const step of steps) yield { kind: \"agent_start\", tool: step.tool, args: step.args };\n    const settled = await Promise.all(\n      steps.map(async (step) => [step.tool, await runAgent(step, {})] as const),\n    );\n    for (const [tool, result] of settled) {\n      results[tool] = result;\n      yield { kind: \"agent_result\", tool, result };\n    }\n    return results;\n  }\n\n  // single + sequential: ordered; each step sees prior results as context.\n  for (const step of steps) {\n    yield { kind: \"agent_start\", tool: step.tool, args: step.args };\n    const result = await runAgent(step, results);\n    results[step.tool] = result;\n    yield { kind: \"agent_result\", tool: step.tool, result };\n  }\n  return results;\n}\n```\n\nRead the two branches side by side:\n\n`Promise.all`\n\n. The agents are independent, so they all fire at\nonce and you pay for the slowest one, not the sum. `for`\n\nloop where each step receives the accumulated\n`results`\n\nas its `context`\n\n. That's how a later agent consumes an earlier one's\noutput. (The generator `yield`\n\ns a small event before and after each agent. That's only so a\n\ntransport can show progress; it doesn't change the logic.)\n\n`plan_execution`\n\n: a signal, not an agent\nHow does the router say \"do these in order\"? With a meta-tool that runs no code:\n\n``` js\n// src/server/orchestrator/registry.ts\nexport const PLAN_EXECUTION_TOOL = \"plan_execution\";\n// ...its tool schema asks for { reason, steps: [{ tool, args, reason }] }\n```\n\nWhen the router selects `plan_execution`\n\n, the orchestrator switches to sequential\n\nmode. The original article treats it purely as a *signal* and leaves the ordering and\n\ndata-passing unspecified. This repo makes one deliberate addition so the demo\n\nactually works end-to-end: ** plan_execution returns the ordered steps**, and the\n\n`results`\n\nforward as context. The order agent then resolves the`resolveTargetProduct`\n\nin`src/server/lib/resolve-product.ts`\n\n). That's the difference between a pattern diagramOnce the agents have produced structured data, a second LLM call turns it into an\n\nanswer. This is the only step with any \"writing\" to do, so it runs warmer and streams\n\nits tokens out.\n\n```\n// src/server/orchestrator/synthesizer.ts\nexport async function* synthesizeStream(query: string, results: AgentContext): AsyncGenerator<string> {\n  const stream = await getOpenAIClient().chat.completions.create({\n    model: getConfig().SYNTH_MODEL,\n    temperature: 0.7,\n    stream: true,\n    messages: [\n      { role: \"system\", content: \"Summarize the agent results into a clear, helpful answer.\" },\n      { role: \"user\", content: `User asked: ${query}\\nResults: ${JSON.stringify(results)}` },\n    ],\n  });\n  for await (const chunk of stream) {\n    const delta = chunk.choices[0]?.delta?.content;\n    if (delta) yield delta;\n  }\n}\n```\n\nPutting the three phases together, the payoff is exactly the inverse of the loop's\n\npain points — and these *enablements*, not the price tag, are the real reason to reach\n\nfor it:\n\n`RouteDecision`\n\n— produced `Promise.all`\n\n; you didn't have to\nteach the model to be concurrent.`executeStream`\n\nis\nan ordinary async function you can unit-test with a stub registry — no API key, no\nflakiness.| Query | Mode | Agents |\n|---|---|---|\n`what do you have?` |\nsingle | `catalog_agent__list_categories` |\n`what's the price, rating and availability of the iPhone 15?` |\nparallel |\n`pricing` + `reviews` + `inventory` (at once) |\n`find a laptop under $1000, make sure it's in stock, then order it` |\nsequential |\n`search` → `check stock` → `order`\n|\n\nSame agents, same data — the router decides the **shape** of the run.\n\nThis isn't \"orchestrator good, loop bad.\" The agentic loop is the right tool when the\n\ntask is genuinely exploratory: you don't know the steps ahead of time, the toolset is\n\nopen-ended, or the agent needs to re-plan mid-flight based on what it discovers. The\n\norchestrator trades that adaptability for predictability — and it assumes you can\n\nenumerate your agents up front. Note too that the router here is itself a single LLM\n\ncall, so a truly novel multi-hop plan it has never seen is out of scope by design.\n\nThe article's framing is the one to keep: **loop for exploration, orchestrator for\nproduction.** If you already know your agents and you need bounded latency, parallel\n\nPlease feel free to reach out on twitter [@roamingcode](https://twitter.com/roamingcode)", "url": "https://wpnews.pro/news/beyond-the-agentic-loop-in-typescript-building-a-shopping-agent-with-the-pattern", "canonical_source": "https://dev.to/stormhub/beyond-the-agentic-loop-in-typescript-building-a-shopping-agent-with-the-orchestrator-pattern-7ka", "published_at": "2026-06-17 00:26:20+00:00", "updated_at": "2026-06-17 00:51:27.254817+00:00", "lang": "en", "topics": ["large-language-models", "ai-agents", "developer-tools"], "entities": ["OpenAI", "TypeScript"], "alternates": {"html": "https://wpnews.pro/news/beyond-the-agentic-loop-in-typescript-building-a-shopping-agent-with-the-pattern", "markdown": "https://wpnews.pro/news/beyond-the-agentic-loop-in-typescript-building-a-shopping-agent-with-the-pattern.md", "text": "https://wpnews.pro/news/beyond-the-agentic-loop-in-typescript-building-a-shopping-agent-with-the-pattern.txt", "jsonld": "https://wpnews.pro/news/beyond-the-agentic-loop-in-typescript-building-a-shopping-agent-with-the-pattern.jsonld"}}