ToolResult
C# · package Toolnexus · SPEC §1 · ToolResult.cs
public sealed record ToolResult(string Output, bool IsError, IDictionary<string, object?>? Metadata = 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 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. Three 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, and
Metadata is free-form — except for one reserved key that turns a result into a suspension.
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 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}");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. |
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. |
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”ITool— what returns thisToolContext— whatExecuteAsyncreceivesSuspension.Pending— the suspension entry point