Skip to content

ToolResult

JavaScript · package toolnexus · SPEC §1 · js/src/types.ts

interface ToolResult {
output: string
isError: boolean
metadata?: Record<string, unknown>
}

What every execute 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.

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.

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.

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)

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. 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. 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 success
const 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)
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.
metadata Record<string, unknown> Free-form, for your code. Reserved: pending holds a §10 Request.