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:
required—read_file's schema hasrequired: ["path"];list_files's hasrequired: []. That's not decoration. It's the contract's way of saying "the model must always supply a path to read a file, but may omit it when listing, and here's what happens if it does" (seelist_files's description:"defaults to '.'").description— the only channel the model has for understanding what a tool does, since it never seesrunTool's source. Every English sentence in there is doing real work: "one level," "Directories end with '/'," "Call again on a subdirectory to go deeper" are all promises yourrunToolimplementation is about to make true.
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:
read_file'sinput_schema.properties.pathhas adescriptionfield ("Relative path to the file").list_files'spathproperty also has one ("Directory to list; defaults to '.'"). Are these twodescriptionfields read by your TypeScript code anywhere inrunTool? If not, who are they for?- Given
required: []forlist_files, is it possible for the model to calllist_fileswithinputbeing the empty object{}? What mustrunTool'slist_filescase 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.
- Use
fs.readFileSync— already imported at the top ofindex.tsasimport * as fs from "node:fs". Signature:fs.readFileSync(path: string, encoding: string): stringwhen you pass an encoding; without an encoding argument it returns aBuffer(raw bytes, not astring) — which would fail every test here sincerunTool's return type isstring. Pass"utf8"explicitly. - If the file doesn't exist,
fs.readFileSyncthrows aNode.jserror (ENOENT: no such file or directory) on its own — you don't need to check existence yourself or write your own error message. Let it propagate. fs.readFileSyncis synchronous — it blocks the whole program until the disk responds, unlike thefs.promises.readFile/awaitpattern you may have seen elsewhere. That's deliberate for this course; why synchronous is fine here (and what async buys you) is lecture 04's job, once you've seen realawaitcode. For today, synchronous is simply correct and simpler.
Hint:fs.readFileSynctakes a path and an encoding, and its return value when given"utf8"is already the stringrunToolneeds — no further processing required. Resist writing atry/catcharound it — §5 explains why letting it throw is the correct behavior, not a shortcut.
Common mistake:tests/02_read_only_tools.test.ts:26has a test named "throws on a missing file so the loop can hand the error back to the model" that just callsexpect(() => 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 yourread_filecase works; check the"returns the exact bytes"test too, since that one can't pass by accident.
Unlock questions:
- 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 ofrunToolinstead of catching it and returning an error string yourself? - Since
input: any, TypeScript will not stop you from callingrunTool("read_file", {})— an object with nopathkey at all. What happens at runtime wheninput.pathisundefinedand you callfs.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.
input.path ?? "."— the??(nullish coalescing) operator, not||.??only falls back when the left side isnull/undefined;||would also fall back on an empty string""or0, which isn't what you want here (an explicit""isn't the same as "omitted").input.pathis either a real string orundefined(never supplied), so??is the precise tool.- Build the
"/"suffix per-entry with.isDirectory(); join everything with"\n"via.join("\n"). - One level only — do not read into subdirectories yourself.
Hint: Method chaining does the whole case in one expression: start fromfs.readdirSync(dir, { withFileTypes: true }), then you'll chain.map()over the resultingDirent[]to build the"/"suffix per entry, then.join("\n")to collapse the array into the one stringrunToolmust return.
Common mistake: Forgetting{ withFileTypes: true }. Without it,fs.readdirSyncreturns plainstring[]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 whyDirentmethods "don't exist" before realizing the option was never passed.
Unlock questions:
- Why does
fs.readdirSync(dir, { withFileTypes: true })returnDirent[]instead ofstring[]— what specific piece of information would be unrecoverable from a bare filename string alone? - The docstring says "Call again on a subdirectory to go deeper."
runTool'slist_filescase contains no loop and no recursive call. Where, concretely — which function, in which lecture — is the "calling again" actually implemented? - Would
input.path || "."(using||instead of??) behave differently frominput.path ?? "."for any input the model could realistically send, givenpath's schema type isstring?
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
- A tool is a schema plus a function, and only one of those two you're allowed to touch today.
TOOLSis a fixed, model-facing contract;runToolis the implementation, and nothing checks that the two agree except careful reading on your part.
switch (name) { ... default: ... }is a dispatch table — one entry point that routes to N implementations by a key, withdefaultas the mandatory safety net for any tool name that isn't (yet, or ever) a real case.
- Node's
fs.readFileSync/fs.readdirSyncare synchronous and block until disk I/O completes — simple and correct for this single-user CLI; theasync/awaitalternative and why it matters is lecture 04's job.
{ withFileTypes: true }turns filenames intoDirents, the only way to know whether an entry is a file or a directory without a secondfscall per entry.
list_filesnever recurses — the model does, onetool_usecall at a time. "One level" in the schema and "no loop inrunTool" are the same design decision seen from two sides.
- Errors throw; nothing in
runToolcatches its own failures. That's not an oversight — it's a contract lecture 05'sagentLoopis built to consume, converting any thrown exception into anis_error: truetool_result the model reads like any other answer.
What we skipped (for future study or future lectures)
- Catching and translating exceptions into
tool_resultblocks — that'sagentLoop, lecture 05. write_file,edit_file,bash— the three tools that change the machine, and the permission gate that guards them — lectures 03 and 04.- Async/await,
fs.promises, and why synchronous I/O is a deliberate simplification here rather than the "correct" choice in general — lecture 04. - Path traversal / sandboxing concerns (what stops
read_filefrom reading/etc/passwd?) — explicitly out of scope for this course (curriculum: "robust sandboxing/security... is a different course"). - Symlinks, permission errors distinct from "file not found," and other
fsedge casesDirentandreadFileSynccan surface — not tested here, and not needed for the fixture bug in lecture 06.
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
- No —
input_schema.properties.*.descriptionstrings are never read by any TypeScript in this project. They exist solely for the model: sent verbatim insidetools: TOOLSon everycallModelcall (lecture 05), read by the model when it decides how to fill in a tool call's arguments.runToolonly ever reads the values the model actually sent (input.path), never the schema text describing what that field means. - Yes,
{}is valid forlist_filessincerequired: [].runTool'slist_filescase must handleinput.path === undefinedby substituting".", or it violates the schema's own promised default. This is exactly what the"defaults to '.'"test intests/02_read_only_tools.test.tschecks.
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
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.fs.readFileSync(undefined, "utf8")throws aTypeErrorat runtime (Node'sfsmodule expects its first argument to be a string,Buffer, URL, or file descriptor —undefinedsatisfies none of those). TypeScript'sinput: anymeans 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
- A bare filename string can't distinguish a file named
subfrom a directory namedsub— the name alone carries no type information.Dirent.isDirectory()calls back into the OS's directory-entry metadata (already fetched byreaddirSyncwhen you passwithFileTypes: true) to answer that question without a second filesystem call per entry. - Nowhere in
runTool, and nowhere in lecture 02 at all. It'sagentLoop(lecture 05): the model receives"sub/"as part oflist_files's result, and if it wants to look insidesub, it issues a brand-newtool_useblock requestinglist_filesagain withpath: "sub"on its next turn — the same "the model recurses by calling again" pattern from lecture 01's trace, just withlist_filesinstead ofread_file/edit_file. - No, not for any input the model can realistically send.
path's schema type isstring, 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. onlynull/undefined), butpathnever carries0orfalse, 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.