Skip to content
AI Full-Stack Wiki

Agent Design Patterns in TypeScript: Chaining to Evaluator-Optimizer

Six proven patterns for LLM systems, with TypeScript code: prompt chaining, routing, parallelization, orchestrator-workers, evaluator-optimizer, and autonomous agents.

Difficulty: Intermediate20 min readUpdated
On this page· The six patterns

Why this matters

Most successful LLM systems are not exotic. They are built from a handful of simple, composable patterns: break a task into steps, route inputs to specialists, run independent work in parallel, delegate subtasks, and check output before returning it. Knowing these patterns lets you pick a design in minutes, explain it to your team, and avoid reaching for a framework when twenty lines of TypeScript would do.

The six patterns#

Hover or tap the steps in each diagram for details. The first five are workflows (code controls the path); the last is an agent (the model does).

Prompt chaining

Prompt chaining patternInput flows through LLM call 1, a code gate that can exit early, LLM call 2, and LLM call 3 to the output.passfailInputLLM call 1outlineGatecode checkExitfails checkLLM call 2draftLLM call 3polishOutput

Tap a step for details.

Prompt chaining. A fixed sequence of LLM calls where each step works on the previous step's output, with programmatic checks in between.
Show text description

Input flows through LLM call 1, a code gate that can exit early, LLM call 2, and LLM call 3 to the output.

Use it when: The task splits cleanly into ordered subtasks and you want each call to be simpler and more accurate.

Example: Write an outline, check it covers required points, draft the article, then polish the tone.

Routing

Routing patternA router LLM classifies the input and sends it to one of three specialized handlers, whose result becomes the output.InputRouterclassifies inputBillingprompt ATechnicalprompt BGeneralsmaller modelOutput

Tap a step for details.

Routing. A first LLM call classifies the input and sends it to the specialized prompt, tool set, or model best suited to it.
Show text description

A router LLM classifies the input and sends it to one of three specialized handlers, whose result becomes the output.

Use it when: Inputs fall into distinct categories that are better handled separately, including routing easy requests to a smaller, cheaper model.

Example: Send billing questions, technical issues, and general questions to different prompts and models.

Parallelization

Parallelization patternThe input fans out to three concurrent LLM calls checking accuracy, tone, and safety; code aggregates the results into the output.InputLLM callaccuracyLLM calltoneLLM callsafetyAggregatein codeOutput

Tap a step for details.

Parallelization. Several LLM calls run at the same time on independent parts of a task, and code aggregates their outputs.
Show text description

The input fans out to three concurrent LLM calls checking accuracy, tone, and safety; code aggregates the results into the output.

Use it when: Subtasks are independent and can run concurrently for speed, or you want several independent checks or votes for confidence.

Example: Run accuracy, tone, and safety checks on a draft in parallel, then combine the verdicts.

Orchestrator-workers

Orchestrator-workers patternAn orchestrator LLM plans subtasks and dispatches them to worker LLM calls; a synthesizer LLM combines their results into the output.InputOrchestratorplans subtasksWorkersubtask 1Workersubtask 2Workersubtask nSynthesizercombines resultsOutput

Tap a step for details.

Orchestrator-workers. An orchestrator LLM breaks the task into subtasks at runtime, delegates them to worker LLM calls, and a synthesizer combines the results.
Show text description

An orchestrator LLM plans subtasks and dispatches them to worker LLM calls; a synthesizer LLM combines their results into the output.

Use it when: You cannot predict the subtasks in advance, for example how many files a code change touches or which sources a question needs.

Example: Plan which files to edit for a feature, have a worker propose each file's change, then merge them into one patch.

Evaluator-optimizer

Evaluator-optimizer patternA generator LLM writes a draft; an evaluator LLM scores it and either sends feedback back to the generator or accepts it as the output.draftfeedbackacceptedInputGeneratorwrites a draftEvaluatorscores vs criteriaOutput

Tap a step for details.

Evaluator-optimizer. One LLM call generates a result while another evaluates it against clear criteria and sends feedback, looping until it passes.
Show text description

A generator LLM writes a draft; an evaluator LLM scores it and either sends feedback back to the generator or accepts it as the output.

