ToolResult — what execute returns
Rust · crate toolnexus · SPEC §1
pub struct ToolResult { pub output: String, pub is_error: bool, // wire: "isError" pub parts: Vec<ContentPart>, // omitted when empty pub metadata: Option<Map<String, Value>>,}The result envelope: output text, optional error flag, optional non-text parts, and optional metadata that can carry a suspension.
When to use it
Section titled “When to use it”Whenever a tool returns: the loop feeds output back to the model, is_error marks failure without throwing, parts carries images/files, and metadata carries host-only facts (exit codes, diffs, pending).
Why this and not the alternative
Section titled “Why this and not the alternative”Return an error as ToolResult::error(..) instead of panicking: SPEC §1 says a failure is a result the model can read, never an exception crossing the tool boundary.
Examples
Section titled “Examples”1. Success and failure on the same tool
Section titled “1. Success and failure on the same tool”use serde_json::json;use toolnexus::{ToolContext, define_tool};
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let sqrt = define_tool("sqrt", "Square root", None, |args, _ctx| async move { let n = args["n"].as_f64().ok_or("n is required")?; if n < 0.0 { return Err(format!("cannot take the square root of {n}")); } Ok(n.sqrt().to_string()) }); let ok = sqrt.call(json!({"n": 16}), ToolContext::default()).await; let bad = sqrt.call(json!({"n": -1}), ToolContext::default()).await; assert!(!ok.is_error && ok.output == "4"); assert!(bad.is_error && bad.output == "cannot take the square root of -1"); println!("ok: {} | {}", ok.output, bad.output); Ok(())}2. Structured output and metadata
Section titled “2. Structured output and metadata”output is the text the model reads, so structured data goes in as JSON text. metadata is for
the host only — it is never sent to the model.
use serde_json::{Map, Value, json};use toolnexus::ToolResult;
fn main() -> Result<(), Box<dyn std::error::Error>> { let rows = json!([{"id": 1, "name": "ada"}, {"id": 2, "name": "linus"}]); let mut meta = Map::new(); meta.insert("rowCount".into(), json!(2)); meta.insert("elapsedMs".into(), json!(12)); let r = ToolResult::ok(serde_json::to_string(&rows)?).with_metadata(meta);
let parsed: Value = serde_json::from_str(&r.output)?; assert_eq!(parsed[1]["name"], "linus"); assert_eq!(r.metadata.as_ref().unwrap()["rowCount"], 2);
// The wire shape: camelCase isError, parts omitted when empty. let wire = serde_json::to_value(&r)?; assert_eq!(wire["isError"], false); assert!(wire.get("parts").is_none()); println!("ok: {} rows, wire keys {:?}", parsed.as_array().unwrap().len(), wire.as_object().unwrap().keys().collect::<Vec<_>>()); Ok(())}3. The reserved key — metadata["pending"] is a suspension
Section titled “3. The reserved key — metadata["pending"] is a suspension”Build a suspension with pending(..) / auth_required(..) rather than by hand; pending_of reads
it back. The loop treats such a result as a §10 request, not as a tool error.
use toolnexus::{Request, ToolResult, auth_required, pending, pending_of};
fn main() { let r = pending(Request { kind: "approval".into(), prompt: "Delete 3 files?".into(), ..Default::default() }); assert!(r.is_error); assert_eq!(r.output, "Delete 3 files?"); let req = pending_of(&r).expect("a suspension"); assert_eq!(req.kind, "approval"); assert!(req.id.starts_with("pnd-"), "an empty id is generated");
let auth = auth_required("https://example.com/login", ""); assert_eq!(auth.output, "Authorization required to continue\nhttps://example.com/login"); assert_eq!(pending_of(&auth).unwrap().kind, "authorization");
// An ordinary error is not a suspension. assert!(pending_of(&ToolResult::error("boom")).is_none()); println!("ok: {} {}", req.kind, req.id);}4. Non-text output — parts alongside output
Section titled “4. Non-text output — parts alongside output”use std::path::PathBuf;
use toolnexus::{ContentPart, ToolResult};
fn main() -> Result<(), Box<dyn std::error::Error>> { // TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. let png = PathBuf::from(std::env::var("TOOLNEXUS_REPO")?).join("examples/media/fixture.png"); let mut r = ToolResult::ok("fixture.png (image/png)"); r.parts.push(ContentPart::from_path(&png)?);
assert_eq!(r.parts[0].kind, "image"); assert_eq!(r.parts[0].mime_type, "image/png"); assert!(!r.parts[0].data.is_empty()); let wire = serde_json::to_value(&r)?; assert_eq!(wire["parts"][0]["mimeType"], "image/png"); println!("ok: {} + {} part(s)", r.output, r.parts.len()); Ok(())}See also
Section titled “See also”Tool— The uniform shape every tool source collapses to: name, description, JSON-Schema parameters, execute.ContentPart— The non-text half of a message: text | image | file | audio, carrying base64 bytes or a URL plus a mimeType — never a path.ToolContext— Optional per-call context handed to execute: cancellation, identity, and host-supplied state.