Skip to content
AI Full-Stack Wiki

Tutorial: Build an Incident Postmortem Writer with Agent Patterns

Combine routing, parallel sections, deterministic checks, and an evaluator-optimizer loop into a postmortem writer with a per-call cost trace, in TypeScript.

Difficulty: Intermediate24 min readUpdated
On this page· What you will build

Why this matters

Agent design patterns presents the patterns one at a time. Real systems combine them, and each one you add costs calls, tokens, and latency. This tutorial builds a tool that turns raw incident notes into a blameless postmortem using three patterns together: routing picks the depth and the model from the severity, parallelization writes the sections at the same time, and an evaluator-optimizer loop checks the result and revises only what failed. A trace prices every call, so you can see what each pattern costs.

What you will build#

$ npm start -- data/incidents/checkout-outage.md
route: sev1 -> claude-opus-5-5, sections: summary, timeline, root_cause, action_items
round 1 (checks): root_cause: Names a person (Bob). Describe the system or decision instead.
round 2 (reviewer): root_cause: It says what happened but not why: name the missing safeguard ...
round 3 (reviewer): approved
  • A router that classifies severity with the smallest model and picks a plan: which sections, which model, how much effort.
  • Section writers that run in parallel, with action items as structured output.
  • Deterministic checks for what code can verify: no names in a blameless narrative, no invented timestamps, due dates in range.
  • A model reviewer for what code cannot judge, and a bounded loop that rewrites only the failing sections.
  • A cost trace with the step, model, tokens, time, and price of every call.
Postmortem writerA small model classifies the incident's severity, which picks the sections and the writing model. The sections are written in parallel. Deterministic checks run first, then a model reviewer; only the failing sections are revised, at most twice, and every call is recorded in a cost trace.planapprovedIncident noteslog + chatRouterHaiku: severitySection writersin parallelCheck + reviewmax 2 revisionsPostmortem+ cost trace

Hover or tap a component to see what it does.

Postmortem writer: routing picks the plan, sections are written in parallel, and checks plus a reviewer send only failing sections back for revision.
Show text description

Three patterns in one pipeline: routing, parallelization, and an evaluator-optimizer loop.

  • Incident notes: A log, a chat excerpt that names people, and follow-up ideas.
  • Router: Classifies sev1 to sev3; the plan for each severity (sections, model, effort) lives in code.
  • Section writers: Summary, timeline, root cause, and structured action items, written at the same time.
  • Check + review: Code checks names, times, and dates for free; a model reviews only what code cannot judge.
  • Postmortem: Markdown, marked approved or not, with every call's model, tokens, time, and price.

You need Node.js 22.18 or later and an Anthropic API key. One sev1 postmortem costs a few cents.

1. Set up the project#

mkdir postmortem && cd postmortem
npm init -y
npm pkg set type=module
npm install @anthropic-ai/sdk zod
npm install -D typescript @types/node
mkdir -p src test data/incidents
npm pkg set scripts.start="node --env-file=.env src/cli.ts" scripts.test="node --test" scripts.typecheck="tsc"

Copy the tsconfig.json from Foundations Tutorial 1 and add your key to .env.

2. Incident notes#

Real notes are messy: a log, a chat excerpt, and some follow-up ideas. Note that the chat names people. A blameless postmortem must not.

data/incidents/checkout-outage.md
# Checkout unavailable
Date: 2026-10-14
Customer impact: all customers could not check out for 38 minutes; about 1,900 orders failed.
 
## Log
14:02 deploy of payments-api v412 started
14:05 alert: checkout error rate above 50%
14:09 on-call paged, incident channel opened
14:21 database connection pool for payments-api at 0 connections
14:31 config rollback started
14:40 error rate back under 1%
14:43 incident resolved
 
## Chat
Bob: I merged the config change that set DB_POOL_SIZE from the template, it was empty in prod
Ana: the template default is 0, nothing validates it
Bob: rolling back now
Ana: we should fail the deploy if a required setting is missing
 
## Follow-ups
- Validate required config values before a deploy (platform)
- Add an alert on connection pool size (payments)
- Document the config template defaults (platform)

