Skip to content

Annotation / reflection tools

Rust · crate toolnexus · SPEC §6

pub fn define_typed_tool<T, F, Fut, E>(name, description, schema: Value, f: F) -> Tool
where T: DeserializeOwned, F: Fn(T, ToolContext) -> Fut, Fut: Future<Output = Result<String, E>>

Derive the schema from the function signature or annotation instead of writing it by hand.

When your tool’s arguments map onto a Rust struct: they are decoded with serde before your function runs, and a decode failure is an isError result naming the problem.

Rust has no runtime reflection, so the schema is passed explicitly and advertised verbatim; the struct gives you typed access instead of indexing a Value.

1. A struct in, a schema and a decoded value out

Section titled “1. A struct in, a schema and a decoded value out”
use serde::Deserialize;
use serde_json::json;
use toolnexus::{ToolContext, define_typed_tool};
#[derive(Deserialize)]
struct Mul {
a: f64,
b: f64,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let schema = json!({"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]});
let t = define_typed_tool("multiply", "Multiply two numbers", schema.clone(), |m: Mul, _c| async move {
Ok::<_, String>((m.a * m.b).to_string())
});
// The schema is advertised verbatim — Rust derives nothing at runtime.
assert_eq!(t.input_schema, schema);
let r = t.call(json!({"a": 6, "b": 7}), ToolContext::default()).await;
assert_eq!(r.output, "42");
println!("ok: {}", r.output);
Ok(())
}

2. #[serde(default)] controls what’s required, #[serde(skip)] drops a field

Section titled “2. #[serde(default)] controls what’s required, #[serde(skip)] drops a field”

The struct’s serde attributes decide what decodes; keep the schema’s required list in step with them by hand.

use serde::Deserialize;
use serde_json::json;
use toolnexus::{ToolContext, define_typed_tool};
#[derive(Deserialize)]
struct Search {
q: String,
#[serde(default)]
limit: Option<u32>,
#[serde(skip)]
internal: u32, // never read from the model's arguments
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let t = define_typed_tool(
"search",
"Search",
json!({"type":"object","properties":{"q":{"type":"string"},"limit":{"type":"number"}},"required":["q"]}),
|a: Search, _c| async move {
Ok::<_, String>(format!("{} (limit {}, internal {})", a.q, a.limit.unwrap_or(10), a.internal))
},
);
let dflt = t.call(json!({"q": "rust"}), ToolContext::default()).await;
let set = t.call(json!({"q": "rust", "limit": 3, "internal": 99}), ToolContext::default()).await;
assert_eq!(dflt.output, "rust (limit 10, internal 0)");
assert_eq!(set.output, "rust (limit 3, internal 0)");
let missing = t.call(json!({"limit": 3}), ToolContext::default()).await;
assert!(missing.is_error && missing.output.contains("missing field `q`"));
println!("ok: {} | {}", dflt.output, missing.output);
Ok(())
}

3. Numbers, nested structs, and registering on a toolkit

Section titled “3. Numbers, nested structs, and registering on a toolkit”

A decode failure never reaches your function: it is an isError result starting invalid arguments: that names the problem, so the model can correct its call.

use serde::Deserialize;
use serde_json::json;
use toolnexus::{BuiltinsConfig, ToolContext, ToolkitOptions, create_toolkit, define_typed_tool};
#[derive(Deserialize)]
struct Point {
x: i64,
y: i64,
}
#[derive(Deserialize)]
struct Segment {
from: Point,
to: Point,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let point = json!({"type":"object","properties":{"x":{"type":"integer"},"y":{"type":"integer"}},"required":["x","y"]});
let length = define_typed_tool(
"manhattan",
"Manhattan length of a segment",
json!({"type":"object","properties":{"from": point, "to": point},"required":["from","to"]}),
|s: Segment, _c| async move {
Ok::<_, String>(((s.to.x - s.from.x).abs() + (s.to.y - s.from.y).abs()).to_string())
},
);
let tk = create_toolkit(ToolkitOptions {
extra_tools: vec![length],
builtins: Some(BuiltinsConfig::off()),
..Default::default()
})
.await?;
assert_eq!(tk.to_openai()[0]["function"]["parameters"]["properties"]["from"]["required"][0], "x");
let ok = tk.execute("manhattan", json!({"from": {"x": 0, "y": 0}, "to": {"x": 3, "y": -4}}), ToolContext::default()).await;
assert_eq!(ok.output, "7");
let bad = tk.execute("manhattan", json!({"from": {"x": "0", "y": 0}, "to": {"x": 1, "y": 1}}), ToolContext::default()).await;
assert!(bad.is_error && bad.output.starts_with("invalid arguments:"));
println!("ok: {} | {}", ok.output, bad.output);
Ok(())
}
  • define_tool — Wrap a plain function with a name, description and schema — the shortest path from code you have to a tool the LLM can call.