AI agents by framework / JavaScript and TypeScript
How to add an AI agent to a React app
A React app runs in the browser, so its AI agent has two halves: a backend endpoint that holds the model key, calls the model with tools and runs them as the signed-in user, and a React component that streams the conversation. The AI SDK covers both, with the useChat hook from @ai-sdk/react in the browser and a streaming route on an Express backend. If the agent only needs to do what your app already does, you can embed one instead.
What an agent in a React app is made of
- A backend endpoint: a route such as
POST /api/chaton the server your React app already talks to. It holds the model API key; anything in the React bundle, includingVITE_variables, is public. - Tools: typed functions on the backend that the model may call, each wrapping an operation your API already performs for the same user.
- The agent loop: also on the backend. The model calls a tool, reads the result and decides the next step until it can answer, up to a step limit.
- A chat component: a React component built on
useChat, which posts the conversation, reads the streamed reply into state and re-renders as text and tool parts arrive. - Authorization and approval: the browser sends your existing session cookie and a CSRF token, the backend binds every tool to that session's user, and the component shows approve and deny buttons before a write runs.
Ways to add an agent to React
| Approach | Good for | What you take on |
|---|---|---|
| AI SDK: @ai-sdk/react with a backend route | A custom agent with your own tools, model provider and components. | The backend endpoint, authorization in every tool, approval UI, rate and cost limits. |
| assistant-ui on top of the AI SDK | Ready-made React chat components (thread, composer, tool call displays) instead of writing your own. | Its runtime and component model, plus the same backend work. |
| 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 widget | Questions 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 assistant-ui integration for AI SDK 7 is described in its AI SDK v7 runtime docs. If you would rather keep the endpoint in the same project as the UI, a full-stack framework does that; see the Next.js guide.
Build an agent with the AI SDK
The AI SDK's useChat hook comes from the package @ai-sdk/react and speaks the same stream protocol as the SDK's server helpers. The examples use AI SDK 7, React 19 with Vite, and an Express 5 backend. Install the client and server packages:
# in the React app
npm install ai @ai-sdk/react
# in the Express backend
npm install ai zodThe 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/openai) and pass its model instead.
1. The endpoint on your backend
The route below assumes the middleware chain from the Express guide: session, an auth check that puts the user ID on res.locals, CSRF protection and a per-user rate limit. Each tool closes over that user ID, so the model can choose an order ID but never whose orders it reads or cancels. The condition isStepCount(5) caps the loop. The setting toolApproval pauses the call to cancelOrder until the user answers, and the option experimental_toolApprovalSecret signs each approval request so a modified client cannot forge one.
import {
convertToModelMessages,
isStepCount,
pipeUIMessageStreamToResponse,
streamText,
toUIMessageStream,
tool,
validateUIMessages,
} from "ai";
import { Router } from "express";
import { z } from "zod";
import { cancelOrder, listOrders } from "./orders.ts"; // your existing data layer
// Mounted at /api/chat behind your session, CSRF and rate-limit middleware.
export const chatRouter = Router().post("/", async (req, res) => {
const userId: string = res.locals.userId; // from the session, never from the request body
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(await validateUIMessages({ messages: req.body?.messages })),
tools: {
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),
}),
},
toolApproval: { cancelOrder: "user-approval" },
experimental_toolApprovalSecret: process.env.TOOL_APPROVAL_SECRET,
stopWhen: isStepCount(5),
});
await pipeUIMessageStreamToResponse({ response: res, stream: toUIMessageStream({ stream: result.stream }) });
});2. Proxy the API in development
Keep the React app and the API on one origin, so the session cookie is sent and no CORS configuration is needed. In production that usually means serving the Vite build from the same host as the API. In development, the Vite dev server proxy forwards requests under /api to Express:
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
// In development, forward API calls to the Express backend so the session cookie stays same-origin.
server: { proxy: { "/api": "http://localhost:3000" } },
});3. The chat component
The hook useChat posts the conversation to /api/chat through the transport and streams the reply into messages. The transport's headers function runs before every request, so it can attach a fresh CSRF token. Tool calls arrive as typed parts: the component shows a status line while the tool listOrders runs, and approve and deny buttons when cancelOrder reaches the state approval-requested. Once every pending approval has an answer, sendAutomaticallyWhen sends the conversation back so the backend can run or skip the tool.
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { useState } from "react";
// Module scope: one transport for the app, created once rather than on every render.
const transport = new DefaultChatTransport({
api: "/api/chat",
headers: async () => {
const res = await fetch("/api/csrf-token");
const { token }: { token: string } = await res.json();
return { "x-csrf-token": token };
},
});
export function Agent() {
const [input, setInput] = useState("");
const { messages, sendMessage, addToolApprovalResponse, status, stop, error } = useChat({
transport,
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
const busy = status === "submitted" || status === "streaming";
return (
<section aria-label="Order assistant">
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, i) => {
if (part.type === "text") return <p key={i}>{part.text}</p>;
if (part.type === "tool-listOrders" && part.state !== "output-available") {
return <p key={i}>Looking up your orders…</p>;
}
if (part.type === "tool-cancelOrder" && part.state === "approval-requested" && !part.approval.isAutomatic) {
const { id } = part.approval;
return (
<div key={i} role="group" aria-label="Approval needed">
<p>Cancel order {JSON.stringify(part.input)}?</p>
<button onClick={() => addToolApprovalResponse({ id, approved: true })}>Cancel it</button>
<button onClick={() => addToolApprovalResponse({ id, approved: false })}>Keep it</button>
</div>
);
}
return null;
})}
</div>
))}
{error && <p role="alert">Something went wrong. Try again.</p>}
<form
onSubmit={(e) => {
e.preventDefault();
if (!input.trim() || busy) return;
sendMessage({ text: input });
setInput("");
}}
>
<input aria-label="Message" value={input} onChange={(e) => setInput(e.currentTarget.value)} />
{busy ? <button type="button" onClick={stop}>Stop</button> : <button type="submit">Send</button>}
</form>
</section>
);
}The conversation lives in the component's state and ends when it unmounts. To keep it across routes, create a chat instance with the Chat class from @ai-sdk/react outside the component tree and pass it as useChat({ chat }).
Production checklist for React
- Keep secrets out of the bundle. Vite exposes every
VITE_-prefixed variable to client code (see env variables and modes). The model key, the approval secret and the session secret belong in backend environment variables only. - Authorize on the backend. Hiding the chat for signed-out users is a UI choice, not a security boundary. The endpoint checks the session and every tool scopes its queries to that user, as the Express checklist describes.
- Send the CSRF token with every chat request. The session cookie goes along automatically, so the endpoint needs the token header to tell your app's requests from a forged one. The transport above fetches it before each request, including the automatic one after an approval.
- Render model output as text. JSX escapes strings, so
{part.text}is safe. Never pass model output todangerouslySetInnerHTML; if you render Markdown, use a renderer that does not pass raw HTML through. - 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 React 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 React backend keeps enforcing its own authorization on every request.
In a React app built with Vite, put the loader in the head of index.html, after the identity hook and before the entry module, not in a component or an effect: it must run once per page, before React makes its first requests. 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 React build.
<head>
<meta charset="utf-8" />
<!-- The identity hook, then the loader, before the app's entry script. -->
<script src="/agent-identity.js"><\/script>
<script src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>
<script type="module" src="/src/main.tsx"><\/script>
</head>The tested React 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 React 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 React 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:
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy React page at http://localhost:5173.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.