Write two more in the same format so the router has something to choose between: a degraded search (some users slow for two hours, sev2) and an expired staging certificate (no customer impact, sev3).

3. Calls with a cost trace#

Every pattern in this tutorial goes through two helpers, ask and askJson, as in the article. This version also records each call, so the cost of every design decision is visible.

src/llm.ts
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import type { z } from "zod";
 
export type Model = "claude-opus-5-5" | "claude-sonnet-5-5" | "claude-haiku-5-5";
 
// Dollars per million tokens, [input, output]. Haiku's price is for prompts up to 100K tokens.
const PRICES: Record<Model, [number, number]> = {
  "claude-opus-5-5": [4, 20],
  "claude-sonnet-5-5": [2, 10],
  "claude-haiku-5-5": [0.1, 0.5],
};
 
export interface CallOptions {
  model: Model;
  system: string;
  effort?: "low" | "medium" | "high";
}
 
export interface TraceEntry {
  step: string;
  model: Model;
  inputTokens: number;
  outputTokens: number;
  ms: number;
  usd: number;
}
 
/** ask/askJson helpers that record every call (step, model, tokens, time, cost) in one trace. */
export function createLLM(client = new Anthropic()) {
  const trace: TraceEntry[] = [];
 
  async function record<T extends Anthropic.Message>(step: string, o: CallOptions, call: () => Promise<T>): Promise<T> {
    const started = performance.now();
    const message = await call();
    const [inPrice, outPrice] = PRICES[o.model];
    const { input_tokens, output_tokens } = message.usage;
    trace.push({
      step,
      model: o.model,
      inputTokens: input_tokens,
      outputTokens: output_tokens,
      ms: performance.now() - started,
      usd: (input_tokens * inPrice + output_tokens * outPrice) / 1_000_000,
    });
    return message;
  }
 
  async function ask(step: string, prompt: string, o: CallOptions): Promise<string> {
    const message = await record(step, o, () =>
      client.messages.create({
        model: o.model,
        max_tokens: 8192,
        output_config: { effort: o.effort ?? "low" },
        system: o.system,
        messages: [{ role: "user", content: prompt }],
      }),
    );
    if (message.stop_reason !== "end_turn") throw new Error(`${step}: stopped with ${message.stop_reason}`);
    return message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("").trim();
  }
 
  async function askJson<T extends z.ZodType>(step: string, schema: T, prompt: string, o: CallOptions): Promise<z.infer<T>> {
    const message = await record(step, o, () =>
      client.messages.parse({
        model: o.model,
        max_tokens: 8192,
        output_config: { effort: o.effort ?? "low", format: zodOutputFormat(schema) },
        system: o.system,
        messages: [{ role: "user", content: prompt }],
      }),
    );
    if (message.parsed_output == null) throw new Error(`${step}: no structured output (stop_reason: ${message.stop_reason})`);
    return message.parsed_output;
  }
 
  return { ask, askJson, trace };
}
export type LLM = ReturnType<typeof createLLM>;

output_tokens includes the model's thinking, which is billed as output, so the trace's cost is the real cost. If you add prompt caching, price cache reads and writes too.

4. Routing: severity picks the plan#

src/route.ts
import { z } from "zod";
import type { LLM, Model } from "./llm.ts";
 
export const SECTIONS = ["summary", "timeline", "root_cause", "action_items"] as const;
export type SectionId = (typeof SECTIONS)[number];
 
const Severity = z.object({
  severity: z.enum(["sev1", "sev2", "sev3"]),
  reason: z.string(),
});
type Severity = z.infer<typeof Severity>["severity"];
 
export interface Plan {
  severity: Severity;
  sections: SectionId[];
  model: Model;
  effort: "low" | "medium";
}
 
// Routing: the severity decides how much to write and which model writes it.
const PLANS: Record<Severity, Omit<Plan, "severity">> = {
  sev1: { sections: [...SECTIONS], model: "claude-opus-5-5", effort: "medium" },
  sev2: { sections: [...SECTIONS], model: "claude-sonnet-5-5", effort: "low" },
  sev3: { sections: ["summary", "action_items"], model: "claude-sonnet-5-5", effort: "low" },
};
 
