npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@loopstack/tool-call-example-workflow

v0.23.4

Published

A simple workflow showing how to implement tool calling in an agentic loopstack workflow.

Downloads

1,192

Readme


title: Tool Calling Example description: Example workflow enabling LLM tool calling (function calling) with custom tools — LlmGenerateTextTool, LlmDelegateToolCallsTool, tool registration

@loopstack/tool-call-example-workflow

A module for the Loopstack AI automation framework.

This module provides an example workflow demonstrating how to enable LLM tool calling (function calling) with custom tools.

Overview

The Tool Call Example Workflow shows how to build agentic workflows where the LLM can invoke custom tools and receive their results. It demonstrates this by asking about the weather in Berlin, where the LLM calls a GetWeather tool to fetch the information.

By using this workflow as a reference, you'll learn how to:

  • Create custom tools with the @Tool decorator and BaseTool
  • Pass tools to the LLM using the tools array in the call-time config
  • Use @Guard decorators for conditional transition routing
  • Handle tool call responses with LlmDelegateToolCallsTool
  • Manage workflow state via the state object passed through transitions
  • Build agentic loops that continue until the LLM has a final answer

This example is essential for developers building AI agents that need to interact with external systems or APIs.

Installation

npm install @loopstack/tool-call-example-workflow

Then register the module in your app:

import { StudioApp } from '@loopstack/common';
import { ToolCallWorkflow, ToolCallingExampleModule } from '@loopstack/tool-call-example-workflow';

@StudioApp({
  title: 'Tool Call Example',
  workflows: [ToolCallWorkflow],
})
@Module({
  imports: [ToolCallingExampleModule],
})
export class MyAppModule {}

Set your Anthropic API key as an environment variable:

ANTHROPIC_API_KEY=sk-ant-...

How It Works

Key Concepts

1. Creating Custom Tools

Define a tool by extending BaseTool and using the @Tool decorator with a description and a Zod schema for arguments:

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 Promise.resolve({
      type: 'text',
      data: 'Mostly sunny, 14C, rain in the afternoon.',
    });
  }
}

The description in uiConfig is passed to the LLM to help it understand when to use the tool.

2. Injecting Tools in the Workflow

Tools are injected via standard NestJS constructor injection:

@Workflow({ ... })
export class ToolCallWorkflow extends BaseWorkflow<Record<string, unknown>, ToolCallState> {
  constructor(
    private readonly llmGenerateText: LlmGenerateTextTool,
    private readonly llmDelegateToolCalls: LlmDelegateToolCallsTool,
    private readonly getWeather: GetWeather,
  ) {
    super();
  }

3. Passing Tools to the LLM

Provide tools to the LLM via the tools array in the config option at call time. The LLM will decide whether to call a tool based on the user's request:

@Transition({ from: 'ready', to: 'prompt_executed' })
async llmTurn(state: ToolCallState): Promise<ToolCallState> {
  const result = await this.llmGenerateText.call(
    {},
    { config: { provider: 'claude', model: 'claude-sonnet-4-6', tools: ['get_weather'] } },
  );
  return { ...state, llmResult: result.data, llmMeta: result.metadata as LlmResultMeta | undefined };
}

The provider, model, tools, and other config fields are passed via { config: { ... } } at call time. The result is stored in the state object for use in routing and subsequent transitions.

4. Guard-Based Conditional Routing

Use the @Guard decorator to conditionally enable transitions. Guards reference methods on the workflow class that return a boolean:

@Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 })
@Guard('hasToolCalls')
async executeToolCalls(state: ToolCallState): Promise<ToolCallState> {
  await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, {
    meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider },
  });
  const result = await this.llmDelegateToolCalls.call({
    message: state.llmResult!.message,
  });
  return { ...state, delegateResult: result.data };
}

hasToolCalls(state: ToolCallState): boolean {
  return state.llmResult?.message.stopReason === 'tool_use';
}

The priority: 10 ensures this transition is evaluated before the terminal @Transition when both could match.

5. Delegating Tool Execution

The LlmDelegateToolCallsTool tool executes the tool calls from the LLM response message:

const result = await this.llmDelegateToolCalls.call({
  message: state.llmResult!.message,
});
return { ...state, delegateResult: result.data };

6. Waiting for Tool Completion

A guard checks whether all delegated tool calls have completed before looping back for another LLM turn:

