Chapter 2: Your First Tool Call
File(s) to edit:
src/tools/read.rsTest to run:cargo test -p mini-claw-code-starter test_read_Estimated time: 15 min
An LLM can't read files, run commands, or browse the web. It can only generate text. But it can ask your code to do those things. That's what tools are.
Goal
Implement ReadTool so that:
- It declares its name, description, and parameter schema.
- When called with
{"path": "some/file.txt"}, it reads the file and returns its contents. - Missing arguments or non-existent files produce errors.
How tool calling works
The LLM never touches the filesystem. It describes what it wants, and your code does it:
sequenceDiagram
participant A as Agent
participant L as LLM
participant T as ReadTool
A->>L: "What's in doc.txt?" + tool schemas
L-->>A: tool_call: read(path="doc.txt")
A->>T: call({"path": "doc.txt"})
T-->>A: "file contents here..."
A->>L: tool result: "file contents here..."
L-->>A: "The file contains..."
The LLM sees a JSON schema describing each tool. When it decides to use one, it outputs a structured request with the tool name and arguments. Your code parses this, runs the real function, and sends the result back.
The Tool trait
Open mini-claw-code-starter/src/types.rs and find the Tool trait:
#![allow(unused)] fn main() { #[async_trait::async_trait] pub trait Tool: Send + Sync { fn definition(&self) -> &ToolDefinition; async fn call(&self, args: Value) -> anyhow::Result<String>; } }
Two methods:
definition()returns the JSON schema that tells the LLM what this tool does and what arguments it takescall()executes the tool and returns a string result
Why #[async_trait] on Tool — and not on Provider?
You'll see this split throughout the book, so it's worth owning the one-liner now:
Tooluses#[async_trait]because we store tools heterogeneously inBox<dyn Tool>(aReadTooland aBashToolcoexist in oneHashMap).Box<dyn …>requires object safety, and a plainasync fnin a trait is not object-safe — it returns an anonymous future type the compiler can't erase. The#[async_trait]macro rewritesasync fn call(&self, …)intofn call(&self, …) -> Pin<Box<dyn Future + Send + '_>>, which is. One heap allocation per call, which is nothing next to the I/O the tool is about to do.Provideruses RPITIT (return-positionimpl Traitin traits, stable since Rust 1.75) because we only ever hold it as a generic parameter —SimpleAgent<P: Provider>— never asdyn Provider. Without object safety to preserve, we get the zero-cost version: no boxing, no allocation, the compiler monomorphizes a unique future type per impl.
The two-line mnemonic:
stored as Box<dyn T> → #[async_trait] (boxed future, object-safe)
used as a generic P: Trait → RPITIT (zero-cost, not object-safe)
That's the whole trade-off. Chapter 6 reprises it with the full Provider signature side-by-side once you've seen both traits in use.
The implementation
Open src/tools/read.rs. You'll see the struct and two stubs.
Step 1: The definition
A ToolDefinition describes the tool to the LLM using JSON Schema:
#![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 .param() builder adds a parameter with its type, description, and whether it's required. When the LLM sees this schema, it knows it can call a tool named "read" with a required string argument "path".
Step 2: The call
Extract the path from the JSON arguments, read the file, return the contents:
#![allow(unused)] fn main() { async fn call(&self, args: Value) -> anyhow::Result<String> { let path = args["path"] .as_str() .context("missing 'path' argument")?; tokio::fs::read_to_string(path) .await .with_context(|| format!("failed to read '{path}'")) } }
Three lines of logic. args is a serde_json::Value — the parsed JSON arguments from the LLM. The context() and with_context() methods (from anyhow) add human-readable error messages.
Here is the data flow:
flowchart LR
A["args: path = foo.txt"] --> B["as_str()"]
B --> C["tokio::fs::read_to_string"]
C --> D["Ok: file contents"]
C --> E["Err: failed to read"]
Run the tests
cargo test -p mini-claw-code-starter test_read_
15 tests verify your tool:
test_read_read_definition— schema has the right name and required paramstest_read_read_file— reads a real file from a temp directorytest_read_read_missing_file— returns an error for nonexistent filestest_read_read_missing_arg— returns an error whenpathis missingtest_read_read_utf8_content— handles multi-line content correctlytest_read_read_empty_file— reads an empty file without error
The pattern
Every tool in this project follows the same three-step pattern:
- Define —
ToolDefinition::new("name", "description").param(...) - Extract — pull arguments from the JSON
Value - Execute — do the thing, return a
String
You'll repeat this for WriteTool, EditTool, and BashTool in later chapters. Once you've written one tool, you've written them all.
Key takeaway
A tool is the bridge between "the LLM wants to read a file" and "the file is actually read." The LLM describes its intent as structured JSON. Your code does the work.
Check yourself
← Chapter 1: Your First LLM Call · Contents · Chapter 3: The Agentic Loop →