Single tool call
What you will achieve
Section titled “What you will achieve”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.
When and why you need this
Section titled “When and why you need this”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.
Step by step
Section titled “Step by step”Step 1 — Define the tool
Section titled “Step 1 — Define the tool”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.
Step 2 — Call the model with the tool
Section titled “Step 2 — Call the model with the tool”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.
Step 3 — Use the execution context
Section titled “Step 3 — Use the execution context”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.
Step 4 — Make parameters optional
Section titled “Step 4 — Make parameters optional”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.
Your options
Section titled “Your options”ParamSpec — the compact schema shorthand:
| Shorthand | Expands to | TypeScript 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 enum | union of enum literals |
{ type: 'object', properties: {...}, required: [...] } | inline object schema | unknown (cast manually) |
{ type: 'array', items: ... } | custom array schema | unknown |
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:
| Return | What the model sees |
|---|---|
string | Plain 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):
| Value | Behaviour |
|---|---|
'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:
| Field | Type | Purpose |
|---|---|---|
step | number | Which iteration of the loop (0-indexed) |
callId | string | Unique id for this tool call (from the model) |
signal | AbortSignal | Abort when the loop is stopped or tool times out |
metrics | Map<string, {...}> | Write arbitrary key/value metrics — appear in run report |
Compare the SDKs
Section titled “Compare the SDKs”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.
Gotchas and next steps
Section titled “Gotchas and next steps”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:
- Parallel tools — the model calls multiple tools in one turn
- Multi-step loop — chain two dependent tools across turns
- Built-in tool runner — use
AgentLoopdirectly for stateful agents - Tools guide — full
defineToolAPI reference