Skip to content

Custom Tools

Heartbit agents interact with the world through tools. You can extend agent capabilities by implementing the Tool trait and registering your tools with an agent.

Every tool implements two methods:

  • definition() — returns a ToolDefinition describing the tool’s name, description, and parameters (JSON Schema).
  • execute() — receives the LLM’s input as a serde_json::Value and returns a ToolOutput.
use heartbit::{Tool, ToolDefinition, ToolOutput, Error};
use serde_json::Value;
use std::pin::Pin;
use std::future::Future;
pub struct MyTool;
impl Tool for MyTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "my_tool".into(),
description: "Does something useful".into(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"input": { "type": "string", "description": "The input value" }
},
"required": ["input"]
}),
}
}
fn execute(
&self,
input: Value,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
Box::pin(async move {
let input_str = input["input"].as_str().unwrap_or_default();
Ok(ToolOutput::success(format!("Processed: {input_str}")))
})
}
}

Pass tools as Vec<Arc<dyn Tool>> to an agent builder:

use std::sync::Arc;
use heartbit::AgentRunner;
let agent = AgentRunner::builder(provider)
.tools(vec![Arc::new(MyTool)])
.build()?;

ToolOutput supports several output formats:

  • ToolOutput::success("result") — plain text output
  • ToolOutput::error("something went wrong") — error result returned to the LLM (the agent can adjust and retry)

ToolDefinition is a struct with three fields: name, description, and input_schema (JSON Schema as serde_json::Value):

ToolDefinition {
name: "search".into(),
description: "Search the web for information".into(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query" },
"max_results": { "type": "number", "description": "Maximum results to return" }
},
"required": ["query"]
}),
}

The input_schema follows JSON Schema format. Use "required" to list mandatory parameters.

The execute method returns a pinned boxed future, allowing full async operation. You can hold references to &self across await points:

pub struct DatabaseTool {
pool: sqlx::PgPool,
}
impl Tool for DatabaseTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "query_db".into(),
description: "Run a read-only database query".into(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"sql": { "type": "string", "description": "SQL query" }
},
"required": ["sql"]
}),
}
}
fn execute(
&self,
input: Value,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
Box::pin(async move {
let sql = input["sql"].as_str().unwrap_or_default();
// Use self.pool across the await point
let rows: Vec<_> = sqlx::query(sql)
.fetch_all(&self.pool)
.await
.map_err(|e| Error::Agent(e.to_string()))?;
Ok(ToolOutput::success(format!("Returned {} rows", rows.len())))
})
}
}

For tools hosted on external MCP servers, you don’t need to implement the Tool trait. Configure MCP server URLs in your agent config and Heartbit discovers tools automatically:

[[agents]]
name = "researcher"
mcp_servers = ["http://localhost:8000/mcp"]

See the MCP Servers guide for details on connecting to MCP tool servers.