Creating Custom Tools
Tools are OneClaw’s “hands” — they execute actions in the world. This guide shows how to create custom tools with proper parameter validation, security sandboxing, and integration with the tool registry.Tool Trait
All tools implement theTool trait:
pub trait Tool: Send + Sync {
/// Tool info for discovery/LLM function calling
fn info(&self) -> ToolInfo;
/// Execute the tool with given parameters
/// Security check happens BEFORE this is called (by ToolRegistry)
fn execute(&self, params: &HashMap<String, String>) -> Result<ToolResult>;
}
Tool Metadata
ToolInfo
pub struct ToolInfo {
/// The name of the tool.
pub name: String,
/// The description of the tool.
pub description: String,
/// The parameters accepted by the tool.
pub params: Vec<ToolParam>,
/// Category for grouping: "io", "network", "system", "notify"
pub category: String,
}
ToolParam
pub struct ToolParam {
/// The name of the parameter.
pub name: String,
/// The description of the parameter.
pub description: String,
/// Whether this parameter is required.
pub required: bool,
}
ToolResult
pub struct ToolResult {
/// Whether the tool execution succeeded.
pub success: bool,
/// The output or error message from the tool.
pub output: String,
/// Additional metadata from the tool execution.
pub metadata: HashMap<String, String>,
}
impl ToolResult {
/// Create a successful tool result with the given output.
pub fn ok(output: impl Into<String>) -> Self {
Self { success: true, output: output.into(), metadata: HashMap::new() }
}
/// Create a failed tool result with the given error message.
pub fn err(message: impl Into<String>) -> Self {
Self { success: false, output: message.into(), metadata: HashMap::new() }
}
/// Attach a metadata key-value pair to this result.
pub fn with_meta(mut self, key: impl Into<String>, val: impl Into<String>) -> Self {
self.metadata.insert(key.into(), val.into());
self
}
}
Example: File Write Tool
Let’s look at the built-inFileWriteTool as a complete example:
//! File Write Tool — Write content to files within workspace
//! Respects Security PathGuard (only writes within allowed workspace)
use oneclaw_core::tool::{Tool, ToolInfo, ToolParam, ToolResult};
use oneclaw_core::error::Result;
use std::collections::HashMap;
use std::path::PathBuf;
/// Tool that writes content to files within a sandboxed workspace.
pub struct FileWriteTool {
workspace: PathBuf,
}
impl FileWriteTool {
/// Create a new `FileWriteTool` scoped to the given workspace directory.
pub fn new(workspace: impl Into<PathBuf>) -> Self {
Self { workspace: workspace.into() }
}
}
impl Tool for FileWriteTool {
fn info(&self) -> ToolInfo {
ToolInfo {
name: "file_write".into(),
description: "Write content to a file within the agent workspace".into(),
params: vec![
ToolParam { name: "path".into(), description: "Relative file path".into(), required: true },
ToolParam { name: "content".into(), description: "Content to write".into(), required: true },
ToolParam { name: "mode".into(), description: "Write mode: overwrite (default) or append".into(), required: false },
],
category: "io".into(),
}
}
fn execute(&self, params: &HashMap<String, String>) -> Result<ToolResult> {
let rel_path = params.get("path")
.ok_or_else(|| oneclaw_core::error::OneClawError::Tool("Missing 'path' param".into()))?;
let content = params.get("content")
.ok_or_else(|| oneclaw_core::error::OneClawError::Tool("Missing 'content' param".into()))?;
let mode = params.get("mode").map(|s| s.as_str()).unwrap_or("overwrite");
// Security: resolve within workspace only
let full_path = self.workspace.join(rel_path);
// Check that resolved path is still within workspace
let canonical_workspace = self.workspace.canonicalize()
.unwrap_or_else(|_| self.workspace.clone());
// Check parent directory (if it exists, canonicalize to resolve ..)
if let Some(parent) = full_path.parent()
&& parent.exists()
&& let Ok(canonical_parent) = parent.canonicalize()
&& !canonical_parent.starts_with(&canonical_workspace)
{
return Ok(ToolResult::err(format!(
"Path escape detected: '{}' is outside workspace", rel_path
)));
}
// Also check for obvious traversal patterns
if rel_path.contains("..") {
return Ok(ToolResult::err(format!(
"Path escape detected: '{}' contains '..'", rel_path
)));
}
// Ensure parent directory exists
if let Some(parent) = full_path.parent()
&& !parent.exists()
{
std::fs::create_dir_all(parent)
.map_err(|e| oneclaw_core::error::OneClawError::Tool(
format!("Failed to create directory: {}", e)
))?;
}
// Write
let result = match mode {
"append" => {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true).append(true).open(&full_path)
.map_err(|e| oneclaw_core::error::OneClawError::Tool(format!("Open failed: {}", e)))?;
file.write_all(content.as_bytes())
.map_err(|e| oneclaw_core::error::OneClawError::Tool(format!("Write failed: {}", e)))?;
format!("Appended {} bytes to {}", content.len(), rel_path)
}
_ => {
std::fs::write(&full_path, content)
.map_err(|e| oneclaw_core::error::OneClawError::Tool(format!("Write failed: {}", e)))?;
format!("Wrote {} bytes to {}", content.len(), rel_path)
}
};
Ok(ToolResult::ok(result)
.with_meta("path", rel_path)
.with_meta("bytes", content.len().to_string())
.with_meta("mode", mode))
}
}
Key Points
- Parameter validation: Check for required params, return error if missing
- Security sandboxing: Prevent path traversal attacks (
../../../etc/passwd) - Metadata: Use
.with_meta()to attach execution details - Error handling: Return
ToolResult::err()for invalid inputs
Example: HTTP Request Tool
Let’s build a tool that makes HTTP requests:use oneclaw_core::tool::{Tool, ToolInfo, ToolParam, ToolResult};
use oneclaw_core::error::{OneClawError, Result};
use std::collections::HashMap;
use reqwest::blocking::Client;
use std::time::Duration;
pub struct HttpRequestTool {
client: Client,
}
impl HttpRequestTool {
pub fn new() -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client");
Self { client }
}
}
impl Tool for HttpRequestTool {
fn info(&self) -> ToolInfo {
ToolInfo {
name: "http_request".into(),
description: "Make an HTTP GET request to a URL".into(),
params: vec![
ToolParam {
name: "url".into(),
description: "The URL to request".into(),
required: true,
},
ToolParam {
name: "headers".into(),
description: "Optional JSON object of headers".into(),
required: false,
},
],
category: "network".into(),
}
}
fn execute(&self, params: &HashMap<String, String>) -> Result<ToolResult> {
// Validate required params
let url = params.get("url")
.ok_or_else(|| OneClawError::Tool("Missing 'url' parameter".into()))?;
// Security: validate URL scheme
if !url.starts_with("http://") && !url.starts_with("https://") {
return Ok(ToolResult::err("Only http:// and https:// URLs are allowed"));
}
// Parse optional headers
let headers: HashMap<String, String> = if let Some(headers_json) = params.get("headers") {
serde_json::from_str(headers_json)
.map_err(|e| OneClawError::Tool(format!("Invalid headers JSON: {}", e)))?
} else {
HashMap::new()
};
// Build request
let mut request = self.client.get(url);
for (key, value) in headers {
request = request.header(key, value);
}
// Execute request
match request.send() {
Ok(response) => {
let status = response.status();
let body = response.text()
.map_err(|e| OneClawError::Tool(format!("Failed to read response: {}", e)))?;
Ok(ToolResult::ok(body)
.with_meta("status_code", status.as_u16().to_string())
.with_meta("url", url)
.with_meta("content_length", body.len().to_string()))
}
Err(e) => {
Ok(ToolResult::err(format!("HTTP request failed: {}", e))
.with_meta("url", url))
}
}
}
}
Example: Database Query Tool
A tool that queries a database:use oneclaw_core::tool::{Tool, ToolInfo, ToolParam, ToolResult};
use oneclaw_core::error::{OneClawError, Result};
use std::collections::HashMap;
use rusqlite::Connection;
use std::sync::Mutex;
pub struct DatabaseQueryTool {
conn: Mutex<Connection>,
}
impl DatabaseQueryTool {
pub fn new(db_path: &str) -> Result<Self> {
let conn = Connection::open(db_path)
.map_err(|e| OneClawError::Tool(format!("Failed to open database: {}", e)))?;
Ok(Self { conn: Mutex::new(conn) })
}
}
impl Tool for DatabaseQueryTool {
fn info(&self) -> ToolInfo {
ToolInfo {
name: "db_query".into(),
description: "Execute a read-only SQL query".into(),
params: vec![
ToolParam {
name: "sql".into(),
description: "SQL SELECT query to execute".into(),
required: true,
},
ToolParam {
name: "limit".into(),
description: "Maximum rows to return (default 100)".into(),
required: false,
},
],
category: "io".into(),
}
}
fn execute(&self, params: &HashMap<String, String>) -> Result<ToolResult> {
let sql = params.get("sql")
.ok_or_else(|| OneClawError::Tool("Missing 'sql' parameter".into()))?;
// Security: only allow SELECT queries
let sql_lower = sql.to_lowercase();
if !sql_lower.trim().starts_with("select") {
return Ok(ToolResult::err("Only SELECT queries are allowed"));
}
// Parse limit
let limit: usize = params.get("limit")
.and_then(|l| l.parse().ok())
.unwrap_or(100);
// Execute query
let conn = self.conn.lock()
.map_err(|e| OneClawError::Tool(format!("Lock error: {}", e)))?;
let mut stmt = conn.prepare(sql)
.map_err(|e| OneClawError::Tool(format!("SQL prepare error: {}", e)))?;
let column_count = stmt.column_count();
let column_names: Vec<String> = (0..column_count)
.map(|i| stmt.column_name(i).unwrap_or("?").to_string())
.collect();
let mut rows = stmt.query([])
.map_err(|e| OneClawError::Tool(format!("SQL query error: {}", e)))?;
let mut results = vec![];
let mut row_count = 0;
while let Ok(Some(row)) = rows.next() {
if row_count >= limit {
break;
}
let mut row_data = vec![];
for i in 0..column_count {
let value: String = row.get::<_, Option<String>>(i)
.unwrap_or(None)
.unwrap_or_else(|| "NULL".to_string());
row_data.push(value);
}
results.push(row_data);
row_count += 1;
}
// Format output as CSV
let mut output = column_names.join(",") + "\n";
for row in &results {
output.push_str(&row.join(","));
output.push('\n');
}
Ok(ToolResult::ok(output)
.with_meta("rows", row_count.to_string())
.with_meta("columns", column_count.to_string()))
}
}
Parameter Validation
Required vs Optional
fn execute(&self, params: &HashMap<String, String>) -> Result<ToolResult> {
// Required parameter
let required = params.get("required")
.ok_or_else(|| OneClawError::Tool("Missing 'required' param".into()))?;
// Optional parameter with default
let optional = params.get("optional")
.map(|s| s.as_str())
.unwrap_or("default_value");
// Optional parameter parsed as number
let count: usize = params.get("count")
.and_then(|s| s.parse().ok())
.unwrap_or(10);
// Continue with execution...
}
Type Validation
// Parse and validate integer
let port: u16 = match params.get("port")
.and_then(|s| s.parse::<u16>().ok())
{
Some(p) if p > 0 && p < 65536 => p,
Some(_) => return Ok(ToolResult::err("Port must be between 1 and 65535")),
None => return Ok(ToolResult::err("Missing or invalid 'port' parameter")),
};
// Parse JSON
let config: serde_json::Value = match params.get("config")
.and_then(|s| serde_json::from_str(s).ok())
{
Some(c) => c,
None => return Ok(ToolResult::err("Invalid JSON in 'config' parameter")),
};
// Validate enum
let mode = match params.get("mode").map(|s| s.as_str()) {
Some("read") | Some("write") | Some("append") => params.get("mode").unwrap(),
Some(other) => return Ok(ToolResult::err(format!("Invalid mode: {}", other))),
None => "read", // default
};
Security Best Practices
Path Traversal Prevention
// Check for directory traversal
if path.contains("..") {
return Ok(ToolResult::err("Path contains '..'"));
}
// Canonicalize and verify
let canonical = full_path.canonicalize()
.map_err(|e| OneClawError::Tool(format!("Invalid path: {}", e)))?;
if !canonical.starts_with(&workspace) {
return Ok(ToolResult::err("Path escapes workspace"));
}
Command Injection Prevention
use std::process::Command;
// BAD: direct shell execution
// Command::new("sh").arg("-c").arg(user_input) // NEVER DO THIS
// GOOD: use Command with args array
Command::new("git")
.arg("status")
.arg("--short")
.output()?;
// If shell is required, sanitize inputs
let safe_path = path.chars()
.filter(|c| c.is_alphanumeric() || *c == '/' || *c == '.' || *c == '_' || *c == '-')
.collect::<String>();
Network Security
// Validate URL scheme
if !url.starts_with("http://") && !url.starts_with("https://") {
return Ok(ToolResult::err("Only HTTP/HTTPS URLs allowed"));
}
// Block internal/localhost access (if needed)
if url.contains("localhost") || url.contains("127.0.0.1") || url.contains("0.0.0.0") {
return Ok(ToolResult::err("Cannot access localhost"));
}
// Set timeouts
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
Tool Registration
Register your tool with the tool registry:use oneclaw_core::tool::registry::ToolRegistry;
use oneclaw_core::security::traits::SecurityLayer;
let registry = ToolRegistry::new();
// Register tool
registry.register(Box::new(HttpRequestTool::new()))?;
registry.register(Box::new(DatabaseQueryTool::new("/data/app.db")?))?;
// List available tools
for tool_info in registry.list_tools() {
println!("Tool: {} ({})", tool_info.name, tool_info.category);
for param in &tool_info.params {
println!(" - {}: {} {}",
param.name,
param.description,
if param.required { "(required)" } else { "" }
);
}
}
// Execute tool
let mut params = HashMap::new();
params.insert("url".into(), "https://api.example.com/data".into());
let result = registry.execute("http_request", ¶ms)?;
if result.success {
println!("Success: {}", result.output);
println!("Metadata: {:?}", result.metadata);
} else {
eprintln!("Error: {}", result.output);
}
Testing Your Tool
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_basic_execution() {
let tool = HttpRequestTool::new();
let info = tool.info();
assert_eq!(info.name, "http_request");
assert_eq!(info.params.len(), 2);
}
#[test]
fn test_tool_missing_required_param() {
let tool = HttpRequestTool::new();
let params = HashMap::new();
let result = tool.execute(¶ms);
assert!(result.is_err());
}
#[test]
fn test_tool_invalid_url_scheme() {
let tool = HttpRequestTool::new();
let mut params = HashMap::new();
params.insert("url".into(), "file:///etc/passwd".into());
let result = tool.execute(¶ms).unwrap();
assert!(!result.success);
assert!(result.output.contains("Only http"));
}
#[test]
fn test_tool_success_with_metadata() {
// This would require a mock HTTP server
// Or use a public test endpoint
}
}
Best Practices
- Validate all inputs: Check required params, validate types, sanitize strings
- Security first: Prevent path traversal, command injection, SSRF
- Return useful errors: Help users understand what went wrong
- Add metadata: Include execution details (time, size, status codes)
- Set timeouts: Network tools should have reasonable timeouts
- Document parameters: Clear descriptions help LLMs use your tools correctly
- Test edge cases: Missing params, invalid inputs, permission errors
- Use ToolResult builders:
.ok(),.err(),.with_meta()for clean code
See Also
- Architecture - Tool layer design
- Security - Security best practices
- API Reference - Full trait documentation