@Transition({ from: 'awaiting_tools', to: 'ready' })
@Guard('allToolsComplete')
async toolsComplete(state: ToolCallState): Promise<ToolCallState> {
  await this.documentStore.save(LlmMessageDocument, {
    role: 'user',
    blocks: state.delegateResult!.toolResults.map((tr) => ({
      type: 'tool_result' as const,
      toolCallId: tr.toolCallId,
      content: tr.content ?? '',
      isError: tr.isError ?? false,
    })),
  });
  return state;
}

allToolsComplete(state: ToolCallState): boolean {
  return state.delegateResult?.allCompleted ?? false;
}

7. Agentic Loop Pattern

The workflow implements an agentic loop:

  1. LLM Turn (ready -> prompt_executed) -- The LLM processes messages and may request tool calls
  2. Execute Tool Calls (prompt_executed -> awaiting_tools) -- If message.stopReason === 'tool_use', delegate tool execution
  3. Tools Complete (awaiting_tools -> ready) -- When all tools finish, loop back for another LLM turn
  4. Final Response (prompt_executed -> end) -- If no tool calls, save the final response
@Transition({ from: 'prompt_executed', to: 'end' })
async respond(state: ToolCallState): Promise<unknown> {
  await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, {
    meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider },
  });
  return {};
}

This pattern allows the LLM to make multiple tool calls before providing a final response.

Workflow Class

The complete workflow class:

import { BaseWorkflow, Guard, Transition, Workflow } from '@loopstack/common';
import type { LlmDelegateResult, LlmGenerateTextResult, LlmResultMeta } from '@loopstack/llm-provider-module';
import { LlmDelegateToolCallsTool, LlmGenerateTextTool, LlmMessageDocument } from '@loopstack/llm-provider-module';
import { GetWeather } from './tools/get-weather.tool';

interface ToolCallState {
  llmResult?: LlmGenerateTextResult;
  llmMeta?: LlmResultMeta;
  delegateResult?: LlmDelegateResult;
}

@Workflow({
  title: 'LLM Tool Calling Example (Berlin Weather)',
  description: 'An example workflow that demonstrates how to use an LLM to call external tools.',
})
export class ToolCallWorkflow extends BaseWorkflow<Record<string, unknown>, ToolCallState> {
  constructor(
    private readonly llmGenerateText: LlmGenerateTextTool,
    private readonly llmDelegateToolCalls: LlmDelegateToolCallsTool,
    private readonly getWeather: GetWeather,
  ) {
    super();
  }

  @Transition({ to: 'ready' })
  async setup(state: ToolCallState): Promise<ToolCallState> {
    await this.documentStore.save(LlmMessageDocument, { role: 'user', text: 'How is the weather in Berlin?' });
    return state;
  }

  @Transition({ from: 'ready', to: 'prompt_executed' })
  async llmTurn(state: ToolCallState): Promise<ToolCallState> {
    const result = await this.llmGenerateText.call(
      {},
      { config: { provider: 'claude', model: 'claude-sonnet-4-6', tools: ['get_weather'] } },
    );
    return { ...state, llmResult: result.data, llmMeta: result.metadata as LlmResultMeta | undefined };
  }

  @Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 })
  @Guard('hasToolCalls')
  async executeToolCalls(state: ToolCallState): Promise<ToolCallState> {
    await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, {
      meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider },
    });
    const result = await this.llmDelegateToolCalls.call({
      message: state.llmResult!.message,
    });
    return { ...state, delegateResult: result.data };
  }

  hasToolCalls(state: ToolCallState): boolean {
    return state.llmResult?.message.stopReason === 'tool_use';
  }

  @Transition({ from: 'awaiting_tools', to: 'ready' })
  @Guard('allToolsComplete')
  async toolsComplete(state: ToolCallState): Promise<ToolCallState> {
    await this.documentStore.save(LlmMessageDocument, {
      role: 'user',
      blocks: state.delegateResult!.toolResults.map((tr) => ({
        type: 'tool_result' as const,
        toolCallId: tr.toolCallId,
        content: tr.content ?? '',
        isError: tr.isError ?? false,
      })),
    });
    return state;
  }

  allToolsComplete(state: ToolCallState): boolean {
    return state.delegateResult?.allCompleted ?? false;
  }

  @Transition({ from: 'prompt_executed', to: 'end' })
  async respond(state: ToolCallState): Promise<unknown> {
    await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, {
      meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider },
    });
    return {};
  }
}

Dependencies

This workflow uses the following Loopstack modules:

  • @loopstack/common - Core framework functionality, BaseWorkflow, BaseTool, decorators
  • @loopstack/llm-provider-module - Provides LlmGenerateTextTool, LlmDelegateToolCallsTool tools, LlmMessageDocument, and result types

About

Author: Jakob Klippel

License: MIT

Additional Resources