Skip to content

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.

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.

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