
What if your CMS admin could answer questions about its own data? Not a scripted search box — a real agent that reads your collections, explains what it finds, and asks for permission before it changes anything. And what if that whole experience lived inside the admin panel you already log into, using the same database you already run?
This tutorial builds exactly that: an AI assistant embedded in a Payload CMS admin panel. The agent is powered by Mastra and talks to your collections through the Payload MCP plugin. Every logged-in admin gets private sessions, conversation memory lives in the same MongoDB your CMS already uses, and the LLM providers are configured from inside the CMS itself — adding a model is a form, not a deployment.
What you will build
- A Mastra agent (cms-agent) that queries your collections and, with approval, creates or updates records
- Collections exposed as MCP tools through @payloadcms/plugin-mcp, read by one long-lived MCP client pinned to the loopback address
- LLM providers and models managed as a Payload collection, so an admin can add one without a deploy
- Chat history and sessions stored in MongoDB through Mastra's MongoDBStore
- Per-admin session isolation (each user sees only their own threads)
- Native tool approvals surfaced in the chat UI — reads run, writes wait for a human
- A chat UI rendered as a Payload admin view, themed by Payload's own design tokens
Throughout the tutorial I will use a small e-commerce example with products, orders and customers collections. Swap in whatever collections your own project has — the pattern is identical.
1. Project setup and dependencies
Start from any Payload 3 + Next.js project (App Router) with a MongoDB database — Payload 3.87 and Mastra 1.67 are used here. Then install the Mastra packages, the AI SDK packages for streaming, and the Payload MCP plugin:
1npm install @mastra/core @mastra/mcp @mastra/memory @mastra/mongodb @mastra/ai-sdk ai @ai-sdk/react @payloadcms/plugin-mcp
The two key pieces: @mastra/mcp lets the agent connect to MCP servers (here, your own CMS), and @mastra/ai-sdk bridges Mastra's agent runtime to the Vercel AI SDK protocol so the chat UI can stream messages with tool-call parts.
2. Expose your collections as MCP tools
The Payload MCP plugin turns collections into Model Context Protocol tools. Configure it in payload.config.ts. For each collection choose the operations to expose (find, create, update) and, crucially, write a description the agent reads to understand what the collection holds and how to create records correctly:
1import { buildConfig } from "payload";2import { mongooseAdapter } from "@payloadcms/db-mongodb";3import { mcpPlugin } from "@payloadcms/plugin-mcp";4import { hasRole, isAdmin } from "./access-types";56export default buildConfig({7 // ... your collections: products, orders, customers ...8 db: mongooseAdapter({ url: process.env.DATABASE_URI || "" }),910 plugins: [11 mcpPlugin({12 collections: {13 products: {14 enabled: { find: true, create: true, update: true },15 description:16 "Products in the catalog. REQUIRED to create: name, price, category.",17 },18 orders: {19 enabled: { find: true, create: true, update: true },20 description:21 "Customer orders. REQUIRED to create: customer (a valid customers id), items[] and total. Creating an order recalculates the customer balance.",22 },23 customers: {24 enabled: { find: true, create: true, update: true },25 description: "Registered customers with contact info and order history.",26 },27 },28 // The plugin's own API-key collection: admins only — but the MCP request29 // path must still read keys, and it queries without overrideAccess.30 overrideApiKeyCollection: (collection) => ({31 ...collection,32 access: {33 ...(collection.access ?? {}),34 read: ({ req }) =>35 (req as { payloadAPI?: string }).payloadAPI === "MCP" ||36 hasRole(req.user, "admin"),37 create: isAdmin,38 update: isAdmin,39 delete: isAdmin,40 admin: ({ req }) => hasRole(req.user, "admin"),41 },42 }),43 }),44 ],45});
The plugin exposes the MCP endpoint at /api/mcp — in a Payload 3 app this is handled by the standard Payload REST catch-all route (src/app/(payload)/api/[...slug]/route.ts), so no extra route file is needed. Each collection becomes namespaced tools like findProducts, createOrders and updateCustomers.
Two details that pay off later. First, the descriptions are the agent's manual: include required fields, relationships (a valid id from which collection) and any side effects that run on write — for example that creating an order recalculates the customer balance — because otherwise the model tries to apply those effects itself with a second tool call. Second, lock down the plugin's API-key collection with overrideApiKeyCollection so only admins can manage keys, while the MCP request path can still read them (the check is req.payloadAPI === "MCP").
3. One long-lived MCP client, pinned to the loopback address
The first version of this integration created a new MCPClient for every chat request and closed it when the stream finished. That works, but it re-discovers the entire tool list on every message, and it forces you to wrap the response stream just to know when disconnecting is safe. The production shape is simpler: create the client once per server process, discover the tools once, and reuse both.
1// src/lib/chat-mcp.ts2import { MCPClient } from "@mastra/mcp";34/**5 * The Payload MCP server runs in the SAME Next.js process. Connect over the6 * loopback address (the public domain goes through a proxy and breaks the MCP7 * transport), and keep ONE client for the whole server process instead of8 * reconnecting on every chat turn.9 */10const SERVER_NAME = "payload";1112function mcpUrl(): URL {13 const raw =14 process.env.MCP_SERVER_URL ||15 `http://127.0.0.1:${process.env.PORT || 3000}/api/mcp`;16 return new URL(raw);17}1819/** Tools whose names start with these verbs never mutate data. */20function isReadOnlyTool(toolName: string): boolean {21 return /^(find|read|list|get|search)/i.test(toolName);22}2324let client: MCPClient | null = null;25let toolsetsPromise: Promise<Awaited<ReturnType<MCPClient["listToolsets"]>>> | null = null;2627export function getPayloadMcpClient(): MCPClient {28 if (client) return client;2930 const apiKey = process.env.MCP_API_KEY;31 if (!apiKey) {32 throw new Error(33 "MCP_API_KEY is not configured. Generate a Payload MCP API key and set it in the environment.",34 );35 }3637 client = new MCPClient({38 id: "payload-mcp",39 servers: {40 [SERVER_NAME]: {41 url: mcpUrl(),42 requestInit: { headers: { Authorization: `Bearer ${apiKey}` } },43 // Dynamic approval: the MCP server's own annotations win when present,44 // otherwise fall back to the tool-name heuristic.45 requireToolApproval: ({ toolName, annotations }) => {46 if (annotations?.readOnlyHint) return false;47 if (annotations?.destructiveHint) return true;48 return !isReadOnlyTool(toolName);49 },50 },51 },52 });5354 // If the server's tool list changes (e.g. an admin toggles a capability),55 // drop the cache so the next request re-discovers the tools.56 try {57 void client.tools58 .onListChanged(SERVER_NAME, () => { toolsetsPromise = null; })59 .catch(() => undefined);60 } catch {61 // Notification subscription is best-effort.62 }6364 return client;65}6667/** Payload toolsets, cached for the lifetime of the process. */68export async function getPayloadToolsets(): Promise<69 Awaited<ReturnType<MCPClient["listToolsets"]>>70> {71 if (!toolsetsPromise) {72 toolsetsPromise = getPayloadMcpClient()73 .listToolsets()74 .catch((error) => {75 // Don't cache failures — allow the next request to retry.76 toolsetsPromise = null;77 throw error;78 });79 }80 return toolsetsPromise;81}
Three things are happening here:
- Loopback URL. When the agent runs inside the same Next.js process as the Payload MCP server, connect to http://127.0.0.1:PORT/api/mcp. Deriving the URL from request.url sends the call out through your public domain — and through whatever reverse proxy or CDN sits in front of it — which breaks the MCP transport.
- Cached toolsets. listToolsets() runs once per process and concurrent callers share a single discovery request. Failures are not cached, so the next request retries; if the server's tool list changes (an admin toggles a capability), onListChanged clears the cache.
- Annotation-aware approvals. The approval predicate prefers the MCP server's own annotations: readOnlyHint means run it, destructiveHint means always ask. Only when neither is present does it fall back to a tool-name heuristic.
Because the client is long-lived there is nothing to tear down per request, so the route handler can return the Mastra stream directly. No ReadableStream wrapper, and no worry about Next's after() closing the connection while the agent is still executing tool calls.
4. Authentication and per-admin threads in one place
Every chat route needs the same three answers: is this an authenticated admin, what is their thread prefix, and is the thread they are asking about theirs. Put them in one module so the rule cannot drift between routes.
1// src/lib/chat-auth.ts2import configPromise from "@payload-config";3import { hasRole } from "@/access-types";4import { getPayload } from "payload";56/**7 * The AI Assistant is admin-only. Every chat route (chat, threads, undo, clear,8 * discard) authenticates through here, so the rule lives in exactly one place.9 */10export const RESOURCE_ID = "cms";11export type ChatUser = { id: string | number };1213export async function authenticateChatAdmin(request: Request): Promise<ChatUser | null> {14 const payload = await getPayload({ config: configPromise });15 try {16 const { user } = await payload.auth({ headers: request.headers });17 if (user?.collection === "users" && hasRole(user, "admin")) {18 return { id: user.id };19 }20 return null;21 } catch {22 return null;23 }24}2526export function threadPrefix(user: ChatUser): string {27 return `admin-${user.id}`;28}2930/**31 * Threads are namespaced per admin: `admin-<id>` (default) or32 * `admin-<id>-<timestamp>` for explicitly created sessions. A provided id is33 * only honoured when it belongs to this admin.34 */35export function resolveThreadId(user: ChatUser, provided?: string | null): string {36 const prefix = threadPrefix(user);37 if (typeof provided === "string" && provided && provided.startsWith(prefix)) {38 return provided;39 }40 return prefix;41}4243export function isOwnThread(user: ChatUser, threadId?: string | null): threadId is string {44 return (45 typeof threadId === "string" &&46 threadId.length > 0 &&47 threadId.startsWith(threadPrefix(user))48 );49}
Note the role check. The MCP API key the agent uses is admin-scoped, so the chat endpoint must be admin-only too — otherwise a manager with a valid session could ask the agent to read collections they cannot open in the admin UI. Thread ids are namespaced admin-<userId> (or admin-<userId>-<timestamp> for explicitly created sessions), and resolveThreadId only honours an id that starts with the caller's own prefix.
5. Let admins configure the LLM providers
Hard-coding a model string means a redeploy every time you want to try another model or provider. Instead, add an llm-providers collection to Payload: a preset select (OpenAI, OpenRouter, Groq, OpenCode Go, or custom), a base URL, an API key stored in the database with field-level access so only admins can read it, the list of model ids on your plan, and an enabled toggle. A small "Fetch models" UI field can call the provider's /models endpoint and write the ids back, so nobody types them by hand.
1// src/collections/LLMProviders.ts — admins manage models from the admin UI2export const LLMProviders: CollectionConfig = {3 slug: "llm-providers",4 admin: { useAsTitle: "title" },5 access: { read: isAdmin, create: isAdmin, update: isAdmin, delete: isAdmin },6 fields: [7 {8 name: "preset",9 type: "select",10 defaultValue: "custom",11 options: LLM_PROVIDER_PRESETS.map((p) => ({ label: p.label, value: p.value })),12 // fills Name / Slug / Base URL client-side (e.g. "OpenCode Go")13 },14 { name: "title", type: "text", required: true },15 // Slug prefixes model ids: "openai" -> openai/gpt-4o16 { name: "slug", type: "text", required: true, unique: true },17 { name: "baseUrl", type: "text", required: true }, // https://api.openai.com/v118 {19 name: "apiKey",20 type: "text",21 required: true,22 // Stored in the database, readable only by admins — never by managers,23 // never by the chat UI.24 access: { read: isAdminFieldLevel, update: isAdminFieldLevel },25 },26 { name: "models", type: "text", hasMany: true }, // the model ids on your plan27 { name: "enabled", type: "checkbox", defaultValue: true },28 { name: "fetchModels", type: "ui" }, // "Fetch models" button -> provider /models29 ],30};
The routing logic that turns a selected model id into a Mastra model config is the interesting part. A model id is providerSlug/modelId; the resolver loads the provider, validates the model, and returns the form Mastra understands:
1// src/lib/llm-providers.ts (core)2export async function resolveModelConfig(3 payload: Payload,4 model: string, // "opencode-go/deepseek-v4-pro"5 options?: { sessionId?: string },6): Promise<OpenAICompatibleConfig | null> {7 const { slug, modelId } = splitModel(model); // providerSlug/modelId8 if (!slug || !modelId) return null;910 const provider = await findEnabledProvider(payload, slug);11 if (!provider?.baseUrl || !provider?.apiKey) return null;12 if (provider.models?.length && !provider.models.includes(modelId)) return null;1314 const headers = buildProviderHeaders(provider.baseUrl, options?.sessionId);15 const withHeaders = headers ? { headers } : {};1617 // Registry-known providers are resolved by Mastra, which picks the correct18 // endpoint per model (chat/completions, /responses or /messages) from its19 // bundled provider registry. Passing `url` would force the plain20 // OpenAI-compatible chat endpoint, so we deliberately omit it.21 if (getProviderConfig(slug)) {22 return { providerId: slug, modelId, apiKey: provider.apiKey, ...withHeaders };23 }2425 // Custom providers fall back to an explicit OpenAI-compatible base URL.26 return {27 id: `${slug}/${modelId}`,28 url: provider.baseUrl.replace(/\/+$/, ""),29 apiKey: provider.apiKey,30 ...withHeaders,31 };32}
- Registry-known providers are returned as { providerId, modelId } and deliberately without a url. Mastra's bundled provider registry then picks the right endpoint per model — some providers serve different models on chat/completions, /responses and /messages — whereas passing a url would force everything down the plain OpenAI-compatible path.
- Custom, OpenAI-compatible providers fall back to { id, url }: the plain chat/completions base URL.
- Some providers need more than a key. OpenCode, for example, rejects requests without a stable x-opencode-session header and asks clients to identify themselves with their own User-Agent — so the resolver derives the right headers from the base URL host and passes the chat thread id as the session, keeping routing and caching stable for the whole conversation.
The chat UI reads the same collection through a server action to build a model picker grouped by provider, and remembers each admin's choice in their own user document — so switching models is a dropdown, not a deployment.
6. Define the Mastra agent
The agent's model is resolved per request from the request context, and there is intentionally no built-in default: if the route cannot resolve a model it returns a 400 instead of silently falling back to something the admin did not choose.
1// src/mastra/index.ts2import { Mastra } from "@mastra/core";3import { mastraStore } from "./memory";4import { cmsAgent } from "./agents/cms-agent";56export const mastra = new Mastra({ agents: { cmsAgent }, storage: mastraStore });78// src/mastra/agents/cms-agent.ts9import { Agent } from "@mastra/core/agent";10import type { OpenAICompatibleConfig } from "@mastra/core/llm";11import { memory } from "../memory";12import { webSearch } from "../tools/webSearch";1314export const cmsAgent = new Agent({15 id: "cms-agent",16 name: "CMS Assistant",17 model: ({ requestContext }): string | OpenAICompatibleConfig => {18 // The route resolves the selected model against the admin-configured LLM19 // providers and sets it on the request context as an OpenAI-compatible20 // config ({ providerId, modelId, apiKey } | { id, url, apiKey }).21 // There is intentionally no built-in default.22 const cfg = requestContext?.get?.("modelConfig");23 if (cfg) return cfg as OpenAICompatibleConfig;24 const selected = requestContext?.get?.("model");25 return typeof selected === "string" && selected ? selected : "";26 },27 memory,28 tools: { webSearch },29 defaultOptions: { maxSteps: 12 },30 instructions: `31You are the AI assistant for this CMS.3233TOOLS34- findProducts, findOrders, findCustomers: read data. Safe, no approval needed.35- create*/update* tools: write data. Gated by the platform.3637WORKFLOW381. Reads are safe. Call the find tools directly instead of describing the query.39 Filters MUST be passed inside \`where\` as a JSON string, e.g.40 where = '{"status":{"equals":"paid"}}'. Use a small limit (5-10).412. Writes are gated: every create/update pauses for the user to Approve or42 Decline in the chat. State briefly what you are about to change and let the43 approval prompt handle consent. Never try to bypass it.443. NEVER run the same write tool twice for one request. If a write fails, report45 the exact error and stop — do not retry automatically.4647SIDE EFFECTS ENFORCED BY THE PLATFORM (do not replicate with extra tool calls)48- Creating an order recalculates the customer balance.49- Therefore do NOT call updateCustomers after createOrders. One write per intent.5051STYLE52- Be professional, concise and practical. Answer in the language the user writes in.53- When you are missing a required value, ask one focused question instead of guessing.54`.trim(),55});
Two things worth calling out. The instructions are the guardrails: they describe each collection, mark the find tools as safe and the write tools as gated, and warn about side effects (here: creating an order already recalculates the customer balance, so the agent must not helpfully call the customer update tool afterwards). And maxSteps bounds the agent loop, so a confused model cannot spin forever.
You can also register your own tools next to the MCP ones. A web search tool is worth having, because the agent cannot know today's exchange rate or read vendor documentation:
1// src/mastra/tools/webSearch.ts (excerpt)2export const webSearch = createTool({3 id: "webSearch",4 description:5 "Search the web for current, factual information. Use before answering questions " +6 "about recent events, technologies or documentation. Falls back to another provider " +7 "if one is rate-limited. Returns a few concise results (title, URL, short snippet).",8 requireApproval: false,9 inputSchema: z.object({10 query: z.string().min(3).describe("The search query, 3+ characters. Be specific."),11 maxResults: z.number().min(1).max(5).default(3),12 }),13 execute: async ({ query, maxResults }) => {14 const { provider, results } = await searchWeb(query, maxResults ?? 3);15 return { provider, query, results };16 },17});1819// Tavily/Firecrawl snippets are full-page text with lots of markdown noise.20// Collapse whitespace and truncate so the tool result stays small — large blobs21// get dropped by some OpenAI-compatible providers and read as "no results".22function cleanContent(value: string): string {23 const collapsed = value.replace(/\s+/g, " ").trim();24 return collapsed.length > 300 ? `${collapsed.slice(0, 300)}…` : collapsed;25}
Two production details about tool output: truncate it (300 characters per snippet here) and keep the shape small. Large tool results get dropped by some OpenAI-compatible providers, and the model then behaves as if the search returned nothing.
7. Memory on the same MongoDB
Here is where things get nice. Payload already stores your content in MongoDB via mongooseAdapter. Mastra's MongoDBStore accepts the same DATABASE_URI environment variable, so conversation memory lands in the same database — just a separate database name (mastra by default) inside it. Zero extra infrastructure.
1// src/mastra/memory.ts2import { Memory } from "@mastra/memory";3import { MongoDBStore } from "@mastra/mongodb";45export const mastraStore = new MongoDBStore({6 id: "mastra-storage",7 uri: process.env.DATABASE_URI || "", // the same MongoDB as Payload8 dbName: process.env.MASTRA_DB_NAME || "mastra",9});1011export const memory = new Memory({12 storage: mastraStore,13 options: {14 lastMessages: 20, // context window of recent history15 generateTitle: true, // auto-title each session from the first message16 },17});
generateTitle is a small delight: after the first exchange, Mastra generates a title for the thread asynchronously, which the UI polls and shows in the sessions list.
8. The chat API route
The route handler at /api/chat does five things: authenticate the admin, resolve the requested model into a provider config, fetch the cached Payload toolsets, stream the agent run, and return that stream as-is.
1// src/app/api/chat/route.ts2export const maxDuration = 60;3export const runtime = "nodejs";4export const dynamic = "force-dynamic";56export async function POST(request: Request) {7 const user = await authenticateChatAdmin(request);8 if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });910 const body = await request.json().catch(() => null);11 const messages = Array.isArray(body?.messages) ? body.messages : [];12 if (messages.length === 0) {13 return NextResponse.json({ error: "No messages provided" }, { status: 400 });14 }1516 const threadId = resolveThreadId(user, body?.data?.threadId);17 const selectedModel = typeof body?.data?.model === "string" ? body.data.model : "";18 if (!selectedModel) {19 return NextResponse.json({ error: "No model selected" }, { status: 400 });20 }2122 const payload = await getPayload({ config: configPromise });23 const modelConfig = await resolveModelConfig(payload, selectedModel, {24 sessionId: threadId, // stable per-conversation routing/caching key25 });26 if (!modelConfig) {27 return NextResponse.json(28 { error: "Selected model is unavailable. Check LLM Providers." },29 { status: 400 },30 );31 }3233 let toolsets;34 try {35 toolsets = await getPayloadToolsets();36 } catch (error) {37 const detail = error instanceof Error ? error.message : "Unknown error";38 return NextResponse.json({ error: "MCP server unavailable", detail }, { status: 503 });39 }4041 const requestContext = new RequestContext();42 requestContext.set("modelConfig", modelConfig);43 requestContext.set("threadId", threadId);4445 try {46 const stream = await handleChatStream({47 mastra,48 agentId: "cms-agent",49 version: "v7",50 params: {51 messages,52 memory: { thread: threadId, resource: RESOURCE_ID },53 requestContext,54 abortSignal: request.signal, // Stop really cancels the provider call55 },56 defaultOptions: { toolsets, maxSteps: 12 },57 });5859 // The MCP client is long-lived, so there is no connection to tear down:60 // return the Mastra stream as-is.61 return createUIMessageStreamResponse({ stream });62 } catch (error) {63 const message = error instanceof Error ? error.message : String(error);64 if (/ECONNRESET|MongoNetworkError|agentic-loop/i.test(message)) {65 return NextResponse.json(66 { error: "Storage temporarily unavailable, please retry." },67 { status: 503 },68 );69 }70 return NextResponse.json({ error: "Chat failed", detail: message }, { status: 500 });71 }72}
Details worth copying:
- Send only the newest message. Server-side memory is the source of truth, so the client posts messages.slice(-1) — the latest user turn, or the assistant message carrying a tool-approval response. Sending the whole history duplicates every previous turn in the model's context.
- Pass an abortSignal tied to request.signal so that hitting Stop actually cancels the provider request, not just the UI.
- Keep MCP failures separate from model failures. If the tool list cannot be fetched, return 503 with "MCP server unavailable" and the underlying detail — otherwise every symptom looks like "the assistant is broken".
- Treat MongoNetworkError and ECONNRESET as 503 and let the admin retry; a transient storage hiccup should not look like a bug in the chat.
The GET handler returns a thread's history. Mastra messages are not in the shape UI messages expect, so convert them with toAISdkMessages({ version: "v7" }) — and remember that UIMessage has no createdAt, so surface the timestamp through message metadata if you want times in the UI.
1// src/app/api/chat/route.ts (GET: history for a thread)2export async function GET(request: Request) {3 const user = await authenticateChatAdmin(request);4 if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });56 const { searchParams } = new URL(request.url);7 const threadId = resolveThreadId(user, searchParams.get("threadId"));89 const agent = mastra.getAgentById("cms-agent");10 const memory = await agent.getMemory();1112 let messages = [];13 try {14 const response = await memory?.recall({ threadId, resourceId: RESOURCE_ID });15 messages = response?.messages ?? [];16 } catch {17 // no history yet18 }1920 const uiMessages = toAISdkMessages(messages, { version: "v7" });2122 // UIMessage carries no createdAt, so surface it through metadata for the UI.23 const createdAtById = new Map(messages.map((m) => [String(m.id), m.createdAt]));24 const enriched = uiMessages.map((m) => {25 const ts = createdAtById.get(String(m.id));26 if (!ts) return m;27 const meta = (m as { metadata?: unknown }).metadata;28 return {29 ...m,30 metadata: { ...(typeof meta === "object" && meta ? meta : {}), createdAt: ts },31 };32 });3334 return NextResponse.json(enriched);35}
9. Sessions API
A companion route at /api/chat/threads manages sessions. Let the storage layer paginate and sort, and apply the per-admin prefix to the page you get back — listing every thread and filtering in memory gets slower with every conversation:
1// src/app/api/chat/threads/route.ts2export async function GET(request: Request) {3 const user = await authenticateChatAdmin(request);4 if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });56 const { searchParams } = new URL(request.url);7 const page = Math.max(0, parseInt(searchParams.get("page") ?? "0", 10) || 0);8 const perPage = Math.min(50, Math.max(1, parseInt(searchParams.get("perPage") ?? "20", 10) || 20));910 const memory = await (await mastra.getAgentById("cms-agent")).getMemory();11 if (!memory) return NextResponse.json({ threads: [], total: 0, page, hasMore: false });1213 try {14 // Storage filters by resource server-side (bounded result set) and sorts;15 // the per-admin `admin-<id>` prefix is applied to the page we got back.16 const result = await memory.listThreads({17 page,18 perPage,19 orderBy: { field: "updatedAt", direction: "DESC" },20 filter: { resourceId: RESOURCE_ID },21 });2223 const prefix = threadPrefix(user);24 const own = (result.threads ?? []).filter((t) => String(t.id).startsWith(prefix));2526 return NextResponse.json({27 threads: own.map((t) => ({28 id: t.id,29 title: t.title || (t.metadata as { title?: string })?.title || null,30 createdAt: t.createdAt,31 updatedAt: t.updatedAt,32 })),33 total: result.total,34 page: result.page,35 hasMore: result.hasMore,36 });37 } catch {38 return NextResponse.json({ threads: [], total: 0, page, hasMore: false });39 }40}4142export async function POST(request: Request) {43 const user = await authenticateChatAdmin(request);44 if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });45 return NextResponse.json({ threadId: `admin-${user.id}-${Date.now()}` });46}4748export async function DELETE(request: Request) {49 const user = await authenticateChatAdmin(request);50 if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });5152 const threadId = new URL(request.url).searchParams.get("threadId");53 if (!isOwnThread(user, threadId)) {54 return NextResponse.json({ error: "Invalid or unauthorized thread" }, { status: 400 });55 }5657 const memory = await (await mastra.getAgentById("cms-agent")).getMemory();58 await memory!.deleteThread(threadId);59 return NextResponse.json({ deleted: true });60}
Because the prefix check lives in isOwnThread(), every mutating route (delete, undo, clear, discard) rejects a thread id that does not belong to the caller. Nobody can read or wipe another admin's conversation even though all threads share one resource id.
10. Human-in-the-loop approvals, natively
Approvals used to mean a separate endpoint that polled for suspended runs, resumed them, and failed with a 404 when the run had already expired. If your Mastra and AI SDK versions support it, the native flow removes that route entirely: requireToolApproval marks the tool call, the stream emits a tool part in the approval-requested state, and the UI answers with addToolApprovalResponse.
1// src/components/admin/ChatUI.tsx (approvals, native AI SDK v7)2const { messages, sendMessage, status, addToolApprovalResponse } = useChat({3 transport,4 // Re-send automatically as soon as the last approval is answered.5 sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,6});78const pendingApproval = useMemo(() => {9 for (const m of [...messages].reverse()) {10 for (const p of [...(m.parts ?? [])].reverse()) {11 if (!isToolUIPart(p) && !isDynamicToolUIPart(p)) continue;12 if (p.state !== "approval-requested") continue;13 if (p.approval?.isAutomatic) continue; // auto-approved tools never block14 return {15 toolName: getToolName(p).replace(/^payload_/, ""),16 approvalId: p.approval.id,17 input: p.input as Record<string, unknown> | undefined,18 };19 }20 }21 return null;22}, [messages]);2324const respondApproval = (approved: boolean) => {25 if (!pendingApproval) return;26 addToolApprovalResponse({ id: pendingApproval.approvalId, approved });27};2829// ...and the banner that renders while a write waits for a human:30{pendingApproval && (31 <div role="status">32 <div>Tool requires approval</div>33 <div>{pendingApproval.toolName} with arguments:</div>34 <pre>{JSON.stringify(pendingApproval.input ?? {}, null, 2)}</pre>35 <button onClick={() => respondApproval(true)}>Approve</button>36 <button onClick={() => respondApproval(false)}>Decline</button>37 </div>38)}
The pieces that make it work:
- The approval is part of the message list, so it survives a page refresh — and the next request only has to carry the assistant message holding the response, not the whole history.
- sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses makes the client re-send the moment the last approval is answered, so the run resumes without an extra click.
- Skip automatic approvals in the banner (approval.isAutomatic) — a tool that is auto-approved should not block the composer with a question.
- Tool state maps cleanly to a chip: input-streaming and input-available → Running…, approval-requested → Waiting approval, approval-responded → Approved or Declined, output-available → Completed, output-error → Error.
This is what makes an admin-facing agent trustworthy: the model can browse anything, but it cannot mutate anything without an explicit human decision in the loop — and the exact tool arguments are on screen at the moment of that decision.
11. Session utility routes
Three small routes round out the UX. /api/chat/undo deletes the last user message and everything after it, so /undo can put that text back into the composer. /api/chat/clear wipes a thread. /api/chat/discard-last removes the partial assistant messages left behind when someone hits Stop — the client sends the exact ids to delete, so the server never has to guess:
1// src/app/api/chat/discard-last/route.ts2export async function POST(request: Request) {3 const user = await authenticateChatAdmin(request);4 if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });56 const body = await request.json().catch(() => ({}));7 if (!isOwnThread(user, body.threadId)) {8 return NextResponse.json({ error: "Invalid or unauthorized thread" }, { status: 400 });9 }1011 const memory = await (await mastra.getAgentById("cms-agent")).getMemory();1213 // Mastra's MongoDB snapshot cleanup can throw ECONNRESET on transient network14 // hiccups. The messages are already marked terminal, so a best-effort delete15 // is fine — don't surface it as an error.16 async function deleteMessagesSafe(mem, ids: string[]) {17 try {18 await mem.deleteMessages(ids);19 } catch (e) {20 const msg = e instanceof Error ? e.message : String(e);21 if (!/ECONNRESET|MongoNetworkError/i.test(msg)) throw e;22 }23 }2425 // The client sends the exact trailing assistant ids it wants removed, so we26 // never have to guess and never touch older history.27 if (Array.isArray(body.ids) && body.ids.length > 0) {28 const ids = body.ids.map(String).filter(Boolean).slice(0, 10);29 await deleteMessagesSafe(memory, ids);30 return NextResponse.json({ ok: true, deleted: true, count: ids.length });31 }3233 // Defensive fallback (no ids): delete only the most recent message if it is34 // an assistant reply. Never touches anything older.35 const { messages } = await memory.recall({ threadId: body.threadId, resourceId: RESOURCE_ID });36 const last = messages[messages.length - 1];37 if (last?.role === "assistant") {38 await deleteMessagesSafe(memory, [String(last.id)]);39 return NextResponse.json({ ok: true, deleted: true, count: 1 });40 }41 return NextResponse.json({ ok: true, deleted: false });42}
Note deleteMessagesSafe: MongoDB snapshot cleanup can throw ECONNRESET on a network hiccup. The messages are already terminal, so a best-effort delete is the right behaviour — swallowing that one error class keeps Stop from reporting a scary failure for something that already worked.
12. The UI: a Payload admin view
Register the chat component as a custom admin view so it appears at /admin/chat:
1// payload.config.ts — register the chat as an admin view2admin: {3 components: {4 views: {5 aiAssistant: {6 Component: "/components/admin/ChatUI#default",7 path: "/chat",8 },9 },10 },11}
The component is a client component that uses Payload's own hooks and CSS variables. useConfig() gives you config.routes.admin for a "back to admin" link, useAuth() gives you the current user so the view can refuse non-admins, and styling with var(--theme-bg), var(--theme-elevation-100), var(--theme-text), var(--style-radius-m) and var(--font-mono) means light/dark mode and future Payload restyles are inherited for free.
1// src/components/admin/ChatUI.tsx2"use client";34import { useChat } from "@ai-sdk/react";5import { useAuth, useConfig } from "@payloadcms/ui";6import { DefaultChatTransport } from "ai";78// Payload's own design tokens: themes, light/dark and future restyles for free.9const v = {10 bg: "var(--theme-bg)",11 text: "var(--theme-text)",12 e100: "var(--theme-elevation-100)",13 radiusM: "var(--style-radius-m)",14 mono: "var(--font-mono)",15 // ...16};1718export default function ChatUI() {19 const { config } = useConfig();20 const { user } = useAuth();21 const adminRoute = config.routes.admin;2223 // Built once: prepareSendMessagesRequest reads the current model/thread from24 // refs at send time, so changing them never requires a new transport.25 const transport = useMemo(26 () =>27 new DefaultChatTransport({28 api: "/api/chat",29 prepareSendMessagesRequest: ({ messages }) => ({30 body: {31 // Server-side memory is the source of truth: send only the newest32 // message (a user turn, or the assistant message carrying an33 // approval response). Sending full history duplicates context.34 messages: messages.slice(-1),35 data: { model: modelRef.current, threadId: threadRef.current },36 },37 }),38 }),39 [],40 );4142 if (user && !user.roles?.includes("admin")) {43 return <div>The AI Assistant is available to administrators only.</div>;44 }4546 // ... sessions sidebar, messages, tool cards, approval banner, composer47}
Everything else is ordinary chat-app work, but these are the parts admins actually notice:
- A sessions sidebar with relative timestamps, "load more" pagination and inline delete confirmation
- Tool cards for every call the agent makes, collapsed by default, showing input/output JSON and a state chip
- An approval banner with the raw arguments and Approve/Decline buttons
- A live status line — "Thinking…" or "Using findOrders…" — inside the assistant message while a run streams
- Slash commands with an autocomplete popup: /undo, /new, /clear, /help
- A grouped model picker that remembers the choice per admin, and a clear notice with a link to LLM Providers when no model is configured
- dir="auto" on messages and an RTL-aware composer, so an Arabic conversation renders correctly
Why this setup is great
- The UI is a Payload admin view. It inherits the admin theme, navigation, authentication and layout, and styling with Payload's CSS variables means light/dark mode and future design changes are picked up for free. No separate frontend to build or deploy.
- One MongoDB for everything. Payload's mongooseAdapter and Mastra's MongoDBStore share the same DATABASE_URI, so your content and your conversation memory live in the same database — easy backups, easy deploys, no extra service to run.
- No second login, and isolation is one prefix: admin-<userId>. Because the assistant is admin-only, managers and customers cannot reach the conversations or the MCP tools at all.
- Safe by default. Reads run automatically; every create and update is suspended and presented to a human for approval with the exact arguments visible.
- Models are configuration, not code. Add an OpenAI-compatible provider from the admin UI (its key protected at field level), fetch its model list, and it appears in the picker — the agent reads the per-request config from the request context.
- Cheap at scale. One MCP connection per server process instead of one per message, a cached tool list, and threads paginated by the storage layer.
Wrap-up
You now have an AI assistant that lives inside your admin panel, understands your data model through MCP, remembers conversations per admin, and never writes without asking. From here the natural next steps are: add more collections or globals to the MCP plugin, expose read-only reporting views, give the agent another in-process tool (search, HTTP calls, file generation), or point it at a RAG store so it can answer questions about your documentation too. The plumbing — a long-lived MCP client, per-request model resolution, memory, sessions, native approvals and a themed UI — stays exactly the same.