AI agents by framework / JavaScript and TypeScript

How to add an AI agent to a NestJS app

An AI agent in a NestJS app is a module with a service that calls a model with tools bound to the signed-in user, and a guarded controller that streams the result to the browser. The AI SDK runs the tool loop and pipes its stream into the Express response; Nest's guards, DI and throttling handle the rest. If the agent only needs to do what your app already does, you can embed one instead.

What an agent in a NestJS app is made of

  • A controller: a POST /api/chat handler in an AgentController that streams the reply. It stays thin.
  • A service: an injectable AgentService that holds the model call, and can inject the services your other modules already export (orders, billing, users) to build its tools.
  • Tools: typed functions the model may call, each wrapping one of those service methods.
  • The agent loop: the model calls a tool, receives the result and decides the next step until it can answer. Cap the number of steps.
  • Authorization and approval: a guard authenticates the request, a parameter decorator hands the user to the handler, tools act only as that user, and writes wait for the user to approve them.

Ways to add an agent to NestJS

ApproachGood forWhat you take on
AI SDK in a Nest service and controllerA custom agent with your own tools, model provider and UI.Tool definitions, authorization in every tool, approval UI, rate and cost limits.
LangChain.js or LangGraph.js in a providerGraph-shaped workflows, checkpoints and the LangChain integrations.A larger dependency and its own abstractions, plus everything in the row above.
Hosted answer-only chat widgetQuestions about your docs or help center.Keeping its content current. It does not act in your app.
Embedded in-app agent (Bunkhouse)Letting signed-in users get tasks done through the pages and API you already have.A script tag, an identity endpoint and hints for operations discovery cannot see.

Build an agent with the AI SDK

The AI SDK is Vercel's TypeScript library for model calls, tool calling and chat UIs, and its NestJS recipe shows the server side. The examples below use AI SDK 7 with NestJS on the default Express platform. Install it with Zod for tool input schemas, and the React bindings for the client:

Terminal
npm install ai zod @ai-sdk/react

The model string "anthropic/claude-sonnet-5.5" goes through the Vercel AI Gateway, which reads the API key from the AI_GATEWAY_API_KEY variable. To call a provider directly, install its package (for example @ai-sdk/openai) and pass its model instead.

1. Tools scoped to the user

Define the tools in a function that takes the authenticated user's ID. Each tool wraps an operation your app already has and closes over that ID, so the model can choose an order ID but never whose orders it reads or cancels. Your data layer should still check that the order belongs to the user.

src/agent-tools.ts
import { tool } from "ai";
import { z } from "zod";
import { cancelOrder, listOrders } from "./orders.js"; // your existing data layer

// The tools close over the authenticated user's ID; the model never chooses whose data they touch.
export function orderTools(userId: string) {
  return {
    listOrders: tool({
      description: "List the signed-in user's recent orders",
      inputSchema: z.object({ status: z.enum(["open", "shipped", "cancelled"]).optional() }),
      execute: async ({ status }) => listOrders(userId, { status }),
    }),
    cancelOrder: tool({
      description: "Cancel one of the signed-in user's open orders",
      inputSchema: z.object({ orderId: z.string() }),
      execute: async ({ orderId }) => cancelOrder(userId, orderId),
    }),
  };
}

2. Guard, service and controller

SessionGuard stands in for your existing authentication guard: it rejects anonymous requests with 401 and attaches the user, which @CurrentUser() reads. AgentService builds the model call. The condition isStepCount(5) lets the model call tools and continue for up to five steps, and toolApproval makes cancelOrder pause until the user answers. The option experimental_toolApprovalSecret signs approval requests so a client cannot forge one in the message history it sends back. The controller takes the Express response with @Res() and hands it to pipeUIMessageStreamToResponse, which writes the stream and ends the response.

