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, ToolEnvelope } from '@loopstack/common';import type { RunContext } from '@loopstack/common';
@Tool({ name: 'get_weather', description: 'Retrieve weather information.', schema: z.object({ location: z.string().describe('City or location name'), }),})export class GetWeather extends BaseTool<{ location: string }, object, string> { protected async handle(): Promise<ToolEnvelope<string>> { return Promise.resolve({ type: 'text', data: 'Mostly sunny, 14C, rain in the afternoon.' }); }}Tool Calling Workflow
LlmDelegateToolCallsTool always dispatches tools with a callback so sub-workflow / HITL / async tools work safely. A wait: true self-loop on awaiting_tools catches each completion via LlmUpdateToolResultTool. Synchronous tools complete immediately and the loop falls through to toolsComplete.
import { BaseWorkflow, Guard, Transition, Workflow } from '@loopstack/common';import type { LlmDelegateResult, LlmGenerateTextResult } from '@loopstack/llm-provider';import { LlmDelegateToolCallsTool, LlmGenerateTextTool, LlmMessageDocument, LlmUpdateToolResultTool,} from '@loopstack/llm-provider';import { GetWeather } from './tools/get-weather.tool';
interface ToolCallState { llmResult?: LlmGenerateTextResult; delegateResult?: LlmDelegateResult;}
@Workflow({})export class ToolCallWorkflow extends BaseWorkflow { constructor( private readonly llmGenerateText: LlmGenerateTextTool, private readonly llmDelegateToolCalls: LlmDelegateToolCallsTool, private readonly llmUpdateToolResult: LlmUpdateToolResultTool, private readonly getWeather: GetWeather, ) { super(); }
@Transition({ to: 'ready' }) async setup(state: ToolCallState) { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: 'How is the weather in Berlin?', }); }
@Transition({ from: 'ready', to: 'prompt_executed' }) async llmTurn(state: ToolCallState) { const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6', tools: ['get_weather'] } }, ); this.assignState({ llmResult: result.data }); }
@Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 }) @Guard('hasToolCalls') async executeToolCalls(state: ToolCallState) { const result = await this.llmDelegateToolCalls.call({ message: state.llmResult!.message, tools: ['get_weather'], callback: { transition: 'toolResultReceived' }, }); this.assignState({ delegateResult: result.data }); }
hasToolCalls(state: ToolCallState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; }
@Transition({ from: 'awaiting_tools', to: 'awaiting_tools', wait: true }) async toolResultReceived(state: ToolCallState, payload: unknown) { const result = await this.llmUpdateToolResult.call({ delegateResult: state.delegateResult!, completedTool: payload, }); this.assignState({ delegateResult: result.data }); }
@Transition({ from: 'awaiting_tools', to: 'ready' }) @Guard('allToolsComplete') toolsComplete(state: ToolCallState) {}
allToolsComplete(state: ToolCallState): boolean { return state.delegateResult?.allCompleted ?? false; }
@Transition({ from: 'prompt_executed', to: 'end' }) respond(_state: ToolCallState) {}}Both LlmGenerateTextTool and LlmDelegateToolCallsTool persist their messages automatically — the assistant turn and the tool_result user turn appear in the document store and conversation history without any explicit documentStore.save() call. Pass config: { save: false } on either tool if you need to handle persistence yourself.
How the Loop Works
setup → llmTurn → [hasToolCalls?] ├─ yes → executeToolCalls → awaiting_tools │ ├─ toolResultReceived (wait, self-loop on async completions) │ └─ toolsComplete (@Guard allToolsComplete) → llmTurn (loop) └─ no → respond (done)llmGenerateTextis called — thetoolsarray in config lists available tools- If the LLM returns
stopReason: 'tool_use', the guard routes toexecuteToolCalls llmDelegateToolCallsdispatches each tool with a callback. Synchronous tools complete immediately; async tools (sub-workflows, HITL) returnpending: trueand firetoolResultReceivedonce each- When
delegateResult.allCompletedis true, the unguarded fall-through (toolsComplete) loops back to the LLM - When the LLM is done (no more tool calls), the fallback transition to
endfires
Key Concepts
toolsarray in config — Lists tool names the LLM can call. Names must match@Tool({ name })values. At startup, Loopstack auto-discovers every@Tool()-decorated provider in the module graph and indexes them by name. If a name doesn’t match, you’ll get an error listing all registered tools — useful for catching typos and missing module imports.llmDelegateToolCalls— Dispatches tool calls from the LLM response message with a callback transition. Pass it the sametoolslist you gavellmGenerateText: only those tools are executed. Required for safety: sub-workflow / HITL / any tool that returnspending: trueonly completes once its callback firesllmUpdateToolResult— Merges each async tool completion into the runningdelegateResult. Thewait: trueself-loop onawaiting_toolscalls it once per completionmessage.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
Under the Hood: How llmDelegateToolCalls Works
LlmDelegateToolCallsTool is what makes a tool-calling workflow actually do work. Without it, the LLM’s tool_call blocks would just sit in the message — nothing would be executed. The tool reads the most recent assistant message, runs every tool the LLM requested, and stores the outputs back as tool_result entries that the next LLM turn can read.
Internally it delegates to LlmDelegateService, which:
- Refuses tools the LLM was not offered — a tool call whose name is not in the
toolsarg is never executed; it becomes atool_resultwithisError: true(Tool "x" is not available to this agent), so the LLM sees it and can correct itself. - Resolves each allowed tool by name from the global tool registry (the same one built by
@Tool()auto-discovery). - Dispatches all tool calls in parallel with
Promise.all— the LLM is free to request multiple tools in one turn, and they run concurrently. - Catches errors per tool so one failing tool doesn’t crash the others. Failures show up as
tool_resultentries withisError: trueand are also collected onresult.errorsfor inspection. - Tracks pending async tools — tools that return
{ pending: true }(typically HITL or sub-workflow tools) don’t produce a result immediately. The result includes apendingCount, andallCompletedstaysfalseuntil those tools fire their completion callbacks.LlmUpdateToolResultToolis the companion that processes those callbacks and updates the delegate result.
The callback arg is required — it’s how async tool completions find their way back to the workflow. For all-synchronous tool sets, allCompleted is already true when executeToolCalls returns and the wait transition is never entered; the callback is harmless overhead. For mixed or async-only sets, the wait transition is what keeps the workflow from silently hanging on pending tools.
Registry References
- Agent example — Parent workflow that delegates to
AgentWorkflowfor tool-calling with weather and calculator tools - Custom Agent example — Reference for hand-rolling the tool loop when you need custom error policy or per-turn logic