Skip to content

ToolResult

Java · package io.github.muthuishere:toolnexus · SPEC §1 · ToolResult.java

public final class ToolResult {
public ToolResult(String output, boolean isError, Map<String, Object> metadata);
public ToolResult(String output, boolean isError, Map<String, Object> metadata,
List<ContentPart> parts);
public String output();
public boolean isError();
public Map<String, Object> metadata();
public List<ContentPart> parts(); // §1B non-text output; null when text-only
}

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, isError 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.

ToolResult is a final class, not a record: parts was added as a fourth constructor argument with the three-arg constructor delegating to it, so every existing call site — and every ok / error factory — kept working unchanged. A result with no parts serialises byte-identically to a pre-multimodal one.

Every time you write a tool. It is the return type of Tool.execute, 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 the model can read “file not found”, pick another path, and carry on. Throwing escapes the loop and ends the run.

output is always a String, and the constructor normalises null to "" — it is what the model reads, so serialize structured data yourself.

output stays REQUIRED even when parts is present. It is what the transcript, compaction, token estimation and any text-only provider see; parts is what a vision-capable provider additionally receives. A tool returning a screenshot sets output to a description ("shot.png (image/png, 82 bytes)") and parts to the image — so the run still reads sensibly on a model that cannot see it. See ContentPart.

import io.github.muthuishere.toolnexus.ToolResult;
import java.util.Map;
public class Example {
static final Map<String, String> CONFIG = Map.of("region", "eu-west-1");
static ToolResult readConfig(String key) {
String v = CONFIG.get(key);
if (v == null) {
// Recoverable: the model can read this and try another key.
return ToolResult.error("No such config key: " + key);
}
return ToolResult.ok(v);
}
public static void main(String[] args) {
ToolResult found = readConfig("region");
if (!found.output().equals("eu-west-1") || found.isError()) {
throw new AssertionError("unexpected: " + found.output());
}
ToolResult missing = readConfig("nope");
if (!missing.isError()) throw new AssertionError("expected isError");
System.out.println("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.

import io.github.muthuishere.toolnexus.ToolResult;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Example {
record Hit(int id, String title) {}
static ToolResult search(String q) {
List<Hit> hits = List.of(new Hit(1, "Getting started"), new Hit(2, "Advanced usage"));
return ToolResult.ok(
// The model reads this. Make it legible, not just valid.
hits.stream().map(h -> "#" + h.id() + " " + h.title()).collect(Collectors.joining("\n")),
// Your code reads this. The model never sees it.
Map.of("title", "search: " + q,
"count", hits.size(),
"ids", hits.stream().map(Hit::id).toList())
);
}
public static void main(String[] args) {
ToolResult res = search("usage");
if (!res.metadata().get("count").equals(2)) throw new AssertionError("count");
if (!res.metadata().get("ids").equals(List.of(1, 2))) throw new AssertionError("ids");
if (!res.output().contains("Advanced usage")) throw new AssertionError("output");
System.out.println("ok: " + res.metadata().get("title"));
}
}

3. Returning an image — parts alongside output

Section titled “3. Returning an image — parts alongside output”
import io.github.muthuishere.toolnexus.ContentPart;
import io.github.muthuishere.toolnexus.ToolResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class Example {
static ToolResult screenshot(Path png) {
ContentPart part = ContentPart.ofFile(png);
// output describes the part; parts carries it. Both, always.
return ToolResult.ok(png.getFileName() + " (" + part.mimeType() + ", " + part.bytes() + " bytes)",
List.of(part));
}
public static void main(String[] args) throws Exception {
ToolResult res = screenshot(Path.of("examples/media/fixture.png"));
if (res.isError()) throw new AssertionError("expected success");
if (!res.output().equals("fixture.png (image/png, 82 bytes)")) throw new AssertionError(res.output());
if (res.parts().size() != 1) throw new AssertionError("one part");
String golden = Files.readString(Path.of("examples/media/fixture.png.base64")).trim();
if (!res.parts().get(0).data().equals(golden)) throw new AssertionError("base64");
// A text-only result has NO parts list at all — not an empty one.
if (ToolResult.ok("done").parts() != null) throw new AssertionError("absent, not empty");
System.out.println("ok: " + res.output());
}
}

4. The reserved key — metadata.pending is a suspension

Section titled “4. 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.

import io.github.muthuishere.toolnexus.Request;
import io.github.muthuishere.toolnexus.ToolResult;
public class Example {
public static void main(String[] args) {
// pending() returns a ToolResult carrying metadata.pending = Request.
// An empty id is filled in for you — it is the correlation key.
ToolResult res = ToolResult.pending(new Request("", "input", "Which environment?"));
if (!res.isError()) throw new AssertionError("a parked call is not a success");
Request req = ToolResult.pendingOf(res);
if (req == null) throw new AssertionError("pendingOf should read the suspension back");
if (!req.kind().equals("input")) throw new AssertionError("kind: " + req.kind());
if (!req.prompt().equals("Which environment?")) throw new AssertionError("prompt");
if (req.id() == null || req.id().isEmpty()) throw new AssertionError("an id is generated");
// authRequired is sugar for the login case.
ToolResult auth = ToolResult.authRequired("https://example.com/login");
Request authReq = ToolResult.pendingOf(auth);
if (!authReq.kind().equals("authorization")) throw new AssertionError("auth kind");
if (!authReq.url().equals("https://example.com/login")) throw new AssertionError("auth url");
// An ordinary result has no suspension.
if (ToolResult.pendingOf(ToolResult.ok("done")) != null) {
throw new AssertionError("expected no suspension on a plain result");
}
System.out.println("ok: " + req.kind() + " | " + authReq.kind());
}
}
Member Returns What it is
output() String The text handed to the model. Never null — null becomes "".
isError() boolean Whether the call failed. Fed back to the model, not thrown.
parts() List<ContentPart> §1B non-text output, or null when the result is text-only.
metadata() Map<String, Object> Free-form, may be null. Reserved: pending holds a §10 Request.
Method What it does
ok(output) / ok(output, metadata) A success result.
ok(output, parts) A success result carrying §1B content parts alongside its describing text.
error(output) / 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) / authRequired(url, prompt) Sugar for kind:"authorization" at a login URL.
pendingOf(result) Reads the Request back off a result, or null.