AI
AI Actions
Define managed AI features with defineAiAction, register them in a plugin and run them with c.get("ai").run(). VitNode Core handles permissions, limits, budgets and history.
An AI action is one AI feature a plugin offers, such as "write an excerpt", "translate a field" or "suggest tags". The plugin describes the prompt and the input and output shapes. VitNode Core runs it: it picks the model, checks permissions, enforces limits, holds budget, calls the provider, validates the answer and records what it cost.
const { output } = await c.get('ai').run({
action: EXCERPT_AI_ACTION,
input: { title, content, locale },
})Why actions exist
Calling generateText with c.get("ai").model() works, but every plugin would then need its own answers to the same questions: who may use this, how much can they spend, what happens when the provider times out, and where did the money go?
Actions answer those once, in Core:
- One place for models. Admins choose the model, fallback and limits per action in the AdminCP. Plugins never hard-code a model.
- One place for limits. Permissions, daily limits, rate limits and concurrency apply to every action the same way.
- One place for accounting. Every run and every provider call is recorded with tokens and cost, and charged to the right budget.
Direct SDK calls with c.get("ai").model() bypass budgets, limits and
history. Keep them for internal tooling you fully control.
Create an action
This example adds a "summarize topic" button to a forum plugin's AdminCP.
Define the action
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 { CONFIG_PLUGIN } from '@/const'
export const zodSummarizeTopicSchema = z.object({
title: z.string().trim().min(1).max(255),
content: z.string().trim().min(1).max(50_000),
})
export 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: CONFIG_PLUGIN.pluginId,
module: 'topics',
permission: 'can_view',
}),
})
export const forumAiActions = [summarizeTopicAiAction]
export const SUMMARIZE_TOPIC_AI_ACTION = aiActionRef(
CONFIG_PLUGIN.pluginId,
summarizeTopicAiAction,
)title and description are message keys. Add their text to the plugin's
messages in the next step.
defineAiAction() validates the definition when the module loads. A bad id, a missing authorize() or an out-of-range limit throws an AiActionDefinitionError at boot, not in production traffic.
Add the title and description
Add the text to the plugin's messages, under the shared ai_actions key and
your plugin id. Every AI page loads ai_actions as one namespace, so all
plugins' action names arrive together.
{
"ai_actions": {
"@vitnode/forum": {
"topic_summarize": {
"title": "Summarize topic",
"description": "Writes a two-sentence summary of a forum topic."
}
}
}
}Register it in the plugin
import { buildApiPlugin } from '@vitnode/core/api/lib/plugin'
import { forumAiActions } from '@/api/ai/actions'
import { adminModule } from '@/api/modules/admin/admin.module'
import { CONFIG_PLUGIN } from '@/const'
export const forumApiPlugin = () =>
buildApiPlugin({
pluginId: CONFIG_PLUGIN.pluginId,
aiActions: forumAiActions,
modules: [adminModule],
})Run it from a route
import { buildRoute } from '@vitnode/core/api/lib/route'
import { z } from 'zod'
import {
SUMMARIZE_TOPIC_AI_ACTION,
zodSummarizeTopicSchema,
} from '@/api/ai/actions'
import { CONFIG_PLUGIN } from '@/const'
export const summarizeTopicRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
adminStaffPermission: { module: 'topics', 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',
},
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 the run fails, run() throws an AiError and the route answers with its error code.
For a complete, real example, read the @vitnode/blog plugin: plugins/blog/src/api/ai/actions.ts and its admin/ai routes.
Action ids
An action has a local id, unique inside its plugin: lowercase, dot-separated words such as topic.summarize.
Core stores it under a canonical key, <pluginId>:<localId>:
@acme/forum:topic.summarize
@vitnode/blog:excerpt.generateTwo plugins may both have an excerpt.generate; their canonical keys differ. The same canonical key twice throws at boot. Settings, history and limits all use the canonical key.
aiActionRef(pluginId, action) builds the canonical key and remembers the action's input and output types. Pass it to run() and TypeScript checks input and infers output. A plain string key also works, but its output is unknown.
Title, description and icon
Every action appears as a row on Artificial Intelligence (AI) → Actions, as a feature on the role form's Artificial intelligence (AI) tab and in a member's AI usage. Each of those needs a human name in every language:
title(required): the message key of a short name, e.g.ai_actions.@vitnode/forum.topic_summarize.titlefor "Summarize topic".description(required): the message key of one sentence on what it does.icon(optional): a Lucide icon name in kebab case, e.g.languagesorwand-sparkles. Without one, the row shows a sparkles icon.
The API returns both keys untranslated. The AdminCP and the member's settings translate them into the page's language. A language without the key falls back to the default language's text. Keep the keys in your plugin's src/locales/en.json under ai_actions → your plugin id, and translate them like any other plugin message.
A title or description that is not a message key is shown as written. That
is handy while prototyping, but it never gets translated.
A blank title or description, or an icon that isn't a kebab-case name, throws at boot.
Prompt versions and instructions
promptVersion is stored with every run. Bump it whenever the prompt changes meaningfully, so the history shows which prompt produced which result.
buildPrompt(input, { instructions }) returns either { prompt, system? } or { messages, system? }. instructions is optional extra guidance an admin set for this action in the AdminCP, or null. Append it to the system prompt.
Input and output
inputSchema(Zod) validates the input before anything else happens. Invalid input fails withAI_INVALID_INPUT.measureInput(input)returns the number of characters counted againstmaxInputCharacters. By default every string in the input counts. Override it when only part of the input reaches the prompt. The blog excerpt, for example, counts only the text it extracts from the HTML.output: "text"makes the model return text.parseText(text, input)turns it into your output, thenoutputSchemachecks it. Throw fromparseText()to reject an answer.output: "object"makes the model return structured JSON, whichoutputSchemavalidates. The action must require thestructured-outputcapability, and it cannot be streamed.
An empty answer, a parseText() that throws or a schema mismatch fails with AI_INVALID_OUTPUT. The user is not charged for it.
export const suggestTagsAiAction = defineAiAction({
id: 'topic.tags',
title: 'ai_actions.@vitnode/forum.topic_tags.title',
description: 'ai_actions.@vitnode/forum.topic_tags.description',
promptVersion: 1,
permission: 'tags',
actors: ['system'],
requiredCapabilities: ['text', 'structured-output'],
defaults: {
maxInputCharacters: 20_000,
maxOutputTokens: 200,
timeoutMs: 30_000,
maxRetries: 1,
},
inputSchema: z.object({ content: z.string().min(1) }),
output: 'object',
outputSchema: z.object({ tags: z.array(z.string().max(32)).max(5) }),
buildPrompt: (input) => ({
system: 'Suggest up to five short, lowercase tags for this forum topic.',
prompt: input.content,
}),
})
export const TOPIC_TAGS_AI_ACTION = aiActionRef(
CONFIG_PLUGIN.pluginId,
suggestTagsAiAction,
)actors: ["system"] makes this a background-only action, so it needs no authorize(). See Actors.
Images
For a vision action, require image-input and pass the image bytes as a file part. Read them server-side, for example with c.get("storage").readBytes(key, maxBytes), so no private file ever has to be made public for the provider:
buildPrompt: (input) => ({
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Describe this image in one sentence.' },
{ type: 'file', data: input.image, mediaType: input.mediaType },
],
},
],
}),maxImages defaults to 1 for these actions and bounds how many images one run may send. Only models that declare image-input are used. See Model capabilities.
Permissions and authorize()
Every action names a permission. Admins grant it per role on the Artificial intelligence (AI) tab of the role form. Several actions may share one key. They then share one switch and one daily counter.
permission: 'summarize' // same as { key: 'summarize', defaultGranted: false }
permission: { key: 'summarize', defaultGranted: true }defaultGranted applies while no role has a setting for the key. The canonical permission is <pluginId>:<key>. Two actions sharing a key must agree on defaultGranted, or boot fails.
authorize() is required for actions users can run. The AI permission says "this role may use AI summaries". authorize() says "this user may read this topic". Both must pass:
// canReadTopic() is the forum plugin's own access check
authorize: async ({ c, input, userId }) =>
await canReadTopic(c, { topicId: input.topicId, userId }),resource is the optional { type, id } the caller passed to run(). It is also stored with the run, so history can link back to the content.
Actors: users and the system
actors decides who may run an action. The server sets the actor; the request never does.
| Actor | Runs with | Charged to |
|---|---|---|
user | c.get("ai").run() | The site budget and the user's AI points |
system | c.get("ai").runAsSystem() | The site budget and the optional system budget |
The default is ["user"]. run() uses the signed-in AdminCP user first, then the site user. Without one, it fails with AI_UNAUTHORIZED.
Use runAsSystem() only from trusted server code: cron jobs, queue tasks, event listeners. No route should pass a client's request into it.
import { isAiError } from '@vitnode/core/api/lib/ai/errors'
import { buildCron } from '@vitnode/core/api/lib/cron'
import { TOPIC_TAGS_AI_ACTION } from '@/api/ai/actions'
export const tagTopicsCron = buildCron({
name: 'tag-new-topics',
description: 'Suggest tags for new forum topics',
schedule: '*/15 * * * *',
handler: async (c) => {
for (const topic of await loadUntaggedTopics(c)) {
try {
const { output } = await c.get('ai').runAsSystem({
action: TOPIC_TAGS_AI_ACTION,
input: { content: topic.content },
resource: { type: 'forum_topic', id: topic.id },
})
await saveTopicTags(c, topic.id, output.tags)
} catch (error) {
// Out of budget: stop now and try again on the next tick.
if (isAiError(error) && error.code === 'AI_BUDGET_EXHAUSTED') return
throw error
}
}
},
})Defaults and limits
defaults sets an action's limits. Admins can override most of them per action in the AdminCP. Values outside the allowed range throw at boot.
| Field | Required | Default | Range |
|---|---|---|---|
maxInputCharacters | Yes | - | 1 – 1,000,000 |
maxOutputTokens | Yes | - | 1 – 200,000 |
timeoutMs | Yes | - | 1,000 – 600,000 |
maxRetries | No | 0 | 0 – 3 |
maxSteps | No | 1 | 1 – 10 |
maxImages | No | 1 with image-input, else 0 | 0 – 10 |
dailyLimit | No | null (no limit) | 0 – 100,000 |
timeoutMsapplies to each attempt.maxRetriesis extra attempts on the same model after a failed provider call.maxStepsis model round trips inside one attempt (tool loops). Each step is billed as its own call.dailyLimitcounts runs per user per day for the action's permission key. A role's daily limit takes precedence over the admin's action setting, which takes precedence over this default.- Admins cannot change
maxImages, because it bounds the budget reserved for images.
Admins can also, per action: switch it off, assign a model, assign a fallback model, and add instructions.
Run options
Prop
Type
run() and runAsSystem() resolve to:
{
output: Output, // validated by outputSchema
runId: number,
usage: {
chargedPoints: string, // points charged to the user, full precision; "0" for system runs
costKnown: boolean,
modelId: string, // the configured model that answered
},
}You can safely return usage to the browser: it never contains prices or provider details.
What happens during a run
- Find the action, check the actor, validate
input. - Check the global AI switch and the action's switch.
- For users: check the role permission, then
authorize(). - Measure the input and the number of images.
- Pick the model: the admin's choice, or the first configured model with every required capability. Resolve the optional fallback, skipping an incompatible one.
- Reserve budget for the worst case and check rate and concurrency limits, in one short database transaction.
- Call the provider outside any transaction. Retry up to
maxRetriestimes, then try the fallback model once. - Validate the output.
- Settle: charge the real cost and release the rest of the reservation.
- Emit
ai.run.completedorai.run.failed.
A canceled run stops immediately. An invalid answer is not retried on the same model, but the fallback model still gets one try. Everything before step 6 costs nothing. Costs, Points & Budgets explains steps 6 and 9.
Stream a text action
c.get("ai").stream() streams a text action for the signed-in user. Object actions cannot be streamed.
handler: async (c) => {
const { textStream } = await c.get('ai').stream({
action: SUMMARIZE_TOPIC_AI_ACTION,
input: c.req.valid('json'),
signal: c.req.raw.signal, // stop paying when the reader leaves
})
return new Response(textStream.pipeThrough(new TextEncoderStream()), {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
})
}The stream also returns result, a promise that resolves to the validated output once the stream ends, and rejects with an AiError if the full text is invalid. A stream makes one attempt on the primary model, with no retries and no fallback. If the reader cancels, the run settles as canceled and the user is not charged.
Error codes
Every refusal and failure is an AiError with a stable code. Routes answer with this JSON body. Key the browser's messages on code, never on the English message:
{
"code": "AI_USER_LIMIT_REACHED",
"message": "You used all your AI points for this period.",
"resetsAt": "2026-11-01T00:00:00.000Z",
"runId": 42
}resetsAt is set for limit and budget errors. runId is set when a run was recorded.
| Code | Status | When |
|---|---|---|
AI_NOT_CONFIGURED | 400 | No ai.models are configured. |
AI_INVALID_INPUT | 400 | The input failed inputSchema, or an object action was streamed. |
AI_INPUT_TOO_LARGE | 413 | The input exceeds maxInputCharacters or maxImages. |
AI_DISABLED | 503 | An admin switched AI off for the whole site. |
AI_ACTION_DISABLED | 403 | An admin switched this action off. |
AI_ACTION_UNKNOWN | 404 | No installed plugin registers this key. |
AI_UNAUTHORIZED | 403 | Not signed in, no AI permission, authorize() refused, or wrong actor. |
AI_MODEL_INCOMPATIBLE | 409 | No model has the required capabilities. |
AI_PRICING_MISSING | 409 | The model has no pricing while a budget cap applies. |
AI_USER_LIMIT_REACHED | 429 | The user's monthly AI points are used up. |
AI_DAILY_LIMIT_REACHED | 429 | The user reached today's limit for this permission. |
AI_RATE_LIMITED | 429 | Too many runs started in the last minute. |
AI_CONCURRENCY_LIMITED | 429 | Too many runs in flight. |
AI_BUDGET_EXHAUSTED | 503 | The site budget (or the system budget) for this month is used up. |
AI_DUPLICATE_REQUEST | 409 | The idempotencyKey was already used. |
AI_PROVIDER_FAILED | 502 | The provider returned an error. |
AI_INVALID_OUTPUT | 502 | The answer was empty or failed parseText() / outputSchema. |
AI_TIMEOUT | 504 | The provider took longer than timeoutMs. |
AI_CANCELED | 408 | The signal aborted the run. |
Import the helpers from @vitnode/core/api/lib/ai/errors: AiError, isAiError(), AI_ERROR_CODES and AI_ERROR_STATUS.
Events
Each run that got past the reservation emits one event after it settles:
| Event | Payload |
|---|---|
ai.run.completed | actionKey, actorType, runId, userId |
ai.run.failed | actionKey, actorType, errorCode, runId, userId |
userId is null for system runs. Refusals before the reservation, such as a missing permission or a reached limit, emit nothing. A listener that throws never breaks the run.
import { buildEventListener } from '@vitnode/core/api/lib/events'
export const aiFailureListener = buildEventListener({
event: 'ai.run.failed',
name: 'log-ai-failures',
handler: async (c, payload) => {
if (!payload.actionKey.startsWith('@acme/forum:')) return
await c
.get('log')
.warn(`AI run ${payload.runId} failed: ${payload.errorCode}`)
},
})See Events for registering listeners.