Skip to content

Tools -- defineTool

defineTool is the ergonomic builder for function tools. It infers TypeScript types from a compact params spec so you get typed args in execute without writing a JSON schema by hand.

  • You want to give the model a callable function (weather lookup, database query, API call, file read, etc.).
  • You want TypeScript inference on the tool’s argument types.

For built-in server-side tools (web search, code interpreter) pass them as plain objects — { type: 'web_search' } — directly in tools: [...]; no defineTool needed for those.

ExportWhat it does
defineTool(input)Build an AgentTool from a name, description, param spec, and execute function.
AgentTool (type)The shape expected by complete(), createAgent(), and delegate().
ParamSpec (type)Allowed param spec values: 'string', 'number', 'boolean', 'string[]', 'number[]', or an inline schema object.
import { complete, defineTool } from '@combycode/llm-sdk';
const getWeather = defineTool({
name: 'get_weather',
description: 'Get the current weather for a city.',
params: {
city: 'string',
unit: { type: 'string', enum: ['celsius', 'fahrenheit'] as const },
},
optional: ['unit'],
execute: ({ city, unit }) => {
// Return value is a string (or ContentPart[]) handed back to the model.
return `It is sunny in ${city} (${unit ?? 'celsius'}).`;
},
});
const { text } = await complete({
model: 'anthropic/claude-haiku-4.5',
apiKey: process.env.ANTHROPIC_API_KEY,
prompt: 'What is the weather in Paris?',
tools: [getWeather],
maxTokens: 128,
});
console.log(text);

Strict mode makes a provider constrain the tool name and argument shape while generating them, instead of leaving you to validate afterwards.

It is opt-in (strict: true on a FunctionTool) everywhere except the OpenAI Responses API, where it has long been the default. Measured against both providers it makes no difference to argument quality — 40 of 40 calls conformed with it and without it, including prompts written to pull away from the schema — while a schema the provider dislikes is rejected with a 400 rather than degraded. Its one real effect is that Anthropic refuses to call a tool that was never declared (10/10 undeclared without it, 0/10 with it), which only matters if something puts an undeclared tool in front of the model.

When you do ask for it, the schema must satisfy that provider’s rules, and the two constrain different things:

OpenAIAnthropic
optional properties (not in required)rejectedfine
maximum, minimum, multipleOf, maxItems, exclusive*finerejected
additionalProperties: true (an open object)rejectedrejected
{ type: 'object' } with no properties keyrejectedfine
more than 20 strict tools in one requestfinerejected
more than 24 optional parameters across all strict schemasfinerejected
”too complex to compile” (no published formula)rejected

On the Responses API, where strict is the default, it is requested only for schemas that satisfy OpenAI’s rules — so a tool with an optional parameter simply runs without it rather than failing. strictSupport(schema, 'openai' | 'anthropic') is exported if you want to ask the question yourself; it returns { ok, reason }, and reason names the property or keyword responsible.

Anthropic’s last three rows are why strict is not defaulted on there. Two are aggregates over the whole request, so no per-schema check can see them, and the third has no published formula at all: 24 optional parameters spread over four tools compiles, the same 24 in one tool does not. Twelve ordinary tools with five optional parameters each already exceed the 24 limit. Non-strict tools count toward none of the limits.

One consequence worth knowing: a generic “router” tool can never be strict. If a parameter must accept any shape ({ type: 'object', additionalProperties: true }), that is the opposite of what strict means, and both providers refuse it.

A tool taking no arguments is unaffected: properties: {} is present but empty, which both providers accept.

Keys listed in optional are optional in the inferred execute args too, so unit above is string | undefined and the ?? 'celsius' is load-bearing. Anthropic keeps that working under strict: asked not to specify a unit it omitted the argument 10/10, asked for fahrenheit it supplied it 10/10, and never invented the second optional one.

complete() runs the full loop until the model stops requesting tools:

