Skip to Content

🛠️ Creating Custom Tools

Custom tools are reusable components that encapsulate specific functionality in your Loopstack workflows. They can accept arguments, use dependency injection, and integrate seamlessly with the workflow execution.

Basic Tool Structure

A custom tool implements the ToolInterface and the execute method:

import { Inject } from '@nestjs/common'; import { z } from 'zod'; import { Input, Tool, ToolInterface, ToolResult } from '@loopstack/common'; import { MathService } from '../services/math.service'; export type MathSumArgs = { a: number; b: number; }; @Tool({ config: { description: 'Math tool calculating the sum of two arguments by using an injected service.', }, }) export class MathSumTool implements ToolInterface { @Inject() private mathService: MathService; @Input({ schema: z .object({ a: z.number(), b: z.number(), }) .strict(), }) args: MathSumArgs; async execute(args: MathSumArgs): Promise<ToolResult<number>> { const sum = this.mathService.sum(args.a, args.b); return Promise.resolve({ data: sum, }); } }

Key Components

1. Required Decorators

@Tool

  • Defines the tool’s metadata and configuration
  • config.description: Human-readable description of what the tool does

@Input({ schema })

  • Defines the expected arguments using a Zod schema
  • Applied to a class property that holds the arguments

2. Arguments Schema

Define the expected arguments using @Input with a Zod schema on a class property, and export a type for use in the execute method:

export type MathSumArgs = { a: number; b: number; }; @Input({ schema: z .object({ a: z.number(), b: z.number(), }) .strict(), }) args: MathSumArgs;

3. Base Class

Implements ToolInterface:

export class MathSumTool implements ToolInterface { // ... }

4. Execute Method

The execute() method contains your tool’s logic. Arguments are passed directly as a parameter:

async execute(args: MathSumArgs): Promise<ToolResult<number>> { const sum = this.mathService.sum(args.a, args.b); return Promise.resolve({ data: sum, }); }

5. Dependency Injection

Tools support NestJS dependency injection. Use @Inject() to inject services:

@Inject() private mathService: MathService;

Stateful Tools

Tools can maintain internal state across invocations. For example, a counter tool that increments each time it is called:

import { Tool, ToolInterface, ToolResult } from '@loopstack/common'; @Tool({ config: { description: 'Counter tool.', }, }) export class CounterTool implements ToolInterface { count: number = 0; async execute(): Promise<ToolResult> { this.count++; return Promise.resolve({ data: this.count, }); } }

This tool requires no arguments and returns an incrementing count each time it is executed.

Using Tools in Workflows

1. Define the Workflow Class

Use the @InjectTool() decorator to register tools in your workflow class. You can also define input arguments, state, output, and helper functions using their respective decorators:

import { z } from 'zod'; import { DefineHelper, InjectTool, Input, Output, State, Workflow } from '@loopstack/common'; import { CreateChatMessage } from '@loopstack/create-chat-message-tool'; import { MathSumTool } from '../tools'; import { CounterTool } from '../tools'; @Workflow({ configFile: __dirname + '/custom-tool-example.workflow.yaml', }) export class CustomToolExampleWorkflow { @InjectTool() private counterTool: CounterTool; @InjectTool() private createChatMessage: CreateChatMessage; @InjectTool() private mathTool: MathSumTool; @Input({ schema: z .object({ a: z.number().default(1), b: z.number().default(2), }) .strict(), }) args: { a: number; b: number; }; @State({ schema: z .object({ total: z.number().optional(), count1: z.number().optional(), count2: z.number().optional(), count3: z.number().optional(), }) .strict(), }) state: { total?: number; count1?: number; count2?: number; count3?: number; }; @Output() result() { return { total: this.state.total, }; } @DefineHelper() sum(a: number, b: number) { return a + b; } }

@Input({ schema }) defines the workflow’s input arguments with a Zod schema and default values.

@State({ schema }) defines the workflow’s mutable state that can be written to during transitions via assign.

@Output() defines a method that returns the workflow’s output when it completes.

@DefineHelper() defines helper functions that can be called from workflow YAML templates (e.g. {{ sum args.a args.b }}).

2. Register in a Module

Add the workflow, tools, and their dependencies to your module. Use imports for tool modules that provide their own providers:

import { Module } from '@nestjs/common'; import { CreateChatMessageToolModule } from '@loopstack/create-chat-message-tool'; import { MathService } from './services/math.service'; import { MathSumTool } from './tools'; import { CounterTool } from './tools'; import { CustomToolExampleWorkflow } from './workflows'; @Module({ imports: [CreateChatMessageToolModule], providers: [CustomToolExampleWorkflow, MathSumTool, CounterTool, MathService], exports: [CustomToolExampleWorkflow, MathSumTool, CounterTool, MathService], }) export class CustomToolModule {}

3. Call in Workflow YAML

Use the tool in your workflow transitions. Reference tools by their property name (camelCase):

title: 'Custom Tool' description: | This workflow demonstrates the usage of custom tools, including both stateless and stateful tools. It performs a simple addition operation using a custom MathSumTool and showcases the behavior of stateless and stateful counter tools. ui: form: properties: a: title: 'First number (a)' b: title: 'Second number (b)' transitions: - id: calculate from: start to: end call: # Use a custom tool - id: calculation tool: mathTool args: a: ${{ args.a }} b: ${{ args.b }} assign: total: ${{ result.data }} # Display the result - tool: createChatMessage args: role: 'assistant' content: | Tool calculation result: {{ args.a }} + {{ args.b }} = {{ state.total }} # Alternatively, use a custom workflow helper function - tool: createChatMessage args: role: 'assistant' content: | Alternatively, using workflow helper function: {{ args.a }} + {{ args.b }} = {{ sum args.a args.b }} # Use a stateful tool - id: count1 tool: counterTool assign: count1: ${{ result.data }} - id: count2 tool: counterTool assign: count2: ${{ result.data }} - id: count3 tool: counterTool assign: count3: ${{ result.data }} - tool: createChatMessage args: role: 'assistant' content: | Counter tool should count: {{ state.count1 }}, {{ state.count2 }}, {{ state.count3 }}
Last updated on