Tools I: Eyes (read_file, list_files)


Lecture Overview

This lecture writes the first real function of the course: runTool, the piece of index.ts that turns a model's tool-use request into something that actually happens on your machine. TOOLS — the schema array read in lecture 01 — is the "ask" half, already fully written; runTool is the "run" half, and right now it's a single line: throw new Error("YOUR CODE HERE"). Every test in tests/02_read_only_tools.test.ts calls runTool directly — no model, no loop, no network — so today is entirely about that one function.

runTool is a switch on the tool's name that routes to the right implementation, with a default case that refuses anything it doesn't recognize. This lecture writes two of its five branches — read_file and list_files, the tools that only look, never change anything. write_file, edit_file, and bash — the branches that touch disk and process — go into this same switch in lecture 03.

How this lecture works: two numbered problems, 2.1 and 2.2, each with a spec, a worked example, constraints, and 2–3 unlock questions whose answers live only in the Appendix. Write real code in index.ts before checking each snippet. This lecture is worth 2 of the course's 10 points: ./ok 02 green plus correct unlock answers.


1. What Is a Tool, Really? Two Halves

Concept

You already met this idea in lecture 01 as an abstract definition; today you see its concrete split inside index.ts itself. Scroll to the top of section 1 and you'll find this comment, verbatim:

// A "tool" is two things: a JSON Schema that DESCRIBES it to the model, and a
// plain function that RUNS it. The model never executes anything — it only
// asks; runTool() below is where things actually happen on this machine.
//
// A tool is a contract: the schema is the signature the model programs
// against; runTool is the implementation behind it.

This is the same split you've used for years without naming it "agent architecture": an interface (a name, a signature, a contract — no body) versus an implementation (the body that actually does the work). TOOLS[0] — the read_file entry — is the interface: it tells the model "there's a function called read_file, it takes one required string field path, and here's a sentence describing what it does." Nothing in that entry touches a disk. The disk-touching happens forty lines lower, inside runTool, in code the model never sees and never runs itself.

Why It Matters

This split is why you can trust an LLM near your filesystem at all without also trusting it to directly execute arbitrary code. The model's output is always just text in a particular shape — a tool_use block asking for read_file with some path. That request is completely inert (lecture 01, §1's checkpoint) until your runTool — code you wrote, code you can read start to finish — decides what "running read_file" actually means. If runTool's read_file case silently also deleted the file first, the model would never know; it only sees whatever string runTool hands back. The schema is a promise about shape; runTool is the only place that promise gets kept.

Your Task

No code yet — predict, then check. TOOLS[1] (given, in index.ts) reads:

{
  name: "list_files",
  description:
    "List the entries of a directory (one level). Directories end with '/'. Call again on a subdirectory to go deeper.",
  input_schema: {
    type: "object",
    properties: { path: { type: "string", description: "Directory to list; defaults to '.'" } },
    required: [],
  },
},

Suppose, hypothetically, runTool's list_files case actually listed directories recursively, ignoring the "one level" claim in the description. Would TypeScript's compiler catch this mismatch? Would ./ok 02? What's the only thing in this entire system that could ever notice the lie?

Checkpoint

Neither the compiler nor ./ok 02 cross-checks a tool's prose description against what its runTool case actually does — nothing type-checks English against behavior. Compare that with input_schema, whose type/required fields would eventually matter for validating the shape of input (not enforced by runTool itself, but by convention the model is expected to follow, since it was trained to produce arguments matching the schema it was given). The only thing that could ever "notice" a lying description is the model, mid-conversation, when the tool's real behavior doesn't match what it was told — and by then it's already made a decision based on the wrong assumption. This is why tool descriptions are load-bearing documentation, not comments: get one wrong and nothing red-flags it for you.

Stuck? See Snippet 1 in the Appendix.


2. Reading the Contract: The TOOLS Array

Concept

Before writing runTool, read TOOLS closely — all of it, not just the two entries you'll implement today. It's the model-facing interface for all five tools, and it's already complete; your job in this section is comprehension, not code. Every field in there is a constraint your implementation must satisfy, and every field it omits is freedom your implementation has.

Two fields matter most for today's work:

Why It Matters

