Add the chat
Put an assistant on the page that does things, not just answers. The model runs on your server with the key; every tool call runs in the browser, under the agent cursor.
<AgentChat> is a chat widget whose agent works the live page through its tools. The model runs on your server,
behind a route at /api/chat, with the API key there: Claude by default, or any model the Vercel AI SDK reaches. Every
tool call runs in the browser, so the cursor plays exactly as it does for any agent, and anything gated on a click
waits for the person.
A complete example
Install the chat, and the AI SDK package of your model’s provider (server side only):
npm install @webmcp-agents/chatpnpm add @webmcp-agents/chatyarn add @webmcp-agents/chatbun add @webmcp-agents/chatnub add @webmcp-agents/chataube add @webmcp-agents/chatnpm install @ai-sdk/anthropicpnpm add @ai-sdk/anthropicyarn add @ai-sdk/anthropicbun add @ai-sdk/anthropicnub add @ai-sdk/anthropicaube add @ai-sdk/anthropicThen two pieces: the widget inside your provider, and the /api/chat route. The highlighted lines are the ones you
add.
import { AgentChat } from "@webmcp-agents/chat";
import { WebAgentsProvider } from "@webmcp-agents/react";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import "@webmcp-agents/core/cursor.css";
import "@webmcp-agents/chat/chat.css";
import { shopper } from "@/agents";
import { tools } from "@/tools";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<WebAgentsProvider tools={tools}>
<App />
<AgentChat
agent={shopper}
// In development the provider's devtools launcher has
// the other corner.
position={import.meta.env.DEV ? "bottom-left" : "bottom-right"}
/>
</WebAgentsProvider>
</StrictMode>,
);In development, the Vite plugin serves /api/chat from the dev server. Add it to the config you have:
import { webAgentsChatDev } from "@webmcp-agents/chat/vite";
import { agents } from "./src/agents";
// In defineConfig({ … }):
plugins: [react(), tailwindcss(), webAgentsChatDev({ agents })], It reads the key from .env.local on the server side, and refuses to start when a VITE_ variable is named like a
secret. In production, /api/chat is a function of your host’s. On Vercel, a file in api/ is one:
import { createChatHandler } from "@webmcp-agents/chat/server";
import { agents } from "../src/agents";
// /api/chat in production: any host that runs a web-standard
// (Request) => Response function. It reads the key and
// WEB_AGENTS_MODEL from process.env, on the server.
const handler = createChatHandler({ agents });
export const GET = handler;
export const POST = handler;"use client";
import { AgentChat } from "@webmcp-agents/chat";
import { WebAgentsProvider } from "@webmcp-agents/react";
import type { ReactNode } from "react";
import "@webmcp-agents/chat/chat.css";
import { shopper } from "@/agents";
import { tools } from "@/tools";
const DEV = process.env.NODE_ENV === "development";
export function Providers({ children }: { children: ReactNode }) {
return (
<WebAgentsProvider tools={tools}>
{children}
<AgentChat
agent={shopper}
// In development the provider's devtools launcher has
// the other corner.
position={DEV ? "bottom-left" : "bottom-right"}
/>
</WebAgentsProvider>
);
}/api/chat is a route handler, in development and in production:
import { createChatHandler } from "@webmcp-agents/chat/server";
import { agents } from "@/agents";
// A route handler runs on the server only. It reads the key and
// WEB_AGENTS_MODEL from process.env.
const handler = createChatHandler({ agents });
export const GET = handler;
export const POST = handler;To make sure the key can never reach a client component, read it in a module that starts with
import "server-only" and taint it with React’s experimental_taintUniqueValue, as examples/nextjs/lib/env.server.ts
does (Field Store on Next.js). The taint APIs are behind a flag: set experimental: { taint: true } in
next.config.ts.
import { AgentChat } from "@webmcp-agents/chat";
import { WebAgentsProvider } from "@webmcp-agents/react";
import type { ReactNode } from "react";
import "@webmcp-agents/chat/chat.css";
import { shopper } from "@/agents";
import { tools } from "@/tools";
const DEV = import.meta.env.DEV;
export function Providers({ children }: { children: ReactNode }) {
return (
<WebAgentsProvider tools={tools}>
{children}
<AgentChat
agent={shopper}
// In development the provider's devtools launcher has
// the other corner.
position={DEV ? "bottom-left" : "bottom-right"}
/>
</WebAgentsProvider>
);
}/api/chat is a server route, in development and in production:
import { createFileRoute } from "@tanstack/react-router";
import { createChatHandler } from "@webmcp-agents/chat/server";
import { agents } from "@/agents";
// Reads the key and WEB_AGENTS_MODEL from process.env, on the server.
const handler = createChatHandler({ agents });
export const Route = createFileRoute("/api/chat")({
server: {
handlers: {
GET: ({ request }) => handler(request),
POST: ({ request }) => handler(request),
},
},
});No example app runs TanStack Start: this route is type-checked against @tanstack/react-start 1.169, not run. The
server-route API has moved between Start releases, so check it against the version you install.
Start’s dev server serves the route, so no Vite plugin is needed for it. You can still add webAgentsChatDev() for its
secret guard (it refuses to start when a VITE_ variable is named like a secret); give its path option something
unused, so it never shadows the real route.
import { AgentChat } from "@webmcp-agents/chat";
import { WebAgentsProvider } from "@webmcp-agents/react";
import type { ReactNode } from "react";
import "@webmcp-agents/chat/chat.css";
import { shopper } from "@/agents";
import { tools } from "@/tools";
const DEV = import.meta.env.DEV;
export function Providers({ children }: { children: ReactNode }) {
return (
<WebAgentsProvider tools={tools}>
{children}
<AgentChat
agent={shopper}
// In development the provider's devtools launcher has
// the other corner.
position={DEV ? "bottom-left" : "bottom-right"}
/>
</WebAgentsProvider>
);
}/api/chat is an endpoint rendered on demand, so the site needs a server adapter (Node, Vercel, Netlify…):
import type { APIRoute } from "astro";
import { getSecret } from "astro:env/server";
import { createChatHandler } from "@webmcp-agents/chat/server";
import { agents } from "@/agents";
// A server endpoint, not a static page: it needs an adapter
// (Node, Vercel, Netlify, …).
export const prerender = false;
// getSecret reads .env.local in development and the server's
// environment in production. Add your provider's key if it is not
// Anthropic's.
const handler = createChatHandler({
agents,
env: {
ANTHROPIC_API_KEY: getSecret("ANTHROPIC_API_KEY"),
WEB_AGENTS_MODEL: getSecret("WEB_AGENTS_MODEL"),
},
});
export const GET: APIRoute = ({ request }) => handler(request);
export const POST: APIRoute = ({ request }) => handler(request);This site is built with Astro but has no chat, so this endpoint is type-checked, not run in a real app.
<AgentChat agent={shopper}> needs no adapter: it talks to /api/chat. The route serves the same agents the page
uses, because the browser sends only the agent’s id and the server reads the instructions from its own copy.
The key
Put the key in .env.local, at the root of the project:
ANTHROPIC_API_KEY=sk-ant-…
It is read on the server only. Never give it a public prefix: VITE_ (Vite, TanStack Start), NEXT_PUBLIC_
(Next.js) or PUBLIC_ (Astro). Those variables are copied into the JavaScript the browser downloads.
Without a key the widget says “Chat isn’t configured on this site”, and the rest of the page works as before.
Pick a model
One variable on the server picks the model, as "<provider>/<model>", and the provider’s own SDK is used when its
key is set (install it beside @webmcp-agents/chat). Without the SDK, the AI Gateway built into the AI SDK serves any
id with one key.
WEB_AGENTS_MODEL=openai/gpt-5.5
OPENAI_API_KEY=sk-…
| Prefix | Package | Key |
|---|---|---|
anthropic/ (the default: anthropic/claude-opus-5-5) |
@ai-sdk/anthropic |
ANTHROPIC_API_KEY |
openai/ |
@ai-sdk/openai |
OPENAI_API_KEY |
google/ |
@ai-sdk/google |
GOOGLE_GENERATIVE_AI_API_KEY |
deepseek/ |
@ai-sdk/deepseek |
DEEPSEEK_API_KEY |
xai/ |
@ai-sdk/xai |
XAI_API_KEY |
mistral/ |
@ai-sdk/mistral |
MISTRAL_API_KEY |
openrouter/<vendor>/<model> |
@openrouter/ai-sdk-provider |
OPENROUTER_API_KEY |
any other a/b id |
none (the AI Gateway) | AI_GATEWAY_API_KEY |
An agent’s own model (Define an agent) beats this default.
How a turn runs
The server runs one model step per request; tools run in the browser between steps.
- The widget sends the conversation and the page’s current tools (names, descriptions, schemas) to
/api/chat. - The model answers with text or with tool calls.
- The widget runs each tool call on the page, under the cursor, and shows it in the conversation.
- It waits for the page’s tools to settle (a navigation brings new ones), then sends the results back for the next step.
A failed tool never ends the run: the result goes back to the model with its error code. The person’s Stop cancels the call in progress. If the run itself fails (the model, the network), Try again resumes from the last tool call that finished, so nothing is done twice.
The route refuses browser requests from other sites, but anyone can still call it directly. A public deployment needs
a guard with rate limits and a daily budget: Deploy has one.
Try it
Start the app with the key in .env.local, open the chat (bottom left in development) and ask:
What’s in my cart?
The model calls get_cart, the call shows in the conversation, and the answer names the Ocean Tee. On the page with
your <AddToCart /> form, ask “Add two more in size L”: the cursor types into the form, and the chat shows the call.
Options you’ll use most
<AgentChat> prop |
What it is for |
|---|---|
agent |
The agent: its instructions, its tools and its helpers. Default: every tool on the page. |
layout |
"docked" (default), a full-height panel the page makes room for, or "floating". A bottom sheet on phones. |
position |
"bottom-right" (default) or "bottom-left". |
suggestions |
Starter prompts. Default: made from the page’s tools. |
adapters |
Several models with a switcher, such as Claude and an on-device model. |
createChatHandler option |
What it is for |
|---|---|
agents |
The top-level agents it serves. |
model |
The default model. Default: WEB_AGENTS_MODEL, else anthropic/claude-opus-5-5. |
maxSteps |
Tool steps per message. Default 8. |
guard, beforeModel |
Rate limits, origin checks, budgets. |
Every option: <AgentChat>, createChatHandler
and webAgentsChatDev. To render the chat with your own shadcn/ui components
instead of the built-in widget, see shadcn chat blocks.