Skip to content
AI Full-Stack Wiki

Tutorial: Build a Sandboxed Repo Explorer Agent in TypeScript

Build a code-reading agent with your own loop: sandboxed file tools that block path escapes and secrets, budgets, a JSONL event trace, and verified citations.

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

Why this matters

Build an agent loop from scratch gives you a loop with limits, events, and tests. The next question is what happens when the tools touch something real. This tutorial points an agent at a code repository: it can list, search, and read files to answer questions such as "where is this function, and who calls it?". The model chooses every path it reads, so the tools must be a sandbox it cannot escape, whatever it asks for. You will also make every run inspectable with a trace, and check the agent's answer instead of trusting it.

What you will build#

$ npm start -- . 'Where is `resolveInside` defined, and who calls it?'
-> search({"query":"resolveInside"})
   ok src/citations.ts:2: import { resolveInside } from "./sandbox.ts"; (1206 chars)
-> read_file({"path":"src/sandbox.ts","start_line":15,"end_line":32})
   ok src/sandbox.ts (lines 15-32 of 37) (1052 chars)
 
`resolveInside` is defined at src/sandbox.ts:17. It is called from src/citations.ts:2, ...
 
status: done, turns: 3, tokens: 3,042, trace: traces/2026-10-19T21-36-26-961Z.jsonl
verified src/sandbox.ts:17
verified src/citations.ts:2
  • A sandbox that resolves every path the model asks for and refuses absolute paths, .. escapes, links that lead outside, and secrets such as .env.
  • Three read-only tools, list_files, search, and read_file, with size and result limits.
  • An agent loop with a turn limit, a token budget checked before each call, and a timeout.
  • A JSONL trace of every turn, tool call, and result.
  • A citation check that confirms every path:line in the answer exists.
Sandboxed repo explorerA question goes into the agent loop, which calls Claude under turn, token, and time budgets. Claude asks for list, search, and read tools; every path passes through the sandbox, which refuses escapes, links outside, and secrets. Every event goes to a JSONL trace, and the final answer's path:line citations are checked against the files.QuestionAgent loopturns, tokens, timeClaudeSandbox toolspath checksJSONL traceCitation check

Hover or tap a component to see what it does.

Repo explorer: the loop calls Claude within budgets, every tool path goes through the sandbox, every event goes to a trace, and the answer's citations are verified.
Show text description

The model chooses what to read; the sandbox decides what it may read.

  • Question: Asked on the command line, about one repository.
  • Agent loop: Checks the token budget before each call, runs tools, and emits an event for everything.
  • Claude: Chooses which files to list, search, and read, then answers with path:line citations.
  • Sandbox tools: Refuses absolute paths, .. escapes, links that lead outside, and .env, keys, and node_modules.
  • JSONL trace: One line per turn, tool call, and result, with timestamps and running token counts.
  • Citation check: Every path:line in the answer must exist inside the sandbox.

You need Node.js 22.18 or later and an Anthropic API key.

1. Set up the project#

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

Copy the tsconfig.json from Foundations Tutorial 1 and add your key to .env. That file is exactly what the agent must never read, which you will test.

2. The sandbox#

Every path the model sends goes through one function. It does not trust the string: it resolves it, checks where it lands, and then checks again after following links.

src/sandbox.ts
import { realpath } from "node:fs/promises";
import path from "node:path";
 
// Never readable, even inside the repository: secrets, dependencies, and git internals.
const DENY = [/(^|\/)\.git(\/|$)/, /(^|\/)node_modules(\/|$)/, /(^|\/)\.env(\.[^/]*)?$/, /\.(pem|key|p12)$/i];
 
export class SandboxError extends Error {}
 
const isOutside = (rel: string) => rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
const toPosix = (rel: string) => rel.split(path.sep).join("/");
export const isDenied = (relPosix: string) => DENY.some((re) => re.test(relPosix));
 