callModel (lecture 05) passes tools: TOOLS straight to the Anthropic API on every single call. The model re-reads this entire array every turn, the same way it re-reads all of messages (lecture 01, §2). If read_file's description were vague or wrong, you'd see it as misbehavior three lectures from now — the model calling tools with wrong arguments, or not calling a tool it clearly needed — with no stack trace pointing back to the real cause. Reading the contract now, carefully, is what lets you debug that gap later instead of guessing at it.

Your Task

Answer without running anything:

  1. read_file's input_schema.properties.path has a description field ("Relative path to the file"). list_files's path property also has one ("Directory to list; defaults to '.'"). Are these two description fields read by your TypeScript code anywhere in runTool? If not, who are they for?
  2. Given required: [] for list_files, is it possible for the model to call list_files with input being the empty object {}? What must runTool's list_files case do to stay correct in that case?

Checkpoint

(1) No — runTool never reads input_schema.properties.path.description at all; that string exists purely for the model, sent over the wire inside the tools array on every callModel call, exactly like a docstring the model reads before deciding how to fill in arguments. Your TypeScript code only ever reads input.path (the actual value the model supplied), never the schema's description of what that field means. (2) Yes — {} is a fully valid call because path isn't required. runTool's list_files case must supply its own default (.) when input.path is absent, exactly as its description promised. If you build list_files assuming input.path is always present, you have a contract mismatch that only shows up when the model calls it the "empty" way — which is exactly what tests/02_read_only_tools.test.ts's "defaults to '.'" test checks.

Stuck? See Snippet 2 in the Appendix.


3. The Dispatch Table: runTool's Shape

Concept

runTool has one job: given a tool name (a string) and its input (an object), run the matching implementation and return a string. You've written this shape before, even if you didn't call it this: a dispatch table (also called a jump table) — one entry point that routes to N different code paths based on a key, instead of hand-rolling if (name === "read_file") {...} else if (name === "list_files") {...} else if (...). TypeScript's switch statement is a dispatch table:

switch (name) {
  case "read_file":
    // ...
  case "list_files":
    // ...
  default:
    // ...
}

Each case is a branch; default is the fallback when name matches no case at all. runTool's default case is not optional flavor: since name arrives as a plain string (not a restricted union type — input: any means TypeScript isn't checking any of this at compile time), any string could show up, including typos, or tool names from some future version of TOOLS you haven't written a case for yet. default is where you refuse those, loudly.

TS-ism: each case here ends in a return, so there's no fallthrough to worry about (the classic C/Java switch footgun where control "falls through" to the next case unless you break). If a case doesn't return, control does fall through to the next one — but every branch you write today returns immediately, so you can set that concern aside for now.

Why It Matters

Five tools, one function, one name string deciding which of five completely different pieces of code runs. Get the dispatch wrong — misspell a case label, forget default, let a case fall through — and the failure mode is silent and specific: the model asks for list_files, and either nothing happens, the wrong tool runs, or an unrelated exception surfaces with no clue that dispatch was the problem. A dispatch table's whole value proposition is that adding a sixth tool later means adding one case, touching nothing else — which is exactly how lecture 03 will extend this same function without rewriting it.

Your Task — Problem 2.1: read_file

