ToolResult
JavaScript · package toolnexus · SPEC §1 · js/src/types.ts
interface ToolResult { output: string isError: boolean parts?: ContentPart[] metadata?: Record<string, unknown>}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 anything that is not text, and metadata is free-form — except for one reserved key that
turns a result into a suspension.
When to use it
Section titled “When to use it”Every time you write a tool. It is the return type of
Tool.execute, so you construct one on every code path — success,
failure, and everything in between.
Why an error flag and not an exception
Section titled “Why an error flag and not an exception”output is always a string — it is what the model reads. Serialize structured data yourself
(JSON.stringify) rather than expecting the loop to do it, so you control exactly what the model
sees.
output stays required, even with parts
Section titled “output stays required, even with parts”Examples
Section titled “Examples”1. Success and failure on the same tool
Section titled “1. Success and failure on the same tool”import assert from "node:assert"import type { ToolResult } from "toolnexus"
function readConfig(key: string): ToolResult { const config: Record<string, string> = { region: "eu-west-1" } if (!(key in config)) { // Recoverable: the model can read this and try another key. return { output: `No such config key: ${key}`, isError: true } } return { output: config[key], isError: false }}
const found = readConfig("region")assert.equal(found.output, "eu-west-1")assert.equal(found.isError, false)
const missing = readConfig("nope")assert.equal(missing.isError, true)console.log("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.
import assert from "node:assert"import type { ToolResult } from "toolnexus"
function search(q: string): ToolResult { const hits = [ { id: 1, title: "Getting started" }, { id: 2, title: "Advanced usage" }, ] return { // The model reads this. Make it legible, not just valid. output: hits.map((h) => `#${h.id} ${h.title}`).join("\n"), isError: false, // Your code reads this. The model never sees it. metadata: { title: `search: ${q}`, count: hits.length, ids: hits.map((h) => h.id) }, }}
const res = search("usage")assert.equal(res.metadata?.count, 2)assert.deepEqual(res.metadata?.ids, [1, 2])assert.ok(res.output.includes("Advanced usage"))console.log("ok:", res.metadata?.title)3. Returning an image — output describes it, parts carries it
Section titled “3. Returning an image — output describes it, parts carries it”import assert from "node:assert"import fs from "node:fs"import { attach, partBytes, type ToolResult } from "toolnexus"
async function screenshot(path: string): Promise<ToolResult> { const part = await attach(path) return { // The transcript, the compactor and a text-only provider only ever see this. output: `${path} (${(part as any).mimeType}, ${partBytes(part)} bytes)`, isError: false, // A vision-capable provider additionally gets the bytes. parts: [part], }}
const golden = fs.readFileSync("examples/media/fixture.png.base64", "utf8").trim()const res = await screenshot("examples/media/fixture.png")
assert.equal(res.isError, false)assert.equal(res.output, "examples/media/fixture.png (image/png, 82 bytes)")assert.equal(res.parts?.length, 1)assert.equal((res.parts?.[0] as any).data, golden)
// Text-only tools simply omit `parts` — byte-identical to the pre-parts contract.const plain: ToolResult = { output: "done", isError: false }assert.equal(plain.parts, undefined)
console.log("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.
You rarely write this by hand; pending builds it for you.
import assert from "node:assert"import { pending, authRequired, pendingOf } from "toolnexus"
// pending() returns a ToolResult carrying metadata.pending = Request.const res = pending({ kind: "input", prompt: "Which environment?" })
assert.equal(res.isError, true) // a parked call is not a successconst req = pendingOf(res)assert.ok(req, "pendingOf reads the suspension back off the result")assert.equal(req?.kind, "input")assert.equal(req?.prompt, "Which environment?")assert.ok(req?.id, "an id is generated as the correlation key")
// authRequired is sugar for the login case.const auth = authRequired("https://example.com/login")assert.equal(pendingOf(auth)?.kind, "authorization")assert.equal(pendingOf(auth)?.url, "https://example.com/login")
// An ordinary result has no suspension.assert.equal(pendingOf({ output: "done", isError: false }), undefined)
console.log("ok:", req?.kind, "|", pendingOf(auth)?.kind)Fields
Section titled “Fields”| Field | Type | What it is |
|---|---|---|
output |
string |
The text handed to the model. Always a string — serialize structured data yourself. |
isError |
boolean |
Whether the call failed. Fed back to the model, not thrown. |
parts |
ContentPart[] |
Non-text output — image / file / audio. Additional to output, never instead of it. Absent ⇒ byte-identical to before. |
metadata |
Record<string, unknown> |
Free-form, for your code. Reserved: pending holds a §10 Request. |
See also
Section titled “See also”ContentPart— what goes inpartsTool— what returns thisToolContext— whatexecutereceivespending— build a suspending resultpendingOf— read a suspension back off a result