export async function route(llm: LLM, incident: string): Promise<Plan> {
  // Classifying is a narrow task: the smallest model does it well, for a fraction of the cost.
  const { severity } = await llm.askJson("route", Severity, `<incident>\n${incident}\n</incident>`, {
    model: "claude-haiku-5-5",
    system:
      "Classify the severity of this incident. sev1: most or all customers blocked from a core flow. " +
      "sev2: customers degraded, or a subset blocked. sev3: no customer impact.",
  });
  return { severity, ...PLANS[severity] };
}

The model only classifies. The plans live in code, where you can review them and change them without touching a prompt. Every category has a definition, which is what keeps a router from guessing between overlapping labels.

5. Section writers#

Each section is an independent task with its own instructions, so the sections can be written at the same time. Action items are structured, because code will check them.

src/sections.ts
import { z } from "zod";
import type { LLM } from "./llm.ts";
import type { Plan, SectionId } from "./route.ts";
 
export const ActionItems = z.object({
  items: z.array(
    z.object({
      action: z.string(),
      owner: z.string().describe("The owning team, such as platform"),
      due: z.iso.date().describe("Due date, YYYY-MM-DD"),
    }),
  ),
});
export type ActionItem = z.infer<typeof ActionItems>["items"][number];
 
type TextSection = Exclude<SectionId, "action_items">;
export type Draft = Partial<Record<TextSection, string>> & { action_items?: ActionItem[] };
 
const INSTRUCTIONS: Record<SectionId, string> = {
  summary: "Two or three sentences: what happened, for how long, and the customer impact in numbers.",
  timeline: "A bullet list of the key events. Start each bullet with its time exactly as written in the log (HH:MM). Use only times from the log.",
  root_cause:
    "One paragraph explaining why it happened: the technical cause and the missing safeguard that let it through. " +
    "Blameless: describe systems and decisions, never name a person.",
  action_items: "Concrete follow-ups that prevent a recurrence or detect it sooner. Each has an owning team and a due date within 30 days of the incident.",
};
 
const system = (id: SectionId) =>
  `You write one section of a blameless incident postmortem. Section: ${id}. Use only facts from the incident notes. Return only the section content, without a heading.`;
 
export function renderSection(id: SectionId, draft: Draft): string {
  if (id === "action_items") return (draft.action_items ?? []).map((i) => `- ${i.action} (owner: ${i.owner}, due ${i.due})`).join("\n");
  return draft[id] ?? "";
}
 
/** Write one section, or rewrite it to fix listed problems. Returns a partial draft to merge. */
export async function writeSection(llm: LLM, plan: Plan, id: SectionId, incident: string, fix?: { problems: string[]; previous: Draft }): Promise<Draft> {
  let prompt = `<incident>\n${incident}\n</incident>\n\nWrite the ${id} section. ${INSTRUCTIONS[id]}`;
  if (fix) {
    prompt += `\n\nRevise your previous version to fix these problems:\n- ${fix.problems.join("\n- ")}\n<previous>\n${renderSection(id, fix.previous)}\n</previous>`;
  }
  const options = { model: plan.model, effort: plan.effort, system: system(id) };
  const step = `${fix ? "revise" : "write"}:${id}`;
 
  if (id === "action_items") return { action_items: (await llm.askJson(step, ActionItems, prompt, options)).items };
  const draft: Draft = {};
  draft[id] = await llm.ask(step, prompt, options);
  return draft;
}

The same function writes and revises. A revision sees the original notes, its previous version, and a list of specific problems: enough to fix them without starting over.

6. Deterministic checks first#

Some quality rules are facts code can verify. Check those in code: it is free, instant, and never wrong about what it checks.

src/check.ts
import type { Plan, SectionId } from "./route.ts";
import { type Draft, renderSection } from "./sections.ts";
 
export interface Problem {
  section: SectionId;
  problem: string;
}
 
const DAY = 86_400_000;
 
