Python agents get tool schema bugs at runtime. A Rust agent gets them at compile time, which is when you want them. This post builds a type-safe tool registry that serializes tool definitions to the correct JSON schema format for Anthropic, OpenAI, and Groq, enforced by the compiler rather than by hoping your schema strings are right.
Analysis Briefing
- Topic: Type-safe LLM tool registry design in Rust
- Analyst: Mike D (@MrComputerScience)
- Context: A technical briefing developed with Claude Sonnet 4.6
- Source: Pithy Cyborg | Pithy Security
- Key Question: How do you catch tool schema bugs at compile time instead of 3am in production?
The Problem With Stringly-Typed Tool Definitions
Every LLM provider wants tools described as JSON. The schemas are slightly different between providers. The naive approach is to write tool definitions as raw JSON strings or serde_json::Value objects and call it done.
This breaks in three ways:
- You typo a field name and the API returns a cryptic error about malformed tool schema
- You add a new tool and forget to update the schema for one of your three providers
- You refactor a tool’s parameters and the schema stays wrong silently
The Rust approach: define your tools once as types, derive their schemas automatically, and let the compiler catch mismatches before they reach the network.
The Tool Trait
Start with a trait that every tool must implement:
use serde::{Deserialize, Serialize};
use serde_json::Value;
use async_trait::async_trait;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSchema {
pub name: String,
pub description: String,
pub parameters: Value, // JSON Schema object
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub content: String,
pub is_error: bool,
}
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters_schema(&self) -> Value;
async fn execute(&self, args: Value) -> Result<ToolResult, ToolError>;
fn schema(&self) -> ToolSchema {
ToolSchema {
name: self.name().to_string(),
description: self.description().to_string(),
parameters: self.parameters_schema(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ToolError {
#[error("Invalid arguments: {0}")]
InvalidArgs(String),
#[error("Execution failed: {0}")]
ExecutionFailed(String),
#[error("Tool not found: {0}")]
NotFound(String),
}
Every tool is a struct that implements Tool. The schema lives with the code that defines the tool’s behavior, not in a separate configuration file that can drift.
A Concrete Tool Implementation
use serde_json::json;
pub struct WebSearchTool {
api_key: String,
}
impl WebSearchTool {
pub fn new(api_key: impl Into<String>) -> Self {
Self { api_key: api_key.into() }
}
}
#[async_trait]
impl Tool for WebSearchTool {
fn name(&self) -> &str {
"web_search"
}
fn description(&self) -> &str {
"Search the web for current information. Use for facts, recent events, and topics requiring up-to-date data."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
},
"max_results": {
"type": "integer",
"description": "Maximum number of results to return (default: 5)",
"default": 5,
"minimum": 1,
"maximum": 20
}
},
"required": ["query"]
})
}
async fn execute(&self, args: Value) -> Result<ToolResult, ToolError> {
let query = args["query"]
.as_str()
.ok_or_else(|| ToolError::InvalidArgs("query must be a string".to_string()))?;
let max_results = args["max_results"].as_u64().unwrap_or(5) as usize;
// Actual search implementation here
let results = format!("Search results for '{}' (max {})", query, max_results);
Ok(ToolResult {
content: results,
is_error: false,
})
}
}
The schema is defined once, inline, next to the execution logic. If you change the parameter names in execute, you change the schema too because they are in the same function.
The Registry
use std::sync::Arc;
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn Tool>>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self { tools: HashMap::new() }
}
pub fn register(&mut self, tool: impl Tool + 'static) -> &mut Self {
self.tools.insert(tool.name().to_string(), Arc::new(tool));
self
}
pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
self.tools.get(name).cloned()
}
pub async fn execute(&self, name: &str, args: Value) -> Result<ToolResult, ToolError> {
let tool = self.tools.get(name)
.ok_or_else(|| ToolError::NotFound(name.to_string()))?;
tool.execute(args).await
}
pub fn schemas(&self) -> Vec<ToolSchema> {
self.tools.values().map(|t| t.schema()).collect()
}
}
The Provider Formatters
Different providers want tool schemas in different shapes. The registry returns Vec<ToolSchema>. Provider formatters convert that to the exact JSON each API expects.
pub trait ProviderFormatter {
fn format_tools(schemas: &[ToolSchema]) -> Value;
}
pub struct AnthropicFormatter;
pub struct OpenAIFormatter;
pub struct GroqFormatter;
impl ProviderFormatter for AnthropicFormatter {
fn format_tools(schemas: &[ToolSchema]) -> Value {
let tools: Vec<Value> = schemas.iter().map(|s| {
json!({
"name": s.name,
"description": s.description,
"input_schema": s.parameters
})
}).collect();
json!(tools)
}
}
impl ProviderFormatter for OpenAIFormatter {
fn format_tools(schemas: &[ToolSchema]) -> Value {
let tools: Vec<Value> = schemas.iter().map(|s| {
json!({
"type": "function",
"function": {
"name": s.name,
"description": s.description,
"parameters": s.parameters
}
})
}).collect();
json!(tools)
}
}
// Groq uses the same format as OpenAI
impl ProviderFormatter for GroqFormatter {
fn format_tools(schemas: &[ToolSchema]) -> Value {
OpenAIFormatter::format_tools(schemas)
}
}
Adding a new provider is adding one impl ProviderFormatter block. Adding a new tool is adding one impl Tool block and one registry.register() call. The two concerns are completely decoupled.
Wiring It Together
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut registry = ToolRegistry::new();
registry
.register(WebSearchTool::new(std::env::var("SEARCH_API_KEY")?))
.register(/* other tools */);
let schemas = registry.schemas();
// Format for Anthropic
let anthropic_tools = AnthropicFormatter::format_tools(&schemas);
// Format for OpenAI / Groq
let openai_tools = OpenAIFormatter::format_tools(&schemas);
// When you get a tool call back from the model:
let tool_name = "web_search";
let tool_args = serde_json::json!({"query": "Rust async runtimes 2026"});
let result = registry.execute(tool_name, tool_args).await?;
println!("Tool result: {}", result.content);
Ok(())
}
What the Type System Catches That Python Does Not
With this setup:
- Forgetting to implement a method on a new tool is a compile error
- Registering a non-
Sendtype in the registry is a compile error - Calling
executewith noawaitis a compile error - Tool names being out of sync between registration and lookup is still a runtime error, but the registry’s
NotFounderror surfaces it clearly rather than silently producing a malformed API call
The remaining runtime errors are argument schema mismatches inside execute. That is where a typed argument extraction helper earns its keep:
fn extract_string<'a>(args: &'a Value, field: &str) -> Result<&'a str, ToolError> {
args[field]
.as_str()
.ok_or_else(|| ToolError::InvalidArgs(format!("field '{}' must be a string", field)))
}
fn extract_u64(args: &Value, field: &str, default: u64) -> u64 {
args[field].as_u64().unwrap_or(default)
}
Not type-safe at the Rust type system level, but at least the errors are explicit and consistent rather than panics or silent wrong values.
Full Project Structure
src/
tools/
mod.rs # Tool trait, ToolSchema, ToolResult, ToolError
registry.rs # ToolRegistry
formatters.rs # AnthropicFormatter, OpenAIFormatter, GroqFormatter
web_search.rs # WebSearchTool
calculator.rs # CalculatorTool
file_read.rs # FileReadTool
agent/
mod.rs # Agent loop using the registry
main.rs
Each tool is its own file. The registry knows about traits, not concrete types. Formatters know about schemas, not tools. The agent knows about the registry, not individual tools.
This is the architecture that scales from three tools to thirty without becoming a maintenance problem.
Mike D writes Rust and builds AI tools. Follow at @MrComputerScience.
Enjoyed this deep dive? Join my inner circle:
- Pithy Cyborg → AI news made simple without hype.
- Pithy Security → Stay ahead of cybersecurity threats.
