AI agents by framework / JavaScript and TypeScript

How to add an AI agent to an Angular app

An Angular app runs in the browser, so its AI agent needs a backend endpoint that holds the model key, calls the model with tools and runs them as the signed-in user. In the app, the Chat class from @ai-sdk/angular streams the conversation into signals that a standalone component renders, and an injectable service keeps it across routes. If the agent only needs to do what your app already does, you can embed one instead.

What an agent in an Angular app is made of

  • A backend endpoint: a POST /api/chat route on your API server, here Express. It holds the model API key; everything in the Angular build, environment files included, is shipped to the browser.
  • Tools: typed functions on the backend that the model may call, each wrapping an operation your API already performs, built per request for the signed-in user.
  • The agent loop: on the backend, the model calls tools and reads their results until it can answer or reaches a step limit.
  • A service and a component: an injectable service owns the chat state, and a standalone component renders it with the built-in control flow blocks.
  • Authorization and approval: the session cookie identifies the user on the backend, a CSRF token accompanies each chat request, and the component asks the user before a write runs.

Ways to add an agent to Angular

ApproachGood forWhat you take on
AI SDK: @ai-sdk/angular with a backend routeA custom agent in an Angular app with its own API server.The backend endpoint, authorization in every tool, approval UI, rate and cost limits.
The same route on Angular's SSR serverApps that already use Angular SSR, whose generated server.ts is an Express app.The same work, and the rendering server now also serves your API and model calls.
An agent framework on the backend (for example Mastra or LangGraph.js)Long-running or multi-agent workflows with memory and orchestration.Another layer to learn and deploy, plus the endpoint, authorization and limits.
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.

The Angular server-side rendering guide shows the Express-based file server.ts; an API router mounted there before the Angular handler serves the route below unchanged.

Build an agent with the AI SDK

The AI SDK's Angular quickstart pairs @ai-sdk/angular with an Express backend, and this guide follows that layout with AI SDK 7, Angular 22 standalone components and Express 5. Install the client and server packages:

Terminal
# in the Angular app
npm install ai @ai-sdk/angular

# in the Express backend
npm install ai zod

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 on the backend. To call a provider directly, install its package (for example @ai-sdk/google) and pass its model instead.

1. The tools and the endpoint

Keeping the tools in a factory makes the binding explicit: orderTools takes the user ID that the session middleware from the Express guide placed on the request, and the model can choose an order ID but never the user. The route passes the same tools to validateUIMessages, which checks the posted history, including earlier tool calls, before it reaches the model.

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

// Built per request, so every tool is bound to the user the session authenticated.
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),
    }),
  };
}

The condition isStepCount(5) caps the loop. The setting toolApproval holds the call to cancelOrder until the user answers, and the option experimental_toolApprovalSecret signs approval requests so a client cannot forge one.

server/chat.ts
import {
  convertToModelMessages,
  isStepCount,
  pipeUIMessageStreamToResponse,
  streamText,
  toUIMessageStream,
  validateUIMessages,
} from "ai";
import { Router } from "express";
import { orderTools } from "./order-tools.ts";

// Mounted at /api/chat behind your session, CSRF and rate-limit middleware.
export const chatRouter = Router().post("/", async (req, res) => {
  const tools = orderTools(res.locals.userId);
  // Validating with the tools also checks the inputs of earlier tool calls in the history.
  const messages = await validateUIMessages({ messages: req.body?.messages, tools });

  const result = 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,
    toolApproval: { cancelOrder: "user-approval" },
    experimental_toolApprovalSecret: process.env.TOOL_APPROVAL_SECRET,
    stopWhen: isStepCount(5),
  });

  await pipeUIMessageStreamToResponse({ response: res, stream: toUIMessageStream({ stream: result.stream }) });
});

In development, start the app with ng serve --proxy-config proxy.conf.json so requests to the API reach Express on the app's own origin and carry its session cookie:

proxy.conf.json
{ "/api/**": { "target": "http://127.0.0.1:3000" } }

2. The chat service

The Chat class keeps its messages, status and error in signals. Creating it in a root service gives the whole app one conversation that survives route changes, and a single place to clear it when the user signs out. The service injects HttpClient, so add provideHttpClient() to the application config if it is not there yet. Angular's built-in XSRF support only covers HttpClient requests; the chat transport sends its own header instead.

src/app/agent.service.ts
import { Chat } from "@ai-sdk/angular";
import { HttpClient } from "@angular/common/http";
import { inject, Injectable } from "@angular/core";
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { firstValueFrom } from "rxjs";