Use it when: You have clear evaluation criteria and iterative refinement measurably improves the result.

Example: Translate a document, have an evaluator flag mistranslated terms, and revise until no issues remain or a round limit is hit.

Autonomous agent

Autonomous agent patternA human gives the LLM agent a task; the agent repeatedly takes actions in its environment and reads the feedback, checks in with the human when needed, and stops when done.taskcheck-insactionfeedbackHumanLLM agentdecides next stepEnvironmenttools, APIs, filesStop

Tap a step for details.

Autonomous agent. An LLM decides its own next step in a loop: it calls tools, observes the results, and continues until the goal is met or a limit is reached.
Show text description

A human gives the LLM agent a task; the agent repeatedly takes actions in its environment and reads the feedback, checks in with the human when needed, and stops when done.

Use it when: The task is open-ended, the number of steps cannot be predicted, and you can afford the cost and verify the outcome.

Example: Fix a failing test: read the code, run the tests, edit files, re-run, and stop when the suite passes.

All examples below share two small helpers: one for a text answer and one for a typed, structured answer.

lib/llm.ts
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import type { z } from "zod";
 
export const client = new Anthropic();
 
type Options = { system?: string; model?: Anthropic.Model; effort?: "low" | "medium" | "high" };
 
export async function ask(prompt: string, o: Options = {}): Promise<string> {
  const message = await client.messages.create({
    model: o.model ?? "claude-opus-5-5",
    max_tokens: 8192,
    ...(o.effort ? { output_config: { effort: o.effort } } : {}),
    ...(o.system ? { system: o.system } : {}),
    messages: [{ role: "user", content: prompt }],
  });
  return message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
}
 
export async function askJson<T extends z.ZodType>(schema: T, prompt: string, o: Options = {}): Promise<z.infer<T>> {
  const message = await client.messages.parse({
    model: o.model ?? "claude-opus-5-5",
    max_tokens: 8192,
    output_config: { effort: o.effort ?? "low", format: zodOutputFormat(schema) },
    ...(o.system ? { system: o.system } : {}),
    messages: [{ role: "user", content: prompt }],
  });
  if (message.parsed_output == null) throw new Error(`No structured output (stop_reason: ${message.stop_reason})`);
  return message.parsed_output;
}

1. Prompt chaining#

Split a task into a fixed sequence of steps, each working on the previous step's output, with code checks (gates) between them. Each call does one narrow thing, which makes it easier to get right and to test.

chaining.ts
import { z } from "zod";
import { ask, askJson } from "./lib/llm.ts";
 
const Outline = z.object({ title: z.string(), sections: z.array(z.string()) });
 
export async function writeReleaseNotes(changelog: string): Promise<string> {
  // Step 1: outline as structured data.
  const outline = await askJson(Outline, `Outline customer-facing release notes for this changelog:\n<changelog>\n${changelog}\n</changelog>`);
 
  // Gate: plain code decides whether to continue.
  if (outline.sections.length < 1 || outline.sections.length > 6) {
    throw new Error(`Outline has ${outline.sections.length} sections; expected 1 to 6`);
  }
 
  // Step 2: draft from the validated outline only.
  const draft = await ask(`Write release notes titled "${outline.title}" with sections: ${outline.sections.join(", ")}.\nSource:\n${changelog}`);
 
  // Step 3: a narrow polishing task, so a faster model is enough.
  return ask(`Edit for clarity and a friendly tone. Keep all facts. Return only the edited text.\n\n${draft}`, {
    model: "claude-sonnet-5-5",
    effort: "low",
  });
}
 
console.log(await writeReleaseNotes("- feat: CSV export for invoices\n- fix: timezone bug in reports\n- perf: dashboard loads 2x faster"));

Use it when a task splits cleanly into fixed steps. Watch out for chains where a step could not be checked: add a gate wherever bad output would waste the remaining calls.

2. Routing#

Classify the input, then send it to a specialized handler: its own prompt, tools, or model. Each handler stays simple, and easy inputs go to cheaper models.

routing.ts
import { z } from "zod";
import { ask, askJson } from "./lib/llm.ts";
 
const Route = z.object({ route: z.enum(["refund", "technical", "general"]) });
 
