defineTool
JavaScript · package toolnexus · SPEC §6 · js/src/native.ts
function defineTool(opts: { name: string description: string inputSchema?: JSONSchema source?: ToolSource run: (args: Record<string, unknown>, ctx?: ToolContext) => unknown | Promise<unknown>}): ToolWraps one function as a Tool. You supply the three things a model
needs — a name, a description, a schema — and a run that does the work; defineTool handles the
rest of the contract: awaiting the result, stringifying it, and turning a thrown exception into
isError: true instead of an escaped crash.
When to use it
Section titled “When to use it”Whenever the capability already lives in your codebase. A database query, a pricing calculation, an internal SDK call, a feature flag lookup — anything you would otherwise have to stand an MCP server in front of just to let the model reach it. This is the cheapest tool source there is: no process, no transport, no config file.
Why this and not the alternative
Section titled “Why this and not the alternative”Do not hand-write the Tool interface for an ordinary function: defineTool sets
source: "native", defaults the schema, and gives you the error contract for free.
Examples
Section titled “Examples”1. A function the model can call
Section titled “1. A function the model can call”inputSchema is optional; omit it for a tool that takes nothing.
import assert from "node:assert"import { defineTool } from "toolnexus"
const greet = defineTool({ name: "greet", description: "Greet someone by name", inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"], }, run: async ({ name }) => `Hello, ${name}!`,})
// It is an ordinary Tool — nothing registered, nothing subclassed.assert.equal(greet.name, "greet")assert.equal(greet.source, "native")
const res = await greet.execute({ name: "Muthu" })assert.equal(res.output, "Hello, Muthu!")assert.equal(res.isError, false)
// No schema? You get an empty object schema, which is valid for every provider.const ping = defineTool({ name: "ping", description: "Health check", run: () => "pong" })assert.deepEqual(ping.inputSchema, { type: "object", properties: {}, additionalProperties: false })assert.equal((await ping.execute({})).output, "pong")
console.log("ok:", res.output)2. Return values, and failure without throwing
Section titled “2. Return values, and failure without throwing”run may return anything. A string is used as-is; anything else is JSON.stringifyd. A throw is
caught and reported as a tool error, so the model sees the message and can correct itself rather
than the loop dying.
import assert from "node:assert"import { defineTool } from "toolnexus"
const lookup = defineTool({ name: "lookup_order", description: "Fetch an order by id", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, run: async ({ id }) => { if (id === "missing") throw new Error(`no such order: ${id}`) // An object is serialised for you — no JSON.stringify at the call site. return { id, total: 42.5, status: "shipped" } },})
const ok = await lookup.execute({ id: "A-1" })assert.equal(ok.isError, false)assert.deepEqual(JSON.parse(ok.output), { id: "A-1", total: 42.5, status: "shipped" })
// A throw becomes isError: true with the message as output. The loop survives.const bad = await lookup.execute({ id: "missing" })assert.equal(bad.isError, true)assert.equal(bad.output, "no such order: missing")
// Sync run functions are fine too — the return value is awaited either way.const now = defineTool({ name: "two", description: "Return two", run: () => 2 })assert.equal((await now.execute({})).output, "2")
console.log("ok:", ok.output, "| error path:", bad.output)3. The full surface — ToolResult passthrough, ctx, and a custom source
Section titled “3. The full surface — ToolResult passthrough, ctx, and a custom source”Return a full ToolResult when you want to control isError yourself or attach metadata.
ctx carries the loop’s cancellation signal and timeout budget. source overrides the default
label, which is how you keep a generated family of tools distinguishable in tk.tools().
import assert from "node:assert"import { defineTool } from "toolnexus"
const search = defineTool({ name: "search_index", description: "Search the internal index", inputSchema: { type: "object", properties: { q: { type: "string" } }, required: ["q"] }, // Any other ToolSource value: "custom", "http", "builtin"... source: "custom", run: async ({ q }, ctx) => { // ctx is optional — always guard it. if (ctx?.signal?.aborted) return { output: "cancelled", isError: true } if (String(q).length < 2) { // A returned object with BOTH output and isError is passed through verbatim. return { output: "query too short", isError: true, metadata: { code: "E_SHORT" } } } return { output: `2 hits for ${q}`, isError: false, metadata: { hits: 2, budgetMs: ctx?.timeout } } },})
assert.equal(search.source, "custom")
const hit = await search.execute({ q: "adapters" }, { timeout: 5000 })assert.equal(hit.output, "2 hits for adapters")assert.equal(hit.metadata?.hits, 2)assert.equal(hit.metadata?.budgetMs, 5000)
// Passthrough: your isError and metadata survive untouched.const short = await search.execute({ q: "a" })assert.equal(short.isError, true)assert.equal(short.metadata?.code, "E_SHORT")
// Cancellation via ctx.signal.const ac = new AbortController()ac.abort()const stopped = await search.execute({ q: "adapters" }, { signal: ac.signal })assert.equal(stopped.output, "cancelled")assert.equal(stopped.isError, true)
console.log("ok:", hit.output, "|", short.output, "|", stopped.output)Options
Section titled “Options”| Option | Type | Required | What it does |
|---|---|---|---|
name |
string |
yes | What the model calls. Must match [a-zA-Z0-9_-] — run untrusted names through sanitize. |
description |
string |
yes | What the model reads to decide whether to call it. This is prompt engineering, not a comment. |
inputSchema |
JSONSchema |
no | Defaults to { type: "object", properties: {}, additionalProperties: false }. |
source |
ToolSource |
no | Defaults to "native". Any of mcp, skill, builtin, native, http, a2a, custom. |
run |
(args, ctx?) => unknown |
yes | The work. Sync or async. |
How run’s return value becomes a ToolResult:
run returns |
Result |
|---|---|
a string |
{ output: <string>, isError: false } |
an object with both output and isError |
passed through unchanged, metadata included |
anything else (object, number, array, undefined) |
{ output: JSON.stringify(value), isError: false } |
| throws | { output: <error message>, isError: true } |
See also
Section titled “See also”tool— the decorator form, for a class of methodscollectTools— gather decorated functions off an objectTool— what this buildshttpTool— the same idea for a remote endpointcreateToolkit— pass these in viaextraTools