NestJS Logo

AI

@nestjs/ai brings language models into a Nest application: models registered by name and injected, calls with timeouts, retries and fallback models, answers validated by a schema, and the methods of your providers as agent functions, which the model calls as tools.

The online store for cat food and supplies gets a hundred support emails a day, and its customers would rather ask the website about their orders than write one. Both are jobs for a language model, inside the order API: sort an email and draft a reply, or answer a customer in a chat with what the API knows about their orders.

The package builds on the AI SDK (ai) and its provider packages, so any model they support works: Claude, GPT, Gemini, Mistral, or a model behind a gateway. Model calls run on the policies of @nestjs/resilience. You write an agent function, a method with @AgentFunction(), and the model is offered it as a tool: every call of one goes through the guards, interceptors, pipes and exception filters your routes use, as the user the model acts for.

In this chapter, you'll give the store's order API:

  • A triage of support emails into a typed object, and a drafted reply, with a fast and cheap model.
  • Retries, timeouts and a fallback model around every call, a budget per call, and a record of what each one cost.
  • Agent functions the model calls as the signed-in customer, among them find-order, which the store's MCP server serves too: one method, two consumers.
  • A chat assistant whose answer streams to the browser as it's written.
  • A refund agent that waits for the support team's approval, as a durable workflow.
  • Tests that run without a model provider.

Installation#

Install the package, @nestjs/resilience, whose policies model calls run on, the AI SDK, a provider package, and Zod, for the schemas:


