Creating Tools
A tool is a reusable unit of logic that extends BaseTool. Tools are injected into workflows and called directly in TypeScript.
Basic Tool
import { z } from 'zod';
import { BaseTool, Tool, ToolResult } from '@loopstack/common';
@Tool({
uiConfig: {
description: 'Short description of what this tool does.',
},
schema: z
.object({
query: z.string().describe('Search query'),
limit: z.number().default(10).describe('Max results'),
})
.strict(),
})
export class SearchTool extends BaseTool {
async call(args: { query: string; limit: number }): Promise<ToolResult<string>> {
return { data: `Found results for: ${args.query}` };
}
}The @Tool Decorator
@Tool({
uiConfig: {
description: 'User-facing description.', // Also seen by LLMs for function calling
},
schema: InputSchema, // Zod schema for input validation
})uiConfig— Object or path to YAML file with tool metadataschema— Zod schema that validates arguments beforecall()is invoked
The call() Method
The only method you need to implement. It receives validated arguments and returns a ToolResult.
async call(args: TArgs): Promise<ToolResult<TData>> {
// Your logic here
return { data: result };
}ToolResult
type ToolResult<TData = any> = {
type?: 'text' | 'image' | 'file';
data?: TData;
error?: string;
metadata?: Record<string, unknown>;
};Return patterns:
return { data: 42 }; // Simple value
return { data: { name: 'result', items: [...] } }; // Typed data
return { error: 'Something went wrong' }; // Error
return { type: 'text', data: 'Mostly sunny, 14C.' }; // Typed output
return { data: result, metadata: { tokensUsed: 150 } }; // With metadataDependency Injection
Use standard NestJS @Inject() to inject services:
import { Inject } from '@nestjs/common';
@Tool({
uiConfig: { description: 'Calculates the sum of two numbers.' },
schema: z.object({ a: z.number(), b: z.number() }).strict(),
})
export class MathSumTool extends BaseTool {
@Inject() private mathService: MathService;
async call(args: { a: number; b: number }): Promise<ToolResult<number>> {
return { data: this.mathService.sum(args.a, args.b) };
}
}Tool State
Tool properties are automatically persisted across invocations within the same workflow run:
@Tool({ uiConfig: { description: 'Counter tool.' } })
export class CounterTool extends BaseTool {
count: number = 0;
async call(): Promise<ToolResult<number>> {
this.count++;
return { data: this.count };
}
}The count value persists even if the workflow pauses and resumes.
Tools for LLM Function Calling
When a tool is exposed to the LLM, the description and schema tell the LLM what the tool does and what arguments it accepts:
@Tool({
uiConfig: {
description: 'Retrieve weather information for a location.',
},
schema: z.object({
location: z.string().describe('City or location name'),
}),
})
export class GetWeather extends BaseTool {
async call(args: { location: string }): Promise<ToolResult> {
return { type: 'text', data: 'Mostly sunny, 14C.' };
}
}In the workflow, list the tool name in the tools array:
const result = await this.claudeGenerateText.call({
claude: { model: 'claude-sonnet-4-6' },
messagesSearchTag: 'message',
tools: ['getWeather'], // Matches the @InjectTool() property name
});Using Tools in Workflows
@InjectTool() private myTool: SearchTool;
@Transition({ from: 'ready', to: 'done' })
async process() {
const result = await this.myTool.call({ query: 'hello', limit: 5 });
this.searchResults = result.data;
}Module Registration
@Module({
providers: [SearchTool, MathService],
exports: [SearchTool],
})
export class MyToolModule {}Then import the module in the workflow’s parent module.
File Structure
src/
├── tools/
│ ├── search.tool.ts
│ ├── math-sum.tool.ts
│ └── index.ts # Re-exports all tools
├── services/
│ └── math.service.ts
├── my-feature.module.ts
└── index.tsRegistry References
- custom-tool-example-module — MathSumTool with injected service, stateful CounterTool, and workflow demonstrating tool usage
- tool-call-example-workflow — GetWeather tool exposed to the LLM for function calling
Last updated on