/** Deterministic checks: free, instant, and never wrong about what they check. */
export function check(draft: Draft, incident: string, plan: Plan): Problem[] {
  const problems: Problem[] = [];
  const chat = /## Chat\n([\s\S]*?)(?:\n## |$)/.exec(incident)?.[1] ?? "";
  const people = [...new Set([...chat.matchAll(/^(\w+):/gm)].map((m) => m[1]!))];
  const logTimes = new Set(incident.match(/\b\d{2}:\d{2}\b/g) ?? []);
  const incidentDate = Date.parse(/^Date:\s*(\S+)/m.exec(incident)?.[1] ?? "");
 
  for (const id of plan.sections) {
    const text = renderSection(id, draft);
    if (!text.trim()) {
      problems.push({ section: id, problem: "The section is empty." });
      continue;
    }
    // Blameless: nobody from the chat may be named in the narrative.
    for (const person of people) {
      if (id !== "timeline" && new RegExp(`\\b${person}\\b`).test(text)) {
        problems.push({ section: id, problem: `Names a person (${person}). Describe the system or decision instead.` });
      }
    }
  }
  for (const time of draft.timeline?.match(/\b\d{2}:\d{2}\b/g) ?? []) {
    if (!logTimes.has(time)) problems.push({ section: "timeline", problem: `The time ${time} does not appear in the log.` });
  }
  for (const item of draft.action_items ?? []) {
    const due = Date.parse(item.due);
    if (!item.owner.trim()) problems.push({ section: "action_items", problem: `"${item.action}" has no owner.` });
    if (!(due > incidentDate && due <= incidentDate + 30 * DAY)) {
      problems.push({ section: "action_items", problem: `"${item.action}" is due ${item.due}; it must be within 30 days after the incident.` });
    }
  }
  return problems;
}

Each problem names its section and says what to do, because it becomes part of the revision prompt.

7. A model reviewer for the rest#

Whether a root cause explains why is a judgment. That part goes to a model, with criteria specific enough to apply.

src/review.ts
import { z } from "zod";
import type { Problem } from "./check.ts";
import type { LLM } from "./llm.ts";
import { type Plan, SECTIONS } from "./route.ts";
import { type Draft, renderSection } from "./sections.ts";
 
const Review = z.object({
  problems: z
    .array(z.object({ section: z.enum(SECTIONS), problem: z.string().describe("Specific and actionable") }))
    .describe("Empty when every criterion is met"),
});
 
// Only what code cannot check. Names, times, and dates are already verified by check().
const CRITERIA = [
  "The summary states the customer impact with numbers.",
  "The root cause explains why it happened, including the missing safeguard, not only what happened.",
  "Each action item would prevent a recurrence or detect it sooner.",
];
 
export async function review(llm: LLM, plan: Plan, incident: string, draft: Draft): Promise<Problem[]> {
  const document = plan.sections.map((id) => `<section id="${id}">\n${renderSection(id, draft)}\n</section>`).join("\n");
  const { problems } = await llm.askJson(
    "review",
    Review,
    `Criteria:\n- ${CRITERIA.join("\n- ")}\n\n<incident>\n${incident}\n</incident>\n\n<postmortem>\n${document}\n</postmortem>`,
    {
      model: "claude-sonnet-5-5",
      system: "You are a strict reviewer of blameless incident postmortems. Report only problems against the criteria. Do not rewrite anything.",
    },
  );
  return problems.filter((p) => plan.sections.includes(p.section)); // ignore comments on sections we did not write
}

The reviewer returns problems tied to sections, not a score. A score tells you a draft is weak; a section and a sentence tell the writer what to fix.

8. Put the patterns together#

src/write.ts
import { check, type Problem } from "./check.ts";
import { createLLM, type LLM } from "./llm.ts";
import { review } from "./review.ts";
import { type Plan, route } from "./route.ts";
import { type Draft, renderSection, writeSection } from "./sections.ts";
 
const HEADINGS = { summary: "Summary", timeline: "Timeline", root_cause: "Root cause", action_items: "Action items" };
 
export interface Round {
  round: number;
  by: "checks" | "reviewer";
  problems: Problem[];
}
 