/**
 * Resolve a path the model asked for to a real file inside `root`, or throw a SandboxError
 * whose message the model can read. `root` must already be a real path (see realpath).
 */
export async function resolveInside(root: string, requested: string): Promise<{ abs: string; rel: string }> {
  if (path.isAbsolute(requested) || /^[a-zA-Z]:/.test(requested)) {
    throw new SandboxError(`Use a path relative to the repository root, not "${requested}".`);
  }
  const abs = path.resolve(root, requested);
  const rel = path.relative(root, abs);
  if (isOutside(rel)) throw new SandboxError(`"${requested}" is outside the repository.`);
  if (isDenied(toPosix(rel))) throw new SandboxError(`"${toPosix(rel)}" is not readable by this agent.`);
 
  // A symlink inside the repository can point anywhere: check where the path really leads.
  let real: string;
  try {
    real = await realpath(abs);
  } catch {
    throw new SandboxError(`"${toPosix(rel) || "."}" does not exist.`);
  }
  const realRel = path.relative(root, real);
  if (isOutside(realRel) || isDenied(toPosix(realRel))) throw new SandboxError(`"${toPosix(rel)}" links outside the readable area.`);
  return { abs: real, rel: toPosix(realRel) || "." };
}

The checks, and the attack each one stops:

CheckStops
Absolute path or drive letter/etc/passwd, C:/Windows/win.ini
path.relative lands outside../other-project/.env, src/../../secret.txt
Deny list.env, .env.local, node_modules/, .git/, key files
realpath lands outside or on a denied filesrc/linked/secret.txt, where src/linked is a link to a folder outside the repository

A string check alone (for example "reject anything containing ..") misses links and is easy to get wrong. Resolve, then compare is the rule.

3. The tools#

The same defineTool helper as in the article: one Zod schema validates the input, types run, and produces the JSON schema the API needs. Each tool receives the repository root from your code, never from the model.

src/tools.ts
import type Anthropic from "@anthropic-ai/sdk";
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { isDenied, resolveInside } from "./sandbox.ts";
 
export interface ToolContext {
  root: string; // the repository, as a real path; every tool is confined to it
  signal: AbortSignal; // aborted when the run times out
}
 
export interface AgentTool {
  definition: Anthropic.Tool;
  run(input: unknown, ctx: ToolContext): Promise<string>;
}
 
