Skip to content
WebMCP Agents
Esc
↑↓navigate↵open⌘Jpreview
On this page

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/chat
pnpm add @webmcp-agents/chat
yarn add @webmcp-agents/chat
bun add @webmcp-agents/chat
nub add @webmcp-agents/chat
aube add @webmcp-agents/chat
npm install @ai-sdk/anthropic
pnpm add @ai-sdk/anthropic
yarn add @ai-sdk/anthropic
bun add @ai-sdk/anthropic
nub add @ai-sdk/anthropic
aube add @ai-sdk/anthropic

Then 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.

  1. The widget sends the conversation and the page’s current tools (names, descriptions, schemas) to /api/chat.
  2. The model answers with text or with tool calls.
  3. The widget runs each tool call on the page, under the cursor, and shows it in the conversation.
  4. 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.

Was this page helpful?