export async function writePostmortem(incident: string, llm: LLM = createLLM(), maxRevisions = 2) {
  // 1. Routing: one cheap call picks the depth and the model.
  const plan = await route(llm, incident);
 
  // 2. Parallelization (sectioning): every section is written at the same time.
  let draft: Draft = Object.assign({}, ...(await Promise.all(plan.sections.map((id) => writeSection(llm, plan, id, incident)))));
 
  // 3. Evaluator-optimizer: check, then revise only what failed, a bounded number of times.
  const rounds: Round[] = [];
  for (let round = 1; ; round++) {
    // Deterministic checks first; the model reviewer only sees drafts that pass them.
    let by: Round["by"] = "checks";
    let problems = check(draft, incident, plan);
    if (problems.length === 0) {
      by = "reviewer";
      problems = await review(llm, plan, incident, draft);
    }
    rounds.push({ round, by, problems });
    if (problems.length === 0 || round > maxRevisions) {
      return { plan, rounds, approved: problems.length === 0, markdown: render(incident, plan, draft), trace: llm.trace };
    }
    const bySection = Map.groupBy(problems, (p) => p.section);
    const fixes = await Promise.all(
      [...bySection].map(([id, list]) => writeSection(llm, plan, id, incident, { problems: list.map((p) => p.problem), previous: draft })),
    );
    draft = Object.assign({}, draft, ...fixes);
  }
}
 
function render(incident: string, plan: Plan, draft: Draft): string {
  const title = /^#\s+(.+)$/m.exec(incident)?.[1] ?? "Incident";
  const body = plan.sections.map((id) => `## ${HEADINGS[id]}\n\n${renderSection(id, draft)}`).join("\n\n");
  return `# Postmortem: ${title}\n\nSeverity: ${plan.severity}\n\n${body}\n`;
}

Four decisions keep the loop cheap and safe:

  • Checks before the reviewer. A draft that names a person fails for free; there is no point paying a model to read it.
  • Only failing sections are rewritten, in parallel. A good timeline is never put at risk by a fix to the root cause.
  • maxRevisions bounds the loop. The evaluator may never be satisfied; the loop must end anyway.
  • An unapproved draft is returned as unapproved, with its last problems, so a person finishes it. It is not passed off as done.

9. The CLI#

src/cli.ts
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { basename } from "node:path";
import { writePostmortem } from "./write.ts";
 
const file = process.argv[2];
if (!file) {
  console.error("usage: npm start -- <incident.md>");
  process.exit(2);
}
 
const result = await writePostmortem(await readFile(file, "utf8"));
const out = `out/${basename(file)}`;
await mkdir("out", { recursive: true });
await writeFile(out, result.markdown);
 
console.log(`route: ${result.plan.severity} -> ${result.plan.model}, sections: ${result.plan.sections.join(", ")}`);
for (const r of result.rounds) {
  console.log(`round ${r.round} (${r.by}): ${r.problems.length ? r.problems.map((p) => `${p.section}: ${p.problem}`).join(" | ") : "approved"}`);
}
 
console.log(`\n${"step".padEnd(22)}${"model".padEnd(19)}${"in".padStart(7)}${"out".padStart(7)}${"ms".padStart(7)}${"cost".padStart(10)}`);
for (const t of result.trace) {
  console.log(
    `${t.step.padEnd(22)}${t.model.padEnd(19)}${String(t.inputTokens).padStart(7)}${String(t.outputTokens).padStart(7)}` +
      `${Math.round(t.ms).toString().padStart(7)}${`$${t.usd.toFixed(5)}`.padStart(10)}`,
  );
}
const total = result.trace.reduce((s, t) => s + t.usd, 0);
console.log(`${"total".padEnd(55)}${`$${total.toFixed(5)}`.padStart(17)}`);
console.log(`\nwrote ${out}${result.approved ? "" : " (NOT approved: a person must finish it)"}`);

10. Run it#

npm start -- data/incidents/checkout-outage.md
route: sev1 -> claude-opus-5-5, sections: summary, timeline, root_cause, action_items
round 1 (checks): root_cause: Names a person (Bob). Describe the system or decision instead.
round 2 (reviewer): root_cause: It says what happened but not why: name the missing safeguard that let the change through.
round 3 (reviewer): approved
 