src/agent/agent.module.ts
import {
  Body,
  CanActivate,
  Controller,
  createParamDecorator,
  ExecutionContext,
  Injectable,
  Module,
  Post,
  Res,
  UnauthorizedException,
  UseGuards,
} from "@nestjs/common";
import {
  convertToModelMessages,
  isStepCount,
  pipeUIMessageStreamToResponse,
  streamText,
  toUIMessageStream,
  type UIMessage,
} from "ai";
import type { Request, Response } from "express";
import { orderTools } from "./agent-tools.js";
import { userFromSession, type User } from "./auth.js"; // your existing session lookup

type AuthedRequest = Request & { user?: User };

@Injectable()
export class SessionGuard implements CanActivate {
  async canActivate(context: ExecutionContext) {
    const req = context.switchToHttp().getRequest<AuthedRequest>();
    const user = await userFromSession(req.headers.cookie);
    if (!user) throw new UnauthorizedException();
    req.user = user;
    return true;
  }
}

export const CurrentUser = createParamDecorator(
  (_data: unknown, context: ExecutionContext) => context.switchToHttp().getRequest<AuthedRequest>().user!,
);

@Injectable()
export class AgentService {
  async stream(user: User, messages: UIMessage[]) {
    return streamText({
      model: "anthropic/claude-sonnet-5.5",
      instructions: "You help the signed-in user manage their orders. Use the tools; never guess order data.",
      messages: await convertToModelMessages(messages),
      tools: orderTools(user.id),
      toolApproval: { cancelOrder: "user-approval" },
      experimental_toolApprovalSecret: process.env.TOOL_APPROVAL_SECRET,
      stopWhen: isStepCount(5),
    });
  }
}

@Controller("api")
@UseGuards(SessionGuard)
export class AgentController {
  constructor(private readonly agent: AgentService) {}

  @Post("chat")
  async chat(@CurrentUser() user: User, @Body() body: { messages: UIMessage[] }, @Res() res: Response) {
    const result = await this.agent.stream(user, body.messages);
    pipeUIMessageStreamToResponse({ response: res, stream: toUIMessageStream({ stream: result.stream }) });
  }
}

@Module({ controllers: [AgentController], providers: [AgentService, SessionGuard] })
export class AgentModule {}

In a real module, inject your existing OrdersService into AgentService and have the tools call it, rather than importing data functions directly. Import AgentModule into your root module.

3. The browser client

The client uses useChat from @ai-sdk/react, which posts to /api/chat by default and streams the reply into its state; Vue and Svelte bindings work the same way. When the model calls cancelOrder, the tool part arrives in the state approval-requested. The answer is sent with addToolApprovalResponse, and sendAutomaticallyWhen resumes the run once every pending approval has a response.

client/agent.tsx
import { useChat } from "@ai-sdk/react";
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { useState } from "react";

export function Agent() {
  const [input, setInput] = useState("");
  const { messages, sendMessage, addToolApprovalResponse } = useChat({
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
  });

  return (
    <div>
      {messages.map((message) =>
        message.parts.map((part, i) => {
          const key = `${message.id}-${i}`;
          if (part.type === "text") return <p key={key}>{part.text}</p>;
          if (part.type === "tool-cancelOrder" && part.state === "approval-requested" && !part.approval.isAutomatic) {
            const { id } = part.approval;
            return (
              <div key={key}>
                <p>Cancel order {JSON.stringify(part.input)}?</p>
                <button onClick={() => addToolApprovalResponse({ id, approved: true })}>Approve</button>
                <button onClick={() => addToolApprovalResponse({ id, approved: false })}>Deny</button>
              </div>
            );
          }
          return null;
        }),
      )}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          sendMessage({ text: input });
          setInput("");
        }}
      >
        <input aria-label="Message" value={input} onChange={(e) => setInput(e.currentTarget.value)} />
      </form>
    </div>
  );
}