import { complete, defineTool } from '@combycode/llm-sdk';
const getUserCity = defineTool({
name: 'get_user_city',
description: "Get the user's current city.",
params: {},
execute: () => 'Paris',
});
const getWeather = defineTool({
name: 'get_weather',
description: 'Get the weather for a city.',
params: { city: 'string' },
execute: ({ city }) => `sunny in ${city}`,
});
const { text } = await complete({
model: process.env.LLM_MODEL!,
apiKey: process.env.LLM_API_KEY,
prompt: 'What is the weather where I am?',
tools: [getUserCity, getWeather],
maxTokens: 512,
});
console.log(text);

execute receives a second ToolExecutionContext argument with run trace ids and call metadata. Useful for logging, correlation, or accessing the agent’s conversation history.

ctx.trace carries three ids:

  • sessionId — the agent id (the ConversationHistory id, same as loop.id)
  • requestId — the run id for this specific .complete() / .stream() invocation
  • callId — this tool call’s id (same as ctx.callId)
import { defineTool } from '@combycode/llm-sdk';
import type { ToolExecutionContext } from '@combycode/llm-sdk';
const loggedTool = defineTool({
name: 'read_db',
description: 'Read a row from the database.',
params: { id: 'string' },
execute: async ({ id }, ctx: ToolExecutionContext) => {
console.log(
`Tool call ${ctx.callId} | agent ${ctx.trace?.sessionId} | run ${ctx.trace?.requestId}`,
);
return `row data for ${id}`;
},
});

Attaching out-of-band data — customDataExtractor

Section titled “Attaching out-of-band data — customDataExtractor”

An AgentTool may declare an optional customDataExtractor(result, args, context) that runs after a successful execute. Its return value is attached to that tool call’s ToolCallReport.customData — for your own telemetry, routing, or audit. The model never sees it (it is not part of the tool result). A throwing extractor is swallowed, so this convenience can never break the tool result.

const lookup: AgentTool = {
definition: { name: 'lookup', description: 'Look up a record', parameters: { id: { type: 'string' } } },
execute: async ({ id }) => fetchRecord(id),
// model never sees this — it lands on the ToolCallReport.
customDataExtractor: (result, args, ctx) => ({ bytes: String(result).length, callId: ctx.callId }),
};

Server-side tools the provider runs are passed as plain objects in tools: [...] (no defineTool): { type: 'web_search' }, { type: 'web_fetch' }, { type: 'code_interpreter' }, { type: 'image_generation' }, { type: 'file_search' }, and { type: 'mcp' }. Provider-specific configuration goes in params, forwarded verbatim (e.g. { type: 'web_fetch', params: { allowed_domains: ['docs.example'], max_content_tokens: 4096 } }).

Programmatic tool calling (OpenAI Responses, gpt-5.6 family). Add { type: 'programmatic_tool_calling' } to let the model write JS that orchestrates your tool calls. Function tools can then declare who may invoke them and the shape they return: allowedCallers?: ('direct' | 'programmatic')[] and outputSchema? on a FunctionTool. Both are emitted only on the OpenAI Responses path (other providers ignore them). Model-gated — of the gpt-5 / o3 / o4 / codex models tested, only gpt-5.6-luna / -sol / -terra accept the builtin; the rest reject it by name.

The model’s program arrives as a program_call content part and its return value as program_result. The tool calls the program makes are ordinary tool_call parts — you execute them exactly as before — each carrying caller: { type: 'program', callerId } pointing back at the program that made it:

const res = await client.complete(messages, {
tools: [
{ type: 'programmatic_tool_calling' },
{ ...getWeather, allowedCallers: ['programmatic'] },
],
});
for (const part of res.content) {
if (part.type === 'program_call') console.log('model wrote:', part.code);
if (part.type === 'program_result') console.log('program returned:', part.result);
}
for (const call of res.toolCalls) {
console.log(call.name, call.caller?.type ?? 'direct'); // -> get_weather program
}

