Skip to content

Toolnexus.ToolResult

Elixir · package toolnexus · SPEC §1 · elixir/lib/toolnexus/types.ex

defmodule Toolnexus.ToolResult do
defstruct output: "", is_error: false, parts: nil, metadata: nil
end

What every execute returns. Four fields, and the whole tool-calling loop is built on them: output is the text handed back to the model, is_error says whether the call failed, parts carries non-text output (an image, a PDF, an audio clip), and metadata is free-form — except for one reserved key that turns a result into a suspension.

output stays required even when parts is present. It is what the transcript, compaction, token estimation and any text-only provider see, so a tool returning an image sets output to a description ("screenshot, 1280x720 png") and parts to the image. A result that sets no parts is byte-identical to one from before parts existed.

Unlike Toolnexus.Tool, nothing here is enforced: every field has a default, so %ToolResult{} is valid and means “empty success”.

Every time you write a tool. It is the return type of Toolnexus.Tool’s execute, so you construct one on every code path.

output is always a String.t() — it is what the model reads. Serialize structured data yourself (Jason.encode!/1) rather than expecting the loop to do it.

1. Success and failure, with the shorthand constructors

Section titled “1. Success and failure, with the shorthand constructors”

ok/1 and error/1 cover the two ordinary cases without writing the struct out.

alias Toolnexus.ToolResult
config = %{"region" => "eu-west-1"}
read_config = fn key ->
case Map.fetch(config, key) do
# Recoverable: the model can read this and try another key.
:error -> ToolResult.error("No such config key: #{key}")
{:ok, v} -> ToolResult.ok(v)
end
end
found = read_config.("region")
true = found.output == "eu-west-1"
false = found.is_error
missing = read_config.("nope")
true = missing.is_error
IO.puts("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.

alias Toolnexus.ToolResult
search = fn q ->
hits = [%{id: 1, title: "Getting started"}, %{id: 2, title: "Advanced usage"}]
%ToolResult{
# The model reads this. Make it legible, not just valid.
output: Enum.map_join(hits, "\n", fn h -> "##{h.id} #{h.title}" end),
is_error: false,
# Your code reads this. The model never sees it.
metadata: %{title: "search: #{q}", count: length(hits), ids: Enum.map(hits, & &1.id)}
}
end
res = search.("usage")
true = res.metadata.count == 2
true = res.metadata.ids == [1, 2]
true = String.contains?(res.output, "Advanced usage")
IO.puts("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 %Toolnexus.Request{} means “this tool cannot finish until something out-of-band happens” — the loop parks the run instead of returning. ToolResult.pending?/1 is how you detect it.

alias Toolnexus.{ToolResult, Request}
# A suspension is a ToolResult whose metadata carries a Request.
req = %Request{id: "pnd-1", kind: "input", prompt: "Which environment?"}
res = %ToolResult{output: req.prompt, is_error: true, metadata: %{pending: req}}
# A parked call is not a success.
true = res.is_error
true = ToolResult.pending?(res)
# Read the request back off the result by matching the metadata.
%{pending: %Request{} = got} = res.metadata
true = got.kind == "input"
true = got.prompt == "Which environment?"
true = got.id == "pnd-1"
# The authorization shape carries a URL instead of expecting typed input.
auth_req = %Request{id: "pnd-2", kind: "authorization", prompt: "Log in", url: "https://example.com/login"}
auth = %ToolResult{output: auth_req.prompt, is_error: true, metadata: %{pending: auth_req}}
true = ToolResult.pending?(auth)
true = auth.metadata.pending.url == "https://example.com/login"
# An ordinary result has no suspension.
false = ToolResult.pending?(ToolResult.ok("done"))
IO.puts("ok: #{got.kind} | #{auth_req.kind}")

4. Non-text output — parts alongside output

Section titled “4. Non-text output — parts alongside output”

A tool that produces an image still owes the model a sentence. output is what the transcript and the compactor read; parts is what a vision-capable provider actually gets.

alias Toolnexus.{ContentPart, ToolResult}
repo = System.get_env("TOOLNEXUS_REPO") || "."
fixture = Path.join(repo, "examples/media/fixture.png")
screenshot = fn ->
case ContentPart.image(fixture) do
# The non-bang form inside execute: an unreadable file is an outcome the model can
# recover from, not a crash that ends the run.
{:error, e} -> ToolResult.error("screenshot: #{Exception.message(e)}")
{:ok, part} -> %ToolResult{output: ContentPart.summary(part), is_error: false, parts: [part]}
end
end
res = screenshot.()
false = res.is_error
# output is never empty just because the payload is binary.
true = res.output == "image (image/png, 82 bytes)"
true = length(res.parts) == 1
true = hd(res.parts).mime_type == "image/png"
# parts cannot collide with a §10 suspension: pending?/1 matches on metadata only,
# so a result carrying an image is still an ordinary, finished result.
false = ToolResult.pending?(res)
true = res.metadata == nil
IO.puts("ok: #{res.output}")
Field Type Default What it is
output String.t() "" The text handed to the model. Serialize structured data yourself.
is_error boolean() false Whether the call failed. Fed back to the model, not raised.
parts [ContentPart.t()] | nil nil Non-text output — see Toolnexus.ContentPart. nil ⇒ byte-identical to a pre-parts result.
metadata map() | nil nil Free-form, for your code. Reserved: :pending holds a §10 Request.
Function What it does
ToolResult.ok/1 A success result with the given text.
ToolResult.error/1 An error result with the given text.
ToolResult.pending?/1 True when metadata.pending holds a %Request{} — i.e. this is a §10 suspension.