To let an LLM call your NestJS code, decorate a service method with @Tool() and its parameters with @ToolParam(), then list the service's module in the agent's tools. This tutorial turns a product catalog service into two tools, types their arguments with Zod, and shows what happens when the model, or your code, gets something wrong.
This is part 2 of AI agents with NestJS. Part 1 sets up the agent used here.
Tested with NestJS 12, LangChain 1.x, Zod 4, nestjs-langchain 2.0 and Node 22.
What is tool calling?
Tool calling, also called function calling, lets a model ask your code to run a function and read its result before it answers. The model never runs anything itself:
With nestjs-langchain, the agent's part comes from two decorators on a method you already have.
How do I turn a NestJS service method into a tool?
Decorate the method with @Tool() and each argument the model fills in with @ToolParam(). Here, in catalog/catalog.service.ts:
import { Injectable } from '@nestjs/common';
import { Tool, ToolParam } from 'nestjs-langchain';
import { ProductsRepository } from './products.repository.js';
@Injectable()
export class CatalogService {
constructor(private readonly products: ProductsRepository) {}
@Tool({ description: 'Returns how many units of a product are in stock.' })
async checkStock(
@ToolParam({
name: 'sku',
description: 'The product reference, such as KB-101.',
})
sku: string,
): Promise<number> {
const stock = await this.products.stockOf(sku);
if (stock === undefined) {
throw new Error(`No product with SKU ${sku}.`);
}
return stock;
}
}
ProductsRepository stands for whatever your app already uses: TypeORM, Prisma, an HTTP client. The tool is a plain service method, so it keeps its dependencies, and your controllers can still call it.
-
The name is the method's,
checkStock, unless@Tool()takes aname. - The descriptions are written for the model: it reads the tool's to decide when to call it, and each parameter's to fill it in.
-
skuneeds no schema: for astring,numberorbooleanparameter, the library reads the type TypeScript records withemitDecoratorMetadata, on in every Nest project. - The return value reaches the model as text: a string as is, a number as its digits, an object as JSON.
How do I give the tools to the agent?
Declare the service in a module, catalog/catalog.module.ts:
import { Module } from '@nestjs/common';
import { CatalogService } from './catalog.service.js';
import { ProductsRepository } from './products.repository.js';
@Module({
providers: [CatalogService, ProductsRepository],
})
export class CatalogModule {}
Then import that module and list it in the agent's tools, in app.module.ts:
import { Module } from '@nestjs/common';
import { LangChainModule } from 'nestjs-langchain';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
import { AskController } from './ask.controller.js';
import { CatalogModule } from './catalog/catalog.module.js';
@Module({
imports: [
CatalogModule,
LangChainModule.register({
// `provider:model`; the provider reads OPENAI_API_KEY.
model: { model: 'openai:gpt-5-mini' },
systemPrompt: 'You sell computer accessories. Answer from the catalog.',
tools: [CatalogModule],
}),
],
controllers: [AppController, AskController],
providers: [AppService],
})
export class AppModule {}
The agent gets every @Tool() method of the providers CatalogModule declares, not those of the modules it imports. At startup, the agent logs how many it got: ready with 1 tool.
How do I type tool arguments with Zod?
Pass a Zod schema to @ToolParam(). Add a second tool to CatalogService, which searches by category and price:
import { z } from 'zod';
const Category = z.enum(['keyboard', 'mouse', 'headset']);
// In CatalogService:
@Tool({ description: 'Searches the catalog by name, cheapest first.' })
searchProducts(
@ToolParam({ name: 'query', description: 'Words of the product name.' })
query: string,
@ToolParam({ name: 'category', schema: Category, optional: true })
category?: z.infer<typeof Category>,
@ToolParam({
name: 'maxPrice',
description: 'The highest price, in euros.',
schema: z.number().positive(),
optional: true,
})
maxPrice?: number,
): Promise<Product[]> {
return this.products.search({ query, category, maxPrice });
}
-
categoryneeds a schema. At runtime, TypeScript erases'keyboard' | 'mouse' | 'headset'toString, so withoutz.enum()the model could send any string. An object or an array needs one too. -
optional: truelets the model leave the argument out. Declare the parameter with?. - The schema is your validation boundary. Bound numbers, enumerate what you can: the model writes these arguments from what the user typed.
How do I see which tools the model called?
Return them next to the answer, from the controller of part 1:
@Post()
async ask(
@Body('question') question: string,
): Promise<{ output: string; tools: string[] }> {
const { output, tools } = await this.agent.run(question);
return { output, tools };
}
Then start the app and ask a question that needs both tools:
npm run start:dev
curl -X POST http://localhost:3000/ask \
-H 'Content-Type: application/json' \
-d '{"question": "Is there a keyboard under 100 euros in stock?"}'
{
"output": "Yes: the K2 mechanical keyboard, at 89 euros, has 12 units in stock. The Silent office keyboard, at 39 euros, is sold out.",
"tools": ["searchProducts", "checkStock", "checkStock"]
}
The model searched the catalog, then checked the stock of each keyboard it found. tools lists every call it made, in order, failed ones included.
What does the model see when a tool fails?
The error's message, and the run goes on: the model can correct itself or tell the user. When checkStock throws for an unknown SKU, the model receives:
Error: No product with SKU XX-000.
Please fix your mistakes.
Arguments that break the schema never reach your method. Asked for a laptop, a model may send category: 'laptop': it then receives Zod's error, expected one of "keyboard"|"mouse"|"headset", and can retry or tell the user the shop sells none.
How do I keep tools safe?
Treat a tool like a public endpoint: the model calls it with arguments written from what the user typed.
-
Keep tools narrow. Read-only where you can, and a specific action rather than a generic one:
findOrder(id), notrunQuery(sql). -
Never let the model say who it acts for. A
userIdargument lets a prompt injection read another user's data.
Next: test the tools without an API key
LangChain's fakeModel() replays the tool calls you queue, so your real methods run with no provider and no network:
import { AIMessage } from '@langchain/core/messages';
import { fakeModel } from '@langchain/core/testing';
const model = fakeModel()
.respondWithTools([{ name: 'checkStock', args: { sku: 'KB-101' } }])
.respond(new AIMessage('12 in stock.'));
Pass it as model in register(), and checkStock runs against your repository. The next article in this series covers testing in detail.
- The code of this article: nestjs-ai-agent-example, tag part-2
- Naming, parameter types and every startup check: docs/tools.md
- Package: nestjs-langchain on npm
- Source, issues and stars: GitHub

Top comments (0)