Skip to content

Single tool call

Define a get_weather tool, prompt 'What is the weather in Paris?', confirm the model calls the tool with city = 'Paris', return 'sunny', and assert the final answer mentions it.

Whenever the model’s knowledge is insufficient — current data, private databases, file system, APIs — you give it a callable function. The model decides when to call it, what arguments to pass, and how to incorporate the result into its reply.

The challenge with raw provider SDKs is that tool schemas are completely different shapes: tools[].function.parameters for OpenAI, tools[].input_schema for Anthropic, tools[].functionDeclarations[].parameters for Google. Feeding tool results back is equally divergent. Google also injects a thought metadata field into some tool calls that must be echoed back verbatim or the API rejects the next turn.

defineTool + complete() solves both: one schema definition, one round-trip call that handles everything.

import { defineTool } from '@combycode/llm-sdk';
const getWeather = defineTool({
name: 'get_weather',
description: 'Get the current weather for a city.',
params: { city: 'string' },
execute: ({ city }) => `sunny in ${city}`,
});

params is a compact spec — 'string' expands to { type: 'string' } in JSON Schema. TypeScript infers { city: string } on args from this spec, so city is typed without any casting.

The execute function can return a string or a ContentPart[]. The SDK converts either into the provider’s tool result format.

import { complete, defineTool } from '@combycode/llm-sdk';
const getWeather = defineTool({
name: 'get_weather',
description: 'Get the current 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 in Paris?',
tools: [getWeather],
maxTokens: 512,
});
console.log(text); // "The weather in Paris is sunny."

complete() internally creates an AgentLoop, sends the tool definitions to the model, calls execute when the model requests the tool, feeds the result back, and returns the final text response. You get the answer in one await.

execute receives a second argument, ToolExecutionContext, with call metadata useful for logging or tracing:

import { defineTool } from '@combycode/llm-sdk';
import type { ToolExecutionContext } from '@combycode/llm-sdk';
const getWeather = defineTool({
name: 'get_weather',
description: 'Get the current weather for a city.',
params: { city: 'string' },
execute: async ({ city }, ctx: ToolExecutionContext) => {
console.log(`call ${ctx.callId} at step ${ctx.step}`);
// ctx.signal is an AbortSignal -- honour it in long I/O
const result = await fetchWeather(city, { signal: ctx.signal });
return result;
},
});

ctx.metrics is a writable Map — write measurements here and they surface in lastReport.steps[n].toolCalls[n].metrics after the run.

All keys in params are required by default. Mark optional ones via optional:

const getWeather = defineTool({
name: 'get_weather',
description: 'Get weather. Unit defaults to celsius.',
params: {
city: 'string',
unit: { type: 'string', enum: ['celsius', 'fahrenheit'] as const },
},
optional: ['unit'],
execute: ({ city, unit }) => `${unit ?? 'celsius'} reading for ${city}: sunny`,
});

TypeScript automatically widens the type of optional params to include undefined.

ParamSpec — the compact schema shorthand:

ShorthandExpands toTypeScript type inferred
'string'{ type: 'string' }string
'number'{ type: 'number' }number
'integer'{ type: 'integer' }number
'boolean'{ type: 'boolean' }boolean
'string[]'{ type: 'array', items: { type: 'string' } }string[]
'number[]'{ type: 'array', items: { type: 'number' } }number[]
{ type: 'string', enum: [...] as const }full JSON Schema enumunion of enum literals
{ type: 'object', properties: {...}, required: [...] }inline object schemaunknown (cast manually)
{ type: 'array', items: ... }custom array schemaunknown

For complex nested schemas, pass the full JSON Schema object as the param spec value. Type inference falls back to unknown for object/array specs — cast in execute.

execute return types:

ReturnWhat the model sees
stringPlain text tool result
ContentPart[]Rich structured result (may include images, embedded data)
Promise<string | ContentPart[]>Async — SDK awaits before sending

toolChoice — control which tool the model must use:

toolChoice is set via the client option when using complete() directly, or via AgentLoop config. The full option set (from ToolChoice in llm/types/tools.ts):

ValueBehaviour
'auto'Model decides whether to call a tool (default)
'required'Model MUST call at least one tool
'none'Model may not call any tool (text-only reply)
{ name: 'tool_name' }Model MUST call this specific tool

ToolExecutionContext fields:

FieldTypePurpose
stepnumberWhich iteration of the loop (0-indexed)
callIdstringUnique id for this tool call (from the model)
signalAbortSignalAbort when the loop is stopped or tool times out
metricsMap<string, {...}>Write arbitrary key/value metrics — appear in run report
import { complete, defineTool } from '@combycode/llm-sdk';

// One tool, one call. complete() runs the full agent loop internally — and the
// SAME file works on every provider (the provider-specific tool round-trip,
// incl. Gemini's thought-signature, is handled inside the SDK).
const getWeather = defineTool({
  name: 'get_weather',
  description: 'Get the current weather for a city.',
  params: { city: 'string' },
  execute: () => 'sunny',
});

const t0 = performance.now();
const { text } = await complete({
  model: process.env.LLM_MODEL!,
  apiKey: process.env.LLM_API_KEY,
  prompt: 'What is the weather in Paris?',
  tools: [getWeather],
  maxTokens: 512,
});

console.log(JSON.stringify({ result: text.trim(), ms: Math.round(performance.now() - t0) }));

Every official SDK requires manually converting the tool result into the provider’s message format (tool role for OpenAI, user role with tool_result blocks for Anthropic, user role with functionResponse parts for Google). ORXA’s complete() does this conversion inside the loop — execute returns a plain string and the SDK handles the rest. Google’s thought-signature injection and echo-back is also handled automatically.

Empty params: {} is valid. A tool with no parameters is a zero-argument function. Omit the params key and you will get a TypeScript error — pass params: {} explicitly.

execute must not throw for recoverable errors. If execute throws, the loop catches it and sends the error message string back to the model (via the onToolCallError hook and continueOnError behaviour). If you want the model to know about an API failure, return a descriptive string; reserve throws for truly unrecoverable cases.

maxTokens applies to the final answer, not each step. Set it high enough for a multi-sentence answer after tool use.

The model may not call the tool. It may answer from its training data instead. Use toolChoice: 'required' if you need a guaranteed call.

Next steps: