AI agent architectures, explained simply

Workflows, agents, multi-agent systems and cross-cutting building blocks: when each AI agent architecture fits best, and when an agent is the wrong answer.

AI agentsBy 8 min read

Many “AI agent” projects don’t need an agent. They need a well-structured workflow, with a call to the model in the right place.

The word agent now covers almost everything, from a chatbot plugged into an FAQ to a system that chains dozens of tool calls without supervision. This confusion often pushes teams to choose the most impressive architecture rather than the best-suited one, and the price is paid in production: unpredictable costs, behaviour that is hard to reproduce, bugs that tests struggle to catch.

From deterministic to autonomous

In a workflow, your code decides the path: the steps are written in advance, and the model steps in at some of them. In an agent, the model decides: it picks the next tool, judges whether it is done, and tries again if needed. Architectures sit along this axis:

  1. Workflows: the flow is coded, the model executes steps.
  2. Agents: a model drives the loop and chooses its actions.
  3. Multi-agent systems: several specialised agents cooperate.

On top of these come cross-cutting building blocks that apply to all of them: RAG, human review, memory, durable execution, guardrails. Each step towards autonomy buys flexibility and pays for it in predictability, cost and testability.

1. Workflows: the code decides the path

A workflow is a classic pipeline in which some steps call the model. The order, the conditions and the retries on error stay in your code. Five forms come up almost every time:

  • Prompt chaining: the output of one step feeds the next, with checks in between (extract, validate, draft). Each call gets a simpler task: a little latency in exchange for accuracy.
  • Routing: a classifier sends the request to the right handler, for example a ticket to billing, technical support or sales.
  • Parallelisation: independent subtasks processed at the same time, or the same question asked several times so the answers can vote.
  • Orchestrator-workers: a model breaks the task down on the fly and hands each piece to a worker, a specialised executor. The breakdown is dynamic, the structure stays fixed.
  • Evaluator-optimiser: one call produces, another evaluates against explicit criteria, until the result passes, in a bounded loop. Useful when the criteria are clear (translation, code subject to security rules, the tone of a letter), pointless when the first answer is good enough.

First example: a cost-based router. The smallest model assesses the complexity of the task and chooses the model that will handle it; if the confidence it reports comes back too low, the code escalates one level, as long as the budget allows. Every call is recorded in a cost ledger.

Workflow: give each task to the smallest capable model01-cost-aware-router.ts
tierconfident, or budget reachedconfidence < 0.85tier + 1STARTtriagesolveescalateEND
  • Model call
  • Code
  • Conditional edge
import { StateGraph, StateSchema, ReducedValue, START, END } from "@langchain/langgraph";
import { ChatAnthropic } from "@langchain/anthropic";
import type { AIMessage } from "@langchain/core/messages";
import { z } from "zod";

const TIERS = ["small", "medium", "large"] as const;
type Tier = (typeof TIERS)[number];

const models: Record<Tier, ChatAnthropic> = {
  small: new ChatAnthropic({ model: "claude-haiku-5-5" }),
  medium: new ChatAnthropic({ model: "claude-sonnet-5-5" }),
  large: new ChatAnthropic({ model: "claude-opus-5-5" }),
};
// $ per million tokens, to be filled in from the current price list
declare const PRICING: Record<Tier, { input: number; output: number }>;
declare function estimateUsd(tier: Tier, task: string): number;