Production checklist for NestJS

  • Authorize in a guard and in every tool. Apply your auth guard to the controller (or globally with APP_GUARD), and scope every query in the tools to the user from the request, never to an ID in the body. Nest's guards documentation covers the pattern.
  • Protect cookie-authenticated routes from CSRF. Nest has no built-in CSRF protection; follow the CSRF guide and send the token from the chat client as a header through the transport's headers option.
  • Treat tool input and tool results as untrusted. The model chooses tool arguments, and data a tool returns (user-written text, emails, web pages) can contain instructions. Validate arguments with the schema and your own business rules, and require approval for anything irreversible.
  • Limit cost. Cap steps, rate-limit the endpoint with @nestjs/throttler using a tracker keyed on the user, and set spending limits with your model provider.
  • Mind @Res(). Injecting the response puts the handler in library-specific mode: interceptors that map the return value do not apply, and exception filters cannot replace a response that has started streaming. Do validation and authorization before the stream starts.
  • Allow for long streams. Check that your proxy does not buffer streaming responses and that the server and load balancer timeouts allow a reply that lasts several model steps.
  • Keep secrets server-side. Load the model key and approval secret through your configuration module from the server environment, never into templates or client bundles.

Option: embed an in-app agent with Bunkhouse

If the agent's job is to help signed-in users do work your NestJS app already supports, you may not need to build the tools, agent loop and chat UI yourself. Bunkhouse adds an Assistant pane to an existing web app with one script tag. When a user opens it, the assistant works with what the page already offers: buttons, links, form fields and tables, the API requests your app makes, a hints file you publish for operations it cannot see, and WebMCP tools your page declares.

The model writes small programs that run in a sandbox on the embed origin, not in your page. They can only call capabilities the assistant has discovered and your configuration allows. Write actions are shown to the user for review before they run, the model never sees credentials, and your NestJS backend keeps enforcing its own authorization on every request.

In NestJS, render the identity hook and the loader in the <head> of your view layout (for example a Handlebars layout used by @Render()), before your application's own scripts, so they load once per document. For signed-in users, add a GET /api/agent-identity handler to a controller that reads the user from your auth guard and signs a short-lived token for it; the signing secret stays in a server environment variable.

views/layout.hbs (excerpt)
<head>
  <script src="/agent-identity.js"><\/script>
  <script src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>
  {{!-- then your application's own scripts --}}
</head>

The tested NestJS integration guide has the complete code for each session mode (signed identity token, server mint and anonymous), CSRF headers and Content Security Policy settings.

When Bunkhouse fits, and when to build it yourself

  • Bunkhouse fits when users want tasks done inside your existing pages, such as filtering, editing or submitting through the controls and API you already have, and you would rather not write and maintain a tool definition for each action, a sandbox for model-written code, an approval UI and usage metering.
  • Build it yourself when the agent must run without a browser page (background jobs, email, chat platforms, scheduled work), when you need to choose the model provider, use your own keys or run inference on your own infrastructure, or when the conversation must be part of your own UI components.
  • Plan for the limits: discovery cannot see server-only routes, workflows the user has not visited, closed shadow roots or cross-origin frames (describe those in a hints file); some controls need a real user gesture; password, payment and file fields are never read, and you can mark other content private.
  • Cost: production use is $49 per month, including $20 of usage credit per paid billing period, then $1 per million input tokens and $3 per million output tokens. See billing.

Try it on your NestJS app first

A coding agent such as Claude Code, Codex or Cursor can run a free, disposable agent trial on a dummy page of your NestJS app. No account, card or model API key is needed: your coding agent acts as the assistant's model. A trial lasts 24 hours and allows 100 turns. Give your agent this instruction, changing the address if your app runs elsewhere:

Agent trialPrompt for your coding agent
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy NestJS page at http://localhost:3000.

Use a page with disposable data: everything the assistant reads is sent to your agent.

The trial evaluation checklist shows how to test a read, a rejected write and an approved change, and what a trial does not prove about production use.