@Injectable({ providedIn: "root" })
export class AgentService {
  private readonly http = inject(HttpClient);

  // A root singleton, so the conversation survives navigation between routes.
  readonly chat = new Chat({
    transport: new DefaultChatTransport({
      api: "/api/chat",
      // The Chat class sends requests with fetch, so HttpClient interceptors and XSRF handling do not apply.
      headers: async () => {
        const { token } = await firstValueFrom(this.http.get<{ token: string }>("/api/csrf-token"));
        return { "x-csrf-token": token };
      },
    }),
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
  });

  // Call from your sign-out flow so the next user on this tab does not see the conversation.
  clear() {
    void this.chat.stop();
    this.chat.messages = [];
  }
}

3. The chat component

The component reads the service's chat directly in its template. Inside the control flow blocks, checks on part.type and part.state narrow the part's type, so part.approval.id type-checks under strict templates. Once every pending approval has an answer, sendAutomaticallyWhen sends the conversation back so the backend runs or skips the tool.

src/app/orders-agent.ts
import { JsonPipe } from "@angular/common";
import { Component, inject, signal } from "@angular/core";
import { AgentService } from "./agent.service";

@Component({
  selector: "app-orders-agent",
  imports: [JsonPipe],
  template: `
    <section aria-label="Order assistant">
      @for (message of chat.messages; track message.id) {
        @for (part of message.parts; track $index) {
          @if (part.type === 'text') {
            <p>{{ part.text }}</p>
          } @else if (part.type === 'tool-cancelOrder' && part.state === 'approval-requested' && !part.approval.isAutomatic) {
            <div role="group" aria-label="Approval needed">
              <p>Cancel order {{ part.input | json }}?</p>
              <button (click)="chat.addToolApprovalResponse({ id: part.approval.id, approved: true })">Cancel it</button>
              <button (click)="chat.addToolApprovalResponse({ id: part.approval.id, approved: false })">Keep it</button>
            </div>
          }
        }
      }
      @if (chat.error) {
        <p role="alert">Something went wrong. Try again.</p>
      }
      <form (submit)="send($event)">
        <input #box aria-label="Message" [value]="input()" (input)="input.set(box.value)" />
        <button type="submit" [disabled]="chat.status === 'submitted' || chat.status === 'streaming'">Send</button>
      </form>
    </section>
  `,
})
export class OrdersAgent {
  protected readonly chat = inject(AgentService).chat;
  protected readonly input = signal("");

  send(event: Event) {
    event.preventDefault();
    const text = this.input().trim();
    if (!text || this.chat.status === "submitted" || this.chat.status === "streaming") return;
    this.chat.sendMessage({ text });
    this.input.set("");
  }
}

Production checklist for Angular

  • Keep secrets out of the build. Files under src/environments are compiled into the bundle. The model key, the approval secret and the session secret belong in backend environment variables only.
  • Authorize on the backend. Route guards decide what the user sees, not what the API allows. The endpoint checks the session and every tool scopes its queries to that user; the Express checklist covers the server side.
  • Send the CSRF token from the transport. Interceptors and the XSRF feature of HttpClient do not see requests the chat makes with fetch, so the transport's headers function must add the token itself.
  • Keep model output out of trusted HTML. Interpolation escapes text and Angular sanitizes values bound to innerHTML. Never pass model output through bypassSecurityTrustHtml; see the Angular security guide.
  • Clear the conversation on sign-out. A root service outlives the user's session in a single-page app, so call the service's clear() in the same code that completes sign-out or an account switch.
  • 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 business rules, and require approval for anything irreversible.
  • Limit cost. Cap steps, rate-limit the endpoint per user and set spending limits with your model provider.

Option: embed an in-app agent with Bunkhouse

If the agent's job is to help signed-in users do work your Angular 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 Angular backend keeps enforcing its own authorization on every request.

In Angular, put the loader in the head of src/index.html, after the identity hook. The Angular CLI adds the app's entry script at the end of the body, so the loader runs first; do not add it from a component, an app initializer or afterNextRender. Serve GET /api/agent-identity from your backend on the same origin; it signs a short-lived token for the user the backend session already authenticated, and the signing secret never enters the Angular build.

src/index.html (excerpt)
<head>
  <base href="/" />
  <!-- The identity hook, then the loader. The CLI adds the app's scripts at the end of the body. -->
  <script src="/agent-identity.js"><\/script>
  <script src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>
</head>

The tested Angular 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 Angular 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 Angular 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 Angular page at http://localhost:4200.

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.