Ship a Safe Agent

Join the course waitlist

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. Unsubscribe anytime.

Module 1 of 8 · Free

The loop: build an agent that cannot go rogue

Six small steps in TypeScript. No API key, no framework. Every step runs, prints known output, and is checked by a test.

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:

terminal npm start
$ 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 learnWhy it matters
Permissions in code, not promptsPrompts are suggestions. Code is a rule.
Skills that reveal tools on demandFewer tools in view means fewer wrong calls.
A fake model you can testDeterministic runs in CI, zero tokens.
Budgets and an event logEvery 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

The model proposes a turn. The harness checks skill, permission and budget before a tool runs. Every step is written to the event log.Modelproposes a TurnHARNESS (your code decides)Skill?tool visiblePermission?allow ask denyBudget?maxStepsTool runsonly if all passEvent logevery turn, block, resultnext turn readsthe context
The model only ever proposes. The harness decides. The log remembers.

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.

terminal
$ git clone https://github.com/bobbyhalljr/ship-a-safe-agent-module-1.git$ cd ship-a-safe-agent-module-1$ npm install$ npm run check

npm 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

Goal: Every tool the agent could ever touch is declared once, with a permission attached.

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.

steps/01-registry/agent.ts new file
// 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

terminal
$ npm run step steps/01-registry/run.ts

You should see

expected.txt
allow  lookup_orderask    issue_refunddeny   delete_customerallow  track_package
Common mistake

Making 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.

In production you would also...
  • 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

Goal: One pure function decides if a tool may run right now.

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.

steps/02-permissions/agent.ts +7 lines
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

terminal
$ npm run step steps/02-permissions/run.ts

You should see

expected.txt
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: ok
Common mistake

Letting 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.

In production you would also...
  • 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

Goal: Tools stay hidden until the skill that owns them loads, and all state lives in one 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.

steps/03-skills-and-context/agent.ts +37 lines
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

terminal
$ npm run step steps/03-skills-and-context/run.ts

You should see

expected.txt
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.
Common mistake

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.

In production you would also...
  • 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

Goal: Replace the LLM with a scripted function so every run is deterministic.

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.

steps/04-fake-model/agent.ts +16 lines
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

terminal
$ npm run step steps/04-fake-model/run.ts

You should see

expected.txt
turn 1: {"kind":"load_skill","name":"refunds"}turn 2: {"kind":"call","name":"lookup_order","args":{"id":"1042"}}
Common mistake

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).

In production you would also...
  • Keep the same Model type and swap in a real provider adapter.
  • Record real model turns and replay them as fixtures.

5. The loop, with limits in the harness

Goal: Run turns until an answer, checking skill, permission and budget before any tool runs.

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.

steps/05-the-loop/agent.ts +30 lines
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

terminal
$ npm run step steps/05-the-loop/run.ts

You should see

expected.txt
{"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."}
Common mistake

Checking permissions inside the tool function. Then a blocked call has already started running. Check in the loop, before tool.run is ever called.

In production you would also...
  • Add a wall clock timeout and a cost budget next to maxSteps.
  • Pause on ask and resume after approval instead of ending the run (Module 7).

6. The event log

Goal: Turn raw events into a log a human can read in five seconds.

formatLog prints one line per event. The run script executes the same task three ways so you can see each guard fire.

steps/06-event-log/agent.ts +13 lines
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

terminal
$ npm run step steps/06-event-log/run.ts

You should see

expected.txt
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: refunds
Common mistake

Logging only the final answer. When something goes wrong you need every call, result and block, in order.

In production you would also...
  • 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.

terminal
$ npm test
exercise/agent.ts your TODOs
// ===== 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");}
  1. shippingModel: answer "Where is my package for order 1042?" using only the shipping skill.
  2. runAgentWithBudget: stop after maxToolCalls tool calls.
  3. 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
exercise/agent.ts solution branch
// ===== 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. deny cannot 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.

Get the full courseJoin the course waitlistPreview Module 2

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.