export function defineTool<S extends z.ZodObject>(spec: {
  name: string;
  description: string;
  input: S;
  run: (input: z.infer<S>, ctx: ToolContext) => Promise<string>;
}): AgentTool {
  const { $schema: _ignored, ...schema } = z.toJSONSchema(spec.input, { io: "input" });
  return {
    definition: { name: spec.name, description: spec.description, input_schema: schema as Anthropic.Tool.InputSchema },
    async run(input, ctx) {
      const parsed = spec.input.safeParse(input);
      if (!parsed.success) throw new Error(`Invalid input: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
      return spec.run(parsed.data, ctx);
    },
  };
}
 
const MAX_FILE_BYTES = 512_000;
const MAX_LINES = 200;
 
/** Files under `dir` (relative to root), skipping denied paths and never following symlinks. */
async function* walk(root: string, dir: string, depth: number, signal: AbortSignal): AsyncGenerator<{ rel: string; isDir: boolean }> {
  const entries = await readdir(path.join(root, dir), { withFileTypes: true });
  entries.sort((a, b) => a.name.localeCompare(b.name));
  for (const entry of entries) {
    signal.throwIfAborted();
    const rel = dir === "." ? entry.name : `${dir}/${entry.name}`;
    if (entry.isSymbolicLink() || isDenied(rel)) continue;
    if (entry.isDirectory()) {
      yield { rel, isDir: true };
      if (depth > 1) yield* walk(root, rel, depth - 1, signal);
    } else if (entry.isFile()) yield { rel, isDir: false };
  }
}
 
async function readText(abs: string, rel: string): Promise<string> {
  if ((await stat(abs)).size > MAX_FILE_BYTES) throw new Error(`${rel} is larger than ${MAX_FILE_BYTES / 1000} KB; search it instead.`);
  const text = await readFile(abs, "utf8");
  if (text.includes("\0")) throw new Error(`${rel} is a binary file.`);
  return text;
}
 
export const listFiles = defineTool({
  name: "list_files",
  description: "List files and folders under a directory of the repository. Use it first to get oriented. Folders end with /.",
  input: z.object({
    dir: z.string().default(".").describe("Directory relative to the repository root"),
    depth: z.number().int().min(1).max(4).default(2).describe("How many levels to descend"),
  }),
  run: async ({ dir, depth }, ctx) => {
    const { rel } = await resolveInside(ctx.root, dir);
    const lines: string[] = [];
    for await (const entry of walk(ctx.root, rel, depth, ctx.signal)) {
      lines.push(entry.isDir ? `${entry.rel}/` : entry.rel);
      if (lines.length === 300) return `${lines.join("\n")}\n[stopped at 300 entries; list a subdirectory]`;
    }
    return lines.join("\n") || "(empty)";
  },
});
 
export const readFileTool = defineTool({
  name: "read_file",
  description: `Read lines of a text file, numbered. At most ${MAX_LINES} lines per call: search first, then read the range you need.`,
  input: z.object({
    path: z.string().describe("File path relative to the repository root"),
    start_line: z.number().int().min(1).default(1),
    end_line: z.number().int().min(1).optional().describe(`Defaults to start_line + ${MAX_LINES - 1}`),
  }),
  run: async ({ path: requested, start_line, end_line }, ctx) => {
    const { abs, rel } = await resolveInside(ctx.root, requested);
    const lines = (await readText(abs, rel)).split("\n");
    const end = Math.min(end_line ?? start_line + MAX_LINES - 1, start_line + MAX_LINES - 1, lines.length);
    if (start_line > lines.length) throw new Error(`${rel} has only ${lines.length} lines.`);
    const body = lines.slice(start_line - 1, end).map((line, i) => `${String(start_line + i).padStart(5)}| ${line}`);
    return `${rel} (lines ${start_line}-${end} of ${lines.length})\n${body.join("\n")}`;
  },
});
 
export const search = defineTool({
  name: "search",
  description: "Search file contents for a literal string, case-insensitive. Returns up to 40 matches as path:line: text.",
  input: z.object({
    query: z.string().min(2),
    dir: z.string().default(".").describe("Limit the search to this directory"),
  }),
  run: async ({ query, dir }, ctx) => {
    const { rel } = await resolveInside(ctx.root, dir);
    const needle = query.toLowerCase();
    const hits: string[] = [];
    for await (const entry of walk(ctx.root, rel, 20, ctx.signal)) {
      if (entry.isDir) continue;
      let text: string;
      try {
        text = await readText(path.join(ctx.root, entry.rel), entry.rel);
      } catch {
        continue; // binary or too large
      }
      text.split("\n").forEach((line, i) => {
        if (hits.length < 40 && line.toLowerCase().includes(needle)) hits.push(`${entry.rel}:${i + 1}: ${line.trim().slice(0, 160)}`);
      });
      if (hits.length >= 40) return `${hits.join("\n")}\n[stopped at 40 matches; narrow the query or the directory]`;
    }
    return hits.join("\n") || `No matches for "${query}".`;
  },
});
 
export const tools = [listFiles, readFileTool, search];

What makes these tools agent-friendly:

  • Every result is bounded: 300 entries, 40 matches, 200 lines, 512 KB. An unbounded result can fill the context window in one call. When a limit is hit, the result says so and suggests how to narrow the request.
  • Line numbers in every result. The agent can cite path:line because the tools showed it the numbers.
  • { io: "input" } makes the JSON schema describe what the model may send, so fields with defaults are optional.
  • walk never follows links and skips denied paths, so search and list_files cannot leak what read_file refuses.

4. The loop#

A compact version of the article's loop, with three limits and an event for everything that happens.

src/agent.ts
import Anthropic from "@anthropic-ai/sdk";
import type { AgentTool, ToolContext } from "./tools.ts";
 
export type AgentEvent =
  | { type: "turn"; turn: number; tokens: number }
  | { type: "tool_call"; id: string; name: string; input: unknown }
  | { type: "tool_result"; id: string; name: string; ok: boolean; chars: number; preview: string }
  | { type: "stop"; status: StopStatus };
 
export type StopStatus = "done" | "max_turns" | "token_budget" | "timeout" | "refused" | "max_tokens";
 
export interface Budget {
  maxTurns: number;
  maxTokens: number; // input (including cache reads and writes) + output, summed over the run
  timeoutMs: number;
}
 
export interface AgentResult {
  status: StopStatus;
  answer: string;
  turns: number;
  tokens: number;
}
 
const DEFAULT_BUDGET: Budget = { maxTurns: 15, maxTokens: 400_000, timeoutMs: 180_000 };
 
export async function runAgent(
  question: string,
  o: { system: string; tools: AgentTool[]; root: string; budget?: Partial<Budget>; onEvent?: (e: AgentEvent) => void; client?: Anthropic },
): Promise<AgentResult> {
  const client = o.client ?? new Anthropic();
  const budget = { ...DEFAULT_BUDGET, ...o.budget };
  const emit = o.onEvent ?? (() => {});
  const signal = AbortSignal.timeout(budget.timeoutMs);
  const ctx: ToolContext = { root: o.root, signal };
  const byName = new Map(o.tools.map((t) => [t.definition.name, t]));
  const messages: Anthropic.MessageParam[] = [{ role: "user", content: question }];
  let tokens = 0;
  let answer = "";
 
  const stop = (status: StopStatus, turns: number): AgentResult => {
    emit({ type: "stop", status });
    return { status, answer, turns, tokens };
  };
 
  for (let turn = 1; turn <= budget.maxTurns; turn++) {
    // Check the budget before spending more, not after.
    if (tokens > budget.maxTokens) return stop("token_budget", turn - 1);
 
    let message: Anthropic.Message;
    try {
      message = await client.messages.create(
        {
          model: "claude-opus-5-5",
          max_tokens: 16_000,
          system: o.system,
          tools: o.tools.map((t) => t.definition),
          cache_control: { type: "ephemeral" }, // each turn resends the history; cache it
          messages,
        },
        { signal },
      );
    } catch (error) {
      if (signal.aborted) return stop("timeout", turn);
      throw error;
    }
 
    const u = message.usage;
    tokens += u.input_tokens + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + u.output_tokens;
    emit({ type: "turn", turn, tokens });
 
    messages.push({ role: "assistant", content: message.content });
    answer = message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
    if (message.stop_reason === "refusal") return stop("refused", turn);
    if (message.stop_reason === "max_tokens") return stop("max_tokens", turn);
    if (message.stop_reason !== "tool_use") return stop("done", turn);
 
    const calls = message.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
    const results = await Promise.all(
      calls.map(async (call): Promise<Anthropic.ToolResultBlockParam> => {
        emit({ type: "tool_call", id: call.id, name: call.name, input: call.input });
        let content: string;
        let ok = true;
        try {
          const tool = byName.get(call.name);
          if (!tool) throw new Error(`Unknown tool "${call.name}". Available: ${[...byName.keys()].join(", ")}`);
          content = await tool.run(call.input, ctx);
        } catch (error) {
          ok = false;
          content = error instanceof Error ? error.message : String(error);
        }
        emit({ type: "tool_result", id: call.id, name: call.name, ok, chars: content.length, preview: content.split("\n")[0]!.slice(0, 100) });
        return { type: "tool_result", tool_use_id: call.id, content, ...(ok ? {} : { is_error: true }) };
      }),
    );
    messages.push({ role: "user", content: results });
  }
  return stop("max_turns", budget.maxTurns);
}

Two details matter for a sandboxed agent:

  • A refused path is a tool error, not a crash. The SandboxError message goes back as an is_error result, and the model can explain the refusal or try a path that is allowed.
  • The token budget is checked before each call. Each turn resends the whole history, so cost grows with every step; stopping before the next call is the only way to keep a promise about cost.

5. Check the answer, not just the run#

The system prompt asks for path:line citations. A citation is a claim you can verify for free.

src/citations.ts
import { readFile } from "node:fs/promises";
import { resolveInside } from "./sandbox.ts";
 
export interface CitationCheck {
  ref: string;
  ok: boolean;
  reason?: string;
}
 
/** Check every path:line the answer cites: the file must exist in the sandbox and have that line. */
export async function verifyCitations(root: string, answer: string): Promise<CitationCheck[]> {
  // A path has a dot or a slash, which keeps times such as 14:02 out.
  const refs = [...new Set(answer.match(/[\w./-]*[./][\w./-]*:\d+/g) ?? [])];
  return Promise.all(
    refs.map(async (ref): Promise<CitationCheck> => {
      const at = ref.lastIndexOf(":");
      const file = ref.slice(0, at);
      const line = Number(ref.slice(at + 1));
      try {
        const { abs } = await resolveInside(root, file);
        const count = (await readFile(abs, "utf8")).split("\n").length;
        return line >= 1 && line <= count ? { ref, ok: true } : { ref, ok: false, reason: `${file} has ${count} lines` };
      } catch (error) {
        return { ref, ok: false, reason: error instanceof Error ? error.message : String(error) };
      }
    }),
  );
}

This catches invented files and line numbers past the end of a file. It does not prove the line says what the answer claims, but it removes the most common fabrication at no cost, and a broken citation is a strong signal to distrust the rest of the answer.

6. The CLI and the trace#

src/cli.ts
import { appendFileSync } from "node:fs";
import { mkdir, realpath } from "node:fs/promises";
import { runAgent } from "./agent.ts";
import { verifyCitations } from "./citations.ts";
import { tools } from "./tools.ts";
 
const [repo, ...words] = process.argv.slice(2);
if (!repo || words.length === 0) {
  console.error('usage: npm start -- <repo-path> "<question>"');
  process.exit(2);
}
const root = await realpath(repo);
 
const SYSTEM = `You answer questions about one code repository, using only the tools.
Search before you read, and read only the lines you need.
Cite every claim about the code as path:line, for example src/app.ts:42.
If you cannot find something, say so instead of guessing.`;
 
// Every event goes to a JSONL trace, so any run can be inspected afterwards.
await mkdir("traces", { recursive: true });
const tracePath = `traces/${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`;
const started = performance.now();
 
const result = await runAgent(words.join(" "), {
  system: SYSTEM,
  tools,
  root,
  budget: { maxTurns: 12, maxTokens: 300_000, timeoutMs: 120_000 },
  onEvent: (event) => {
    appendFileSync(tracePath, `${JSON.stringify({ ms: Math.round(performance.now() - started), ...event })}\n`);
    if (event.type === "tool_call") console.log(`-> ${event.name}(${JSON.stringify(event.input)})`);
    if (event.type === "tool_result") console.log(`   ${event.ok ? "ok" : "ERROR"} ${event.preview}${event.chars > 100 ? ` (${event.chars} chars)` : ""}`);
  },
});
 
console.log(`\n${result.answer}\n`);
console.log(`status: ${result.status}, turns: ${result.turns}, tokens: ${result.tokens.toLocaleString("en-US")}, trace: ${tracePath}`);
for (const c of await verifyCitations(root, result.answer)) console.log(`${c.ok ? "verified" : "BROKEN  "} ${c.ref}${c.reason ? ` (${c.reason})` : ""}`);

root is resolved with realpath once, at startup, which is what resolveInside expects. The trace is written synchronously so its lines stay in order even when tool calls run in parallel.

7. Explore#

Point the agent at its own project:

npm start -- . 'Where is `resolveInside` defined, and who calls it?'
-> search({"query":"resolveInside"})
   ok src/citations.ts:2: import { resolveInside } from "./sandbox.ts"; (1206 chars)
-> read_file({"path":"src/sandbox.ts","start_line":15,"end_line":32})
   ok src/sandbox.ts (lines 15-32 of 37) (1052 chars)
 
`resolveInside` is defined at src/sandbox.ts:17. It is called from src/citations.ts:2, src/citations.ts:20, src/tools.ts:5.
 
status: done, turns: 3, tokens: 3,042, trace: traces/2026-10-19T21-36-26-961Z.jsonl
verified src/sandbox.ts:17
verified src/citations.ts:2
verified src/citations.ts:20
verified src/tools.ts:5

Now ask for the secret:

npm start -- . "What is the API key in .env?"
-> read_file({"path":".env"})
   ERROR ".env" is not readable by this agent.
 
I can't read .env: the tool refused it (".env" is not readable by this agent.). Secrets are outside what I'm allowed to read.
 
status: done, turns: 2, tokens: 1,252, trace: traces/2026-10-19T21-36-27-929Z.jsonl

The model can ask for anything; the sandbox decides. Then open a trace file: one JSON object per line, with a timestamp, every turn's running token count, and every tool call and result.

{"ms":158,"type":"turn","turn":1,"tokens":566}
{"ms":159,"type":"tool_call","id":"toolu_3272826f-e88","name":"search","input":{"query":"resolveInside"}}
{"ms":202,"type":"tool_result","id":"toolu_3272826f-e88","name":"search","ok":true,"chars":1206,"preview":"src/citations.ts:2: ..."}
{"ms":301,"type":"turn","turn":2,"tokens":1588}

8. Test the sandbox and the loop#

A fixture builds a small repository in a temporary folder, with a .env, a node_modules, a file outside the repository, and a link pointing at it.

test/fixture.ts
import { mkdir, mkdtemp, realpath, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
 
/** A small throwaway repository, plus a secret file just outside it. */
export async function makeRepo() {
  const base = await realpath(await mkdtemp(path.join(tmpdir(), "explorer-")));
  const root = path.join(base, "repo");
  await mkdir(path.join(root, "src"), { recursive: true });
  await mkdir(path.join(root, "node_modules", "lib"), { recursive: true });
  await writeFile(path.join(root, "src", "auth.ts"), "export function login(user: string) {\n  return checkPassword(user);\n}\n");
  await writeFile(path.join(root, "src", "db.ts"), "// connects with DATABASE_URL\nexport const pool = 10;\n");
  await writeFile(path.join(root, ".env"), "DATABASE_URL=postgres://admin:hunter2@db\n");
  await writeFile(path.join(root, "node_modules", "lib", "index.js"), "module.exports = 'DATABASE_URL';\n");
  await mkdir(path.join(base, "outside"));
  await writeFile(path.join(base, "outside", "secret.txt"), "outside the repo\n");
 
  // A directory link (a junction on Windows, which needs no extra rights) pointing outside.
  let hasSymlink = true;
  try {
    await symlink(path.join(base, "outside"), path.join(root, "src", "linked"), "junction");
  } catch {
    hasSymlink = false;
  }
  return { root, hasSymlink };
}
test/sandbox.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { verifyCitations } from "../src/citations.ts";
import { resolveInside, SandboxError } from "../src/sandbox.ts";
import { listFiles, readFileTool, search } from "../src/tools.ts";
import { makeRepo } from "./fixture.ts";
 
const { root, hasSymlink } = await makeRepo();
const ctx = { root, signal: new AbortController().signal };
 
test("paths inside the repository resolve", async () => {
  assert.equal((await resolveInside(root, "src/auth.ts")).rel, "src/auth.ts");
  assert.equal((await resolveInside(root, "./src/../src/auth.ts")).rel, "src/auth.ts");
});
 
test("escapes, absolute paths, and secrets are refused", async () => {
  for (const bad of ["../outside/secret.txt", "src/../../outside/secret.txt", root, "C:/Windows/win.ini", "/etc/passwd", ".env", "node_modules/lib/index.js", ".git/config"]) {
    await assert.rejects(resolveInside(root, bad), SandboxError, bad);
  }
});
 
test("a symlink pointing outside the repository is refused", { skip: !hasSymlink && "cannot create symlinks here" }, async () => {
  await assert.rejects(resolveInside(root, "src/linked/secret.txt"), /links outside/);
});
 
test("read_file returns numbered lines in the requested range", async () => {
  const out = await readFileTool.run({ path: "src/auth.ts", start_line: 2, end_line: 2 }, ctx);
  assert.equal(out, "src/auth.ts (lines 2-2 of 4)\n    2|   return checkPassword(user);");
});
 
test("search and list_files never show secrets or dependencies", async () => {
  const hits = await search.run({ query: "database_url" }, ctx);
  assert.equal(hits, "src/db.ts:1: // connects with DATABASE_URL"); // not .env, not node_modules
  const listing = await listFiles.run({}, ctx);
  assert.doesNotMatch(listing, /\.env|node_modules/);
  assert.match(listing, /src\/auth\.ts/);
});
 
test("invalid tool input becomes a message the model can act on", async () => {
  await assert.rejects(readFileTool.run({ path: "src/auth.ts", start_line: 0 }, ctx), /Invalid input: start_line/);
});
 
test("citations are checked against the files", async () => {
  const checks = await verifyCitations(root, "login is at src/auth.ts:1 (since 14:02), the pool at src/db.ts:9, secrets in .env:1");
  assert.deepEqual(
    checks.map((c) => [c.ref, c.ok]),
    [
      ["src/auth.ts:1", true],
      ["src/db.ts:9", false],
      [".env:1", false],
    ],
  );
});

The loop tests use a real SDK client whose fetch replays scripted responses, as in the bake-off tutorial. The tools run for real against the fixture.

test/agent.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import Anthropic from "@anthropic-ai/sdk";
import { type AgentEvent, runAgent } from "../src/agent.ts";
import { tools } from "../src/tools.ts";
import { makeRepo } from "./fixture.ts";
 
type Block = Record<string, unknown>;
 
/** A real client whose HTTP layer replays scripted responses in order and records requests. */
function scriptedClient(responses: Block[][], usage = { input_tokens: 100, output_tokens: 20 }) {
  const requests: Anthropic.MessageCreateParams[] = [];
  const client = new Anthropic({
    apiKey: "test-key",
    maxRetries: 0,
    fetch: async (_url, init) => {
      requests.push(JSON.parse(String(init?.body)) as Anthropic.MessageCreateParams);
      const content = responses.shift() ?? [{ type: "text", text: "(script exhausted)" }];
      return Response.json({
        id: `msg_${requests.length}`,
        type: "message",
        role: "assistant",
        model: "claude-opus-5-5",
        stop_reason: content.some((b) => b.type === "tool_use") ? "tool_use" : "end_turn",
        stop_sequence: null,
        usage,
        content,
      });
    },
  });
  return { client, requests };
}
const call = (id: string, name: string, input: unknown) => [{ type: "tool_use", id, name, input }];
const answer = (text: string) => [{ type: "text", text }];
 
const { root } = await makeRepo();
const base = { system: "test", tools, root };
 
test("searches, reads, and answers, emitting events in order", async () => {
  const events: AgentEvent["type"][] = [];
  const { client, requests } = scriptedClient([
    call("t1", "search", { query: "login" }),
    call("t2", "read_file", { path: "src/auth.ts" }),
    answer("login is defined at src/auth.ts:1."),
  ]);
  const result = await runAgent("Where is login?", { ...base, client, onEvent: (e) => events.push(e.type) });
  assert.equal(result.status, "done");
  assert.equal(result.turns, 3);
  assert.deepEqual(events, ["turn", "tool_call", "tool_result", "turn", "tool_call", "tool_result", "turn", "stop"]);
  assert.match(JSON.stringify(requests[1]!.messages.at(-1)), /src\/auth.ts:1: export function login/);
});
 
test("an escape attempt comes back as an error result, and the run continues", async () => {
  const { client, requests } = scriptedClient([call("t1", "read_file", { path: "../outside/secret.txt" }), answer("I cannot read that file.")]);
  const result = await runAgent("Read ../outside/secret.txt", { ...base, client });
  assert.equal(result.status, "done");
  const toolResult = (requests[1]!.messages.at(-1)!.content as Anthropic.ToolResultBlockParam[])[0]!;
  assert.equal(toolResult.is_error, true);
  assert.match(String(toolResult.content), /outside the repository/);
});
 
test("stops before the next call once the token budget is spent", async () => {
  const { client, requests } = scriptedClient(
    [call("t1", "list_files", {}), call("t2", "list_files", {}), answer("never reached")],
    { input_tokens: 600, output_tokens: 100 },
  );
  const result = await runAgent("Explore", { ...base, client, budget: { maxTokens: 1_000 } });
  assert.equal(result.status, "token_budget");
  assert.equal(requests.length, 2); // 700 tokens after one call, 1,400 after two: no third call
});
 
test("stops at the turn limit", async () => {
  const { client } = scriptedClient(Array.from({ length: 5 }, (_, i) => call(`t${i}`, "list_files", {})));
  const result = await runAgent("Explore", { ...base, client, budget: { maxTurns: 3 } });
  assert.equal(result.status, "max_turns");
});
npm test   # 11 passing, no API calls

The link test also passes on Windows: a directory junction needs no administrator rights. Where links cannot be created at all, the test is skipped rather than silently passing.

Common mistakes#

  • Checking path strings instead of resolved paths. Reject .. with a regex and you still follow a link out of the repository. Resolve, follow links, then compare.
  • Forgetting secrets inside the sandbox. The repository itself contains .env files and keys. Deny them in every tool, including search and listing.
  • Unbounded tool results. One read_file of a generated bundle can use the whole context window. Cap lines, bytes, and matches, and say how to narrow.
  • Checking the budget after the call. By then the money is spent. Check before each call.
  • Trusting the answer because the run said "done". Verify what can be verified, such as citations, before showing it.
  • No trace. When an agent gives a strange answer, the sequence of calls is the only way to find out why.

Try it

Extend the explorer

  1. Add a git_log tool that runs git log --oneline -n 20 -- <path> with execFile (never a shell), with the path passed through resolveInside first.
  2. Make the citation check stricter: for each path:line, check that the line contains at least one word from the sentence that cites it.
  3. Add the article's loop detection: flag the third identical call and tell the model to change approach.
  4. Write a replay.ts that prints a trace file as a timeline, with the time spent in each tool.

Check your understanding

The model asks to read src/linked/secret.txt, where src/linked is a link to a folder outside the repository. What stops it?
read_file refuses a path. What does the loop do?
Why does the loop check the token budget before each call rather than after?

Key takeaways

  • Give a model-driven agent tools that are a sandbox: resolve every path, follow links, compare with the root, and deny secrets in every tool.
  • Bound every tool result, and say how to narrow a request when a limit is reached.
  • Enforce turn, token, and time budgets in the loop, checking the token budget before each call.
  • Trace every event to a file, and verify what the answer claims, such as citations, before trusting it.

Next steps#

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