Chapter 4: Messages & Types
File(s) to edit: none —
src/types.rsis pre-filled in the starter. This chapter is a study-only deep dive into the type system you have already been using. Test to run:cargo test -p mini-claw-code-starter test_mock_still passes after this chapter (and did before) because the actual implementation work is insrc/mock.rs, which you filled in Chapter 1. The tests exercise the shapes defined intypes.rs, which is why we connect them here. Estimated time: 20 min (study only)
Goal
- Understand how the
Messageenum's four variants (System,User,Assistant,ToolResult) give every conversation participant a typed representation. - Understand the
ToolDefinitionbuilder pattern and why tools describe their JSON Schema parameters at construction time rather than hand-writing JSON. - Understand
ToolSetas the runtime registry that lets the agent dispatch tool calls by name. - Understand the
Providertrait's RPITIT signature and why it leaves room for any LLM backend to drop in without changing agent code.
Every coding agent is, at its core, a loop over a conversation. The user speaks, the model replies, tools produce results, and those results go back to the model. Before we can build that loop, we need a type system that represents every participant and every kind of payload in the conversation.
This chapter walks through the foundational types that the rest of the codebase depends on. Nothing here needs to be written by you -- src/types.rs is complete in the starter. Read for comprehension; the hands-on work resumes in Chapter 5a.
How the types connect
flowchart TD
U[Message::User] --> P[Provider::chat]
S[Message::System] --> P
P --> AT[AssistantTurn]
AT --> SR{StopReason}
SR -->|Stop| Text[Final text response]
SR -->|ToolUse| TC[ToolCall]
TC --> TS[ToolSet::get]
TS --> T[Tool::call]
T --> TR[Message::ToolResult]
TR --> P
Why a rich message type?
If you look at a raw LLM API (OpenAI, Anthropic), messages are JSON blobs with a role field: "system", "user", or "assistant". That is fine for a one-shot chatbot, but a coding agent needs more:
- Tool results that carry the ID of the tool call they answer, so the model can correlate request and response.
- System instructions that configure the model's behavior.
Claude Code models all of these as variants of a single Message enum. Our starter uses a simplified version with four variants.
File layout
All types live in a single file: src/types.rs. This includes the Message enum, AssistantTurn, ToolDefinition, ToolCall, Tool trait, ToolSet, Provider trait, TokenUsage, and StopReason.
1.1 The Message enum
Here is the full enum with its four variants:
#![allow(unused)] fn main() { pub enum Message { System(String), User(String), Assistant(AssistantTurn), ToolResult { id: String, content: String }, } }
The starter uses plain enum variants instead of wrapper structs. There are no message IDs, no serde tags, no constructors -- you construct variants directly:
#![allow(unused)] fn main() { let msg = Message::User("Hello".to_string()); let sys = Message::System("You are a helpful assistant".to_string()); let result = Message::ToolResult { id: call_id.clone(), content: "file contents here".to_string(), }; }
Let's walk through each variant.
System
#![allow(unused)] fn main() { Message::System(String) }
System messages carry instructions injected by the agent, not typed by the user. They configure the model's behavior (e.g., "You are a coding assistant").
User
#![allow(unused)] fn main() { Message::User(String) }
Straightforward -- the human's input. One message per turn.
Assistant
#![allow(unused)] fn main() { Message::Assistant(AssistantTurn) }
This is the richest variant. The model's response is wrapped in an AssistantTurn struct (described below). The model can return text, tool calls, or both.
ToolResult
#![allow(unused)] fn main() { Message::ToolResult { id: String, content: String } }
After the agent executes a tool, it packages the output into a ToolResult variant and appends it to the conversation. The id field links this result back to the specific ToolCall it answers -- without this, the model cannot correlate which result belongs to which call when multiple tools run in a single turn.
Note that in the starter, tool results are simple strings. There is no is_truncated flag or separate struct.
1.2 AssistantTurn
The assistant's response is captured in an AssistantTurn struct:
#![allow(unused)] fn main() { pub struct AssistantTurn { pub text: Option<String>, pub tool_calls: Vec<ToolCall>, pub stop_reason: StopReason, pub usage: Option<TokenUsage>, } }
The model can return text, tool calls, or both. text is Option<String> because when the model decides to use a tool, it may produce no human-readable text at all -- it just emits one or more ToolCall entries. The stop_reason tells the agent loop whether to execute tools and continue, or to present the response to the user and stop.
The usage field is Option<TokenUsage> because we attach token counts at parse time from the API response. Mock providers in tests may leave it as None.
1.3 StopReason
#![allow(unused)] fn main() { pub enum StopReason { /// The model finished — check `text` for the response. Stop, /// The model wants to use tools — check `tool_calls`. ToolUse, } }
This tiny enum drives the entire agent loop. When the provider parses the LLM response:
Stopmeans the model is done -- itstextfield contains the final answer for the user.ToolUsemeans the model wants to invoke tools -- the agent should look attool_calls, execute them, append the results, and call the provider again.
The agent loop uses match on stop_reason to decide whether to break or continue.
1.4 ToolCall
#![allow(unused)] fn main() { pub struct ToolCall { pub id: String, pub name: String, pub arguments: Value, } }
When the LLM responds with StopReason::ToolUse, it includes one or more ToolCall entries. Each has:
id-- a unique identifier assigned by the API (e.g.,"call_abc123"). This is whatToolResultMessage::tool_use_idreferences.name-- which tool to invoke (e.g.,"bash","read","edit").arguments-- a JSON object whose shape matches the tool's parameter schema.
The agent loop uses name to look up the tool in the ToolSet, passes arguments to tool.call(), and wraps the output in a Message::ToolResult whose id matches the ToolCall's id.
1.5 ToolDefinition and the builder pattern
Rust concept: the builder pattern
The ToolDefinition uses the builder pattern -- a common Rust idiom where
methods take self by value and return Self, enabling method chaining like
.param(...).param(...). Each call consumes the struct and returns a modified
version. This works because Rust's move semantics mean there is no overhead --
no cloning, no reference counting. The compiler optimizes the chain into a
series of in-place mutations. You will see this pattern throughout the codebase:
ToolSet::new().with(tool1).with(tool2), SimpleAgent::new(provider).tool(bash).
Every tool must describe itself to the LLM with a JSON Schema so the model knows what parameters are available. ToolDefinition holds this schema and provides a builder API for constructing it without hand-writing JSON:
#![allow(unused)] fn main() { pub struct ToolDefinition { pub name: &'static str, pub description: &'static str, pub parameters: Value, } }
The constructor initializes an empty object schema:
#![allow(unused)] fn main() { impl ToolDefinition { pub fn new(name: &'static str, description: &'static str) -> Self { Self { name, description, parameters: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), } } } }
.param() -- add a simple parameter
#![allow(unused)] fn main() { pub fn param( mut self, name: &str, type_: &str, description: &str, required: bool, ) -> Self { self.parameters["properties"][name] = serde_json::json!({ "type": type_, "description": description }); if required { self.parameters["required"] .as_array_mut() .unwrap() .push(Value::String(name.to_string())); } self } }
This is the workhorse. Most tool parameters are simple types -- a "string" for a file path, a "number" for a line offset. The builder takes self by value and returns it, enabling chained calls:
#![allow(unused)] fn main() { ToolDefinition::new("read", "Read a file from disk") .param("path", "string", "Absolute path to the file", true) .param("offset", "number", "Line number to start reading from", false) .param("limit", "number", "Maximum number of lines to read", false) }
.param_raw() -- add a complex parameter
#![allow(unused)] fn main() { pub fn param_raw( mut self, name: &str, schema: Value, required: bool, ) -> Self { self.parameters["properties"][name] = schema; if required { self.parameters["required"] .as_array_mut() .unwrap() .push(Value::String(name.to_string())); } self } }
Some parameters need richer schemas -- enums, arrays, nested objects. param_raw lets you pass an arbitrary serde_json::Value as the schema. For example, an edit tool might define:
#![allow(unused)] fn main() { .param_raw("changes", serde_json::json!({ "type": "array", "items": { "type": "object", "properties": { "old_string": { "type": "string" }, "new_string": { "type": "string" } } } }), true) }
Implement ToolDefinition in src/types.rs. There are no dedicated
unit tests for the builder itself in the starter -- its correctness is
exercised indirectly by every tool's _definition test (for example
test_read_read_definition in tests/read.rs). Making cargo build -p mini-claw-code-starter
succeed is the practical check here.
1.6 The Tool trait
This is the central abstraction. Every tool -- Bash, Read, Write, Edit -- implements this 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>; } }
Just two required methods -- this is deliberately minimal:
definition() returns the tool's schema. This is called once when registering tools and whenever the agent needs to send tool definitions to the LLM. It returns a reference (&ToolDefinition) because the definition is static for the lifetime of the tool.
call() is the execution entry point. It receives the JSON arguments the LLM provided and returns a String result (or an error). This is async because most tools do I/O -- reading files, running subprocesses, making HTTP requests.
Note that call() returns anyhow::Result<String> -- not a ToolResult struct. The starter simplifies tool output to plain strings. If a tool fails, you can return Ok(format!("error: {e}")) to let the model see the error and recover, or return Err(e) for unrecoverable situations.
The trait uses #[async_trait] and is marked Send + Sync so tools can be stored as Box<dyn Tool> in the ToolSet and called from async contexts. For why Tool uses #[async_trait] while Provider uses RPITIT, see Why two async trait styles?.
1.7 ToolSet
The agent needs to look up tools by name when the LLM requests a tool call. ToolSet is a HashMap-backed registry:
#![allow(unused)] fn main() { pub struct ToolSet { tools: HashMap<String, Box<dyn Tool>>, } }
The key methods:
#![allow(unused)] fn main() { impl ToolSet { pub fn new() -> Self { Self { tools: HashMap::new() } } /// Builder-style: add a tool and return self. pub fn with(mut self, tool: impl Tool + 'static) -> Self { self.push(tool); self } /// Add a tool, keyed by its definition name. pub fn push(&mut self, tool: impl Tool + 'static) { let name = tool.definition().name.to_string(); self.tools.insert(name, Box::new(tool)); } /// Look up a tool by name. pub fn get(&self, name: &str) -> Option<&dyn Tool> { self.tools.get(name).map(|t| t.as_ref()) } /// Collect all tool schemas for the provider. pub fn definitions(&self) -> Vec<&ToolDefinition> { self.tools.values().map(|t| t.definition()).collect() } } impl Default for ToolSet { fn default() -> Self { Self::new() } } }
A few design points:
with()enables builder-style chaining:ToolSet::new().with(ReadTool::new()).with(BashTool::new()).push()extracts the name from the tool's definition, so you never pass the name manually -- one source of truth.definitions()collects all schemas into aVecthat the provider sends to the LLM at the start of each turn.Box<dyn Tool>is the trait object that makes heterogeneous storage possible. The'staticbound onpush/withensures the tool lives long enough.
ToolSet has no dedicated test of its own in the starter -- it is exercised
by the test_single_turn_* suite (Chapter 3) and test_multi_tool_* suite
(Chapter 12), both of which construct real ToolSets and assert their
definitions are rendered correctly.
1.8 TokenUsage
LLM APIs report token counts with each response. Tracking these is useful for cost awareness and debugging.
#![allow(unused)] fn main() { #[derive(Debug, Clone, Default)] pub struct TokenUsage { pub input_tokens: u64, pub output_tokens: u64, } }
The starter uses a simplified TokenUsage with just input and output token counts. It is stored as Option<TokenUsage> in AssistantTurn -- mock providers in tests set it to None, while the real OpenRouterProvider populates it from the API response.
The Default impl is covered by test_cost_tracker_token_usage_default in
tests/cost_tracker.rs (used again in Chapter 17). If you want to run it in
isolation:
cargo test -p mini-claw-code-starter test_cost_tracker_token_usage_default
1.9 The Provider trait
The Provider trait
The Provider trait is defined in src/types.rs. It abstracts over any LLM backend:
#![allow(unused)] fn main() { pub trait Provider: Send + Sync { fn chat<'a>( &'a self, messages: &'a [Message], tools: &'a [&'a ToolDefinition], ) -> impl Future<Output = anyhow::Result<AssistantTurn>> + Send + 'a; } }
Unlike Tool, Provider uses RPITIT (return-position impl Trait in traits) rather than #[async_trait]. The full trade-off is covered in Why two async trait styles?.
A blanket impl lets Arc<P> also be a Provider, which is needed later for sharing a provider between an agent and its subagents:
#![allow(unused)] fn main() { impl<P: Provider> Provider for Arc<P> { ... } }
We implement the MockProvider in Chapter 5a and the OpenRouterProvider in Chapter 5b.
Putting it all together
After implementing src/types.rs, run the full chapter test suite:
cargo test -p mini-claw-code-starter test_mock_
What the tests verify
test_mock_message_user-- constructs aMessage::Userand verifies it holds the expected stringtest_mock_message_system-- constructs aMessage::Systemand verifies it holds the expected stringtest_mock_message_tool_result-- constructs aMessage::ToolResultand verifies bothidandcontentare correcttest_mock_assistant_turn-- builds anAssistantTurnwith text and verifiesstop_reasonisStoptest_mock_tool_definition_builder-- uses the builder to add parameters and verifies the resulting JSON schema has the correct structuretest_mock_tool_definition_optional_param-- adds an optional parameter and verifies it does not appear in therequiredarraytest_mock_toolset_empty-- creates an emptyToolSetand verifiesget()returnsNonefor any nametest_mock_token_usage_default-- verifies thatTokenUsage::default()initializes both counters to zero
What you built
This chapter established the type vocabulary for the entire agent:
Message-- a four-variant enum carrying every kind of conversation entry: system instructions, user input, assistant responses, and tool results.AssistantTurn-- the model's response, containing optional text, tool calls, a stop reason, and optional token usage.StopReason-- the binary signal that drives the agent loop: keep going or stop.ToolDefinition-- a builder for JSON Schema tool descriptions that the LLM uses to understand what tools are available.ToolCall-- the request side of tool execution, linked by ID toMessage::ToolResult.Tooltrait -- the minimal async interface every tool must implement:definition()andcall().ToolSet-- aHashMap-backed registry for looking up tools by name at runtime.Providertrait -- the async LLM abstraction, generic over any backend.TokenUsage-- per-request token tracking.
Key takeaway
The entire agent -- tools, providers, the loop itself -- is built on the vocabulary defined in this chapter. Getting these types right (especially the Message enum and StopReason) determines whether the agent loop is simple or tangled. The types are the contract; everything else is implementation.
None of these types do anything on their own -- they are the nouns of the system. In the next chapter, we will implement the MockProvider and OpenRouterProvider, giving these types their first verbs.
Check yourself
← Chapter 3: The Agentic Loop · Contents · Chapter 5a: Provider & Streaming Foundations →