ToolResult
C# · package Toolnexus · SPEC §1 · ToolResult.cs
public sealed record ToolResult( string Output, bool IsError, IDictionary<string, object?>? Metadata = null, IReadOnlyList<ContentPart>? Parts = null){ public static ToolResult Ok(string output, IDictionary<string, object?>? metadata = null); public static ToolResult Error(string output, IDictionary<string, object?>? metadata = null); public static ToolResult OkWithParts(string output, IReadOnlyList<ContentPart> parts, IDictionary<string, object?>? metadata = null); public static ToolResult Pending(Request request); public static ToolResult AuthRequired(string url, string prompt = "Authorization required to continue"); public static Request? PendingOf(ToolResult? result);}What every ExecuteAsync returns. Four fields, and the whole tool-calling loop is built on them:
Output is the text handed back to the model, IsError says whether the call failed, Metadata
is free-form — except for one reserved key that turns a result into a suspension — and
Parts carries non-text output: an image, a PDF, an audio clip.
It is a record, so it is immutable and gives you value equality and with expressions for free.
When to use it
Section titled “When to use it”Every time you write a tool. It is the return type of
ITool.ExecuteAsync, so you construct one on every code path.
Why Parts is the fourth parameter, and Output is still required
Section titled “Why Parts is the fourth parameter, and Output is still required”Output stays required even when Parts is present. It is what the canonical transcript, the
compactor, token estimation and any text-only provider see — a tool returning a screenshot sets
Output to a description ("screenshot, 1280x720 png") and Parts to the image. Returning an
image with an empty Output makes the result invisible to everything except the one provider call
that happened to render it.
Use OkWithParts(output, parts) rather than the positional constructor; it says what it means and
cannot get the argument order wrong.
Why the static factories rather than the constructor
Section titled “Why the static factories rather than the constructor”A tool that fails does not throw — it returns Error(...). The loop feeds that text back to the
model as the tool result, so it can read “file not found”, pick another path, and carry on. Throwing
escapes the loop and ends the run.
Output is normalised: a null becomes "", so it is always safe to read.
Examples
Section titled “Examples”1. Success and failure on the same tool
Section titled “1. Success and failure on the same tool”using Toolnexus;
var config = new Dictionary<string, string> { ["region"] = "eu-west-1" };
ToolResult ReadConfig(string key) => config.TryGetValue(key, out var v) ? ToolResult.Ok(v) // Recoverable: the model can read this and try another key. : ToolResult.Error($"No such config key: {key}");
var found = ReadConfig("region");if (found.Output != "eu-west-1" || found.IsError) throw new Exception($"unexpected: {found.Output}");
var missing = ReadConfig("nope");if (!missing.IsError) throw new Exception("expected IsError");
Console.WriteLine($"ok: {found.Output} | {missing.Output}");2. Structured output and metadata
Section titled “2. Structured output and metadata”Output must be a string, so serialize deliberately. Metadata rides alongside for your code —
the model never sees it, which makes it the right place for bookkeeping.
using Toolnexus;
var hits = new[] { (Id: 1, Title: "Getting started"), (Id: 2, Title: "Advanced usage") };
var res = ToolResult.Ok( // The model reads this. Make it legible, not just valid. string.Join("\n", hits.Select(h => $"#{h.Id} {h.Title}")), // Your code reads this. The model never sees it. new Dictionary<string, object?> { ["title"] = "search: usage", ["count"] = hits.Length, ["ids"] = hits.Select(h => h.Id).ToArray(), });
if (Convert.ToInt32(res.Metadata!["count"]) != 2) throw new Exception("count");if (!res.Output.Contains("Advanced usage")) throw new Exception("output");
// It is a record: value equality and `with` come for free.var asError = res with { IsError = true };if (!asError.IsError || asError.Output != res.Output) throw new Exception("with-expression");
Console.WriteLine($"ok: {res.Metadata["title"]}");3. The reserved key — Metadata["pending"] is a suspension
Section titled “3. The reserved key — Metadata["pending"] is a suspension”Metadata is free-form with one exception. A pending key holding a Request means “this tool
cannot finish until something out-of-band happens” — the loop parks the run instead of returning.
The producer and reader helpers are static methods on ToolResult.
using Toolnexus;
// Pending() returns a ToolResult carrying Metadata["pending"] = Request.// An empty Id is filled in for you — it is the correlation key.var res = ToolResult.Pending(new Request { Kind = "input", Prompt = "Which environment?" });
if (!res.IsError) throw new Exception("a parked call is not a success");
var req = ToolResult.PendingOf(res);if (req is null) throw new Exception("PendingOf should read the suspension back");if (req.Kind != "input") throw new Exception($"kind: {req.Kind}");if (req.Prompt != "Which environment?") throw new Exception("prompt");if (string.IsNullOrEmpty(req.Id)) throw new Exception("an id is generated");
// AuthRequired is sugar for the login case; the prompt has a default.var auth = ToolResult.AuthRequired("https://example.com/login");var authReq = ToolResult.PendingOf(auth)!;if (authReq.Kind != "authorization") throw new Exception("auth kind");if (authReq.Url != "https://example.com/login") throw new Exception("auth url");
// An ordinary result has no suspension.if (ToolResult.PendingOf(ToolResult.Ok("done")) is not null) throw new Exception("expected none");
Console.WriteLine($"ok: {req.Kind} | {authReq.Kind}");4. Non-text output — Parts
Section titled “4. Non-text output — Parts”using Toolnexus;
var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? ".";var png = Path.Combine(repo, "examples/media/fixture.png");
// Output DESCRIBES; Parts CARRIES. Both, always.var res = ToolResult.OkWithParts( ContentPart.FromFile(png).DescribeInText(), // "image (image/png, 82 bytes)" [ContentPart.FromFile(png)]);
if (res.IsError) throw new Exception("a screenshot is a success");if (res.Parts is null || res.Parts.Count != 1) throw new Exception("one part");if (res.Parts[0].Type != "image") throw new Exception(res.Parts[0].Type);// The transcript and the compactor read Output, never the base64.if (res.Output != "image (image/png, 82 bytes)") throw new Exception(res.Output);
// Absent Parts is the pre-multimodal shape, unchanged.if (ToolResult.Ok("done").Parts is not null) throw new Exception("absent means absent");
Console.WriteLine($"ok: {res.Output} | {res.Parts[0].Describe()}");Members
Section titled “Members”| Member | Type | What it is |
|---|---|---|
Output |
string |
The text handed to the model. null is normalised to "". |
IsError |
bool |
Whether the call failed. Fed back to the model, not thrown. |
Metadata |
IDictionary<string, object?>? |
Free-form, may be null. Reserved: pending holds a §10 Request. |
Parts |
IReadOnlyList<ContentPart>? |
Non-text output (§1B). The fourth positional parameter, appended after Metadata. Absent ⇒ byte-identical to pre-multimodal behaviour. |
Static factories
Section titled “Static factories”| Method | What it does |
|---|---|
Ok(output, metadata?) |
A success result. |
Error(output, metadata?) |
A failure result the model can recover from. |
OkWithParts(output, parts, metadata?) |
A success carrying non-text content alongside its describing text. |
Pending(request) |
A §10 suspension. Generates an Id when the request has none. |
AuthRequired(url, prompt?) |
Sugar for Kind:"authorization" at a login URL. |
PendingOf(result) |
Reads the Request back off a result, or null. |
See also
Section titled “See also”ContentPart— whatPartsholdsITool— what returns thisToolContext— whatExecuteAsyncreceivesSuspension.Pending— the suspension entry point