Skip to content

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.

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.

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}");

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}");
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.
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.