What you will build
By the end you will have a 129 line agent that handles a refund request, stops for human approval before moving money, refuses to delete customers no matter what it is told, stops at a step budget, and writes an event log you can read and replay. This is the actual output:
$ npm startrun 1: no approvaluser Customer wants a refund for order 1042.skill loaded refundscall lookup_order({"id":"1042"})result order 1042: $48.00, placed 12 days agocall issue_refund({"id":"1042","amount":"48.00"})BLOCKED issue_refund: needs approvalanswer I need approval before I can issue_refund.skills in context: refundsrun 2: refund approveduser Customer wants a refund for order 1042.skill loaded refundscall lookup_order({"id":"1042"})result order 1042: $48.00, placed 12 days agocall issue_refund({"id":"1042","amount":"48.00"})result refunded $48.00 on order 1042answer Refunded $48.00 on order 1042.skills in context: refundsrun 3: step budget 2user Customer wants a refund for order 1042.skill loaded refundscall lookup_order({"id":"1042"})result order 1042: $48.00, placed 12 days agoanswer Stopped: step budget of 2 reached.skills in context: refunds| You will learn | Why it matters |
|---|---|
| Permissions in code, not prompts | Prompts are suggestions. Code is a rule. |
| Skills that reveal tools on demand | Fewer tools in view means fewer wrong calls. |
| A fake model you can test | Deterministic runs in CI, zero tokens. |
| Budgets and an event log | Every run stops and every run can be explained. |
Why: rules in a prompt are not rules
In July 2025, SaaStr founder Jason Lemkin was building with Replit's AI agent during a declared code freeze. He had told it not to change anything, "eleven times in ALL CAPS", by his account. The agent ran a destructive command and wiped his production database anyway. (He was later able to roll it back.) The Register
A year earlier, a Canadian tribunal held Air Canada liable after its support chatbot invented a bereavement refund policy and a customer relied on it (Moffatt v. Air Canada, 2024 BCCRT 149). The company paid. Decision
Both agents had instructions. Neither had a harness. In this module you put the rules where the model cannot talk its way past them: in the code that runs the tools.
The mental model
Hold onto one sentence: the model proposes, the harness decides. Each step below adds one box from this picture.
Setup
You need Node 20 or newer. Clone the starter repo. Each folder in steps/ is the complete code after that step, so you can jump in anywhere or compare your work.
$ git clone https://github.com/bobbyhalljr/ship-a-safe-agent-module-1.git$ cd ship-a-safe-agent-module-1$ npm install$ npm run checknpm run check typechecks everything and runs every step against its expected output. You should see # pass 6.
1. A tool registry with a permission on every tool
Start with the inventory. Each tool has a name, a description the model sees, a permission, and the function that actually runs. The permission lives next to the code, not in a prompt.
// Ship a Safe Agent, Module 1: the loop.// Fake model. No API key. No network. export type Permission = "allow" | "ask" | "deny";export type Args = Record<string, string>;export type Tool = { name: string; description: string; permission: Permission; run: (args: Args) => string }; export const registry = new Map<string, Tool>();export function register(tool: Tool): void { if (registry.has(tool.name)) throw new Error(`duplicate tool: ${tool.name}`); registry.set(tool.name, tool);} register({ name: "lookup_order", description: "Get an order by id.", permission: "allow", run: ({ id }) => `order ${id}: $48.00, placed 12 days ago` });register({ name: "issue_refund", description: "Refund an order to the original card.", permission: "ask", run: ({ id, amount }) => `refunded $${amount} on order ${id}` });register({ name: "delete_customer", description: "Delete a customer record.", permission: "deny", run: () => "deleted" });register({ name: "track_package", description: "Carrier tracking for an order.", permission: "allow", run: ({ id }) => `order ${id}: out for delivery` });Hint: why is it written this way?
register throws on duplicates so two tools can never shadow each other. A shadowed tool is how a safe name ends up running unsafe code.
Run it
$ npm run step steps/01-registry/run.tsYou should see
allow lookup_orderask issue_refunddeny delete_customerallow track_packageMaking permission optional with a default of allow. A new tool should never be silently callable. Here permission is required by the type, so forgetting it is a compile error.
- Load permissions from config so ops can change them without a deploy.
- Scope permissions per tenant or per user role, not globally.
2. Permissions: allow, ask, deny
checkPermission takes a tool and the set of approvals for this run and returns a verdict. It has no side effects, so it is trivial to test, and it is the only place that decision is made.
export type Verdict = { ok: true } | { ok: false; reason: string }; export function checkPermission(tool: Tool, approved: Set<string>): Verdict { if (tool.permission === "deny") return { ok: false, reason: "denied" }; if (tool.permission === "ask" && !approved.has(tool.name)) return { ok: false, reason: "needs approval" }; return { ok: true };}Run it
$ npm run step steps/02-permissions/run.tsYou should see
lookup_order no approval: ok approved: okissue_refund no approval: needs approval approved: okdelete_customer no approval: denied approved: deniedtrack_package no approval: ok approved: okLetting the approved set unlock deny tools. Notice delete_customer stays denied even when approved. Deny means the agent never does it; a human does it elsewhere.
- Store approvals with who approved, when, and for which arguments.
- Expire approvals after the run so they cannot be replayed.
3. Skills and the context
A skill is a named bundle: a short description for the menu, longer instructions, and the tools it unlocks. The systemPrompt lists skill names only, plus the instructions of skills already loaded.
export type Skill = { name: string; description: string; body: string; tools: string[] };export const skills: Skill[] = [ { name: "refunds", description: "Refund or credit a customer order.", body: "Full refund within 30 days to the original card. After 30 days, store credit only.", tools: ["lookup_order", "issue_refund"] }, { name: "shipping", description: "Track or reroute a package.", body: "Reroutes allowed until the label is printed.", tools: ["track_package"] },]; export type Event = | { type: "user"; text: string } | { type: "skill_loaded"; name: string } | { type: "tool_call"; name: string; args: Args } | { type: "tool_result"; name: string; result: string } | { type: "blocked"; name: string; reason: string } | { type: "answer"; text: string }; export type Context = { events: Event[]; loadedSkills: Set<string>; visibleTools: Set<string> }; export function newContext(task: string): Context { return { events: [{ type: "user", text: task }], loadedSkills: new Set(), visibleTools: new Set() };} export function loadSkill(ctx: Context, name: string): void { const skill = skills.find((s) => s.name === name); if (!skill || ctx.loadedSkills.has(name)) return; ctx.loadedSkills.add(name); skill.tools.forEach((t) => ctx.visibleTools.add(t)); ctx.events.push({ type: "skill_loaded", name });} // What you would send a real model: every description, only the loaded bodies.export function systemPrompt(ctx: Context): string { const catalog = skills.map((s) => `- ${s.name}: ${s.description}`).join("\n"); const loaded = skills.filter((s) => ctx.loadedSkills.has(s.name)).map((s) => s.body).join("\n"); return `Skills:\n${catalog}\n\n${loaded}`.trim();}Hint: why is it written this way?
The Context is the agent's only memory. If it is not in events, loadedSkills or visibleTools, the next turn cannot know it. That is what makes replay possible.
Run it
$ npm run step steps/03-skills-and-context/run.tsYou should see
visible before: nonevisible after: lookup_order, issue_refund Skills:- refunds: Refund or credit a customer order.- shipping: Track or reroute a package. Full refund within 30 days to the original card. After 30 days, store credit only.Putting every tool in the system prompt up front. With 40 tools in view, models pick wrong ones. Show the menu, load the skill, then reveal its tools.
- Version skills so a run records which instructions it used.
- Cap how many skills can load in one run.
4. A fake model you can test
A Model is just (ctx) => Turn. A turn is one of three things: load a skill, call a tool, or answer. refundModel reads the context and decides, exactly like a real model would, but predictably.
export type Turn = | { kind: "load_skill"; name: string } | { kind: "call"; name: string; args: Args } | { kind: "answer"; text: string };export type Model = (ctx: Context) => Turn; const did = (ctx: Context, tool: string) => ctx.events.some((e) => e.type === "tool_result" && e.name === tool); export const refundModel: Model = (ctx): Turn => { const last = ctx.events[ctx.events.length - 1]; if (!ctx.loadedSkills.has("refunds")) return { kind: "load_skill", name: "refunds" }; if (!did(ctx, "lookup_order")) return { kind: "call", name: "lookup_order", args: { id: "1042" } }; if (last.type === "blocked") return { kind: "answer", text: `I need approval before I can ${last.name}.` }; if (!did(ctx, "issue_refund")) return { kind: "call", name: "issue_refund", args: { id: "1042", amount: "48.00" } }; return { kind: "answer", text: "Refunded $48.00 on order 1042." };};Run it
$ npm run step steps/04-fake-model/run.tsYou should see
turn 1: {"kind":"load_skill","name":"refunds"}turn 2: {"kind":"call","name":"lookup_order","args":{"id":"1042"}}Testing your harness against a real model. Results drift and cost money. Test the harness with a scripted model; evaluate the real model separately (Module 8).
- Keep the same
Modeltype and swap in a real provider adapter. - Record real model turns and replay them as fixtures.
5. The loop, with limits in the harness
This is the whole harness. The model proposes a turn; the loop applies the three checks from the diagram; only then does the tool run. Every outcome becomes an event.
export type Limits = { maxSteps: number; approved: Set<string> }; export function runAgent(task: string, model: Model, limits: Limits): Context { const ctx = newContext(task); for (let step = 1; step <= limits.maxSteps; step++) { const turn = model(ctx); if (turn.kind === "answer") { ctx.events.push({ type: "answer", text: turn.text }); return ctx; } if (turn.kind === "load_skill") { loadSkill(ctx, turn.name); continue; } ctx.events.push({ type: "tool_call", name: turn.name, args: turn.args }); const tool = registry.get(turn.name); if (!tool || !ctx.visibleTools.has(turn.name)) { ctx.events.push({ type: "blocked", name: turn.name, reason: "not loaded" }); continue; } const verdict = checkPermission(tool, limits.approved); if (!verdict.ok) { ctx.events.push({ type: "blocked", name: turn.name, reason: verdict.reason }); continue; } ctx.events.push({ type: "tool_result", name: turn.name, result: tool.run(turn.args) }); } ctx.events.push({ type: "answer", text: `Stopped: step budget of ${limits.maxSteps} reached.` }); return ctx;}Hint: why is it written this way?
The order of checks matters: not loaded, then permission, then run. Each block writes an event and ends with a clear answer, so the user always learns why.
Run it
$ npm run step steps/05-the-loop/run.tsYou should see
{"type":"user","text":"Customer wants a refund for order 1042."}{"type":"skill_loaded","name":"refunds"}{"type":"tool_call","name":"lookup_order","args":{"id":"1042"}}{"type":"tool_result","name":"lookup_order","result":"order 1042: $48.00, placed 12 days ago"}{"type":"tool_call","name":"issue_refund","args":{"id":"1042","amount":"48.00"}}{"type":"blocked","name":"issue_refund","reason":"needs approval"}{"type":"answer","text":"I need approval before I can issue_refund."}Checking permissions inside the tool function. Then a blocked call has already started running. Check in the loop, before tool.run is ever called.
- Add a wall clock timeout and a cost budget next to
maxSteps. - Pause on
askand resume after approval instead of ending the run (Module 7).
6. The event log
formatLog prints one line per event. The run script executes the same task three ways so you can see each guard fire.
export function formatLog(ctx: Context): string { const lines = ctx.events.map((e) => { switch (e.type) { case "user": return `user ${e.text}`; case "skill_loaded": return `skill loaded ${e.name}`; case "tool_call": return `call ${e.name}(${JSON.stringify(e.args)})`; case "tool_result": return `result ${e.result}`; case "blocked": return `BLOCKED ${e.name}: ${e.reason}`; case "answer": return `answer ${e.text}`; } }); return [...lines, `skills in context: ${[...ctx.loadedSkills].join(", ") || "none"}`].join("\n");}Run it
$ npm run step steps/06-event-log/run.tsYou should see
run 1: no approvaluser Customer wants a refund for order 1042.skill loaded refundscall lookup_order({"id":"1042"})result order 1042: $48.00, placed 12 days agocall issue_refund({"id":"1042","amount":"48.00"})BLOCKED issue_refund: needs approvalanswer I need approval before I can issue_refund.skills in context: refunds run 2: refund approveduser Customer wants a refund for order 1042.skill loaded refundscall lookup_order({"id":"1042"})result order 1042: $48.00, placed 12 days agocall issue_refund({"id":"1042","amount":"48.00"})result refunded $48.00 on order 1042answer Refunded $48.00 on order 1042.skills in context: refunds run 3: step budget 2user Customer wants a refund for order 1042.skill loaded refundscall lookup_order({"id":"1042"})result order 1042: $48.00, placed 12 days agoanswer Stopped: step budget of 2 reached.skills in context: refundsLogging only the final answer. When something goes wrong you need every call, result and block, in order.
- Ship events to your tracing stack with a run id.
- Redact secrets and personal data before logging (Module 3).
Graded exercises
Open exercise/agent.ts. It contains the finished agent plus three TODOs. Run the tests; they fail until you are done.
$ npm test// ===== EXERCISES: make `npm test` pass ===== // Exercise 1: a model for "Where is my package for order 1042?"// It should load the shipping skill, call track_package with { id: "1042" },// then answer with the tracking result. It must never call a refunds tool.export const shippingModel: Model = (ctx) => { void ctx; throw new Error("TODO: exercise 1");}; // Exercise 2: a tool-call budget.// Same as runAgent, but also stop when limits.maxToolCalls tool calls have been made.// Stop with the answer: `Stopped: tool call budget of ${maxToolCalls} reached.`export type BudgetLimits = Limits & { maxToolCalls: number };export function runAgentWithBudget(task: string, model: Model, limits: BudgetLimits): Context { void task; void model; void limits; throw new Error("TODO: exercise 2");} // Exercise 3: summarize a run from its event log.export type Summary = { skills: string[]; toolCalls: number; blocked: string[]; answer: string };export function summarize(ctx: Context): Summary { void ctx; throw new Error("TODO: exercise 3");}- shippingModel: answer "Where is my package for order 1042?" using only the shipping skill.
- runAgentWithBudget: stop after
maxToolCallstool calls. - summarize: turn the event log into skills, tool call count, blocks and the answer.
Hint for exercise 1
Copy the shape of refundModel: load the skill if missing, call the tool if there is no result yet, otherwise answer with the result.
Hint for exercise 2
You do not need to rewrite the loop. Wrap the model: count tool_call events in the context, and if the budget is spent, return an answer turn instead of a call.
Hint for exercise 3
One pass over ctx.events with a check on e.type is enough.
Show solution
// ===== EXERCISES: solutions ===== // Exercise 1export const shippingModel: Model = (ctx): Turn => { if (!ctx.loadedSkills.has("shipping")) return { kind: "load_skill", name: "shipping" }; const result = ctx.events.find((e) => e.type === "tool_result" && e.name === "track_package"); if (!result) return { kind: "call", name: "track_package", args: { id: "1042" } }; return { kind: "answer", text: result.type === "tool_result" ? result.result : "" };}; // Exercise 2export type BudgetLimits = Limits & { maxToolCalls: number };export function runAgentWithBudget(task: string, model: Model, limits: BudgetLimits): Context { const counted: Model = (ctx) => { const calls = ctx.events.filter((e) => e.type === "tool_call").length; const turn = model(ctx); if (turn.kind === "call" && calls >= limits.maxToolCalls) return { kind: "answer", text: `Stopped: tool call budget of ${limits.maxToolCalls} reached.` }; return turn; }; return runAgent(task, counted, limits);} // Exercise 3export type Summary = { skills: string[]; toolCalls: number; blocked: string[]; answer: string };export function summarize(ctx: Context): Summary { const skills: string[] = [], blocked: string[] = []; let toolCalls = 0, answer = ""; for (const e of ctx.events) { if (e.type === "skill_loaded") skills.push(e.name); if (e.type === "tool_call") toolCalls++; if (e.type === "blocked") blocked.push(`${e.name}: ${e.reason}`); if (e.type === "answer") answer = e.text; } return { skills, toolCalls, blocked, answer };}Or run git diff main solution -- exercise/agent.ts.
When npm test prints # pass 5, you are done.
Recap
- Every tool declares a permission.
denycannot be approved away. - Skills hide tools until they are needed.
- The Context is the only memory, so runs are replayable.
- The model proposes; the harness checks skill, permission and budget, then runs.
- Every outcome is an event you can read.
Check yourself
1. The model returns a call to delete_customer. What stops it?
The harness. checkPermission returns "denied" because the tool is registered as deny, so the loop logs a blocked event and the tool function never runs. Nothing in the prompt is involved.
2. Why can the model not call issue_refund before loading the refunds skill?
Tools are only visible after their skill loads. runAgent checks visibleTools first and blocks with "not loaded", so a confused model cannot reach tools for the wrong job.
3. Where does the agent keep memory between turns?
Only in the Context: the event log plus loaded skills and visible tools. The model is a pure function of that context, which is why a run can be replayed and tested.
4. A run hits maxSteps. Is that a bug?
No. It is the budget working. The harness stops and says why instead of looping forever and spending money.
5. What is the difference between "ask" and "deny"?
ask means a human can approve it for this run (the approved set). deny means no approval can unlock it from the agent.
Next: Module 2, trust the output
Your harness trusts whatever the model returns. Module 2 breaks that: the model hands back a refund with a malformed amount, and you build a schema gate that repairs once and then fails, plus act / ask / skip thresholds in code.
Founding price $149 (regular $199) for the first 50 buyers or through Nov 8. The waitlist gets first access when checkout opens. No spam, only course updates.