AI
Action options
Reference for defineAiAction in VitNode. Every AI action field, default and limit, the options of c.get("ai").run(), runAsSystem() and stream(), the run result, and the order of checks during a run.
This page lists everything an AI action can declare and everything a run accepts and returns. Use it when you change an action you already built; to build your first one, follow AI actions. The examples continue that page's @vitnode/forum Summarize topic action.
import { defineAiAction } from '@vitnode/core/api/lib/ai/action'
import { aiActionRef } from '@vitnode/core/api/lib/ai/registry'defineAiAction(definition) validates the definition when the module loads and fills in defaults. Anything invalid throws an AiActionDefinitionError at boot. Register the result in buildApiPlugin({ aiActions: [...] }).
Definition fields
Prop
Type
Ids and canonical keys
Core stores an action under its canonical key, <pluginId>:<id>:
@vitnode/forum:topic.summarize
@vitnode/blog:article.reviewTwo plugins may both have a topic.summarize, because their canonical keys differ. The same canonical key twice throws at boot. Settings, history, limits and field ai options all use the canonical key.
aiActionRef(pluginId, action) returns the canonical key typed with the action's input and output. 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 on Artificial Intelligence (AI) ā Actions, on the role form's Artificial intelligence (AI) tab and on the member's AI usage page. title and description are message keys such as ai_actions.@vitnode/forum.topic_summarize.title. The API returns them untranslated and each page translates them, falling back to the default language. Keep them in your plugin's src/locales/en.json under ai_actions ā your plugin id, and translate them like any other plugin message.
A value that is not a message key is shown as written and never translated, which is handy while prototyping. A blank title or description, or an icon that is not a kebab-case name, throws at boot.
Prompt and instructions
buildPrompt(input, { instructions }) returns either { prompt, system? } or { messages, system? }, the same prompt shapes the AI SDK takes. instructions is extra guidance an admin set for this action in the AdminCP, or null. Append it to the system prompt:
buildPrompt: (input, { instructions }) => ({
system: ['Summarize the forum topic in two plain sentences.', instructions]
.filter(Boolean)
.join('\n'),
prompt: `${input.title}\n\n${input.content}`,
}),promptVersion is stored with every run, so the AI logs show which prompt produced which result.
Input and output
inputSchemavalidates the input first. Invalid input fails withAI_INVALID_INPUT.measureInput(input)returns the characters counted againstmaxInputCharacters. Override it when only part of the input reaches the prompt. The blog's article review counts the title and at most 30,000 characters of the article.output: "text": the model returns text,parseText(text, input)turns it into your value, thenoutputSchemachecks it.output: "object": the model returns JSON through the AI SDK'sOutput.object, andoutputSchemavalidates it. The action must requirestructured-output, and it cannot be streamed.
An empty answer, a parseText() that throws or a schema mismatch fails with AI_INVALID_OUTPUT, and the user is not charged.
const topicTitleIdeasAiAction = defineAiAction({
id: 'topic.title-ideas',
title: 'ai_actions.@vitnode/forum.topic_title_ideas.title',
description: 'ai_actions.@vitnode/forum.topic_title_ideas.description',
promptVersion: 1,
permission: { key: 'summarize', defaultGranted: true },
requiredCapabilities: ['text', 'structured-output'],
defaults: { maxInputCharacters: 20_000, maxOutputTokens: 200, timeoutMs: 30_000 },
inputSchema: z.object({ content: z.string().min(1) }),
output: 'object',
outputSchema: z.object({ titles: z.array(z.string().max(120)).max(3) }),
buildPrompt: (input) => ({
system: 'Suggest up to three short titles for this forum topic.',
prompt: input.content,
}),
authorize: async ({ c }) =>
await checkStaffPermission(c, {
type: 'admin',
plugin: manifest.id,
module: topicContentType.permissionModule,
permission: 'can_view',
}),
})It sits next to Summarize topic and reuses that file's imports. Both actions use the summarize permission, so they share one role switch and one daily counter. Actions that share a key must declare the same defaultGranted, or boot fails.
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 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 run them. See Model capabilities.
Permissions and authorize()
Every action names a permission, which admins grant per role on the Artificial intelligence (AI) tab of the role form:
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 stored permission is <pluginId>:<key>, for example @vitnode/forum:summarize.
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, or the run fails with AI_UNAUTHORIZED. It receives { c, input, resource, userId }, where resource is the optional { type, id } the caller passed to run().
Actors
actors decides who may run an action. The server picks the actor; a 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 |
run() uses the signed-in AdminCP user first, then the site user, and fails with AI_UNAUTHORIZED when there is neither. A system-only action (actors: ['system']) needs no authorize(). Call runAsSystem() only from trusted server code such as cron jobs, queue tasks and event listeners; Run an action in a background job shows one.
Defaults and limits
defaults sets an action's limits. Admins can override most of them per action on Artificial Intelligence (AI) ā Actions. A value outside its range throws 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 wins over the admin's action setting, which wins over this default.- Admins cannot change
maxImages, because it bounds the budget reserved for images.
Admins can also switch an action off, assign it a model and a fallback model, and add instructions.
Run options
c.get("ai").run(), runAsSystem(), stream() and estimate() take the same request:
Prop
Type
Run result
| Method | Returns |
|---|---|
run(), runAsSystem() | Promise<{ output, runId, usage }> |
stream() | Promise<{ textStream, result, runId }>. See Streaming. |
estimate() | Promise<{ maxPoints, maxUsd }>, the most the run could cost as decimal strings, or null without pricing |
{
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
},
}usage is safe to send to the browser. It never contains prices or provider details.
What happens during a run
run(),stream()andestimate()find the signed-in user (AI_UNAUTHORIZEDwithout one).- Find the action (
AI_ACTION_UNKNOWN), check the actor, validateinput(AI_INVALID_INPUT). - Check that
ai.modelsis not empty (AI_NOT_CONFIGURED), then the global AI switch and the action's switch. - For users: check the role permission, then
authorize(). - Measure the input against
maxInputCharacters. - Pick the model: the admin's choice, or the first configured model with every required capability.
stream()also requiresstreaming. Resolve the optional fallback, skipping an incompatible one. - Build the prompt and count its images against
maxImages. - 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 at once. An invalid answer is not retried on the same model, but the fallback model still gets one try. Nothing before step 8 costs anything. Costs and budgets explains steps 8 and 11.