step                  model                   in    out     ms      cost
route                 claude-haiku-5-5       293     62    166  $0.00006
write:timeline        claude-opus-5-5        339    202    103  $0.00540
write:summary         claude-opus-5-5        326    152    104  $0.00434
write:root_cause      claude-opus-5-5        351    153    105  $0.00446
write:action_items    claude-opus-5-5        342    200    107  $0.00537
revise:root_cause     claude-opus-5-5        423    150     96  $0.00469
review                claude-sonnet-5-5      603     70     98  $0.00191
revise:root_cause     claude-opus-5-5        428    172    102  $0.00515
review                claude-sonnet-5-5      625     35     99  $0.00160
total                                                           $0.03298
 
wrote out/checkout-outage.md

Read the trace from the top:

  • Routing cost almost nothing. The Haiku call is a fraction of a cent, and it decided which model wrote everything else.
  • The four writes started together, so they finish in about the time of the slowest one. They appear in the order they finished, not the order they started.
  • The checks cost nothing and caught the first problem. Only after they passed did the reviewer, a paid call, look at the draft.
  • Each revision rewrote one section, not the whole document.

Now run the sev3 incident. The router picks the short plan, and the whole postmortem costs roughly a tenth of the sev1 one:

route: sev3 -> claude-sonnet-5-5, sections: summary, action_items
round 1 (reviewer): approved
...
total                                                           $0.00321

11. Test the loop#

The sections run in parallel, so requests arrive in no fixed order. Instead of a queue of answers, the fake client answers each request by looking at it.

test/fake.ts
import Anthropic from "@anthropic-ai/sdk";
 
export interface FakeRequest {
  model: string;
  system: string;
  prompt: string;
}
 
/**
 * A real Anthropic client whose HTTP layer answers each request with `respond(request)`.
 * Answering by content, not by order, keeps tests deterministic when calls run in parallel.
 */
export function fakeClient(respond: (req: FakeRequest) => string) {
  const requests: FakeRequest[] = [];
  const client = new Anthropic({
    apiKey: "test-key",
    maxRetries: 0,
    fetch: async (_url, init) => {
      const body = JSON.parse(String(init?.body)) as { model: string; system: string; messages: { content: string }[] };
      const req = { model: body.model, system: body.system, prompt: body.messages[0]!.content };
      requests.push(req);
      return Response.json({
        id: `msg_${requests.length}`,
        type: "message",
        role: "assistant",
        model: body.model,
        stop_reason: "end_turn",
        stop_sequence: null,
        usage: { input_tokens: 100, output_tokens: 20 },
        content: [{ type: "text", text: respond(req) }],
      });
    },
  });
  return { client, requests };
}
test/postmortem.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { check } from "../src/check.ts";
import { createLLM } from "../src/llm.ts";
import type { Plan } from "../src/route.ts";
import { writePostmortem } from "../src/write.ts";
import { type FakeRequest, fakeClient } from "./fake.ts";
 
const incident = `# Checkout unavailable
Date: 2026-10-14
Customer impact: all customers could not check out for 38 minutes.
 
## Log
14:02 deploy started
14:43 resolved
 
## Chat
Bob: I merged the config change
`;
 
const full: Plan = { severity: "sev1", sections: ["summary", "timeline", "root_cause", "action_items"], model: "claude-opus-5-5", effort: "medium" };
 
test("checks catch names, invented times, bad due dates, and empty sections", () => {
  const problems = check(
    {
      summary: "Checkout failed for 38 minutes.",
      timeline: "- 14:02 deploy started\n- 14:15 alert fired",
      root_cause: "Bob merged a bad config change.",
      action_items: [{ action: "Validate config", owner: "platform", due: "2027-01-01" }],
    },
    incident,
    full,
  );
  assert.deepEqual(
    problems.map((p) => p.section),
    ["root_cause", "timeline", "action_items"],
  );
  assert.match(problems[0]!.problem, /Bob/);
  assert.match(problems[1]!.problem, /14:15/);
  assert.equal(check({}, incident, full).length, 4); // every planned section is missing
});
 