const handlers: Record<z.infer<typeof Route>["route"], (q: string) => Promise<string>> = {
  refund: (q) => ask(q, { system: "You handle refund requests. Explain the refund policy: full refunds within 30 days." }),
  technical: (q) => ask(q, { system: "You are a senior support engineer. Ask for logs when needed and give exact steps." }),
  general: (q) => ask(q, { system: "You answer general product questions briefly.", model: "claude-haiku-4-5" }),
};
 
export async function handle(question: string): Promise<string> {
  // The router is a cheap, fast call that only classifies.
  const { route } = await askJson(Route, `Route this customer message:\n<message>\n${question}\n</message>`, {
    model: "claude-sonnet-5-5",
  });
  console.log("route:", route);
  return handlers[route](question);
}
 
console.log(await handle("The app crashes when I upload a PDF larger than 10 MB."));

Use it when inputs fall into distinct categories that need different handling. Watch out for overlapping categories; give the router clear definitions and a fallback route.

3. Parallelization#

Run independent LLM calls at the same time and combine the results. Two forms: sectioning (split a task into independent parts) and voting (run the same task several times and aggregate, for higher confidence).

parallel.ts
import { z } from "zod";
import { askJson } from "./lib/llm.ts";
 
const Finding = z.object({ issues: z.array(z.string()), severity: z.enum(["none", "low", "high"]) });
 
// Sectioning: each reviewer checks one aspect, all at once.
export async function reviewCode(diff: string) {
  const aspects = ["security vulnerabilities", "performance problems", "missing error handling"];
  const results = await Promise.all(
    aspects.map((aspect) => askJson(Finding, `Review this diff only for ${aspect}.\n<diff>\n${diff}\n</diff>`)),
  );
  return aspects.map((aspect, i) => ({ aspect, ...results[i]! }));
}
 
// Voting: three independent judgments; flag only when a majority agrees.
const Verdict = z.object({ isSpam: z.boolean() });
export async function isSpam(text: string): Promise<boolean> {
  const votes = await Promise.all([1, 2, 3].map(() => askJson(Verdict, `Is this message spam?\n<message>\n${text}\n</message>`)));
  return votes.filter((v) => v.isSpam).length >= 2;
}
 
console.log(await reviewCode(`+ const user = await db.query("SELECT * FROM users WHERE id = " + req.params.id);`));
console.log(await isSpam("Congratulations! You won a $500 gift card. Click here to claim."));

Use it when subtasks are independent (latency drops to the slowest call) or when you need more confidence than one call gives. Watch out for cost: parallel calls multiply tokens.

4. Orchestrator-workers#

An orchestrator model decides at run time how to split the task, workers handle the pieces, and a final step combines them. Unlike sectioning, the subtasks are not known in advance.

orchestrator.ts
import { z } from "zod";
import { ask, askJson } from "./lib/llm.ts";
 
const Plan = z.object({
  subtasks: z.array(z.object({ file: z.string(), instructions: z.string() })).describe("At most 5 subtasks"),
});
 
export async function planAndExecute(request: string, files: Record<string, string>) {
  // The orchestrator plans with knowledge of the codebase.
  const plan = await askJson(
    Plan,
    `Request: ${request}\nFiles: ${Object.keys(files).join(", ")}\nPlan one subtask per file that must change.`,
    { effort: "medium" },
  );
 
  // Workers run in parallel, each seeing only its own file.
  const changes = await Promise.all(
    plan.subtasks.slice(0, 5).map(async (t) => ({
      file: t.file,
      code: await ask(`${t.instructions}\nReturn only the full updated file.\n<file path="${t.file}">\n${files[t.file] ?? ""}\n</file>`),
    })),
  );
 
  // Synthesis: check the pieces fit together.
  const summary = await ask(
    `Summarize these changes for a pull request description and flag any inconsistencies between files:\n${changes
      .map((c) => `<file path="${c.file}">\n${c.code}\n</file>`)
      .join("\n")}`,
  );
  return { changes, summary };
}
 
const result = await planAndExecute("Rename the 'qty' field to 'quantity' everywhere", {
  "src/cart.ts": "export type Item = { id: string; qty: number };",
  "src/checkout.ts": "export const total = (items: { qty: number; price: number }[]) => items.reduce((s, i) => s + i.qty * i.price, 0);",
});
console.log(result.summary);

