An agent is a loop that lets a model choose the next step. That is all it is. Tonight we build one part by part, in TypeScript you can run, and for every part we name the exact way it fails in production.
In a workflow, you decide the order of the steps and write them down. In an agent, the model decides the next step each time round, using what it just learned. Same tools, same code. The difference is who is holding the steering wheel.
A model that decides, tools it may use, a history of what happened, and a rule that ends the loop. Beginners build the first three and forget the fourth.
flowchart LR
G["Goal"] --> M{"1 Model
reads history, picks next step"}
M -- "asks for a tool" --> T["2 Tool
your code runs it"]
T --> O["3 History
result appended"]
O --> M
M -- "4 Stop rule" --> DONE["Answer"]
classDef m fill:#EEE6FF,stroke:#1F2937,color:#0E1726;
classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
classDef w fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
class M m;
class DONE ok;
class T w;
The model never runs anything. It writes down a name and some arguments, like an order slip. Your code decides whether to fill that order. That gap is where every guardrail in this course lives.
No framework. A for loop, a call to the model, a call to a tool, and an append. If you can read this, you can read any agent framework, because they all wrap exactly this.
The model arrives as a parameter called policy. That single choice is why the whole thing is testable with no API key: you hand it a scripted brain and assert on the stops.
// The brain: history in, next action out. Nothing else.
type Policy = (history: Entry[]) => Promise<{ action: Action }>;
type Action =
| { kind: "final"; text: string }
| { kind: "tools"; calls: ToolCall[] };
export async function runAgent(
question: string,
policy: Policy,
registry: Registry,
ctx: ToolContext,
{ maxSteps = 8 } = {},
) {
const history: Entry[] = [{ role: "question", text: question }];
for (let step = 1; step <= maxSteps; step++) {
const { action } = await policy(history);
// The model says it is finished. Believe it, and stop.
if (action.kind === "final") return { answer: action.text, step };
// Independent calls go together, not one after another.
const results = await Promise.all(
action.calls.map((c) => registry.call(c.name, c.args, ctx)),
);
for (const r of results)
history.push({ role: "observation", text: r.observation });
}
// The step cap. Without this line you have a bill, not a feature.
return { answer: "stopped: hit the step cap", step: maxSteps };
}
A customer writes in about a broken order. Read the middle column as "the model asks", and the right column as "your code answers". Nothing magic happens anywhere.
| Step | The model asks for | Your code hands back |
|---|---|---|
| 0 | reads the question | "Order o-1001 turned up damaged" |
| 1 | lookup_order({orderId:"o-1001"}) | GBP 42.00, delivered, 3 days ago |
| 2 | read_refund_policy({}) | auto-refund up to GBP 100, within 30 days |
| 3 | issue_refund({orderId, reason:"damaged"}) | refunded GBP 42.00, receipt rf-o-1001 |
| 4 | says it is done | stop reason done · 4 steps · 3 tool calls |
"It finished" is the stop you write first. The other three are the ones that save you, because the classic agent incident is not a crash. It is a run that never decides it is done.
If every run returns a string, all failures look the same in your logs. Return a stopReason and each one becomes a different alert with a different fix.
| stopReason | What it usually means | What you change | Tell the user |
|---|---|---|---|
| done | it worked | nothing | the answer |
| step_cap | the task needs more steps than you allowed, or the tools are too weak | better tools, or split the task | honest partial result |
| budget | context is growing every turn, or the model is re-reading the same thing | trim history, cache, cheaper model | "this needs a human" |
| no_progress | a tool keeps returning nothing useful and the model keeps retrying it | fix the tool's error message | "I could not find that" |
The model produces text. That is all it can ever do. A "tool call" is text in an agreed shape: a name and some arguments. Your code reads that text, checks it, and decides whether to run anything.
flowchart LR
M{"Model"} -- "writes text
name plus arguments" --> P["Your code
is this tool allowed"]
P -- "no such tool" --> E["Error observation"]
P -- "bad arguments" --> E
P -- "valid" --> F["The real function
database, API, email"]
F --> R["Result"]
R --> B["Bound it
truncate long output"]
B --> H["Back into history"]
E --> H
classDef m fill:#EEE6FF,stroke:#1F2937,color:#0E1726;
classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726;
class M m;
class P,B ok;
class E bad;
A name the model can type, a description it reads to choose, a schema that says what the arguments must look like, and the effect this has on the world.
effect. Marking a tool as read, write or irreversible is what later decides whether a crashed run can safely be replayed. It costs one word now and saves an incident later.
import { z } from "zod";
const issueRefund = defineTool({
// 1 · snake_case, short, unmistakable. The model types this.
name: "issue_refund",
// 2 · this IS prompt text. It is how the model chooses.
description:
"Refund one order in full. Only works inside the refund " +
"policy. Money leaves the account, so use it once.",
// 3 · the contract. Narrow types here are narrow privileges later.
schema: z.object({
orderId: z.string().min(3),
reason: z.enum(["damaged", "late", "wrong_item"]),
}),
// 4 · what it does to the world: read | write | irreversible
effect: "irreversible",
async run({ orderId, reason }, ctx) {
const order = await findOrder(orderId);
// ctx comes from YOUR code, never from the model's arguments.
if (!order || order.tenantId !== ctx.tenantId)
return `refused: no order ${orderId} on this account`;
// The rule is re-checked HERE. The model only asked.
if (order.totalGbp > MAX_AUTO_REFUND)
return `refused: over the GBP ${MAX_AUTO_REFUND} limit`;
return await refund(order, reason);
},
});
When an agent "picks the wrong tool", nine times in ten the tool was badly named or vaguely described. This is not documentation. It is the only thing the model reads when it chooses.
| Instead of | Write | Because |
|---|---|---|
get_data(type, id) | lookup_order(orderId) | a name that says the noun cannot be confused with three other lookups |
"Handles orders." | "Look up ONE order by id. Returns total, status, delivery date." | says what it returns, so the model knows if this is the step it needs |
update(entity, fields) | cancel_order(orderId) · change_address(orderId, address) | one verb per tool. A generic writer is impossible to reason about or to secure |
search(q) and find(q) and query(q) | one of them | near-duplicate tools are the number one cause of wrong-tool errors |
"Give the agent database access" can mean two completely different systems. One of them can only ever refund one order that the caller owns. The other can do anything SQL can do, and a probabilistic caller decides which.
Enforce what is allowed in the tool, server-side, in code. Not in the prompt. A prompt is a request. A schema plus a server check is a rule.
// GOOD: narrow, typed, and the server re-checks everything
{
name: "refund_order",
input: {
orderId: "string",
reason: "enum[damaged, late, wrong_item]"
}
}
// server re-checks: caller owns this order,
// amount is under the cap,
// it was not already refunded.
// Worst case if the model is wrong or steered:
// ONE wrong refund, under the cap, fully logged.
// DANGEROUS: unbounded, and the model picks how far it reaches
{
name: "run_sql",
input: { query: "string" }
}
// Worst case: every row of every table, read or deleted.
// Same family of mistake:
{ name: "http_get", input: { url: "string" } } // your whole VPC
{ name: "shell", input: { cmd: "string" } } // game over
{ name: "send_email",input: { to: "string", body: "string" } }
The model will call a tool that does not exist, and pass arguments that do not validate, and hit a service that is down. All three are normal. None of them should end a nine-step run.
"Invalid input" tells the model nothing, so it guesses again and you pay for another step. Name the field, say what was wrong, list what is valid. Then the next step is a fix instead of a repeat.
// Every failure comes back as an OBSERVATION the model can read.
async call(name, rawArgs, ctx): Promise<ToolResult> {
const tool = byName.get(name);
// 1 · invented a tool. Say so, and say what exists.
if (!tool) return {
ok: false, retryable: true,
observation: `error: no tool named "${name}". ` +
`Available tools: ${names.join(", ")}.`,
};
// 2 · bad arguments. Name the field and the reason.
const parsed = tool.parse(rawArgs);
if (!parsed.ok) return {
ok: false, retryable: true,
observation: `error: invalid arguments for ${name}. ` +
parsed.message, // "reason: expected damaged|late"
};
// 3 · the dependency fell over. Still an observation.
try {
const raw = await tool.invoke(parsed.args, ctx);
return { ok: true, ...bound(raw, tool.maxChars) };
} catch (err) {
return {
ok: false, retryable: true,
observation: `error: ${name} failed. ${message(err)}`,
};
}
}
A tool that returns 40,000 characters does not just cost you once. That text sits in the history and is re-sent on every remaining step of the run. One chatty tool can be the whole bill.
| One noisy tool | returns 10k tokens of JSON on step 2 |
| A six step run | that result is re-sent on steps 3, 4, 5, 6 · 5× the tokens you thought |
| The fix, part 1 | the tool returns a summary, not the raw payload · ids and totals, not every field |
| The fix, part 2 | a hard maxChars per tool, and the truncation says so, so the model knows it saw a slice |
| The fix, part 3 | page it: next_cursor beats "here is everything" |
| The tell | your p95 cost per run is fine and your p99 is 20× it. That is one tool, on one input |
Every tool you add is more prompt on every call, one more thing to pick wrongly, and one more door into your systems. Adding tools feels like adding ability. Past a point it subtracts reliability.
| Tools in the prompt | What happens | What to do |
|---|---|---|
| 3 to 7 | the model picks well and the catalogue is cheap | stay here for as long as you can |
| 8 to 15 | near-duplicates start getting confused with each other | merge overlapping tools, sharpen the descriptions |
| 16 to 40 | choice quality drops and the schemas are a real cost on every call | show only the tools this phase needs, or route to a sub-agent |
| 40 and up | you are no longer designing, you are hoping | make tool discovery a tool: search, then call |
Three lookups that do not depend on each other do not need three round trips through the model. Modern APIs let the model ask for several tools in one step, and your loop decides whether they run together or one by one.
flowchart TB
subgraph SEQ["One at a time · 6 model calls, 6 waits"]
direction LR
A1["model"] --> A2["tool a"] --> A3["model"] --> A4["tool b"] --> A5["model"] --> A6["tool c"]
end
subgraph PAR["Together · 2 model calls, 1 wait"]
direction LR
B1["model"] --> B2["tool a"]
B1 --> B3["tool b"]
B1 --> B4["tool c"]
B2 --> B5["model"]
B3 --> B5
B4 --> B5
end
SEQ ~~~ PAR
classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726;
classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
class A1,A3,A5 bad;
class B1,B5 ok;
await Promise.all(calls.map(...)). It is the cheapest latency win an agent has.Everything from the last few slides still holds. MCP (Model Context Protocol) just standardises how a tool is offered: write it once as a server, and any agent can call it. The architectural change is quieter and bigger. Most tools you ship, you did not write.
flowchart LR
AG{"Your agent"} --> C["MCP client"]
C --> GW["Gateway
auth plus allow-list"]
GW --> S1["Your server
issue_refund"]
GW --> S2["Third-party server
you do not run this one"]
classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
classDef un fill:#FEE4E2,stroke:#1F2937,color:#0E1726;
class GW,S1 ok;
class S2 un;
"Planning" is not one thing. Pick on purpose, because each shape has a different price and a different way of going wrong.
| Shape | Model calls | Good for | Breaks as | Guardrail |
|---|---|---|---|---|
| none (a workflow) | 1 per fuzzy step | the path is known and stable | no branch for the case nobody predicted | an explicit fallback, and a metric on it |
| react | 1 per step, count unknown | open-ended work, multi-hop lookups, recovery | wandering, and errors compounding | step cap, budget, repeat detection |
| plan then execute | 1 to plan, then 1 per step | predictable shape, and you want the plan reviewable | the plan goes stale at step two | validate each step, allow bounded replanning |
| reflect | 2 to 3 per step | one high-value artifact where quality beats cost | polishing forever, critic agrees with itself | hard round cap, critic uses a rubric |
flowchart LR T["Think
what do I need next"] --> A["Act
call a tool"] A --> O["Look
read the result"] O --> T T -. "goal met or capped" .-> D["Done"] classDef t fill:#EEE6FF,stroke:#1F2937,color:#0E1726; classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class T t; class D ok;
flowchart LR G["Goal"] --> P["Plan
one model call, all the steps"] P --> S["Run step n"] S --> V{"Did it pass
its check"} V -- "yes" --> S2{"More steps"} S2 -- "yes" --> S S2 -- "no" --> D["Done"] V -- "no" --> R{"Replans left"} R -- "yes" --> P R -- "no" --> STOP["Stop and say so"] classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726; classDef m fill:#EEE6FF,stroke:#1F2937,color:#0E1726; class P m; class D ok; class STOP bad;
flowchart LR
D1["Draft"] --> C{"Critic
scores against a rubric"}
C -- "fails the rubric" --> D2["Revise"]
D2 --> C
C -- "passes, or 2 rounds used" --> OUT["Ship it"]
classDef m fill:#EEE6FF,stroke:#1F2937,color:#0E1726;
classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
class C m;
class OUT ok;
Do you know the steps before you start? Does the next step depend on the last result? Is this one artifact where quality is worth paying triple for?
Because then it is a decision with a reason attached, which is exactly what Project 3 asks you to defend. A function you can run beats a preference you can only assert.
export function choosePlanner(shape: TaskShape): PlannerChoice {
// Cheapest answer first, and it wins ties.
if (shape.pathKnown && !shape.stepsDependOnResults) {
return { kind: "none",
because: "the path is known, so a workflow is cheaper and testable" };
}
if (shape.qualityOverCost && !shape.stepsDependOnResults) {
return { kind: "reflect",
because: "one artifact where quality is worth extra calls" };
}
if (shape.pathKnown) {
return { kind: "plan_then_execute",
because: "predictable shape, so plan once and validate each step" };
}
return { kind: "react",
because: "the next step is only knowable after the last observation" };
}
// $ npm run lab .../s07-agent-architecture/decide.ts
//
// 1 · planning react
// because the next step is only knowable after the last
// breaks as wandering, and error compounding
// guardrail step cap, budget, repeat detection, validation
Say each step is right 95 percent of the time. That sounds good. Chain ten of them and the whole run is right about 60 percent of the time, with nothing thrown, nothing logged, and no error anywhere to find.
You cannot make the model more reliable tonight. You can stop a bad step from being built on. Catch four out of five bad steps and a ten-step run goes from 35 percent correct to 82 percent, with the same model.
Not another model call. A schema that must parse, a number that must be in range, an id that must exist, a claim that must appear in the retrieved text. Boring code, from Session 2.
// Bare chain: every step trusts the one before it.
export function chainSuccess(perStep: number, steps: number) {
return perStep ** steps;
}
// With a check between steps. `catchRate` is the share of bad
// steps your validation actually catches and retries.
export function chainSuccessWithChecks(
perStep: number, steps: number, catchRate: number,
) {
const effective = perStep + (1 - perStep) * catchRate;
return chainSuccess(Math.min(effective, 1), steps);
}
// 90% per step, 10 steps:
chainSuccess(0.9, 10); // 0.349 ouch
chainSuccessWithChecks(0.9, 10, 0.8); // 0.817 same model
// And the number that sets your step cap:
maxStepsFor(0.95, 0.9); // 2 steps before you drop under 90%
maxStepsFor(0.99, 0.9); // 10
maxStepsFor(0.999, 0.9); // 105
People say "give the agent memory" and mean any of these. They have different lifetimes, different costs and different ways of going wrong.
| Kind | Lives for | Example | Breaks as |
|---|---|---|---|
| Scratchpad | one step | the model's reasoning before it picks a tool | you keep it all and pay for it forever |
| Run history | one run | the calls and observations so far | grows every step, so cost and latency climb |
| Run state | survives a crash | which steps completed, what they returned | if it only lives in the process, the run dies with it |
| Long-term facts | forever | "this customer prefers email" | stale facts retrieved as gospel |
If the only copy of "what has happened so far" is a variable inside a running function, then your run is exactly as durable as that process. Deploys, timeouts, rate limits and crashes all end it.
flowchart TB
subgraph P1["State inside the process"]
direction LR
A["step 1"] --> B["step 2"] --> C["step 3"] --> X["pod restarts"]
X --> Y["start again at step 1
pay twice, re-send every email"]
end
subgraph P2["State in a store"]
direction LR
D["step 1"] --> E["step 2"] --> F["step 3"] --> G["pod restarts"]
G --> H["resume at step 4
steps 1 to 3 are not repeated"]
end
P1 ~~~ P2
classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726;
classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
class X,Y bad;
class G,H ok;
Three rules and they are all boring. Write the state down after each step, outside the process. On restart, skip what is already recorded. Know which steps are safe to redo.
Batching the saves to "reduce writes" quietly reintroduces the exact problem you were solving. Checkpoint after every step, every time.
export async function runDurable(runId, steps, store, perform) {
// Load, or start fresh. `completed.length` IS the resume point.
const record = (await store.load(runId)) ?? {
runId, status: "running", completed: [], approvedSteps: [],
};
for (let i = record.completed.length; i < steps.length; i++) {
const step = steps[i];
// Waiting on a human is a STATE, not a held-open socket.
if (needsApproval(step) &&
!record.approvedSteps.includes(step.name)) {
record.status = "awaiting_approval";
record.pending = { name: step.name, effect: step.effect };
await store.save(record);
return record; // come back tomorrow, different process
}
const output = await perform(step, record.completed);
record.completed.push({ name: step.name, output, effect: step.effect });
// AFTER every step. Not every five. Not at the end.
await store.save(record);
}
record.status = "done";
await store.save(record);
return record;
}
This is the three-line table you need before a resume, a retry or a replay. It is why every tool declared an effect back on slide ten.
| Effect | Example | Safe to replay | Needs |
|---|---|---|---|
| read | lookup_order, search_docs | yes, free | nothing |
| write | update_address, create_ticket | yes, but only because of the key | an idempotency key derived from the intent |
| irreversible | issue_refund, send_email, delete_account | no | a key, an audit log entry, and a receipt a human can see |
Once the state lives in a store, "wait for a human to approve this refund" stops being hard. It is one more status. Nothing is holding a connection open, and a different process can pick the run up tomorrow.
stateDiagram-v2
[*] --> running
running --> done: no steps left
running --> failed: a step threw
failed --> running: resume
running --> awaiting_approval: irreversible step
awaiting_approval --> running: approved
awaiting_approval --> cancelled: declined
done --> [*]
cancelled --> [*]
Multi-agent means an orchestrator handing subtasks to specialists. It is drawn as an org chart and sold as a capability. It is really a trade: you buy parallelism and clean context, and you pay coordination, multiplied cost, and new failures.
flowchart TB
O{"Orchestrator"}
O --> A["Researcher
own tools, own history"]
O --> B["Writer
own tools, own history"]
O --> C["Checker
own tools, own history"]
A --> S["Merge"]
B --> S
C --> S
classDef o fill:#EEE6FF,stroke:#1F2937,color:#0E1726;
class A,B,C o;
The reason that survives contact with production is dull: each worker gets a clean history. One subtask's forty lines of tool output never lands in another's window. Everything else people claim is usually available from one agent with better tools.
| Shape | How it works | Use when | Breaks as |
|---|---|---|---|
| Orchestrator and workers | one splits, several run at once, one merges | independent subtasks, different context each | merge quality: the orchestrator trusts every worker |
| Handoff | one agent passes the whole conversation to another | clear domains: billing, then technical support | ping-pong between two agents that both decline |
| Pipeline | fixed stages, output of one is input to the next | a known sequence | this is a workflow. Just write the workflow |
| Blackboard | agents read and write one shared state | rarely, in research settings | nobody can tell you who wrote what, or why |
Splitting costs a call to split and a call to merge, before any work happens. Divide the budget rather than sharing it, or the first worker eats the pot. And a failed worker must be named, not quietly missing from the answer.
One agent's confident guess becomes another agent's fact. In a single agent that is one mistake. Across three, it is a mistake that arrives with a second opinion attached.
// Divide, do not share. A shared pot is a first-come budget.
const perWorker = (budgetUsd - coordinationUsd) / subtasks.length;
const results = await Promise.all(subtasks.map(async (task) => {
// A FRESH history per worker. This is the point of the pattern.
const isolated = [`goal: ${task.goal}`];
try {
const { output, costUsd } = await worker(task, isolated);
if (costUsd > perWorker)
return { id: task.id, output, trusted: false, costUsd,
note: "over its share" };
return { id: task.id, output, trusted: true, costUsd };
} catch (err) {
// Named, not silently dropped from the merge.
return { id: task.id, output: "", trusted: false,
costUsd: 0, note: message(err) };
}
}));
const merged = merge(results.filter((r) => r.trusted));
const excluded = results.filter((r) => !r.trusted).map((r) => r.id);
// The number that ends the argument:
coordinationShare(result); // 0.5 = half the bill was talking
| Pattern | Buys you | Breaks as | Guardrail |
|---|---|---|---|
| The loop | the model can take steps | it never stops | four stops: done, cap, budget, no progress |
| Tool use | reach into real systems | wrong tool, wrong args, over-reach | few tools, typed contract, server-side rules |
| Tool results | the model learns what happened | context bloat, re-sent every step | summarise, bound, paginate |
| MCP and imported tools | reach without writing glue | privileges nobody reviewed | gateway, allow-list, pin versions |
| Planning | handles the unknown | wandering, error compounding | validate between steps, cap the replans |
| Memory | continuity | history growth, stale facts | trim, expire, never treat as authority |
| Long runs | real multi-step work | restart from zero, double side effects | checkpoint each step, idempotency keys |
| Multi-agent | parallel specialists, clean context | coordination cost, error propagation | split only on a real seam, divide the budget |
One folder per decision, all of it offline, no API key. If you only do one thing after tonight, run the tests and read the names: they are the failure modes in this deck, written as assertions.
| Folder | The slides it makes runnable |
|---|---|
tools/ | a tool as a typed contract, the registry as an allow-list, errors as observations, bounded results |
loop/ | the loop, the four stops, repeat detection, parallel tool calls |
planning/ | the four planner shapes, choosePlanner, the compounding math, plan with validation |
durability/ | checkpoint and resume, pause for a human, what is safe to replay |
multi-agent/ | shouldSplit, isolated worker context, divided budget, the coordination tax |
real-world/ | an order support agent whose refund tool refuses the model |
Five boxes for your own system. If it earned an agent, draw it. If it did not, draw the workflow you chose instead: that is a full answer, and tonight's table still applies to it.
Tools are contracts your code enforces. Errors are messages, not exceptions. Steps multiply, so check between them. State belongs outside the process. Stay single until a real seam forces multi. Every pattern ships with a failure mode, name it before it names you.