$ npm i --save @nestjs/ai @nestjs/resilience ai @ai-sdk/anthropic zod
Hint Every @ai-sdk/* provider package works the same way: @ai-sdk/openai, @ai-sdk/google, @ai-sdk/mistral, @ai-sdk/amazon-bedrock and the others. Schemas can come from any Standard Schema library that produces JSON Schema, as for @nestjs/mcp: Zod 4.2 or later and ArkType as they are, Valibot through toStandardJsonSchema(). Each major version of @nestjs/ai supports one major version of the AI SDK, ai 7 for this one, and it needs Node.js 22 or later, as the AI SDK does.

Register the models#

Import AiModule in the root module, with the models the application uses, by name. Calls that name no model use default:

app.module.ts
JS TS

import { anthropic } from '@ai-sdk/anthropic';
import { Module } from '@nestjs/common';
import { AiModule } from '@nestjs/ai';
import { SupportModule } from './support/support.module';

@Module({
  imports: [
    AiModule.forRoot({
      isGlobal: true,
      models: {
        default: anthropic('claude-opus-5-5'),
        fast: {
          model: anthropic('claude-haiku-5-5'),
          fallback: 'default',
        },
      },
      defaults: { timeout: '30s', retry: { attempts: 3 } },
    }),
    SupportModule,
  ],
})
export class AppModule {}

A model is what a provider package returns, such as anthropic('claude-opus-5-5'), or a definition with it as model and settings of its own: fallback, the models tried when it fails, pricing, timeout, retry, and generation settings such as maxOutputTokens. defaults apply to every call: here, an attempt may take 30 seconds, and each model gets three attempts. Models describes every setting.

The provider package reads its API key from the environment, ANTHROPIC_API_KEY for @ai-sdk/anthropic. To pass it from ConfigService instead, create the provider in a factory: see Async configuration.

isGlobal: true lets every module of the application inject AiService. Without it, the module isn't global: AiService is injected only in the module that imports AiModule.forRoot(). To share one configuration with some modules only, import it in a module of your own that exports it, and import that module where models are called:

llm/llm.module.ts
JS TS

import { anthropic } from '@ai-sdk/anthropic';
import { Module } from '@nestjs/common';
import { AiModule } from '@nestjs/ai';

@Module({
  imports: [
    AiModule.forRoot({ models: { default: anthropic('claude-opus-5-5') } }),
  ],
  exports: [AiModule],
})
export class LlmModule {}

Calling a model#

AiService makes the calls. generateText() sends a prompt, with instructions (the system prompt), and resolves with the answer:

support/replies.service.ts
JS TS

import { Injectable } from '@nestjs/common';
import { AiService } from '@nestjs/ai';

@Injectable()
export class RepliesService {
  constructor(private readonly ai: AiService) {}

  async draft(email: string): Promise<string> {
    const { text } = await this.ai.generateText({
      model: 'fast',
      label: 'draft-reply',
      instructions:
        'Draft a short, friendly reply to the customer. ' +
        'Sign it "The Cat Store".',
      prompt: email,
      maxOutputTokens: 400,
    });
    return text;
  }
}

model names one of the module's models, default when it's left out, and label names the call in usage events and in the events of its policies. prompt is the user's message; messages takes a whole conversation instead, as the AI SDK's ModelMessage objects (see Conversations). The result has more than the text:

PropertyWhat it is
textThe answer: the text of the last model call
finishReasonWhy the model stopped: 'stop', 'length' when it reached maxOutputTokens, or 'tool-calls' when it still asked for tools after maxSteps model calls
usageThe tokens of every model call, summed: inputTokens, outputTokens, totalTokens, cachedInputTokens and reasoningTokens
costWhat the call cost, for models with pricing
stepsOne per model call, with its text, tool calls, usage, and the attempts it took
toolCalls, toolResultsThe tools the model called, with their input, and what each returned or why it failed
responseMessagesThe assistant and tool messages of the call, to append to a conversation
modelThe model that answered: its name, provider and modelId

A call that names a model the module doesn't configure, an agent function that doesn't exist, or both prompt and messages, is refused with an AiConfigurationError before anything is sent.

Structured output#

Sorting an email is a job for generateObject(). The model is sent the schema's JSON Schema, and its answer is parsed and validated by the schema, so object is typed by it:

support/triage.service.ts
JS TS

import { Injectable } from '@nestjs/common';
import { AiService } from '@nestjs/ai';
import * as z from 'zod';

export const Triage = z.object({
  category: z
    .enum(['shipping', 'billing', 'product', 'other'])
    .describe('What the email is about'),
  orderId: z
    .string()
    .nullable()
    .describe('The order id it mentions, such as ord_123, or null'),
  urgent: z.boolean().describe('Whether the customer needs an answer today'),
});

@Injectable()
export class TriageService {
  constructor(private readonly ai: AiService) {}

  async triage(email: string) {
    const { object } = await this.ai.generateObject({
      model: 'fast',
      label: 'triage',
      schema: Triage,
      instructions:
        'You sort the support email of a store for cat food and supplies.',
      prompt: email,
    });
    return object; // { category, orderId, urgent }
  }
}

The model reads the .describe() texts, as it reads an agent function's: say what each field means, with an example value. schemaName and schemaDescription name and describe the object, for providers that pass them on to the model. Besides object, the result has everything generateText()'s has: usage, cost, steps and the rest.

An answer that isn't JSON, or doesn't match the schema, rejects with AiOutputError, which carries the model's text. It isn't retried, and no fallback model is tried: the model did answer.

The schema's transforms run on the answer, while the model is sent the JSON Schema of its input side. The store also extracts the lines of its suppliers' invoices, and wants the due date as a Date; the model is asked for an ISO date string:

suppliers/invoices.service.ts
JS TS

import { Injectable } from '@nestjs/common';
import { AiService } from '@nestjs/ai';
import * as z from 'zod';

const Invoice = z.object({
  number: z.string().describe('The invoice number, such as INV-2041'),
  total: z.number().describe('The total, in cents'),
  dueDate: z.iso.date().transform((value) => new Date(`${value}T00:00:00Z`)),
  lines: z.array(
    z.object({
      description: z.string(),
      amount: z.number().describe('In cents'),
    }),
  ),
});

@Injectable()
export class InvoicesService {
  constructor(private readonly ai: AiService) {}

  async extract(email: string) {
    const { object } = await this.ai.generateObject({
      label: 'invoice',
      schema: Invoice,
      schemaName: 'invoice',
      instructions: 'Extract the invoice from the email of a supplier.',
      prompt: email,
    });
    return object; // object.dueDate is a Date
  }
}

The same call works in a queue job, where the store reads its suppliers' mailbox: the code that calls the model doesn't change with where it runs.

With tools, the model can look things up before it answers, and the object is the answer of its last model call. A model that is still calling tools after maxSteps model calls has given no object, so the call rejects with AiOutputError too.

Async configuration#

forRootAsync() takes the options from a factory, a useClass or a useExisting provider. Here, the factory creates the provider with the API key from ConfigService:

app.module.ts
JS TS

import { createAnthropic } from '@ai-sdk/anthropic';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AiModule } from '@nestjs/ai';

@Module({
  imports: [
    ConfigModule.forRoot(),
    AiModule.forRootAsync({
      isGlobal: true,
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => {
        const anthropic = createAnthropic({
          apiKey: config.getOrThrow<string>('ANTHROPIC_API_KEY'),
        });
        return {
          models: {
            default: anthropic('claude-opus-5-5'),
            fast: { model: anthropic('claude-haiku-5-5'), fallback: 'default' },
          },
          defaults: { timeout: '30s' },
        };
      },
    }),
  ],
})
export class AppModule {}

A useClass provider implements AiOptionsFactory, whose createAiOptions() returns the options. isGlobal stays at the top level, and so does modelNames.

@InjectModel() (see Models) needs a provider for each model name it injects, and forRootAsync() creates them before its factory has run: for the names @InjectModel() was seen asking for when the module was defined, in the classes the application had imported by then. A class loaded later, such as one of a lazily loaded module, lists its names in modelNames: ['fast']. A name nothing configures fails at startup, with the name of the class that asked for it.

Module options#

OptionDefaultDescription
modelsRequired. The models, by name: a LanguageModel, or { model, ... } with the settings below
models.*.fallbackThe names of the models tried, in order, when this one fails
models.*.pricinginput, output and cachedInput: prices per million tokens
models.*.timeout, retry, resilienceThe model's own policies, which win over defaults, as its generation settings do
defaults.timeout'60s'How long an attempt may take; false for no limit
defaults.retry3 attempts, from '1s'The options of RetryPolicy: attempts, backoff (delay, factor, maxDelay, jitter, or a function), retryIf; false for none
defaults.resilienceA policy, or the name of a ResilienceService preset, in place of retry and timeout
defaults.maxSteps10The model calls one call makes while the model asks for tools, and one durable run (whose own default is 25)
defaults.budgetmaxTokens and maxCost, per call and per durable run
defaults.maxOutputTokens, temperature, providerOptions, headersPassed to the model
onUsageCalled after every model call that answered
includeevery moduleThe modules whose providers are searched for agent functions
isGlobalfalseWhether the module is global. Top level of forRootAsync()
modelNamesMore model names @InjectModel() may inject, for classes loaded after the module is defined. Top level of forRootAsync()

Every option of defaults is also an option of each call, which wins. timeout, retry, resilience and the generation settings are also settings of a model's definition, which win over defaults.

Example#

A working example is available here.

Edit on GitHub

Support us

Nest is an MIT-licensed open source project. It can grow thanks to the support of these awesome people. If you'd like to join them, please read more here.

Principal Sponsors

SerpApi LogoTrilon LogoMojam Logo

Sponsors / Partners

Become a sponsor