Cersei

Cookbook: Custom Tools

Build custom tools for database queries, API calls, deploy pipelines, and more.

Cookbook: Custom Tools

Database Query Tool

use cersei::prelude::*;

#[derive(Tool)]
#[tool(name = "db_query", description = "Execute a SQL query", permission = "execute")]
struct DbQueryTool {
    pool: sqlx::PgPool,
}

#[async_trait]
impl ToolExecute for DbQueryTool {
    type Input = DbQueryInput;
    async fn run(&self, input: DbQueryInput, _ctx: &ToolContext) -> ToolResult {
        match sqlx::query(&input.sql).fetch_all(&self.pool).await {
            Ok(rows) => ToolResult::success(format!("{} rows returned", rows.len())),
            Err(e) => ToolResult::error(format!("Query failed: {e}")),
        }
    }
}

#[derive(Deserialize, JsonSchema)]
struct DbQueryInput {
    /// The SQL query to execute
    sql: String,
}

HTTP API Tool

#[derive(Tool)]
#[tool(name = "api_call", description = "Call an HTTP API endpoint", permission = "execute")]
struct ApiCallTool;

#[async_trait]
impl ToolExecute for ApiCallTool {
    type Input = ApiCallInput;
    async fn run(&self, input: ApiCallInput, _ctx: &ToolContext) -> ToolResult {
        let client = reqwest::Client::new();
        let resp = match input.method.as_str() {
            "POST" => client.post(&input.url).body(input.body.unwrap_or_default()).send().await,
            _ => client.get(&input.url).send().await,
        };

        match resp {
            Ok(r) => {
                let status = r.status().as_u16();
                let body = r.text().await.unwrap_or_default();
                ToolResult::success(format!("HTTP {status}: {body}"))
            }
            Err(e) => ToolResult::error(format!("Request failed: {e}")),
        }
    }
}

#[derive(Deserialize, JsonSchema)]
struct ApiCallInput {
    url: String,
    #[serde(default = "default_get")]
    method: String,
    body: Option<String>,
}

fn default_get() -> String { "GET".into() }

Deploy Tool

#[derive(Tool)]
#[tool(name = "deploy", description = "Deploy to production", permission = "dangerous")]
struct DeployTool;

#[async_trait]
impl ToolExecute for DeployTool {
    type Input = DeployInput;
    async fn run(&self, input: DeployInput, ctx: &ToolContext) -> ToolResult {
        let output = std::process::Command::new("bash")
            .args(["-c", &format!("cd {} && ./deploy.sh {}", ctx.working_dir.display(), input.env)])
            .output();

        match output {
            Ok(o) if o.status.success() => {
                ToolResult::success(String::from_utf8_lossy(&o.stdout).to_string())
            }
            Ok(o) => ToolResult::error(String::from_utf8_lossy(&o.stderr).to_string()),
            Err(e) => ToolResult::error(format!("Deploy failed: {e}")),
        }
    }
}

#[derive(Deserialize, JsonSchema)]
struct DeployInput {
    /// Target environment: staging or production
    env: String,
}

Registering Custom Tools

let agent = Agent::builder()
    .provider(Anthropic::from_env()?)
    .tools(cersei::tools::coding())  // built-in tools
    .tool(DbQueryTool { pool })      // + custom
    .tool(ApiCallTool)               // + custom
    .tool(DeployTool)                // + custom
    .build()?;

Manual Tool (without derive)

struct ManualTool;

#[async_trait]
impl Tool for ManualTool {
    fn name(&self) -> &str { "manual" }
    fn description(&self) -> &str { "A manually implemented tool" }
    fn input_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "input": { "type": "string", "description": "The input value" }
            },
            "required": ["input"]
        })
    }
    fn permission_level(&self) -> PermissionLevel { PermissionLevel::ReadOnly }

    async fn execute(&self, input: Value, _ctx: &ToolContext) -> ToolResult {
        let text = input["input"].as_str().unwrap_or("");
        ToolResult::success(format!("Processed: {text}"))
    }
}

On this page