AI Tool Calling
Enable the LLM to call workflow tools (function calling). The LLM decides which tools to invoke, and LlmDelegateToolCallsTool executes them.
Create a Tool for the LLM
Tools exposed to the LLM need a description so the LLM knows when to use them:
import { z } from 'zod';
import { BaseTool, Tool, ToolResult } from '@loopstack/common';
@Tool({
uiConfig: {
description: 'Retrieve weather information.',
},
schema: z.object({
location: z.string(),
}),
})
export class GetWeather extends BaseTool {
async call(_args: unknown): Promise<ToolResult> {
return { type: 'text', data: 'Mostly sunny, 14C, rain in the afternoon.' };
}
}Tool Calling Workflow
import {
LlmGenerateTextTool,
LlmGenerateTextResult,
LlmMessageDocument,
LlmDelegateToolCallsTool,
LlmDelegateResult,
} from '@loopstack/llm-provider-module';
import { BaseWorkflow, Final, Guard, Initial, InjectTool, ToolResult, Transition, Workflow } from '@loopstack/common';
@Workflow({ uiConfig: __dirname + '/tool-call.ui.yaml' })
export class ToolCallWorkflow extends BaseWorkflow {
@InjectTool({ provider: 'claude', model: 'claude-sonnet-4-6', tools: ['getWeather'] }) llmGenerateText: LlmGenerateTextTool;
@InjectTool({ provider: 'claude' }) llmDelegateToolCalls: LlmDelegateToolCallsTool;
@InjectTool() getWeather: GetWeather;
llmResult?: LlmGenerateTextResult;
delegateResult?: LlmDelegateResult;
@Initial({ to: 'ready' })
async setup() {
await this.repository.save(LlmMessageDocument, {
role: 'user',
content: 'How is the weather in Berlin?',
});
}
@Transition({ from: 'ready', to: 'prompt_executed' })
async llmTurn() {
const result: ToolResult<LlmGenerateTextResult> = await this.llmGenerateText.call({
messagesSearchTag: 'message',
});
this.llmResult = result.data;
}
@Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 })
@Guard('hasToolCalls')
async executeToolCalls() {
const result: ToolResult<LlmDelegateResult> = await this.llmDelegateToolCalls.call({
message: this.llmResult!.message,
});
this.delegateResult = result.data;
}
hasToolCalls() {
return this.llmResult?.message.stopReason === 'tool_use';
}
@Transition({ from: 'awaiting_tools', to: 'ready' })
@Guard('allToolsComplete')
async toolsComplete() {}
allToolsComplete() {
return this.delegateResult?.allCompleted;
}
@Final({ from: 'prompt_executed' })
async respond() {
await this.repository.save(LlmMessageDocument, this.llmResult!.message, {
meta: { response: this.llmResult!.response, provider: 'claude' },
});
}
}How the Loop Works
setup → llmTurn → [hasToolCalls?]
├─ yes → executeToolCalls → toolsComplete → llmTurn (loop)
└─ no → respond (done)llmGenerateTextis called — thetoolsarray is configured on@InjectTool- If the LLM returns
stopReason: 'tool_use', the guard routes toexecuteToolCalls llmDelegateToolCallsexecutes the requested tools and stores results- The loop continues back to the LLM
- When no more tool calls are needed, the fallback
@Finalfires
Key Concepts
toolsarray — Configured on@InjectTool(), lists tool property names the LLM can call (must match@InjectTool()property names)llmDelegateToolCalls— Executes tool calls from the LLM response messagemessage.stopReason === 'tool_use'— The LLM wants to call a toolallCompleted— All delegated tool calls have finished@Guard+priority— Routes between tool calling and final response
Registry References
- tool-call-example-workflow — Complete tool calling loop with GetWeather tool, guard-based routing, and delegate pattern
Last updated on