The program suspends at each await, so its calls still arrive one turn at a time; answer them the usual way and the program resumes.

Keep the program_call part in your history and send it back. Dropping it is not just a lost audit trail — the model re-emits the program and runs the whole thing again from the start. The adapter also re-sends the provider items the program is bound to, which the API requires.

allowedCallers is enforced locally as well as by the provider: a tool without it is direct-only, so model-written code cannot reach a tool that never opted in. A violation denies that single call (an error result to the model, plus an onWarning with code tool_caller_not_allowed) instead of ending the run.

Files a hosted tool produces (e.g. code-execution charts or data files) are surfaced uniformly on response.files (FileOutput[]{ id?, name?, mimeType?, data?, url?, ref?, source? }), independent of generated media. You don’t fetch per-provider — retrieveFile(file) / streamFile(file) resolve every shape (id via the provider’s files API, inline base64 data from Google/xAI, or a url). See the Code execution guide and Retrieving output files.

Which models support which builtin is in the catalog: capabilities.builtinTools, catalog.supportsBuiltinTool(provider, model, tool), or select('code_interpreter'). Coverage: web_search on all providers; code_interpreter on all except OpenRouter; web_fetch on Anthropic (web_fetch_20260318) and Google (urlContext) — OpenAI’s web_search already opens pages, and xAI / OpenRouter expose no separate fetch tool.

Seeing what ran. Provider-run builtins surface a durable trail on response.builtinToolCalls and, while streaming, { type: 'builtin_tool_start' } / { type: 'builtin_tool_end' } events as each runs. Each entry carries what the tool ran:

interface BuiltinToolCall {
tool: string; // 'web_search' | 'web_fetch' | 'code_interpreter'
id?: string;
code?: string; // code_interpreter: the code the model executed
output?: string; // code_interpreter: the code's stdout / logs
query?: string; // web_search: the query the model searched for
url?: string; // web_search: a page opened/read; web_fetch: the URL fetched
}

The payload is normalized across providers and present on both complete() and streamed responses (the builtin_tool_end event carries the same fields). These are informational — unlike tool_call_* (a function call the client must execute), the provider runs these itself. Use them to show a ”🔎 Searching: ” / “⚙️ Running code” panel with the actual code and output.

OpenAI’s hosted MCP tool lets the model call a remote MCP server that OpenAI connects to. Identify the server with exactly one of three targets (use the exported McpToolParams type for editor help):

import type { McpToolParams } from '@combycode/llm-sdk';
// 1. Public server — OpenAI dials the URL directly.
{ type: 'mcp', params: { server_label: 'docs', server_url: 'https://mcp.example/sse' } }
// 2. Managed connector (Gmail, Drive, …).
{ type: 'mcp', params: { server_label: 'gmail', connector_id: 'connector_gmail' } }
// 3. Secure MCP Tunnel — reach a private/local server (behind NAT/firewall, no
// public URL) through an outbound tunnel registered under a tunnel id.
{ type: 'mcp', params: { server_label: 'local', tunnel_id: 'tnl_abc123' } }

Optional params: authorization, headers, require_approval, allowed_tools, server_description. OpenAI enforces the “exactly one target” rule server-side.

This is the provider-hosted MCP path. For connecting the SDK itself to MCP servers as a client, see MCP (Model Context Protocol).

A large tool block is paid for on every request. lazy: true registers a tool without declaring it: the model finds it with a built-in tool_search and runs it through call_tool. Measured over 308 tools, that is identical correctness at -72% / -97% cost per task, for one extra round trip — and more expensive than declaring everything below roughly a hundred tools.

import { connectMcp, defineTool } from '@combycode/llm-sdk';
await connectMcp({ url: 'https://mcp.deepwiki.com/mcp' }, { lazy: true }); // whole server
defineTool({ name: 'rare_thing', description: 'Rarely needed.', params: {}, lazy: true, execute: () => 'ok' });

Full guide, including when NOT to use it: Lazy tools.