Extending Skills (Tools) Tutorial¶
This tutorial covers how to write custom tools for ClawSeed.
Tool Trait Recap¶
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters_schema(&self) -> serde_json::Value;
async fn execute(&self, args: serde_json::Value, ctx: &dyn ToolContext) -> anyhow::Result<ToolResult>;
fn spec(&self) -> ToolSpec { /* default implementation, usually no need to override */ }
}
Step 1: The Simplest Tool¶
Create a stateless tool with no external dependencies. Example: CalculatorTool:
use async_trait::async_trait;
use clawseed_api::{Tool, ToolResult, ToolContext};
use serde_json::{Value, json};
pub struct CalculatorTool;
#[async_trait]
impl Tool for CalculatorTool {
fn name(&self) -> &str { "calculator" }
fn description(&self) -> &str {
"Perform arithmetic calculations. Supports add, subtract, multiply, divide."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"function": {
"type": "string",
"enum": ["add", "subtract", "multiply", "divide"],
"description": "The operation to perform"
},
"a": { "type": "number", "description": "First operand" },
"b": { "type": "number", "description": "Second operand" }
},
"required": ["function", "a", "b"]
})
}
async fn execute(&self, args: Value, _ctx: &dyn ToolContext) -> anyhow::Result<ToolResult> {
let function = args.get("function").and_then(|v| v.as_str()).unwrap_or("");
let a = args.get("a").and_then(|v| v.as_f64()).unwrap_or(0.0);
let b = args.get("b").and_then(|v| v.as_f64()).unwrap_or(0.0);
let result = match function {
"add" => a + b,
"subtract" => a - b,
"multiply" => a * b,
"divide" => {
if b == 0.0 {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Division by zero".into()),
});
}
a / b
}
_ => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Unknown function: {function}")),
});
}
};
Ok(ToolResult {
success: true,
output: result.to_string(),
error: None,
})
}
}
Key points:
- Struct is stateless, implements Send + Sync
- Parameters extracted from serde_json::Value using .and_then() for safe access
- Errors returned via ToolResult { success: false, error: Some(...) }, never panic
- The LLM understands the tool's purpose and parameters through description and parameters_schema
Step 2: Accessing Workspace Files¶
File operation tools use ToolContext's workspace_dir() to sandbox paths:
use std::path::Path;
use clawseed_api::{Tool, ToolResult, ToolContext};
pub struct FileReadTool;
#[async_trait]
impl Tool for FileReadTool {
fn name(&self) -> &str { "file_read" }
fn description(&self) -> &str { "Read the contents of a file within the workspace" }
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Relative path within workspace" }
},
"required": ["path"]
})
}
async fn execute(&self, args: Value, ctx: &dyn ToolContext) -> anyhow::Result<ToolResult> {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
let full_path = ctx.workspace_dir().join(path);
// Security check: path must be within workspace
let canonical = match std::fs::canonicalize(&full_path) {
Ok(p) => p,
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Cannot resolve path: {e}")),
});
}
};
let workspace_canon = std::fs::canonicalize(ctx.workspace_dir())
.unwrap_or_else(|_| ctx.workspace_dir().to_path_buf());
if !canonical.starts_with(&workspace_canon) {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Path '{}' is outside workspace", path)),
});
}
let content = std::fs::read_to_string(&canonical)?;
Ok(ToolResult { success: true, output: content, error: None })
}
}
Key points:
- Always use ctx.workspace_dir() to build paths
- Use canonicalize to prevent ../ and other path traversal attacks
- Verify the canonical path starts with the workspace prefix
- IO errors propagated via ? to anyhow::Result
Step 3: Constructor Injection¶
Tools that need runtime dependencies (Memory, Provider, etc.) receive them via constructor injection:
use clawseed_api::{Tool, ToolResult, ToolContext};
use clawseed_api::memory_traits::Memory;
pub struct MyMemoryTool {
memory: Arc<dyn Memory>,
}
impl MyMemoryTool {
pub fn new(memory: Arc<dyn Memory>) -> Self {
Self { memory }
}
}
#[async_trait]
impl Tool for MyMemoryTool {
fn name(&self) -> &str { "my_memory_tool" }
fn description(&self) -> &str { "Stores and retrieves data" }
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"key": { "type": "string", "description": "The key to store" },
"value": { "type": "string", "description": "The value to store" }
},
"required": ["key", "value"]
})
}
async fn execute(&self, args: Value, _ctx: &dyn ToolContext) -> anyhow::Result<ToolResult> {
let key = args.get("key").and_then(|v| v.as_str()).unwrap_or("");
let value = args.get("value").and_then(|v| v.as_str()).unwrap_or("");
// Use injected memory directly
self.memory.store(key, value, MemoryCategory::Core, None).await?;
Ok(ToolResult {
success: true,
output: format!("Stored '{}'", key),
error: None,
})
}
}
Key points:
- Receive dependencies via new(Arc<dyn T>) constructor
- Security checks are handled by the Hook pipeline (SecurityPolicy), not individual tools
- Tools access only ctx.workspace_dir() from the execution context
Step 4: Stateful Tools¶
Tools are stateless singletons by design. When state is needed, use Arc<Mutex<T>>:
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use clawseed_api::{Tool, ToolResult, ToolContext};
use serde_json::{Value, json};
pub struct CounterTool {
count: Arc<Mutex<usize>>,
}
impl CounterTool {
pub fn new() -> Self {
Self { count: Arc::new(Mutex::new(0)) }
}
}
#[async_trait]
impl Tool for CounterTool {
fn name(&self) -> &str { "counter" }
fn description(&self) -> &str { "Increment and read a counter" }
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["increment", "read"], "description": "Action to perform" }
},
"required": ["action"]
})
}
async fn execute(&self, args: Value, _ctx: &dyn ToolContext) -> anyhow::Result<ToolResult> {
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("read");
let mut count = self.count.lock().unwrap();
match action {
"increment" => {
*count += 1;
Ok(ToolResult { success: true, output: count.to_string(), error: None })
}
"read" => {
Ok(ToolResult { success: true, output: count.to_string(), error: None })
}
_ => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Unknown action: {action}")),
}),
}
}
}
Step 5: Registering Tools¶
Register new tools in the all_tools() function in clawseed-tools/src/registry.rs:
pub fn all_tools(workspace_dir: PathBuf, config: &Config) -> Vec<Box<dyn Tool>> {
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
// ... existing tools ...
// Add your tool
tools.push(Box::new(MyTool::new()));
tools
}
Conditional Registration¶
Some tools can be conditionally enabled based on configuration:
Config-Driven Control¶
Step 6: Remote Tools (Android Client)¶
Android clients register tools over WebSocket — no server-side code changes needed:
val client = ClawseedClient(
gatewayUrl = "ws://localhost:3000/ws/chat",
tools = listOf(
ToolSpec(
"local_contacts",
"Query phone contacts",
contactsSchema // JSONObject JSON Schema
),
)
) { request ->
when (request.name) {
"local_contacts" -> ToolCallResult.Success(queryContacts(request.args))
else -> ToolCallResult.Failure("unknown tool")
}
}
client.connect()
Limitations of remote tools:
- No ToolContext access (no server-side memory, security policy, etc.)
- 30-second execution timeout
- Results transmitted over WebSocket
Best Practices¶
- Validate parameters: Extract all parameters safely using
.and_then(), returnToolResult::errorfor missing params - Sandbox file access: File operations must be scoped to the workspace
- Degrade gracefully: Skip features when capabilities are absent, don't error
- Clear descriptions:
descriptionis the LLM's only way to understand the tool's purpose - Accurate JSON Schema:
parameters_schemadetermines the parameter format the LLM generates - Design for idempotency: Tools should be idempotent where possible, avoiding unintended side effects
- Wrap errors: Return all errors through
ToolResult::error, never panic - Prefer async: Use async APIs like
tokio::fsfor IO operations