type CostLine = { step: string; tier: Tier; inTok: number; outTok: number; usd: number };
const costLine = (step: string, tier: Tier, raw: AIMessage): CostLine => {
  const u = raw.usage_metadata ?? { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
  const p = PRICING[tier];
  const usd = (u.input_tokens * p.input + u.output_tokens * p.output) / 1e6;
  return { step, tier, inTok: u.input_tokens, outTok: u.output_tokens, usd };
};

const State = new StateSchema({
  task: z.string(),
  budgetUsd: z.number(),
  tier: z.enum(TIERS).default("small"),
  answer: z.string().default(""),
  confidence: z.number().default(0),
  // Cost log per decision: each call appends its own line.
  ledger: new ReducedValue(z.array(z.custom<CostLine>()).default(() => []), {
    reducer: (all, added) => all.concat(added),
  }),
});
type S = typeof State.State;
const spent = (s: S) => s.ledger.reduce((t, l) => t + l.usd, 0);

// Routing by complexity: the smallest model does the sorting.
async function triage(s: S) {
  const { raw, parsed } = await models.small
    .withStructuredOutput(z.object({ complexity: z.enum(["simple", "moderate", "hard"]) }), { method: "jsonSchema", includeRaw: true })
    .invoke(`Assess the complexity of this task: ${s.task}`);
  const tier = ({ simple: "small", moderate: "medium", hard: "large" } as const)[parsed.complexity];
  return { tier, ledger: [costLine("triage", "small", raw as AIMessage)] };
}

async function solve(s: S) {
  const { raw, parsed } = await models[s.tier]
    .withStructuredOutput(z.object({ answer: z.string(), confidence: z.number().min(0).max(1) }), { method: "jsonSchema", includeRaw: true })
    .invoke(s.task);
  return { ...parsed, ledger: [costLine("solve", s.tier, raw as AIMessage)] };
}

const escalate = (s: S) => ({ tier: TIERS[TIERS.indexOf(s.tier) + 1] });

// Early exit on confidence, escalation for as long as the budget allows.
function next(s: S) {
  if (s.confidence >= 0.85) return END;
  const up = TIERS[TIERS.indexOf(s.tier) + 1];
  if (!up) return END;
  if (spent(s) + estimateUsd(up, s.task) > s.budgetUsd) return END;
  return "escalate";
}

export const graph = new StateGraph(State)
  .addNode("triage", triage)
  .addNode("solve", solve)
  .addNode("escalate", escalate)
  .addEdge(START, "triage")
  .addEdge("triage", "solve")
  .addConditionalEdges("solve", next, ["escalate", END])
  .addEdge("escalate", "solve")
  .compile();

// The ledger goes into your database: it is what tells you how much the routing saves.
// const out = await graph.invoke({ task, budgetUsd: 0.05 });
// await analytics.insert(out.ledger.map((l) => ({ ...l, taskId })));

Second example: orchestrator-workers, where the workers are simple tool calls, which builds a summary of a customer account from several systems. The planner only sees the tools the caller is allowed to use, and its plan is filtered again by the code, because a model can invent a tool. The calls go out in parallel with Send, and the synthesis step gets an explicit priority rule for when two sources contradict each other.

Orchestrator-workers: parallel calls, under permissions04-multi-tool-orchestrator.ts
Send × Nempty planresultsSTARTplannerexecute ×NresolveEND
  • Model call
  • Tools
  • Conditional edge
import { StateGraph, StateSchema, ReducedValue, Send, START, END } from "@langchain/langgraph";
import type { StructuredToolInterface } from "@langchain/core/tools";
import { z } from "zod";
import { llm } from "./models";

type Capability = "crm.read" | "crm.write" | "billing.read" | "email.send";

// Catalogue: each tool declares the capabilities it requires.
declare const catalog: { tool: StructuredToolInterface; requires: Capability[] }[];

// Permission scoping: only the tools covered by the caller's scopes.
const allowedFor = (scopes: Capability[]) =>
  catalog.filter((e) => e.requires.every((c) => scopes.includes(c))).map((e) => e.tool);

const Step = z.object({
  tool: z.string(),
  args: z.record(z.string(), z.unknown()),
  reason: z.string(),
});
type Step = z.infer<typeof Step>;
type ToolResult = { tool: string; ok: boolean; output: string };

const State = new StateSchema({
  task: z.string(),
  scopes: z.array(z.custom<Capability>()),
  plan: z.array(Step).default(() => []),
  // Parallel results accumulate: without a reducer, the last one would overwrite the others.
  results: new ReducedValue(z.array(z.custom<ToolResult>()).default(() => []), {
    reducer: (all, added) => all.concat(added),
  }),
  final: z.string().default(""),
});
type S = typeof State.State;

// Capability-based routing: the planner only sees the authorised tools.
async function planner(s: S) {
  const allowed = allowedFor(s.scopes);
  const catalog = allowed.map((t) => `- ${t.name}: ${t.description}`).join("\n");
  const { steps } = await llm.withStructuredOutput(z.object({ steps: z.array(Step) }), { method: "jsonSchema" }).invoke(
    `Available tools:\n${catalog}\n\nTask: ${s.task}\n` +
      "List the independent calls needed (they will run in parallel).",
  );
  // Defence in depth: filter again, the model may invent a tool.
  const names = new Set(allowed.map((t) => t.name));
  return { plan: steps.filter((st) => names.has(st.tool)) };
}

// Parallel execution: one Send per step, all in the same super-step.
const fanOut = (s: S) =>
  s.plan.length ? s.plan.map((step) => new Send("execute", { step })) : "resolve";

async function execute({ step }: { step: Step }) {
  const t = catalog.find((e) => e.tool.name === step.tool)!.tool;
  try {
    const output = await t.invoke(step.args);
    return { results: [{ tool: step.tool, ok: true, output: String(output) }] };
  } catch (e) {
    return { results: [{ tool: step.tool, ok: false, output: (e as Error).message }] };
  }
}

// Conflict resolution: an explicit priority rule between sources of truth.
async function resolve(s: S) {
  const res = await llm.invoke(
    `Task: ${s.task}\nTool results:\n${JSON.stringify(s.results, null, 2)}\n\n` +
      "If two sources contradict each other, billing takes precedence over the CRM. Flag each discrepancy explicitly.",
  );
  return { final: res.text };
}

export const graph = new StateGraph(State)
  .addNode("planner", planner)
  .addNode("execute", execute)
  .addNode("resolve", resolve)
  .addEdge(START, "planner")
  .addConditionalEdges("planner", fanOut, ["execute", "resolve"])
  .addEdge("execute", "resolve")
  .addEdge("resolve", END)
  .compile();

// await graph.invoke({ task: "Summarise the ACME account", scopes: ["crm.read", "billing.read"] });

Works best when

  • The steps are known in advance: extract, qualify, sort, summarise, translate, classify.
  • Cost must stay predictable and each step testable on its own.

Avoid when

  • A business rule, an SQL query or a regular expression is enough.
  • Branches for unexpected cases keep multiplying: it’s time to move to an agent.

2. Agents: the model drives the loop

The model reasons, chooses an action (call a tool, read a file), observes the result, and starts again until it judges that it is done. This is the ReAct loop (Reason + Act), the basis of most current agents, from Claude Code to Codex.

The example is an agent that searches internal documentation. toolsCondition reads the model’s decision; when the tools return, the system’s policy takes back control: every three iterations, a fast model checks that the agent is making progress; at the eighth, the agent must conclude without tools. LangGraph’s recursionLimit remains the safety net of last resort.

ReAct: a bounded agent loop, with self-critique03-react-planning.ts
tool_callscontinueanswerevery 38 iterationsSTARTagenttoolsreflectdegradeEND
  • Model call
  • Tools
  • Conditional edge
import { StateGraph, StateSchema, MessagesValue, START, END, GraphRecursionError } from "@langchain/langgraph";
import { ToolNode, toolsCondition } from "@langchain/langgraph/prebuilt";
import { tool } from "@langchain/core/tools";
import { AIMessage, HumanMessage, SystemMessage } from "@langchain/core/messages";
import { z } from "zod";
import { llm, fast } from "./models";

declare function searchInternalDocs(query: string): Promise<string>;

const searchDocs = tool(({ query }) => searchInternalDocs(query), {
  name: "search_docs",
  description: "Searches the internal documentation",
  schema: z.object({ query: z.string() }),
});
const tools = [searchDocs];
const model = llm.bindTools(tools);
// The history contains tool_use blocks: the Anthropic API requires the tools to stay declared.
// tool_choice "none" declares them without allowing them to be called.
const critic = fast.bindTools(tools, { tool_choice: "none" });
const finalizer = llm.bindTools(tools, { tool_choice: "none" });

const MAX_ITERATIONS = 8;
const REFLECT_EVERY = 3;

const State = new StateSchema({
  messages: MessagesValue,
  iterations: z.number().default(0),
});
type S = typeof State.State;

// Reason + Act: the model reasons, then calls a tool (or not).
async function agent(s: S) {
  const res = await model.invoke([
    new SystemMessage("Reason step by step, use the tools if needed, then answer."),
    ...s.messages,
  ]);
  return { messages: [res], iterations: s.iterations + 1 };
}

// Reflect: periodic self-critique to break unproductive loops.
async function reflect(s: S) {
  const critique = await critic.invoke([
    ...s.messages,
    new HumanMessage("In 2 sentences: are you making progress towards the answer or going round in circles? What should change?"),
  ]);
  return { messages: [new HumanMessage(`[self-critique] ${critique.text}`)] };
}

// Graceful degradation: a partial answer rather than an error.
async function degrade(s: S) {
  const res = await finalizer.invoke([
    ...s.messages,
    new HumanMessage("Iteration limit reached. Give the best partial answer and list what is missing."),
  ]);
  return { messages: [res] };
}

// Observe: after the tools, the system's policy decides what happens next.
const afterTools = (s: S) => {
  if (s.iterations >= MAX_ITERATIONS) return "degrade";
  if (s.iterations % REFLECT_EVERY === 0) return "reflect";
  return "agent";
};

export const graph = new StateGraph(State)
  .addNode("agent", agent)
  .addNode("tools", new ToolNode(tools))
  .addNode("reflect", reflect)
  .addNode("degrade", degrade)
  .addEdge(START, "agent")
  .addConditionalEdges("agent", toolsCondition, ["tools", END])
  .addConditionalEdges("tools", afterTools, ["agent", "reflect", "degrade"])
  .addEdge("reflect", "agent")
  .addEdge("degrade", END)
  .compile();

// Technical safety net: recursionLimit, in super-steps.
export async function run(question: string) {
  try {
    return await graph.invoke({ messages: [new HumanMessage(question)] }, { recursionLimit: 40 });
  } catch (e) {
    if (e instanceof GraphRecursionError) return { messages: [new AIMessage("I couldn't reach a conclusion.")] };
    throw e;
  }
}

This freedom comes at a price: the number of steps, and so the cost and latency, varies from one run to the next, and an error at the third turn contaminates everything after it. Hence the two limits in the example, to which production adds tools with minimal permissions, traces and a set of evaluation cases. With createAgent, these guardrails become middleware, such as modelCallLimitMiddleware.

The loop has several variants:

  • Plan-and-execute: a complete plan, executed step by step, replanned if needed. Fewer calls to the large model, more control over long tasks.
  • ReWOO: the plan references tool results in advance; the tools run without going back through the model, which only returns to write the answer. Far fewer tokens, less responsiveness to the unexpected.
  • Reflexion: after a failure, the agent analyses its trajectory and keeps the lesson in memory for the next attempt. The example’s reflect node borrows the idea, during the run.
  • Tree search (Tree of Thoughts, LATS): several branches explored, evaluated, abandoned. Expensive, reserved for problems where exploration pays off.
  • CodeAct: the agent writes and runs code in a sandbox instead of calling tools one by one; a loop or a filter then fits in a single action.

Works best when

  • The number of steps is unknown and the tools are varied: investigating a bug, cross-checking scattered sources.
  • A long task where cost matters: go with plan-and-execute.

Avoid when

  • You can draw the diagram of the task: write the workflow.
  • Cost and latency must be guaranteed in advance.

3. Multi-agent systems: several agents that cooperate

The work is split between specialised agents, each with its own context, tools and instructions. The point is not to get “experts” talking to each other: it is to isolate contexts, for real parallelism and a coordinator that only reads summaries. Several topologies exist:

  • Supervisor: one agent delegates to sub-agents (called like tools, or chosen at each turn as in the example) and aggregates their results. The most common and the most controllable.
  • Handoffs: agents pass the conversation to one another, from the front desk to the specialist, without a central coordinator.
  • Agent pipeline: research, writing and review in sequence; a workflow whose steps are agents.
  • Debate: agents argue, a judge decides. Useful for verification, ideally with different models.
  • Network and shared blackboard: each agent calls on the others or writes to a shared state. Flexible, but hard to debug and to bound: keep it for exploration.

The example prepares a client meeting. At each turn, a supervisor chooses who acts: a researcher (web search), an analyst (CRM, billing) or, last, a writer. The first two are ReAct agents that start with a clean context, act here one after the other and only send back a summary; the writer is a simple model call. The number of turns is bounded by the code, which also stops the loop as soon as the brief is written. LangChain also offers a shorter form, where each specialist becomes a tool of a main agent created with createAgent; the graph below makes explicit what that form hides.

Multi-agent: a supervisor and its specialists06-supervisor.ts
tasksummarybrief writtenSTARTsupervisorresearcheranalystwriterEND
  • Model call
  • Conditional edge
import { StateGraph, StateSchema, ReducedValue, Command, GraphRecursionError, START, END } from "@langchain/langgraph";
import { createAgent, type ReactAgent } from "langchain";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { llm, fast } from "./models";

// Ports: web search, CRM and billing.
declare function webSearch(query: string): Promise<string>;
declare function crmAccount(company: string): Promise<string>;
declare function listInvoices(company: string): Promise<string>;

const webSearchTool = tool(({ query }) => webSearch(query), {
  name: "web_search",
  description: "Web search",
  schema: z.object({ query: z.string() }),
});
const crmTool = tool(({ company }) => crmAccount(company), {
  name: "crm_account",
  description: "Account record in the CRM",
  schema: z.object({ company: z.string() }),
});
const invoicesTool = tool(({ company }) => listInvoices(company), {
  name: "list_invoices",
  description: "Invoices and payments for the account",
  schema: z.object({ company: z.string() }),
});

// Each specialist is a full ReAct agent (createAgent): its own prompt, tools and context.
const researcherAgent = createAgent({
  model: llm,
  tools: [webSearchTool],
  systemPrompt: "You research a company: news, public tenders, hiring. Return a sourced summary.",
});
const analystAgent = createAgent({
  model: llm,
  tools: [crmTool, invoicesTool],
  systemPrompt: "You analyse the business relationship: contracts, invoices, unpaid bills. Return a summary with figures.",
});

type Report = { agent: string; summary: string };
const MAX_ROUNDS = 6;

const State = new StateSchema({
  brief: z.string(),
  task: z.string().default(""),
  rounds: z.number().default(0),
  // Each specialist appends its report: the supervisor only sees these summaries.
  reports: new ReducedValue(z.array(z.custom<Report>()).default(() => []), {
    reducer: (all, added) => all.concat(added),
  }),
  final: z.string().default(""),
});
type S = typeof State.State;

const Route = z.object({
  next: z.enum(["researcher", "analyst", "writer", "FINISH"]),
  task: z.string().describe("Short instruction for the chosen agent"),
});

// The supervisor picks who steps in and with what instruction; the code bounds the loop.
async function supervisor(s: S) {
  if (s.final || s.rounds >= MAX_ROUNDS) return new Command({ goto: END });
  const done = s.reports.map((r) => `## ${r.agent}\n${r.summary}`).join("\n\n") || "(nothing yet)";
  const route = await fast.withStructuredOutput(Route, { method: "jsonSchema" }).invoke(
    `You coordinate the preparation of a client meeting.\nRequest: ${s.brief}\n\nAlready done:\n${done}\n\n` +
      "Who should step in now, and with what instruction? The writer goes last.",
  );
  if (route.next === "FINISH") return new Command({ goto: END });
  return new Command({ goto: route.next, update: { task: route.task, rounds: s.rounds + 1 } });
}

// Each specialist starts with a blank context: the supervisor's instruction, nothing else.
// A specialist that loops returns a partial report instead of failing the whole graph.
const delegate = (name: string, agent: ReactAgent) => async (s: S) => {
  try {
    const out = await agent.invoke({ messages: [{ role: "user", content: s.task }] }, { recursionLimit: 20 });
    return { reports: [{ agent: name, summary: out.messages.at(-1)?.text ?? "" }] };
  } catch (e) {
    if (!(e instanceof GraphRecursionError)) throw e;
    return { reports: [{ agent: name, summary: "Research cut short: step limit reached." }] };
  }
};

const researcher = delegate("researcher", researcherAgent);
const analyst = delegate("analyst", analystAgent);

// The writer needs no tools: a single model call is enough.
async function writer(s: S) {
  const notes = s.reports.map((r) => `## ${r.agent}\n${r.summary}`).join("\n\n");
  const res = await llm.invoke(`Write a one-page meeting brief from these notes.\n\n${notes}`);
  return { final: res.text };
}

export const graph = new StateGraph(State)
  .addNode("supervisor", supervisor, { ends: ["researcher", "analyst", "writer", END] })
  .addNode("researcher", researcher)
  .addNode("analyst", analyst)
  .addNode("writer", writer)
  .addEdge(START, "supervisor")
  .addEdge("researcher", "supervisor")
  .addEdge("analyst", "supervisor")
  .addEdge("writer", "supervisor")
  .compile();

The gain can be substantial: in Anthropic’s write-up(new tab), a multi-agent system performs 90% better than a single agent on their internal research evaluation. But it uses about fifteen times as many tokens as a chat (four times for a single agent): the gain comes largely from those tokens, and coordination adds its own errors.

Works best when

  • Broad research that splits into independent tracks, or a context that saturates a single agent.
  • Several areas of expertise that get in each other’s way in a single context.

Avoid when

  • Coupled subtasks, such as writing consistent code across several files.
  • A context problem that a single, better-equipped agent would solve.

4. Cross-cutting building blocks

These are not architectures: they can be added to any of the three families.

RAG: giving the model the right pages

RAG (retrieval-augmented generation) searches your documents for the useful passages and puts them in the context before answering: chunk, index (vector and full-text search), retrieve, rerank, then answer while citing its sources. Handed to an agent that decides when and what to search, it becomes agentic RAG, more flexible and more expensive.

The example cites its passages, then the code checks that each citation points to a retrieved passage: a single citation that can’t be found, and confidence drops to zero. Below 0.6, a web search is tried once; if the answer is still weak, it is flagged.

RAG: answer by citing verified sources02-rag-citations.ts
confidence ≥ 0.6too lowweb triedSTARTretrievegeneratewebSearchflagEND
  • Model call
  • Code
  • Tools
  • Conditional edge
import { StateGraph, StateSchema, START, END } from "@langchain/langgraph";
import type { DocumentInterface } from "@langchain/core/documents";
import { z } from "zod";
import { llm } from "./models";

// Ports: vector store (pgvector, Qdrant…) and web search (Tavily, Exa…).
interface Retriever { search(q: string, k: number): Promise<DocumentInterface[]> }
interface WebSearch { search(q: string): Promise<DocumentInterface[]> }
declare const retriever: Retriever;
declare const web: WebSearch;

const Answer = z.object({
  text: z.string(),
  citations: z.array(z.string()).describe("ids of the passages used, e.g. doc-12#3"),
  confidence: z.number().min(0).max(1),
});

const State = new StateSchema({
  question: z.string(),
  docs: z.array(z.custom<DocumentInterface>()).default(() => []),
  answer: Answer.extend({ flagged: z.boolean().optional() }).optional(),
  usedWeb: z.boolean().default(false),
});
type S = typeof State.State;

const retrieve = async (s: S) => ({ docs: await retriever.search(s.question, 6) });
const webSearch = async (s: S) => ({ docs: await web.search(s.question), usedWeb: true });

async function generate(s: S) {
  const context = s.docs.map((d) => `[${d.metadata.id}] ${d.pageContent}`).join("\n\n");
  const res = await llm.withStructuredOutput(Answer, { method: "jsonSchema" }).invoke([
    ["system",
      "Answer ONLY from the context. Every claim cites an id in square brackets. " +
      "If the context is not enough, say so and set confidence < 0.5."],
    ["user", `Context:\n${context}\n\nQuestion: ${s.question}`],
  ]);

  // Deterministic grounding: we don't take the model's word for it.
  const known = new Set(s.docs.map((d) => String(d.metadata.id)));
  const grounded = res.citations.length > 0 && res.citations.every((c) => known.has(c));
  return { answer: { ...res, confidence: grounded ? res.confidence : 0 } };
}

const flagLowConfidence = (s: S) => ({ answer: { ...s.answer!, flagged: true } });

function route(s: S) {
  if (s.answer!.confidence >= 0.6) return END;
  return s.usedWeb ? "flag" : "webSearch";
}

export const graph = new StateGraph(State)
  .addNode("retrieve", retrieve)
  .addNode("generate", generate)
  .addNode("webSearch", webSearch)
  .addNode("flag", flagLowConfidence)
  .addEdge(START, "retrieve")
  .addEdge("retrieve", "generate")
  .addConditionalEdges("generate", route, ["webSearch", "flag", END])
  .addEdge("webSearch", "generate")
  .addEdge("flag", END)
  .compile();

Works best when

  • A large, living body of text: procedures, regulations, documentation.
  • The answer exists in documents, but nobody knows where.

Avoid when

  • Structured data: an SQL query exposed as a tool will be exact.
  • A corpus that fits in the context: give it in full, with prompt caching.

Human review

The system stops before an action with side effects, presents what it is about to do and waits: approve, edit, reject. In LangGraph, interrupt() pauses the graph, the checkpointer keeps its state, and execution resumes later, once the person has answered.

The example handles refunds. The code decides when to call on a human: confidence below 0.8 or a refund above €200. In both cases, the case handler approves, edits or rejects; otherwise, the policy approves on its own. On top of that come an idempotency key and an audit trail, and every human correction can serve as an example for evaluating the system.

Human approval: a refund reviewed before it runs05-human-in-the-loop.ts
unsure or > €200safeapprove, editrejectSTARTproposereviewautoApproveexecuteEND
  • Model call
  • Code
  • Human
  • Conditional edge
import { StateGraph, StateSchema, ReducedValue, Command, MemorySaver, START, END, interrupt } from "@langchain/langgraph";
import { z } from "zod";
import { llm } from "./models";

type Ticket = { id: string; customer: string; body: string; orderTotal: number };
interface Refunds { apply(ticketId: string, d: Decision, idempotencyKey: string): Promise<void> }
declare const refunds: Refunds;

const Decision = z.object({
  action: z.enum(["refund", "reject", "escalate"]),
  amount: z.number().nonnegative().optional(),
  rationale: z.string(),
  confidence: z.number().min(0).max(1),
});
type Decision = z.infer<typeof Decision>;

type AuditEntry = { at: string; actor: string; event: string; data?: unknown };
const audit = (actor: string, event: string, data?: unknown): AuditEntry => ({
  at: new Date().toISOString(), actor, event, data,
});

type HumanReview = {
  decision: "approve" | "edit" | "reject";
  reviewer: string;
  edited?: Decision;
  comment?: string;
};

const State = new StateSchema({
  ticket: z.custom<Ticket>(),
  proposal: Decision.optional(),
  approved: Decision.nullable().default(null),
  // Audit trail: every event is appended, nothing is ever erased.
  audit: new ReducedValue(z.array(z.custom<AuditEntry>()).default(() => []), {
    reducer: (all, added) => all.concat(added),
  }),
});
type S = typeof State.State;

async function propose(s: S) {
  const proposal = await llm.withStructuredOutput(Decision, { method: "jsonSchema" }).invoke(
    `Customer service ticket (order of €${s.ticket.orderTotal}):\n${s.ticket.body}\nPropose an action and your confidence.`,
  );
  return { proposal, audit: [audit("agent", "proposed", proposal)] };
}

// Only a small, safe refund goes through on its own: everything else (low confidence, large amount,
// missing amount, rejection or escalation) goes to a human.
const needsHuman = (s: S) => {
  const p = s.proposal!;
  return p.action === "refund" && p.amount !== undefined && p.amount <= 200 && p.confidence >= 0.8
    ? "autoApprove"
    : "review";
};

const autoApprove = (s: S) => ({ approved: s.proposal!, audit: [audit("policy", "auto_approved")] });

function review(s: S) {
  // On resume, the node is replayed from the start: no side effects before interrupt().
  const human = interrupt<{ ticketId: string; proposal: Decision }, HumanReview>({
    ticketId: s.ticket.id,
    proposal: s.proposal!,
  });
  const entry = audit(human.reviewer, `human_${human.decision}`, { comment: human.comment, edited: human.edited });

  if (human.decision === "reject") {
    return new Command({ goto: END, update: { approved: null, audit: [entry] } });
  }
  const approved = human.decision === "edit" && human.edited ? human.edited : s.proposal!;
  return new Command({ goto: "execute", update: { approved, audit: [entry] } });
}

async function execute(s: S) {
  await refunds.apply(s.ticket.id, s.approved!, `refund:${s.ticket.id}`);
  return { audit: [audit("system", "executed", s.approved)] };
}

export const graph = new StateGraph(State)
  .addNode("propose", propose)
  .addNode("autoApprove", autoApprove)
  .addNode("review", review, { ends: ["execute", END] })
  .addNode("execute", execute)
  .addEdge(START, "propose")
  .addConditionalEdges("propose", needsHuman, ["review", "autoApprove"])
  .addEdge("autoApprove", "execute")
  .addEdge("execute", END)
  .compile({ checkpointer: new MemorySaver() }); // in production: PostgresSaver

// One thread per ticket: that is what lets you resume later.
const thread = (ticketId: string) => ({ configurable: { thread_id: `ticket:${ticketId}` } });

// When paused, the result contains __interrupt__: the proposal to show the case handler.
export const handleTicket = (ticket: Ticket) => graph.invoke({ ticket }, thread(ticket.id));

// Later, from the back office (another HTTP request):
export const resume = (ticketId: string, review: HumanReview) =>
  graph.invoke(new Command({ resume: review }), thread(ticketId));

Works best when

  • Irreversible, external or regulated actions: writing to a customer, refunding, deleting, publishing.
  • The start of a project, before data proves the system is reliable.

Avoid when

  • Hundreds of approvals a day: they end up signed off without being read.
  • Reversible, low-stakes actions.

Other building blocks

  • Memory: the conversation context, to be compacted as it grows, and whatever must survive from one session to the next, often in a vector index. GDPR requires it: personal data must be erasable.
  • Durable execution: a checkpoint after each step, so you can resume without starting over (LangGraph’s checkpointer, Temporal, Inngest). In LangGraph, it is also what makes interrupt() possible.
  • Guardrails: validated inputs and outputs, typed schemas, token and iteration budgets, in code. The format of a structured output can be guaranteed by the API (native structured outputs, strict mode); business rules are for your code to check.
  • Bounded tools: pagination, filters and truncation by default, otherwise a single call saturates the context. MCP (Model Context Protocol) rather than a hand-written connector for each agent.
  • Observability: traces, costs, latencies, alerts on loops, so you know why an agent took a given path.
  • The right model at each step: a capable model for reasoning, a fast model for sorting or extracting. Prompts, tools and models remain replaceable parts.

Combining and evolving

Architectures combine: triage that sends simple cases to a lightweight agent and complex ones to a team of agents, a supervisor whose agents work in parallel, a single agent that only hands over to a heavier system on edge cases. In practice, many production systems are workflows with one or two agentic nodes, placed where the path can’t be predicted.

A healthy trajectory adds one building block at a time, each justified by a measurement:

  1. a workflow or a single agent, to prove the value;
  2. routing, when requests fall clearly into distinct types;
  3. specialists, when a domain deserves it;
  4. evaluators, when volume justifies automatic quality control.

Choosing your architecture

  1. How much control? If you have to justify every decision to an auditor, stay with a workflow or a single agent, with human review at the sensitive points.
  2. Is the path predictable? Yes: a workflow. No: an agent, using plan-and-execute if the task is long. Independent tracks: a supervisor. A conversational journey by domain: agents that hand over to each other.
  3. What constraints? Tight budget or short deadline: a workflow or a single agent, to evolve later.
SituationArchitectureWho decides the pathCost
Steps known in advance(sorting support tickets, extracting the data from an invoice)Workflow: prompt chaining, routingYour codePredictable
Measurable, improvable quality(translating a contract, generating code subject to security rules)Workflow: evaluator-optimiserYour code, in a bounded loopPredictable, several passes
Unknown number of steps, varied tools(diagnosing a production incident, answering a question about internal documentation)Agent: ReAct loopThe modelVariable
Long task, cost and control to keep in check(migrating a code module, putting together a response to a call for tenders)Agent: plan-and-executeThe model, then the planMore predictable than ReAct
Broad research that can run in parallel, saturated context(competitive intelligence, preparing a client meeting from several sources)Multi-agent: supervisorA model, which delegatesHigh
Conversational journey by domain(a customer assistant that routes to billing, delivery or technical support)Multi-agent: handoffsThe agents, in turnVariable

Key takeaways

  • In a workflow, the code decides the path; in an agent, the model does.
  • Only add autonomy when a measured need calls for it.
  • RAG, human review, memory and guardrails can be added to any architecture.
  • Guardrails are written in code, not in the prompt.

Start simple, measure, and let real problems tell you when to go further.

An AI agent project to discuss?

A few lines about your context are enough: I’ll reply within 24 hours to set up a first 30-minute call.

Write a message

Frequently asked questions

What is the difference between a workflow and an AI agent?

In a workflow, the code decides the path: the steps are written in advance and the model steps in at some of them. In an agent, the model decides: it picks the next tool, judges whether it is done and tries again if needed. A workflow is more predictable and cheaper, an agent more flexible.

When do you need an AI agent rather than a workflow?

When the path is not known in advance and depends on what you discover along the way: investigating a bug, cross-checking scattered information, answering a question that takes a variable number of searches. If you can draw the diagram of the task, a workflow is enough and will cost less.

When should you use RAG, and when should you do without it?

RAG suits a large, living body of text: a knowledge base, procedures, documentation. It is pointless when the answer sits in a structured database, where an SQL query exposed as a tool will be exact, or when the corpus fits in the model’s context window, where you can simply give it the whole thing.

How do you limit hallucinations in a RAG system?

By checking in code that every citation points to a passage that was actually retrieved, rather than trusting the confidence the model reports: this check rules out invented sources, even if it does not prove the passage says what the answer attributes to it. An answer without a verifiable source is flagged or refused. Quality depends mostly on retrieval: a RAG system that answers badly is most often one that searches badly.

How do you stop a ReAct agent from looping forever?

With two limits. A business limit, written into the graph, that forces the agent to return its best partial answer after a given number of iterations, with tools disabled. And a technical limit, such as LangGraph’s recursionLimit, as a last resort. A periodic self-critique also helps spot an agent that is going round in circles.

Does a multi-agent system cost more?

Yes. According to Anthropic, an agent uses about four times as many tokens as a chat, and a multi-agent system about fifteen times as many. Multi-agent is justified when the task splits into independent parts; when the subtasks are coupled, a single agent does better.

Where should human review (human-in-the-loop) go?

In front of actions that are irreversible, visible outside the organisation or regulated: writing to a customer, refunding, deleting, publishing. The code decides when to call on a human, for example below a confidence threshold or above a certain amount. Too many approvals end up signed off without being read.

What is the difference between ReAct and plan-and-execute?

ReAct decides the next action at each turn, based on the latest result: flexible, but every step goes back through the model. Plan-and-execute first writes a complete plan, then executes it step by step, replanning only when needed: fewer calls to the most expensive model and more control, which suits long tasks.

Should you move to multi-agent when one agent is no longer enough?

Not necessarily. Before adding more agents, you can strengthen the existing one: better tools, bounded tool responses, domain skills loaded on demand. Multi-agent is justified when the task splits into independent tracks, or when it mixes several areas of expertise that get in each other’s way in a single context.

Can you combine several architectures?

Yes, and most systems in production do: triage at the head of a workflow that sends complex cases to an agent, an orchestrator whose workers run in parallel, a single agent that hands over to a heavier system on edge cases. You start with the simplest option and add a building block when a measurement justifies it.

Are these architectures specific to LangGraph?

No. Workflows, agents, multi-agent systems and building blocks such as RAG or human review are framework-independent patterns. LangGraph makes them explicit (graph, shared state, interrupt and resume), but you find them with the Vercel AI SDK, Mastra, or in hand-written code.