AI
AI actions
Create your first managed AI action in a VitNode plugin. Define it with defineAiAction, register it, run it with c.get("ai").run() from an AdminCP route and find the run in the AI logs.
An AI action is one AI feature a plugin offers, such as "write an excerpt", "translate a field" or "summarize a topic". Your plugin describes the prompt and the shape of the input and output. VitNode Core runs it. It picks the model, checks permissions, enforces limits, holds budget, calls the provider through the Vercel AI SDK, validates the answer and records what it cost.
On this page you add a Summarize topic action to the @vitnode/forum plugin, run it from an AdminCP route, and find the run in the AI logs.
generateText with c.get("ai").model() works, but it skips permissions,
budgets, AI points and run history. Use an action for anything people can
trigger, and keep direct SDK calls for internal tooling.
Before you begin
- A language model with the
textcapability inai.modelsof your API config. See AI setup. - The
@vitnode/forumplugin in your app. Any plugin with an API part (src/config.api.ts) works the same way; swap the plugin id, paths and content type. - An AdminCP account with a root role. Root roles hold every AI permission and have no point cap. Any other role needs the permission, a monthly points allowance (the default is
0) and a priced model; AI in the AdminCP walks through that.
Build the action
Define the action
Create src/api/ai/actions.ts in the forum plugin:
import { defineAiAction } from '@vitnode/core/api/lib/ai/action'
import { aiActionRef } from '@vitnode/core/api/lib/ai/registry'
import { checkStaffPermission } from '@vitnode/core/api/lib/check-staff-permission'
import { z } from 'zod'
import { topicContentType } from '@/content/topic'
import { manifest } from '@/manifest.gen'
export const zodSummarizeTopicSchema = z.object({
title: z.string().trim().min(1).max(200),
content: z.string().trim().min(1).max(50_000),
})
const summarizeTopicAiAction = defineAiAction({
id: 'topic.summarize',
title: 'ai_actions.@vitnode/forum.topic_summarize.title',
description: 'ai_actions.@vitnode/forum.topic_summarize.description',
icon: 'text-quote',
promptVersion: 1,
permission: { key: 'summarize', defaultGranted: true },
requiredCapabilities: ['text'],
defaults: {
maxInputCharacters: 50_000,
maxOutputTokens: 300,
timeoutMs: 30_000,
},
inputSchema: zodSummarizeTopicSchema,
output: 'text',
outputSchema: z.string().min(1).max(600),
parseText: (text) => text.trim(),
buildPrompt: (input, { instructions }) => ({
system: ['Summarize the forum topic in two plain sentences.', instructions]
.filter(Boolean)
.join('\n'),
prompt: `${input.title}\n\n${input.content}`,
}),
// The AI permission never replaces access to the content itself.
authorize: async ({ c }) =>
await checkStaffPermission(c, {
type: 'admin',
plugin: manifest.id,
module: topicContentType.permissionModule,
permission: 'can_view',
}),
})
export const forumAiActions = [summarizeTopicAiAction]
export const SUMMARIZE_TOPIC_AI_ACTION = aiActionRef(
manifest.id,
summarizeTopicAiAction,
)idis local to the plugin. Core stores the action as@vitnode/forum:topic.summarize.permissionis the switch admins flip per role.defaultGranted: truekeeps it on until a role says otherwise.authorize()asks whether this admin may view forum topics.topicContentType.permissionModuleistopic, derived from the type idforum.topic.aiActionRef()builds the canonical key and carries the input and output types, sorun()can check them.
defineAiAction() validates the definition when the module loads, so a bad id or a missing authorize() throws at boot instead of in production traffic. Every field is listed in Action options.
Add the title and description
title and description are message keys. Add their text to the plugin's messages under the shared ai_actions key and your plugin id. Keep the existing keys in the file:
{
"ai_actions": {
"@vitnode/forum": {
"topic_summarize": {
"title": "Summarize topic",
"description": "Writes a two-sentence summary of a forum topic."
}
}
}
}The AdminCP, the role form and the member's AI usage page translate these keys in the reader's language.
Register the action in the plugin
Open src/config.api.ts and pass the list to aiActions. Leave the existing modules as they are:
import { forumAiActions } from '@/api/ai/actions'
export const forumApiPlugin = () =>
buildApiPlugin({
manifest,
aiActions: forumAiActions,
modules: [
adminModule,
// ...forumModule and the public content module stay unchanged
],
})Run the action from a route
Create an AdminCP route that validates the body and passes it to c.get("ai").run():
import { z } from 'zod'
import {
SUMMARIZE_TOPIC_AI_ACTION,
zodSummarizeTopicSchema,
} from '@/api/ai/actions'
import { buildRoute } from '@/api/builders'
import { topicContentType } from '@/content/topic'
export const summarizeTopicRoute = buildRoute({
adminStaffPermission: {
module: topicContentType.permissionModule,
permission: 'can_view',
},
route: {
method: 'post',
description: 'Summarize a forum topic with the configured AI model.',
path: '/summarize',
request: {
body: {
required: true,
content: { 'application/json': { schema: zodSummarizeTopicSchema } },
},
},
responses: {
200: {
content: {
'application/json': { schema: z.object({ text: z.string() }) },
},
description: 'The summary',
},
400: { description: 'No AI model is configured or the input is invalid' },
403: { description: 'No access to topics or to this AI feature' },
429: { description: 'A personal AI limit was reached' },
502: { description: 'The AI provider failed or answered unusably' },
503: { description: 'AI is switched off or the site budget is used up' },
},
},
handler: async (c) => {
const { output } = await c.get('ai').run({
action: SUMMARIZE_TOPIC_AI_ACTION,
input: c.req.valid('json'),
})
return c.json({ text: output }, 200)
},
})TypeScript infers output as string from the action's outputSchema. When a run fails, run() throws an AiError and the route answers with its status and a JSON body. See AI errors and limits.
Mount the route
Create an ai module for the route:
import { buildModule } from '@/api/builders'
import { summarizeTopicRoute } from './routes/summarize.route'
export const aiAdminModule = buildModule({
name: 'ai',
routes: [summarizeTopicRoute],
})Then add it to the forum's existing admin module:
import { aiAdminModule } from './ai/ai.admin.module'
export const adminModule = buildModule({
name: 'admin',
routes: [],
modules: [
aiAdminModule,
buildContentAdminModule({
containers: [forumContainers],
contentTypes: [topicContent, commentContent],
}),
],
})The route is now served at POST /api/@vitnode/forum/admin/ai/summarize.
Check the result
Start or restart the dev server so the API loads the new action, then sign in to the AdminCP.
-
Open Artificial Intelligence (AI) ā Actions. A Summarize topic row appears with its description and icon. Nothing has been spent yet.
-
Add a temporary button that calls the route with
fetcher. Createsrc/pages/check-summarize.tsx:plugins/forum/src/pages/check-summarize.tsx import { Button } from '@vitnode/core/components/ui/button' import { fetcher } from '@vitnode/core/tanstack/fetcher' import { useState } from 'react' import { manifest } from '@/manifest.gen' export const CheckSummarize = () => { const [result, setResult] = useState('') const [isPending, setIsPending] = useState(false) const onClick = async () => { setIsPending(true) try { const response = await fetcher({ plugin: manifest.id, method: 'post', module: 'admin/ai', path: '/summarize', args: { body: { title: 'Parking on Saturday', content: 'The lot is closed on Saturday morning for repainting. Park on Elm Street instead.', }, }, }) setResult(JSON.stringify(await response.json())) } finally { setIsPending(false) } } return ( <div className="flex flex-col gap-3"> <Button isLoading={isPending} onClick={onClick}> Summarize a test topic </Button> {result ? <output className="leading-relaxed">{result}</output> : null} </div> ) }Render
<CheckSummarize />on a forum page, such assrc/pages/forum-page.tsx. The browser sends the AdminCP session cookie from every page, so open that page in the browser you signed in with and press the button. It shows{"text":"ā¦"}with a two-sentence summary. This call spends real provider tokens. Remove the button when you are done. -
Open Debug Panel ā AI logs. The newest row is
@vitnode/forum:topic.summarizewith statussucceeded, the model that answered, the tokens and the cost.
If the call answers an error instead, the body names it, for example { "code": "AI_NOT_CONFIGURED", ... } when ai.models is empty. AI errors and limits lists every code and its fix.
Your own React code calls the route the same way, with fetcher. See Fetcher.