A Payload CMS plugin that adds an admin-panel chat view for reading, creating, and updating content through natural language. Schema-aware, streaming, and provider-agnostic via the Vercel AI SDK — Anthropic, OpenAI, Google, Mistral, Bedrock, etc. Multiple providers can be wired up at once with a model selector.
pnpm add @jhb.software/payload-chat-agent @ai-sdk/anthropicInstall whichever @ai-sdk/* provider package(s) you want to use alongside the plugin.
import { chatAgentPlugin } from '@jhb.software/payload-chat-agent'
import { createAnthropic } from '@ai-sdk/anthropic'
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
export default buildConfig({
plugins: [
chatAgentPlugin({
defaultModel: 'claude-sonnet-4-20250514',
model: (id) => anthropic(id),
}),
],
})Registers a chat view at /admin/chat and a streaming endpoint at /api/chat-agent/chat.
Provider API keys are never read from process.env by the plugin — pass them explicitly through your model factory.
| Option | Type | Required | Description |
|---|---|---|---|
model |
(modelId: string) => LanguageModel |
Yes | Resolves a model id to a Vercel AI SDK LanguageModel. Called once per request with the selected model |
defaultModel |
string |
Yes | Model id used when no per-request override is provided |
availableModels |
ModelOption[] |
No | Models the user can choose from in the chat UI (selector shown when 2+ entries) |
systemPrompt |
({ req, defaultPrompt }) => string | Promise<string> |
No | Customize the agent's system prompt. Wrap or replace defaultPrompt and return the final string. Called per request, so the prompt can be loaded from a Payload global / varied per tenant (see below) |
access |
(req) => boolean |
No | Override the default auth check (default: requires authenticated user) |
maxSteps |
number |
No | Maximum tool-use loop steps per request (default: 20) |
modes |
ModesConfig |
No | Agent modes configuration (see below) |
adminView |
{ path, Component } |
No | Customize the admin chat view route or component |
navLink |
boolean |
No | Show a "Chat" link at the top of the admin nav sidebar (default: true) |
budget |
BudgetConfig |
No | Optional token budget (see below) |
emptyState |
EmptyStateConfig |
No | Customize the empty chat screen — title, markdown description, and suggested-prompt chips (see below) |
tools |
({ req, defaultTools, modelId }) => ToolMap |
No | Compose the final toolset — add user or provider-native tools, drop defaults, etc. (see below) |
toolDiscovery |
{ searchTool, eager? } |
No | Anthropic's Tool Search Tool — defer cold-path tool definitions and load them on demand (see below) |
The factory pattern lets you route each model id to the appropriate provider — typically by id prefix:
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY })
chatAgentPlugin({
defaultModel: 'claude-sonnet-4-20250514',
availableModels: [
{ id: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ id: 'gpt-4o', label: 'GPT-4o' },
],
model: (id) => (id.startsWith('claude-') ? anthropic(id) : openai(id)),
})Tool-calling support is per-model, not per-provider. This plugin relies heavily on tool calls — stick to tool-capable models (e.g. claude-sonnet-4, gpt-4o).
| Mode | Behavior |
|---|---|
read |
Write tools removed entirely — the agent cannot attempt writes |
ask |
Write tools available but require explicit user confirmation before executing |
read-write |
Full access, no confirmation required |
superuser |
Full access with overrideAccess: true (bypasses Payload access control) |
chatAgentPlugin({
modes: {
default: 'ask',
access: {
'read-write': ({ req }) => req.user?.role === 'admin',
superuser: ({ req }) => req.user?.role === 'superadmin',
},
},
})readis always available and cannot be restricted- Modes without an access function are available to all authenticated users
superuserrequires an explicit access function to be enabled- Users only see modes they have access to
Customize what editors see before they've sent the first message. Without this option, the chat opens to a generic "What can I help you with?" headline and a few example prompt chips.
Example:
chatAgentPlugin({
emptyState: {
title: 'Content Assistant',
description:
'I can help with **drafting**, **translating**, and finding stale pages. ' +
'I cannot delete content or change user permissions.',
starterPrompts: ['Audit my recent draft posts', 'Translate the homepage tagline to German'],
},
})Any endpoint with a custom.description is discoverable by the agent via the callEndpoint tool. Optionally attach a custom.schema describing the request/response contract (query, body, response) — when present, it's handed to the agent alongside the description so it can construct valid calls without trial-and-error:
endpoints: [
{
path: '/publish/:id',
method: 'post',
custom: {
description: 'Publish a document by ID',
schema: {
body: { notify: { type: 'boolean' } },
response: { id: { type: 'string' }, status: { type: 'string' } },
},
},
handler: async (req) => {
/* ... */
},
},
]custom.schema leaves are passed through verbatim — use whatever shape your team already documents endpoints with (plain descriptors, JSON Schema, etc.). Route params like :id belong in the path, not the schema.
One systemPrompt factory produces the full prompt the agent sees. It receives the auto-generated prompt (collection / global slug catalog, mode rules, admin link patterns, ...) as defaultPrompt along with the authenticated req, and returns the final string. Wrap defaultPrompt to extend, or ignore it to replace.
The factory runs on every chat request, so the prompt can be loaded from a Payload global and edited in the admin panel:
chatAgentPlugin({
systemPrompt: async ({ req, defaultPrompt }) => {
const settings = await req.payload.findGlobal({ slug: 'settings' })
const extra = settings?.chatAgentPrompt?.trim()
return extra ? `${defaultPrompt}\n\n${extra}` : defaultPrompt
},
})To replace the auto-generated prompt entirely, ignore defaultPrompt and return your own string. The same callback can vary per tenant, locale, or req.user.role — see the dev app's payload.config.ts for a runnable example.
One tools factory composes the full toolset the agent sees. It receives the plugin's default tools (the Payload Local API bindings below), the authenticated req, and the selected modelId for this request, and returns the final name -> Tool map. Modeled on Payload's lexicalEditor({ features: ({ defaultFeatures }) => ... }): spread defaultTools to keep them, omit to drop, and add your own under any name.
import { anthropic } from '@ai-sdk/anthropic'
import { tool } from 'ai'
import { z } from 'zod'
chatAgentPlugin({
defaultModel: 'claude-sonnet-4-20250514',
model: (id) => anthropic(id),
tools: ({ defaultTools, req }) => ({
// Keep all the built-ins (`find`, `create`, `getCollectionSchema`, ...)
...defaultTools,
// Provider-native web tools (executed server-side by Anthropic):
webSearch: anthropic.tools.webSearch_20250305({ maxUses: 5 }),
webFetch: anthropic.tools.webFetch_20250910(),
// A custom tool that calls an external service, closing over `req.user`:
sendSlackMessage: tool({
description: 'Post a message to the #ops channel via Slack webhook',
inputSchema: z.object({ text: z.string() }),
execute: async ({ text }) => {
const res = await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `${req.user?.email}: ${text}` }),
})
return { ok: res.ok, status: res.status }
},
}),
}),
})In a multi-provider setup, gate provider-native tools on modelId so they're only sent to a compatible provider — otherwise e.g. OpenAI will reject an Anthropic-native webSearch_* tool the moment someone picks gpt-5-mini:
import { anthropic } from '@ai-sdk/anthropic'
import { openai } from '@ai-sdk/openai'
chatAgentPlugin({
defaultModel: 'claude-sonnet-4-20250514',
availableModels: [
{ id: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ id: 'gpt-5-mini', label: 'GPT-5 mini' },
],
model: (id) => (id.startsWith('claude-') ? anthropic(id) : openai(id)),
tools: ({ defaultTools, modelId }) => ({
...defaultTools,
...(modelId.startsWith('claude-')
? { webSearch: anthropic.tools.webSearch_20250305({ maxUses: 5 }) }
: { webSearch: openai.tools.webSearch() }),
}),
})Classification for mode filtering:
- Provider-native tools (no
execute— the provider runs them, e.g.anthropic.tools.webSearch_*,openai.tools.webSearch,google.tools.googleSearch/google.tools.urlContext) are treated as reads: available inreadmode, not gated byneedsApprovalinask. Make sure the configured model actually supports the tool you pass — the provider rejects unsupported combinations at call time, and some tools (e.g. Anthropic'swebFetch_20260209with dynamic filtering) require specific models. Each provider typically bills web search per call (~$10 / 1k searches) in addition to tokens. - User-defined executable tools (anything with an
executefunction) default to the safe "write" classification: excluded inread, gated behindneedsApproval: trueinask, passed through inread-write/superuser. The plugin can't know the tool's side effects.
The plugin does not merge — what the factory returns is what the agent sees. Omit tools entirely to use the defaults. Runnable examples of custom tools (Axiom Logs, Vercel Logs, Slack webhook) live in chat-agent/dev/src/customTools.ts.
Large toolsets (many collections × CRUD + custom endpoints + provider-native tools) push tool definitions into the multi-thousand-token range, and Anthropic charges for them on every step of a tool-use loop. Anthropic's Tool Search Tool lets you mark cold-path tools with defer_loading: true so their definitions are held out of the system-prompt prefix until Claude finds them via a search call.
Opt in by passing a searchTool:
import { anthropic } from '@ai-sdk/anthropic'
chatAgentPlugin({
defaultModel: 'claude-sonnet-4-20250514',
model: (id) => anthropic(id),
toolDiscovery: {
searchTool: anthropic.tools.toolSearchBm25_20251119(),
// Optional: override the default eager set. Anthropic recommends 3–5 tools.
// Default: ['find', 'findByID', 'count', 'findGlobal', 'getCollectionSchema']
// eager: ['find', 'findByID', 'getCollectionSchema'],
},
})Every tool not named in eager is sent with providerOptions.anthropic.deferLoading: true. The search tool is registered under a reserved internal key so it can't collide with user-defined tools. Either Anthropic search variant works — toolSearchBm25_20251119() (natural-language) or toolSearchRegex_20251119().
Activates only when the resolved modelId starts with claude-. For OpenAI, Google, and other providers the option is silently ignored — tools are sent eagerly as before — so it's safe to leave configured in a multi-provider setup.
Cap tokens per request with two functions — the plugin stays agnostic about whether you want per-user, per-day, global, or something else:
chatAgentPlugin({
budget: {
// Return remaining tokens. 0 or negative → 429; null → unlimited.
check: async ({ req }) => 50_000 - (await getUsageToday(req.user!.id)),
// Awaited after the response completes, with the actual token usage.
record: ({ req, usage }) => addUsageToday(req.user!.id, usage.totalTokens ?? 0),
},
})Successful responses carry X-Budget-Remaining, and GET /api/chat-agent/budget returns { remaining }.
For a ready-made Payload-backed store with daily/monthly periods and user/global scopes:
import { chatAgentPlugin, createPayloadBudget } from '@jhb.software/payload-chat-agent'
const chatBudget = createPayloadBudget({
limit: 50_000,
period: 'daily',
scope: 'user',
})
export default buildConfig({
collections: [chatBudget.collection /* ... */],
plugins: [chatAgentPlugin({ budget: chatBudget.budget /* ... */ })],
})Errors from check/record are not swallowed — a broken usage store fails loudly rather than silently letting unlimited spend through.
| Tool | Description |
|---|---|
find |
Query documents with filters, pagination, and sorting |
findByID |
Get a single document by ID |
create |
Create a new document |
update |
Update a document by ID |
delete |
Delete a document by ID |
count |
Count documents matching a query |
findGlobal |
Get a global document |
updateGlobal |
Update a global document |
callEndpoint |
Invoke a custom API endpoint |
Additional tools registered via the tools option — including provider-native web tools like webSearch / webFetch — appear alongside these. See Extending or customizing tools.
The same orchestration that powers POST /chat-agent/chat is exported as a runAgent(req, opts) function so you can invoke the agent off-HTTP — from a Payload task, a cron-triggered endpoint, a webhook. No client connection, no SSE; you await the result and consume result.text / result.totalUsage / result.fullStream. Throws a clear error if chatAgentPlugin() is not installed in the given Payload config.
req carries both the actor (req.user) and the Local API (req.payload). For callers without an HTTP request (a Payload task handler, an internal worker), construct one with Payload's createLocalReq({ user }, payload) helper. runAgent throws if req.user is missing unless you pass overrideAccess: true — gate the endpoint upstream so a misconfigured cron can't accidentally invoke an unauthenticated agent.
The preferred way to wire this up is a dedicated service-account collection with API-key auth. The cron runner authenticates as a service-account document; Payload resolves req.user to that account; the endpoint handler hands req straight through to runAgent so tool calls inherit the service account's access — no overrideAccess needed.
// payload.config.ts — add a service-account collection alongside your users.
{
slug: 'service-accounts',
auth: { useAPIKey: true, disableLocalStrategy: true },
fields: [{ name: 'name', type: 'text', required: true }],
}// e.g. endpoints.ts — POST /api/audit-content, called on a schedule.
import { runAgent } from '@jhb.software/payload-chat-agent'
export const auditEndpoint = {
path: '/audit-content',
method: 'post',
handler: async (req) => {
// Pin the collection so a regular user session can't trigger audits.
if (!req.user || req.user.collection !== 'service-accounts') {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const result = await runAgent(req, {
mode: 'read',
messages:
'Audit the posts collection: list every post with no featuredImage, by title and id.',
skipBudget: true, // automated runs don't charge a per-user cap
})
return Response.json({ text: await result.text })
},
}Trigger it from any cron source — Vercel Cron, GitHub Actions, Upstash, etc. — by sending the API key:
curl -X POST https://your-app.com/api/audit-content \
-H 'Authorization: service-accounts API-Key <your-key>'Key runAgent options:
| Option | Default | Notes |
|---|---|---|
messages |
(required) | A single string, a UIMessage[], or a ModelMessage[] — discriminated structurally |
mode |
plugin default | read | ask | read-write | superuser |
model |
defaultModel |
Model id forwarded to the plugin's model(id) factory |
overrideAccess |
false |
Bypass Payload access control on tool calls. Required for mode: 'superuser' |
maxSteps |
plugin's maxSteps |
Per-call tool-loop cap |
systemPrompt |
derived | Replace (string) or extend (function) the auto-generated prompt |
tools |
plugin-resolved | Narrow or replace the toolset for this call |
abortSignal |
none | Recommended for runs that may exceed the host's idle timeout |
skipBudget |
false |
Skips both check and record. Most background jobs want true |
This plugin is published as a beta. Review these before enabling it in production.
- Access defaults to any authenticated user. Without a custom
access, every signed-in Payload user can use the agent and forward CMS content to your LLM provider. Gate it to specific roles in real deployments. - Prompt injection. The agent reads arbitrary CMS content — including user-submitted content. Untrusted content can attempt to override the system prompt.
askmode requires confirmation before writes;read-writeandsuperuserdo not. Keep untrusted installs onaskorread. - Schema is sent to the LLM. Every collection, global, block, locale, and field option (including
selectlabels) is included in the system prompt regardless of the current user's access. custom.descriptionis the opt-in forcallEndpoint. A plugin that adds one will automatically expose that endpoint to the agent. Audit before publishing, and prefer endpoints that re-check access inside their handler.- Usage tracking on conversations is not authoritative.
totalTokensround-trips through the client and isn't trustworthy for billing. Usebudget.recordfor anything audit-grade.
Warning: This plugin is actively evolving and may undergo significant changes. While it is functional, please thoroughly test before using in production environments.
Have a suggestion for the plugin? Any feedback is welcome!
We welcome contributions! Please open an issue to report bugs or suggest improvements, or submit a pull request with your changes.