Chapter 2: Your First Tool
Now that you have a mock provider, it is time to build your first tool. You will
implement ReadTool – a tool that reads a file and returns its contents. This
is the simplest tool in our agent, but it introduces the Tool trait pattern
that every other tool follows.
Goal
Implement ReadTool so that:
- It declares its name, description, and parameter schema.
- When called with a
{"path": "some/file.txt"}argument, it reads the file and returns its contents as a string. - Missing arguments or non-existent files produce errors.
Key Rust concepts
The Tool trait
Open mini-claw-code-starter/src/types.rs and look at 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 metadata about the tool: its name, a description, and a JSON schema describing its parameters. The LLM uses this to decide which tool to call and how to format the arguments.call()actually executes the tool. It receives aserde_json::Valuecontaining the arguments and returns a string result.
ToolDefinition
#![allow(unused)]
fn main() {
pub struct ToolDefinition {
pub name: &'static str,
pub description: &'static str,
pub parameters: Value,
}
}
As you saw in Chapter 1, ToolDefinition has a builder API for declaring
parameters. For ReadTool, we need a single required parameter called "path"
of type "string":
#![allow(unused)]
fn main() {
ToolDefinition::new("read", "Read the contents of a file.")
.param("path", "string", "The file path to read", true)
}
Under the hood, the builder constructs the JSON Schema you saw in Chapter 1.
The last argument (true) marks the parameter as required.
Why #[async_trait] instead of plain async fn?
You might wonder why we use the async_trait macro instead of writing
async fn directly in the trait. The reason is trait object compatibility.
Later, in the agent loop, we will store tools in a ToolSet – a HashMap-backed
collection of different tool types behind a common interface. This requires
dynamic dispatch, which means the compiler needs to know the size of the
return type at compile time.
async fn in traits generates a different, uniquely-sized Future type for
each implementation. That breaks dynamic dispatch. The #[async_trait] macro
automatically rewrites async fn into a method that returns
Pin<Box<dyn Future<...>>>, which has a known, fixed size regardless of
which tool produced it. You write normal async fn code, and the macro
handles the boxing for you.
Here is the data flow when the agent calls a tool:
flowchart LR
A["LLM returns<br/>ToolCall"] --> B["args: JSON Value<br/>{"path": "f.txt"}"]
B --> C["Tool::call(args)"]
C --> D["Result: String<br/>(file contents)"]
D --> E["Sent back to LLM<br/>as ToolResult"]
The LLM never touches the filesystem. It produces a JSON request, your code executes it, and returns a string.
The implementation
Open mini-claw-code-starter/src/tools/read.rs. The struct, Default impl, and
method signatures are already provided.
Remember to annotate your impl Tool for ReadTool block with
#[async_trait::async_trait]. The starter file already has this in place.
Step 1: Implement new()
Create a ToolDefinition and store it in self.definition. Use the builder:
#![allow(unused)]
fn main() {
ToolDefinition::new("read", "Read the contents of a file.")
.param("path", "string", "The file path to read", true)
}
Step 2: definition() – already provided
The definition() method is already implemented in the starter – it simply
returns &self.definition. No work needed here.
Step 3: Implement call()
This is where the real work happens. Your implementation should:
- Extract the
"path"argument fromargs. - Read the file asynchronously.
- Return the file contents.
Here is the shape:
#![allow(unused)]
fn main() {
async fn call(&self, args: Value) -> anyhow::Result<String> {
// 1. Extract path
// 2. Read file with tokio::fs::read_to_string
// 3. Return contents
}
}
Some useful APIs:
args["path"].as_str()returnsOption<&str>. Use.context("missing 'path' argument")?fromanyhowto convertNoneinto a descriptive error.tokio::fs::read_to_string(path).awaitreads a file asynchronously. Chain.with_context(|| format!("failed to read '{path}'"))?for a clear error message.
That is it – extract the path, read the file, return the contents.
Running the tests
Run the Chapter 2 tests:
cargo test -p mini-claw-code-starter ch2
What the tests verify
test_ch2_read_definition: Creates aReadTooland checks that its name is"read", description is non-empty, and"path"is in the required parameters.test_ch2_read_file: Creates a temp file with known content, callsReadToolwith the file path, and checks the returned content matches.test_ch2_read_missing_file: CallsReadToolwith a path that does not exist and verifies it returns an error.test_ch2_read_missing_arg: CallsReadToolwith an empty JSON object (no"path"key) and verifies it returns an error.
There are also additional edge-case tests (empty files, unicode content, wrong argument types, etc.) that will pass once your core implementation is correct.
Recap
You built your first tool by implementing the Tool trait. The key patterns:
ToolDefinition::new(...).param(...)declares the tool’s name, description, and parameters.#[async_trait::async_trait]on theimplblock lets you writeasync fn call()while keeping trait object compatibility.tokio::fsfor async file I/O.anyhow::Contextfor adding descriptive error messages.
Every tool in the agent follows this exact same structure. Once you understand
ReadTool, the remaining tools are variations on the theme.
What’s next
In Chapter 3: Single Turn you will write a function
that matches on StopReason to handle a single round of tool calls.