Use it when a task needs several pieces of work you cannot predict up front. Watch out for plans that are too large; cap the number of subtasks and validate file names against what exists.

5. Evaluator-optimizer#

One call generates, another evaluates against clear criteria, and the generator revises using the feedback, until the evaluator approves or a round limit is reached.

evaluator.ts
import { z } from "zod";
import { ask, askJson } from "./lib/llm.ts";
 
const Review = z.object({
  approved: z.boolean(),
  problems: z.array(z.string()).describe("Specific, actionable problems; empty if approved"),
});
 
export async function writeWithReview(task: string, criteria: string[], maxRounds = 3) {
  let draft = await ask(task);
  for (let round = 1; round <= maxRounds; round++) {
    const review = await askJson(
      Review,
      `Evaluate the draft against every criterion.\nCriteria:\n- ${criteria.join("\n- ")}\n<draft>\n${draft}\n</draft>`,
      { system: "You are a strict reviewer. Approve only if every criterion is fully met." },
    );
    if (review.approved) return { draft, rounds: round };
    draft = await ask(`${task}\n\nRevise this draft to fix these problems:\n- ${review.problems.join("\n- ")}\n<draft>\n${draft}\n</draft>\nReturn only the revised text.`);
  }
  return { draft, rounds: maxRounds, approved: false };
}
 
console.log(
  await writeWithReview("Write a 3-sentence error message shown when a payment fails.", [
    "Says what happened in plain language",
    "Tells the user exactly what to do next",
    "Does not blame the user",
    "Mentions no internal error codes",
  ]),
);

Use it when you can state clear quality criteria and revision measurably helps, as in writing, translation, or code that must pass tests. Watch out for vague criteria, which make the evaluator approve everything or nothing. Where possible, use a deterministic check (tests, a linter, a schema) instead of a model as the evaluator.

6. Autonomous agent#

The model works in a loop with tools, deciding each step from the results of the last, until the task is done or a limit is reached. It is the most flexible pattern and the least predictable. The agent loop article builds one properly, with limits, events, and error handling.

Choosing and combining#

If the task…Start with
Has fixed stepsPrompt chaining
Comes in distinct kindsRouting
Splits into independent parts, or needs confidenceParallelization
Needs a plan you cannot write in advanceOrchestrator-workers
Has clear quality criteria and benefits from revisionEvaluator-optimizer
Has open-ended steps that depend on what is discoveredAutonomous agent

Patterns compose: a router can send hard cases to an orchestrator; an agent's final answer can go through an evaluator. Build the simplest combination that meets your quality bar, and measure it on real inputs.

Common mistakes#

  • Using a framework before you understand the pattern. Each pattern here is a few dozen lines. Frameworks help later; they hide what is happening early.
  • Gates that do nothing. A chain without checks passes bad intermediate output along. Validate between steps.
  • Unbounded loops. Evaluator-optimizer and agents both need a maximum number of rounds.
  • Model-graded everything. When a deterministic check exists (tests, schemas, regexes), use it.

Try it

Combine two patterns

  1. Build a router that sends "write a product description" requests to an evaluator-optimizer, and short questions straight to a single call.
  2. Define four criteria for product descriptions (length, mentions the main benefit, no superlatives such as "best ever", ends with a call to action).
  3. Log how many rounds each description takes over ten requests, and adjust the criteria wording until most pass within two rounds.

Check your understanding

What distinguishes orchestrator-workers from parallel sectioning?
Which pattern fits a support system with billing, technical, and general questions that need different prompts?
What is the most important safeguard in an evaluator-optimizer loop?

Key takeaways

  • Five workflow patterns (chaining, routing, parallelization, orchestrator-workers, evaluator-optimizer) cover most LLM systems; agents cover the rest.
  • Each pattern is a few dozen lines of TypeScript on top of two helpers: a text call and a structured call.
  • Put code gates between steps, cap every loop, and prefer deterministic checks where they exist.
  • Compose patterns, and choose the simplest combination that meets your quality bar.

Next steps#

Finished this article? Track it to see your progress on the roadmap.