Spec. Implement the read_file case (plus the switch skeleton and default case, since this is the first case you're writing). read_file receives input.path, reads that file, and returns its contents as a string.

Worked example. Given a file hello.txt containing the bytes hello nano (no trailing newline), runTool("read_file", { path: "hello.txt" }) must return the string "hello nano" — not "hello nano\n", not a Buffer, not JSON-wrapped. Exact bytes, as UTF-8 text.

Constraints.

Hint: fs.readFileSync takes a path and an encoding, and its return value when given "utf8" is already the string runTool needs — no further processing required. Resist writing a try/catch around it — §5 explains why letting it throw is the correct behavior, not a shortcut.
Common mistake: tests/02_read_only_tools.test.ts:26 has a test named "throws on a missing file so the loop can hand the error back to the model" that just calls expect(() => runTool(...)).toThrow(). The untouched stub — throw new Error("YOUR CODE HERE") — also throws on every call, so this specific test passes even before you write any code. Don't mistake it for evidence your read_file case works; check the "returns the exact bytes" test too, since that one can't pass by accident.

Unlock questions:

  1. Lecture 01 §3 Q4 already established that an error is still a valid result, not a crash (recall the skeleton's doc comment). Given that precedent, why is it correct — not lazy — to let fs.readFileSync's exception propagate out of runTool instead of catching it and returning an error string yourself?
  2. Since input: any, TypeScript will not stop you from calling runTool("read_file", {}) — an object with no path key at all. What happens at runtime when input.path is undefined and you call fs.readFileSync(undefined, "utf8")?

Checkpoint

Once your read_file case is in place, you can check it in isolation — before touching list_files — by filtering vitest directly (bypassing ./ok's all-or-nothing wrapper):

npx vitest run tests/02_read_only_tools.test.ts -t "read_file"

Expected:

 RUN  v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano

 Test Files  1 passed (1)
      Tests  2 passed | 2 skipped (4)

Both read_file tests pass; the two list_files tests are skipped by the -t filter, not failing — that's expected, you haven't written that case yet.

Stuck? See Snippet 3 in the Appendix.


4. Problem 2.2: list_files and Node's fs.readdirSync

Concept

list_files is one level deeper than read_file in two ways: its input is optional (§2), and its output requires classifying each entry, not just returning raw bytes. fs.readdirSync(dir, { withFileTypes: true }) returns an array of Dirent objects (short for "directory entry") instead of plain filename strings. Gloss: Dirent is a small Node.js class wrapping one directory entry; it carries .name (the filename, a string) and methods like .isDirectory(), .isFile() that tell you what kind of entry it is — information a plain string[] of names could never carry, since a name alone doesn't say whether "sub" is a file called sub or a directory called sub.

That's exactly the distinction the tool's description promises: "Directories end with '/'." You build that suffix yourself, per entry, using .isDirectory().

Why It Matters

Look back at TOOLS[1]'s description: "List the entries of a directory (one level)... Call again on a subdirectory to go deeper." Notice what's absent from runTool: any recursion. list_files never calls itself. The "going deeper" the description promises happens entirely on the model's side of the wire — this is the lecture 01 trace again: the model sees "sub/" in the result, decides that's interesting, and issues a second, separate tool_use request for list_files with path: "sub" on its next turn. Two calls to runTool, two round trips through agentLoop (lecture 05), zero loops inside list_files itself. The "/" suffix isn't cosmetic — it's the one signal the model has to decide whether recursing is worth it at all.

Your Task

Spec. Add a list_files case to the same switch. Given a directory path (or . if input.path is absent), return a newline-joined string of that directory's immediate entries, with / appended to any entry that's a directory.

Worked example. A directory containing a file a.txt and a subdirectory sub should produce the string "a.txt\nsub/" (order doesn't matter — the tests sort before comparing). Note there's no trailing newline, and no separate output for what's inside sub.

Constraints.

Hint: Method chaining does the whole case in one expression: start from fs.readdirSync(dir, { withFileTypes: true }), then you'll chain .map() over the resulting Dirent[] to build the "/" suffix per entry, then .join("\n") to collapse the array into the one string runTool must return.
Common mistake: Forgetting { withFileTypes: true }. Without it, fs.readdirSync returns plain string[] filenames with no .isDirectory() method at all — TypeScript will flag the missing method as a compile error, which is at least loud, but it's easy to lose an hour wondering why Dirent methods "don't exist" before realizing the option was never passed.

Unlock questions:

  1. Why does fs.readdirSync(dir, { withFileTypes: true }) return Dirent[] instead of string[] — what specific piece of information would be unrecoverable from a bare filename string alone?
  2. The docstring says "Call again on a subdirectory to go deeper." runTool's list_files case contains no loop and no recursive call. Where, concretely — which function, in which lecture — is the "calling again" actually implemented?
  3. Would input.path || "." (using || instead of ??) behave differently from input.path ?? "." for any input the model could realistically send, given path's schema type is string?

Checkpoint

Run the full lecture 02 suite:

./ok 02

Expected — real output, captured from this exact repo:

ok: running tests for problem 02

 RUN  v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano

 Test Files  1 passed (1)
      Tests  4 passed (4)

All 4 tests green: both read_file tests and both list_files tests. That's Problem 2.1 and 2.2 done — 2 of the course's 10 points, plus correct answers to every unlock question above.

Stuck? See Snippet 4 in the Appendix.


5. Errors Throw, Not Return

Concept

Look at what you just wrote: neither case catches anything. fs.readFileSync on a missing path throws; you don't wrap it in try/catch; it just propagates straight out of runTool. This is the convention for every tool in this course, not just today's two: failures throw exceptions. runTool never returns an error as a normal string, and never returns null/undefined to signal "something went wrong."

Compare this to a design where runTool returned, say, { ok: false, error: "..." } — a result type you'd have to check at every call site. Throwing instead means the type signature stays simple (runTool always returns a plain string, full stop) while failure is handled structurally, by whoever calls runTool and chooses to catch. Right now, nothing catches it — tests/02_read_only_tools.test.ts's "throws on missing file" test is itself the catcher, using expect(() => ...).toThrow().

Why It Matters

This is a seed for lecture 05, so plant it now rather than relearning it then: agentLoop will wrap every runTool call in a try/catch, and when it catches something, it doesn't crash the program or drop the tool call — it converts the exception into an ordinary tool_result block with is_error: true, and sends that back to the model, exactly like a successful result. The model reads the error message as text and gets to react — retry with a different path, ask the user, give up gracefully. You already saw this predicted in lecture 01's unlock question 4 (the hypothetical edit_file failure): "the loop does not stop — an error is still a result, just an unhappy one." Today's read_file throwing on a missing file is the first real instance of that pattern, not just a hypothetical.

Your Task

No new code — just connect the dots. In lecture 01's trace, messages[4] held a successful tool_result (is_error: false). If, instead, the model had asked for read_file on a path that doesn't exist, and your runTool throws exactly the way you just built it to, what string would end up inside that tool_result's content field once lecture 05's loop catches it? (You don't need lecture 05's code to answer this — you need to know what err.message looks like for a Node fs error.)

Checkpoint

Something close to "ENOENT: no such file or directory, open 'nope.txt'" — whatever fs.readFileSync puts in its Error.message when the file is missing, unmodified, because runTool never touches or rewrites the error before it leaves the function. Whatever string Node's error carries is exactly what the model will eventually read as the reason its request failed — one more reason not to swallow or generic-ify errors inside runTool today, even though nothing is watching for it yet.

Stuck? See Snippet 5 in the Appendix.


Key Takeaways

  1. A tool is a schema plus a function, and only one of those two you're allowed to touch today. TOOLS is a fixed, model-facing contract; runTool is the implementation, and nothing checks that the two agree except careful reading on your part.
  1. switch (name) { ... default: ... } is a dispatch table — one entry point that routes to N implementations by a key, with default as the mandatory safety net for any tool name that isn't (yet, or ever) a real case.
  1. Node's fs.readFileSync/fs.readdirSync are synchronous and block until disk I/O completes — simple and correct for this single-user CLI; the async/await alternative and why it matters is lecture 04's job.
  1. { withFileTypes: true } turns filenames into Dirents, the only way to know whether an entry is a file or a directory without a second fs call per entry.
  1. list_files never recurses — the model does, one tool_use call at a time. "One level" in the schema and "no loop in runTool" are the same design decision seen from two sides.
  1. Errors throw; nothing in runTool catches its own failures. That's not an oversight — it's a contract lecture 05's agentLoop is built to consume, converting any thrown exception into an is_error: true tool_result the model reads like any other answer.

What we skipped (for future study or future lectures)


Appendix: Reference Implementation

Check your work here after attempting each section. Don't read ahead — one snippet at a time.

Snippet 1: The two halves, in index.ts
export const TOOLS: Anthropic.Tool[] = [
  {
    name: "read_file",
    description: "Read and return the contents of a file at a relative path.",
    input_schema: {
      type: "object",
      properties: { path: { type: "string", description: "Relative path to the file" } },
      required: ["path"],
    },
  },
  {
    name: "list_files",
    description:
      "List the entries of a directory (one level). Directories end with '/'. Call again on a subdirectory to go deeper.",
    input_schema: {
      type: "object",
      properties: { path: { type: "string", description: "Directory to list; defaults to '.'" } },
      required: [],
    },
  },
  // ... write_file, edit_file, bash follow (unchanged by this lecture)
];

This whole array is GIVEN — already in your index.ts, nothing to write. The two entries above are the "ask" half for today's two tools; runTool's read_file/list_files cases (Snippets 3–4) are the "run" half.

Snippet 2: Unlock answers — contract reading
  1. No — input_schema.properties.*.description strings are never read by any TypeScript in this project. They exist solely for the model: sent verbatim inside tools: TOOLS on every callModel call (lecture 05), read by the model when it decides how to fill in a tool call's arguments. runTool only ever reads the values the model actually sent (input.path), never the schema text describing what that field means.
  2. Yes, {} is valid for list_files since required: []. runTool's list_files case must handle input.path === undefined by substituting ".", or it violates the schema's own promised default. This is exactly what the "defaults to '.'" test in tests/02_read_only_tools.test.ts checks.
Snippet 3: Dispatch skeleton + read_file
export function runTool(name: string, input: any): string {
  switch (name) {
    case "read_file":
      return fs.readFileSync(input.path, "utf8");

    default:
      throw new Error(`Unknown tool: ${name}`);
  }
}
Snippet 3: Dispatch skeleton + read_file — unlock answers
  1. runTool's contract is "return a string, or throw." There is no third option for "something went wrong but I'll still return a string" — that would force every caller to inspect the returned string to guess whether it's real content or an error message, which is exactly the ambiguity a thrown exception avoids. Section 5 formalizes this: letting the exception propagate is the entire convention, not a shortcut you're taking because catching is hard.
  2. fs.readFileSync(undefined, "utf8") throws a TypeError at runtime (Node's fs module expects its first argument to be a string, Buffer, URL, or file descriptor — undefined satisfies none of those). TypeScript's input: any means this isn't caught at compile time; it surfaces only when the code actually runs, which is precisely why the tests exist — to catch what the type system, by design, opted out of catching here.
Snippet 4: list_files case + full runTool so far
export function runTool(name: string, input: any): string {
  switch (name) {
    case "read_file":
      return fs.readFileSync(input.path, "utf8");

    case "list_files": {
      const dir = input.path ?? ".";
      return fs
        .readdirSync(dir, { withFileTypes: true })
        .map((e) => (e.isDirectory() ? e.name + "/" : e.name))
        .join("\n");
    }

    default:
      throw new Error(`Unknown tool: ${name}`);
  }
}

(write_file, edit_file, bash cases are added into this exact switch in lecture 03 — nothing above changes when they arrive.)

Snippet 4: list_files case + full runTool so far — unlock answers
  1. A bare filename string can't distinguish a file named sub from a directory named sub — the name alone carries no type information. Dirent.isDirectory() calls back into the OS's directory-entry metadata (already fetched by readdirSync when you pass withFileTypes: true) to answer that question without a second filesystem call per entry.
  2. Nowhere in runTool, and nowhere in lecture 02 at all. It's agentLoop (lecture 05): the model receives "sub/" as part of list_files's result, and if it wants to look inside sub, it issues a brand-new tool_use block requesting list_files again with path: "sub" on its next turn — the same "the model recurses by calling again" pattern from lecture 01's trace, just with list_files instead of read_file/edit_file.
  3. No, not for any input the model can realistically send. path's schema type is string, and the only "falsy for || but not for ??" case here is an explicit empty string "" — which the model would only send if it deliberately meant "list the empty-string path," an edge case neither operator changes in a way that matters for this course's tests or fixture. They diverge in general (0, false, "" vs. only null/undefined), but path never carries 0 or false, so the two are interchangeable here — ?? is simply the more precise tool for "was this omitted," and worth defaulting to as a habit.
Snippet 5: Tracing an error forward to lecture 05

If runTool("read_file", { path: "nope.txt" }) throws Error: ENOENT: no such file or directory, open 'nope.txt', lecture 05's agentLoop catches it and pushes:

{ type: "tool_result", tool_use_id: "toolu_...", content: "ENOENT: no such file or directory, open 'nope.txt'", is_error: true }

content is err.message, untouched — the same string Node itself produced, now readable by the model as its next turn's input. Nothing about today's read_file case changes to make this work later; it works because you let the exception propagate instead of catching it.


End of lecture.