Tool Execution Loop
Build a full request, execute, continue loop around a model that calls your functions
This guide walks through a complete tool calling flow with VernLLM: defining a tool, letting the model request it, executing it yourself, and feeding the result back for a final answer.
VernLLM never runs a tool on your behalf. It only transports the request and parses what comes back, so the execution step below is always something your own application code does.
1. Define the tool
A ToolDefinition is just a name, a description, and a JSON Schema for its arguments:
const weatherTool = {
name: 'get_weather',
description: 'Gets the current weather for a given city',
parameters: {
type: 'object',
properties: {
city: { type: 'string' },
},
required: ['city'],
},
};Add argumentsSchema if you want VernLLM to validate the parsed arguments client side before
handing them back to you, using any safeParse compatible validator such as Zod. Wrapping the tool
in defineTool() also lets TypeScript type arguments from that schema instead of unknown, see
Typed arguments per tool:
import { z } from 'zod';
import { defineTool } from 'vern-llm';
const weatherTool = defineTool({
name: 'get_weather',
description: 'Gets the current weather for a given city',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
argumentsSchema: z.object({ city: z.string() }),
});2. Make the first call
Pass tools to call(). The model decides on its own whether to answer directly or request a
tool, since toolChoice defaults to 'auto':
const first = await llm.call({
systemPrompt: 'You are a helpful weather assistant.',
userContent: 'What is the weather in New York right now?',
tools: [weatherTool],
});first is a CallWithToolsResult<T>. Check first.type to see what happened:
if (first.type === 'tool_calls') {
console.log('Model wants to call:', first.toolCalls);
} else {
console.log('Model answered directly:', first.content);
}3. Execute the tool yourself
When first.type is 'tool_calls', run each requested tool with your own application logic.
ToolCall.arguments is already parsed JSON, and validated if argumentsSchema was set. Since
weatherTool was wrapped in defineTool(), arguments is already typed as { city: string }
here, no cast needed:
async function getWeather(city: string) {
const response = await fetch(`https://weather.example.com/api?city=${encodeURIComponent(city)}`);
return response.json();
}
const toolResults = [];
if (first.type === 'tool_calls') {
for (const call of first.toolCalls) {
const weather = await getWeather(call.arguments.city);
toolResults.push({
toolCallId: call.id,
content: weather,
});
}
}If a tool execution fails, set isError: true on the corresponding ToolResult. fromAnthropic
maps it onto tool_result.is_error, and fromBedrock maps it onto toolResult.status: 'error'.
Gemini has no equivalent wire concept and ignores it silently.
4. Continue the conversation
Call call() again, this time with an assistant turn carrying the original toolCalls and a
matching tool turn carrying the results, appended to history:
const final = await llm.call({
systemPrompt: 'You are a helpful weather assistant.',
userContent: 'What is the weather in New York right now?',
tools: [weatherTool],
history: [
{
role: 'assistant',
toolCalls: first.toolCalls,
},
{
role: 'tool',
toolResults,
},
],
});
if (final.type === 'content') {
console.log(final.content);
}VernLLM validates this shape before sending anything. A tool turn must immediately follow an
assistant turn that requested tools, and every requested toolCallId needs exactly one matching
result, no more, no fewer. An invalid history throws LLMError('validation') before a request is
made.
Parallel tool calls
The model can request more than one tool in a single turn. first.toolCalls is always an array,
so the same loop from step 3 handles one call or several without changes:
const first = await llm.call({
userContent: 'What is the weather and time in Paris?',
tools: [weatherTool, timeTool],
});
if (first.type === 'tool_calls') {
const toolResults = await Promise.all(
first.toolCalls.map(async (call) => ({
toolCallId: call.id,
content: await runTool(call.name, call.arguments),
})),
);
const final = await llm.call({
userContent: 'What is the weather and time in Paris?',
tools: [weatherTool, timeTool],
history: [
{ role: 'assistant', toolCalls: first.toolCalls },
{ role: 'tool', toolResults },
],
});
}Each adapter serializes the results into that provider's own native format rather than exposing VernLLM's wire shape directly. The OpenAI-compatible adapter sends one message per tool result, already the native format there. Anthropic, Bedrock, and Gemini instead expect every result from one turn combined into a single content-block-carrying turn, so those adapters merge the results back together on your behalf. Either way, you never need to think about the provider-specific shape yourself.
Forcing a specific tool
Set toolChoice: { name: 'get_weather' } to force the model to call one particular tool instead
of leaving the decision to it:
const result = await llm.call({
userContent: 'New York',
tools: [weatherTool],
toolChoice: { name: 'get_weather' },
});This is useful when the user message alone should always map to a specific action, and you would rather skip the model deciding whether to call it at all.
Full flow, put together
async function askAboutWeather(city: string) {
const first = await llm.call({
userContent: `What is the weather in ${city}?`,
tools: [weatherTool],
});
if (first.type !== 'tool_calls') {
return first.content;
}
const toolResults = await Promise.all(
first.toolCalls.map(async (call) => ({
toolCallId: call.id,
content: await getWeather(call.arguments.city),
})),
);
const final = await llm.call({
userContent: `What is the weather in ${city}?`,
tools: [weatherTool],
history: [
{ role: 'assistant', toolCalls: first.toolCalls },
{ role: 'tool', toolResults },
],
});
return final.type === 'content' ? final.content : undefined;
}See Tool Calling for the full reference on result shapes,
toolChoice, caching behavior, and per-provider notes.