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.
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
Tap a step for details.
Show text descriptionHide 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
Tap a step for details.
Show text descriptionHide 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
Tap a step for details.
Show text descriptionHide 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
Tap a step for details.
Show text descriptionHide 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
Tap a step for details.
Show text descriptionHide 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
Tap a step for details.
Show text descriptionHide 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.
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.
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.
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).
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.
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.
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 steps | Prompt chaining |
| Comes in distinct kinds | Routing |
| Splits into independent parts, or needs confidence | Parallelization |
| Needs a plan you cannot write in advance | Orchestrator-workers |
| Has clear quality criteria and benefits from revision | Evaluator-optimizer |
| Has open-ended steps that depend on what is discovered | Autonomous 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
- Build a router that sends "write a product description" requests to an evaluator-optimizer, and short questions straight to a single call.
- Define four criteria for product descriptions (length, mentions the main benefit, no superlatives such as "best ever", ends with a call to action).
- Log how many rounds each description takes over ten requests, and adjust the criteria wording until most pass within two rounds.
Check your understanding
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.
Related articles
- Workflows vs Agents: When an LLM App Needs AutonomyLearn the difference between LLM workflows and agents, when each is the right choice, and how to build the same feature both ways in TypeScript to compare them.
- Build an AI Agent Loop from Scratch in TypeScriptWrite a production-ready agent loop in TypeScript: Zod-typed tools, turn, token, and time limits, parallel tool calls, error handling, events for your UI, and unit tests.
- Multi-Agent Systems: Orchestrators, Subagents, and When to Use ThemBuild an orchestrator that delegates to parallel subagents in TypeScript, write good briefs, control token costs, and learn when multiple agents beat a single one.