DEV Community

Cover image for NestJS Tool Calling: Turn Services into LLM Tools
Martin-Bruel
Martin-Bruel

Posted on

NestJS Tool Calling: Turn Services into LLM Tools

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:

Tool calling process

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 a name.
  • 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.
  • sku needs no schema: for a string, number or boolean parameter, the library reads the type TypeScript records with emitDecoratorMetadata, 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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode
  • category needs a schema. At runtime, TypeScript erases 'keyboard' | 'mouse' | 'headset' to String, so without z.enum() the model could send any string. An object or an array needs one too.
  • optional: true lets 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 };
}
Enter fullscreen mode Exit fullscreen mode

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?"}'
Enter fullscreen mode Exit fullscreen mode
{
  "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"]
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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), not runQuery(sql).
  • Never let the model say who it acts for. A userId argument 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.'));
Enter fullscreen mode Exit fullscreen mode

Pass it as model in register(), and checkStock runs against your repository. The next article in this series covers testing in detail.

Top comments (0)