Chapter 9: File Tools
File(s) to edit:
src/tools/write.rs,src/tools/edit.rs(theTODO ch9:stubs).src/tools/read.rswas completed back in Chapter 2 — this chapter revisits it as the baseline and contrasts it with the design decisions that come with writing and editing. Tests to run:cargo test -p mini-claw-code-starter test_read_(ReadTool),cargo test -p mini-claw-code-starter test_write_(WriteTool),cargo test -p mini-claw-code-starter test_edit_(EditTool) Estimated time: 50 min
Goal
- Revisit
ReadTool(built in Ch2) as the baseline and understand the trade-offs of its minimal design vs. production tools that add line-numbering and offset/limit. - Implement
WriteToolwith automatic parent directory creation so the agent can create new files without a separatemkdirstep. - Implement
EditToolwith a uniqueness check so the agent can make surgical string replacements in existing files. - Understand why tool errors are returned as
Err(...)in the starter (the agent loop converts them to messages the LLM can read and recover from -- the detailed rationale is in Chapter 6 §"Why tool errors never terminate the agent").
A coding agent that cannot touch the filesystem is just a chatbot with delusions of grandeur. It can describe code changes, suggest fixes, explain algorithms -- but it cannot do any of it. The tools you built in Chapter 6 gave your agent hands. In this chapter you give those hands something to hold: files.
File operations are the most fundamental tools in any coding agent's toolkit.
Claude Code ships with Read, Write, and Edit tools (among many others), and
every competitor -- Cursor, Aider, OpenCode -- has its own version. The
operations are simple (read bytes, write bytes, search-and-replace), but the
design choices around them determine whether the agent can reliably modify a
codebase or whether it stumbles over its own edits. You will implement all three
tools in this chapter: ReadTool, WriteTool, and EditTool.
How the file tools work together
flowchart LR
W[WriteTool] -->|creates file| FS[(Filesystem)]
E[EditTool] -->|search & replace| FS
R[ReadTool] -->|reads content| FS
W -.->|"auto-creates parent dirs"| FS
E -.->|"checks uniqueness first"| FS
sequenceDiagram
participant LLM
participant Agent
participant FS as Filesystem
LLM->>Agent: write(path, content)
Agent->>FS: create dirs + write file
FS-->>Agent: ok
Agent-->>LLM: "wrote /path/to/file"
LLM->>Agent: edit(path, old, new)
Agent->>FS: read, check uniqueness, replace, write
FS-->>Agent: ok
Agent-->>LLM: "edited /path/to/file"
LLM->>Agent: read(path)
Agent->>FS: read file
FS-->>Agent: file contents
Agent-->>LLM: file contents
6.1 ReadTool
ReadTool is the simplest of the file tools: it takes a path, reads the file
with tokio::fs::read_to_string, and returns the raw contents as a string. No
line numbering, no offset/limit, no transformation. That is what both the
starter and the reference implementation (mini-claw-code/src/tools/read.rs)
do -- we keep it deliberately minimal so the rest of the chapter (Write, Edit)
has room to breathe.
Design discussion: why production agents add more
Production agents like Claude Code go further. Their read tool typically
numbers every line (cat -n style) and supports partial reads via offset and
limit parameters. Two reasons this matters in real systems:
- Line numbers give the LLM a coordinate system. "Replace the string on line 42" is precise. "Replace the string somewhere around the middle of the function" is not. This becomes especially valuable for the Edit tool, where the model has to produce an exact string to match and numbered lines help it copy the right chunk.
- Offset/limit protects the context window. A single 50k-line generated file can blow past the model's context. Paginated reads let the LLM fetch what it needs without burning the whole budget on one file.
Neither of these appear in the starter or the reference implementation in
this book -- they are extensions we point at but deliberately leave out so the
core Tool implementation stays a dozen lines. Adding them yourself is one of
the listed extensions at the end of the chapter.
The starter stub
Open src/tools/read.rs:
#![allow(unused)] fn main() { use anyhow::Context; use serde_json::Value; use crate::types::*; pub struct ReadTool { definition: ToolDefinition, } impl Default for ReadTool { fn default() -> Self { Self::new() } } impl ReadTool { /// Create a new ReadTool with its JSON schema definition. /// /// The schema should declare one required parameter: "path" (string). pub fn new() -> Self { unimplemented!( "Create a ToolDefinition with name \"read\" and a required \"path\" parameter" ) } } #[async_trait::async_trait] impl Tool for ReadTool { fn definition(&self) -> &ToolDefinition { &self.definition } async fn call(&self, _args: Value) -> anyhow::Result<String> { unimplemented!( "Extract \"path\" from args, read file with tokio::fs::read_to_string, return contents" ) } } }
You need to fill in two methods:
new()-- build aToolDefinitionwith name"read"and a required"path"parameter.call()-- extract the path, read the file, and return its contents.
Implementing the ReadTool
The definition. One required parameter: path. The LLM sees this as a JSON
Schema and knows it must provide path.
#![allow(unused)] fn main() { pub fn new() -> Self { Self { definition: ToolDefinition::new("read", "Read the contents of a file.") .param("path", "string", "Absolute path to the file", true), } } }
The call() method. Read the file and return its contents as a String:
#![allow(unused)] fn main() { async fn call(&self, args: Value) -> anyhow::Result<String> { let path = args["path"] .as_str() .context("missing 'path' argument")?; let content = tokio::fs::read_to_string(path) .await .with_context(|| format!("failed to read '{path}'"))?; Ok(content) } }
Rust concept: anyhow::Context for rich errors
The .context("missing 'path' argument")? and .with_context(|| format!("failed to read '{path}'")) calls wrap the underlying error with a human-readable message. context() takes a static string; with_context() takes a closure for dynamic messages (avoiding the allocation when the ? path is not taken). Both return anyhow::Error, which chains the original error underneath -- so the full error message reads like "failed to read 'foo.rs': No such file or directory". This chaining is what makes anyhow errors informative without custom error types.
Notice that call() returns anyhow::Result<String>, not ToolResult. The
starter's Tool trait is simplified -- tools return plain strings on success.
If the tool encounters an error (missing argument, I/O failure), it returns
Err(...). The agent loop converts errors to error messages that the LLM sees.
Possible extensions. A production-grade ReadTool would add offset and
limit parameters for partial reads and format output with tab-separated line
numbers (like cat -n). Neither is in this book's reference implementation;
both are well-scoped exercises if you want to go further.
What the output looks like
Given a file with three lines:
alpha
beta
gamma
The tool returns the raw file contents:
alpha
beta
gamma
This is the simplest approach. Production tools extend it with line numbers and partial-read support, which are useful for large files and for giving the LLM precise line references for later edits -- see the design discussion above.
6.2 WriteTool
Writing a file is conceptually simple: take a path and content, write the content to the path. But there is one practical detail that makes a big difference: creating parent directories automatically.
When the LLM writes src/handlers/auth/middleware.rs, the src/handlers/auth/
directory might not exist yet. A naive tool would fail with "No such file or
directory." The agent would then need to call bash("mkdir -p ...") and retry.
This wastes a tool-use round and confuses the model. Better to handle it
silently.
The starter stub
Open src/tools/write.rs:
#![allow(unused)] fn main() { use anyhow::Context; use serde_json::Value; use crate::types::*; pub struct WriteTool { definition: ToolDefinition, } impl Default for WriteTool { fn default() -> Self { Self::new() } } impl WriteTool { /// Schema: required "path" and "content" parameters. pub fn new() -> Self { unimplemented!( "Use ToolDefinition::new(name, description).param(...).param(...)" ) } } #[async_trait::async_trait] impl Tool for WriteTool { fn definition(&self) -> &ToolDefinition { &self.definition } async fn call(&self, _args: Value) -> anyhow::Result<String> { unimplemented!( "Extract path and content, create parent dirs, write file, return format!(\"wrote {path}\")" ) } } }
Implementing the WriteTool
The definition. Two required parameters: path and content.
#![allow(unused)] fn main() { pub fn new() -> Self { Self { definition: ToolDefinition::new("write", "Write content to a file, creating directories as needed") .param("path", "string", "Absolute path to write to", true) .param("content", "string", "Content to write", true), } } }
The call() method. Extract the arguments, create parent directories,
write the file, and return a confirmation string:
#![allow(unused)] fn main() { async fn call(&self, args: Value) -> anyhow::Result<String> { let path = args["path"] .as_str() .context("missing 'path' argument")?; let content = args["content"] .as_str() .context("missing 'content' argument")?; // Create parent directories if let Some(parent) = std::path::Path::new(path).parent() { if !parent.as_os_str().is_empty() { tokio::fs::create_dir_all(parent).await?; } } tokio::fs::write(path, content).await?; Ok(format!("wrote {path}")) } }
The return value is format!("wrote {path}") -- a simple confirmation string.
The agent sees this and knows the write succeeded.
Walking through the code
Two required parameters. Both path and content are required. There is no
optional behavior here -- you always need both.
Auto-creating directories. The create_dir_all call is the key design
choice. It mirrors mkdir -p -- if the directory already exists, it is a no-op.
If intermediate directories are missing, it creates them all. The guard
!parent.as_os_str().is_empty() handles the edge case where the path has no
parent component (e.g., a bare filename like "file.txt"), where calling
create_dir_all("") would fail.
Overwrite semantics. tokio::fs::write overwrites the file if it already
exists and creates it if it does not. There is no append mode, no conflict
detection. This is deliberate -- the tool is a clean write, not a merge. If the
LLM wants to modify an existing file, it should use the Edit tool.
Confirmation string. The result reports "wrote /path/to/file". This gives
the model confirmation that the write succeeded.
6.3 EditTool
The Edit tool is the most interesting of the three, and it teaches the most important design lesson in this book: errors are values, not exceptions.
The Edit tool performs a search-and-replace on a file. It takes a path, an
old_string to find, and a new_string to replace it with. The critical
constraint: old_string must appear exactly once in the file. Zero matches
means the model got the string wrong. More than one match means the replacement
is ambiguous -- we do not know which occurrence to change.
Both of these are expected failure modes, not bugs. The model frequently gets strings slightly wrong (missing whitespace, wrong indentation, stale content from a previous edit). The tool must report these failures clearly so the model can correct itself.
The starter stub
Open src/tools/edit.rs:
#![allow(unused)] fn main() { use anyhow::{Context, bail}; use serde_json::Value; use crate::types::*; pub struct EditTool { definition: ToolDefinition, } impl Default for EditTool { fn default() -> Self { Self::new() } } impl EditTool { /// Schema: required "path", "old_string", "new_string" parameters. pub fn new() -> Self { unimplemented!( "Use ToolDefinition::new(name, description).param(...).param(...).param(...)" ) } } #[async_trait::async_trait] impl Tool for EditTool { fn definition(&self) -> &ToolDefinition { &self.definition } async fn call(&self, _args: Value) -> anyhow::Result<String> { unimplemented!( "Extract args, read file, verify old_string appears exactly once, replace, write back" ) } } }
Implementing the EditTool
The definition. Three required parameters: path, old_string, and
new_string.
#![allow(unused)] fn main() { pub fn new() -> Self { Self { definition: ToolDefinition::new( "edit", "Replace an exact string in a file. The old_string must appear exactly once.", ) .param("path", "string", "Absolute path to the file to edit", true) .param("old_string", "string", "The exact string to find", true) .param("new_string", "string", "The replacement string", true), } } }
The call() method. Read the file, check uniqueness, replace, write back:
#![allow(unused)] fn main() { async fn call(&self, args: Value) -> anyhow::Result<String> { let path = args["path"] .as_str() .context("missing 'path' argument")?; let old = args["old_string"] .as_str() .context("missing 'old_string' argument")?; let new = args["new_string"] .as_str() .context("missing 'new_string' argument")?; let content = tokio::fs::read_to_string(path) .await .with_context(|| format!("failed to read '{path}'"))?; let count = content.matches(old).count(); if count == 0 { bail!("old_string not found in '{path}'"); } if count > 1 { bail!("old_string appears {count} times in '{path}', must be unique"); } let updated = content.replacen(old, new, 1); tokio::fs::write(path, &updated).await?; Ok(format!("edited {path}")) } }
The return value is format!("edited {path}") on success.
Walking through the code
Three required parameters. path, old_string, and new_string are all
required. The model must specify exactly what to find and what to replace it
with. There is no regex, no line-number-based editing, no diff format. Just
plain string replacement. This simplicity is a feature -- it is unambiguous and
easy for the model to use correctly.
The uniqueness check. This is the heart of the tool:
#![allow(unused)] fn main() { let count = content.matches(old).count(); if count == 0 { bail!("old_string not found in '{path}'"); } if count > 1 { bail!("old_string appears {count} times in '{path}', must be unique"); } }
Rust concept: bail! macro
bail!("old_string not found in '{path}'") is shorthand for return Err(anyhow::anyhow!("...")). It immediately returns an error from the function with the given message. It is part of the anyhow crate and works in any function that returns anyhow::Result. Compare with ? (which propagates an existing error) -- bail! creates a new error on the spot.
Two branches, both returning errors via bail!. In the starter's simplified
Tool trait, tools return anyhow::Result<String>. When the tool returns an
Err, the agent loop converts it to an error message that the LLM sees. The
model can then retry with a corrected string.
Error handling in the simplified trait
The starter's Tool trait returns anyhow::Result<String> from call(). This
means error handling is straightforward -- use bail!() or ? for any failure,
and the agent loop takes care of converting errors to messages the LLM can read.
In the agent's execute_tools method, a tool call is handled like this:
#![allow(unused)] fn main() { match tool.call(call.arguments.clone()).await { Ok(result) => result, Err(e) => format!("error: {e}"), } }
An Err from call() becomes a string like "error: old_string not found in 'foo.rs'".
The model sees this and knows to try a different string.
A more sophisticated design (used by Claude Code) distinguishes between
recoverable tool-level errors (returned as success values) and genuine I/O
failures (returned as Err). The starter keeps things simple by using Err
for both -- the agent loop handles them the same way regardless.
6.4 Integration: Write, Edit, Read
The real power of these tools comes from combining them. A typical agent workflow looks like this:
- Write a new file
- Edit to fix a bug or refine the code
- Read to verify the result
Here is what that looks like as tool calls:
Agent: I'll create the handler file.
-> write(path: "/tmp/project/handler.rs", content: "fn main() { println!(\"hello\"); }")
<- "wrote /tmp/project/handler.rs"
Agent: Let me update the greeting.
-> edit(path: "/tmp/project/handler.rs", old_string: "hello", new_string: "goodbye")
<- "edited /tmp/project/handler.rs"
Agent: Let me verify the change.
-> read(path: "/tmp/project/handler.rs")
<- "fn main() { println!(\"goodbye\"); }"
Each tool does one thing and communicates its result clearly. The agent sees the output of each step and decides what to do next. If the edit had failed (wrong string), the agent would see the error and retry with the correct string.
This write-edit-read pattern is how Claude Code modifies files in practice. It does not generate a complete file and overwrite -- that would lose any content outside the modified section. Instead, it uses surgical edits on the specific lines that need to change, then reads the result to confirm. This is more reliable and produces smaller diffs.
6.5 How Claude Code does it
Claude Code's file tools follow the same protocol but with more sophistication:
Read supports images and PDFs. It detects binary files and renders them appropriately (base64-encoded images are sent as multimodal content blocks). It has smarter truncation with token counting rather than character counting, and it warns when a file is empty.
Write checks for protected files. Claude Code maintains a list of files
that should never be overwritten (.env, credentials.json, etc.) and blocks
writes to them. It also integrates with the permission system to require user
approval before overwriting existing files in certain modes.
Edit is considerably more powerful. It supports multiple edits in a single call, has a diff preview mode, handles encoding detection, and validates that the edit produces syntactically valid code (for supported languages). It also has a more nuanced uniqueness check that considers context lines around the match to disambiguate.
But the core protocol is identical to what you just built. A struct holds the
definition. The Tool trait provides the interface. The call method does
the work. The agent loop dispatches and collects results. Understanding our
three simple tools gives you the foundation to understand Claude Code's full
tool suite.
6.6 Tool file organization
All three tools live in src/tools/, alongside the other tools you will build
in later chapters. The module structure in the starter:
src/tools/
mod.rs -- re-exports all tools
ask.rs -- AskTool (bonus)
bash.rs -- BashTool (Chapter 10)
edit.rs -- EditTool
read.rs -- ReadTool
write.rs -- WriteTool
The mod.rs barrel re-exports everything:
#![allow(unused)] fn main() { mod ask; mod bash; mod edit; mod read; mod write; pub use ask::*; pub use bash::BashTool; pub use edit::EditTool; pub use read::ReadTool; pub use write::WriteTool; }
This lets consumers write use crate::tools::{ReadTool, WriteTool, EditTool}
without reaching into individual modules.
6.7 Tests
Run the file tool tests:
cargo test -p mini-claw-code-starter test_read_ # ReadTool
cargo test -p mini-claw-code-starter test_write_ # WriteTool
cargo test -p mini-claw-code-starter test_edit_ # EditTool
Cargo test filters are substring matches, not regex, so you cannot OR them
together into a single invocation. Run the three commands separately, or
drop all three prefixes with a catch-all like
cargo test -p mini-claw-code-starter -- --test-threads=1 if you want to
see everything at once.
Here is what each test verifies:
ReadTool tests (in test_read_)
test_read_read_definition-- Checks that the tool definition has the name "read".test_read_read_file-- Reads a file and verifies the content appears in the output.test_read_read_missing_file-- Attempts to read a file that does not exist. Verifies that the result is anErr.
WriteTool tests (in ``)
test_write_creates_file-- Writes content to a new file, verifies the result contains a confirmation, and reads back the file to confirm the content.test_write_creates_dirs-- Writes to a file inside nested directories. All intermediate directories are created automatically.test_write_overwrites_existing-- Writes to a file that already has content. Verifies the old content is replaced.
EditTool tests (in ``)
test_edit_replaces_string-- Edits a string in a file. Verifies the result says "edited" and the file is updated.test_edit_not_found-- Attempts to replace a string that does not exist. Verifies the result is anErr.test_edit_not_unique-- Attempts to replace a string that appears multiple times. Verifies the error mentions the ambiguity.
Recap
Three tools, one pattern. Every tool in this chapter follows the same structure:
- A struct with a
definition: ToolDefinitionfield. - A
new()constructor that builds the definition with the parameter builder from Chapter 4. - A
Toolimpl withdefinition()andcall().
The pattern scales. When you add Bash in Chapter 10, the shape is identical --
only the call() logic changes. This is the power of the Tool trait: a
uniform interface that makes every tool interchangeable from the agent's
perspective.
The key lessons from this chapter:
- Automate the obvious. The
WriteToolcreates parent directories automatically, saving the agent a wasted tool-use round. - Check uniqueness. The
EditToolrequires the old string to appear exactly once. Zero matches means the model got the string wrong. Multiple matches means the replacement is ambiguous. - Errors propagate cleanly. Tools return
anyhow::Result<String>. The agent loop catches errors and converts them to messages the LLM can read and recover from.
Key takeaway
File tools are the agent's hands on the codebase. The three-tool split -- read, write, edit -- gives the LLM clear verbs for distinct operations rather than one overloaded "file" tool. The EditTool's uniqueness check is the single most important design decision: it forces the LLM to provide an unambiguous match, catching mistakes early and enabling reliable self-correction.
In Chapter 10: Bash Tool, you will build the most powerful (and most dangerous) tool in the agent's arsenal -- one that can run arbitrary shell commands.
Check yourself
← Chapter 8: System Prompt · Contents · Chapter 10: Bash Tool →