// A scripted "model": answers by section, and fixes the root cause only when asked to revise it.
function respond(rootCause: (revising: boolean) => string) {
  return (req: FakeRequest) => {
    if (req.system.startsWith("Classify")) return '{"severity":"sev1","reason":"all customers blocked"}';
    if (req.system.includes("strict reviewer")) return '{"problems":[]}';
    const section = /Section: (\w+)/.exec(req.system)?.[1];
    if (section === "summary") return "All customers could not check out for 38 minutes.";
    if (section === "timeline") return "- 14:02 deploy started\n- 14:43 resolved";
    if (section === "action_items") return '{"items":[{"action":"Validate config","owner":"platform","due":"2026-10-28"}]}';
    return rootCause(req.prompt.includes("Revise"));
  };
}
 
test("routes, writes sections in parallel, and revises only the failing section", async () => {
  const { client, requests } = fakeClient(respond((revising) => (revising ? "A config change set the pool to 0 because nothing validated it." : "Bob broke it.")));
  const result = await writePostmortem(incident, createLLM(client));
 
  assert.equal(result.approved, true);
  assert.deepEqual(
    result.rounds.map((r) => r.by),
    ["checks", "reviewer"],
  );
  assert.equal(requests[0]!.model, "claude-haiku-5-5"); // the router
  assert.equal(requests.filter((r) => r.prompt.includes("Revise")).length, 1); // only root_cause was rewritten
  assert.match(result.markdown, /## Root cause\n\nA config change/);
  assert.deepEqual(
    result.trace.map((t) => t.step).slice(-2),
    ["revise:root_cause", "review"],
  );
});
 
test("stops after the revision limit and reports the draft as not approved", async () => {
  const { requests, client } = fakeClient(respond(() => "Bob broke it."));
  const result = await writePostmortem(incident, createLLM(client), 2);
  assert.equal(result.approved, false);
  assert.equal(result.rounds.length, 3); // two revisions, then a final check
  assert.equal(requests.filter((r) => r.prompt.includes("Revise")).length, 2);
});
 
test("the trace prices every call by its model", async () => {
  const { client } = fakeClient(respond(() => "A change set the pool to 0 because nothing validated it."));
  const { trace } = await writePostmortem(incident, createLLM(client));
  const route = trace.find((t) => t.step === "route")!;
  assert.equal(route.usd, (100 * 0.1 + 20 * 0.5) / 1_000_000); // Haiku: 100 in, 20 out
  const write = trace.find((t) => t.step === "write:summary")!;
  assert.equal(write.usd, (100 * 4 + 20 * 20) / 1_000_000); // Opus for a sev1
});
npm test   # 4 passing, no API calls

Common mistakes#

  • Asking a model to check what code can check. Names, timestamps, dates, and required fields are facts. Verify them in code, before any paid review.
  • Rewriting the whole document on every round. It costs more and can break sections that were already fine. Revise only what failed.
  • An unbounded evaluator loop. Cap the revisions, and report a draft that never passed as unapproved.
  • Vague review criteria. "Is it good?" gets approval or rejection at random. Name each criterion, and ask for problems tied to a section.
  • Routing to plans hidden in prompts. Keep the plan for each route in code, where it can be reviewed and tested.
  • Not tracing cost per step. Without it you cannot tell which pattern is worth what it costs.

Try it

Extend the postmortem writer

  1. Add a deterministic check that every number in the summary appears in the notes, so the impact cannot be invented.
  2. Use voting for the router: classify three times with Haiku and take the majority. Compare its cost in the trace with one Sonnet call.
  3. Route sev2 to Haiku for the timeline only, since copying log lines is a narrow task, and check whether quality holds.
  4. Add a --max-usd flag that stops revising once the trace's total passes a budget.

Check your understanding

A draft names an engineer in the root cause. Which part of the system should catch it?
The reviewer flags only the root cause. What should the next round do?
Why does the router use the smallest model?

Key takeaways

  • Combine patterns deliberately: routing chooses the plan, parallelization cuts latency, and an evaluator-optimizer loop raises quality.
  • Check facts in code before paying a model to review, and give the reviewer specific criteria and section-level problems.
  • Revise only what failed, bound the number of rounds, and label a draft that never passed as unapproved.
  • Trace every call with its model, tokens, time, and cost, so each pattern's price is visible.

Next steps#

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