Logo VitNode

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.
Use actions for anything users can trigger

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

plugins/forum/src/api/ai/actions.ts
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.

plugins/forum/src/locales/en.json
{
  "ai_actions": {
    "@vitnode/forum": {
      "topic_summarize": {
        "title": "Summarize topic",
        "description": "Writes a two-sentence summary of a forum topic."
      }
    }
  }
}

Register it in the plugin

plugins/forum/src/config.api.ts
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

plugins/forum/src/api/modules/admin/ai/routes/summarize.route.ts
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.generate

Two 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.title for "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. languages or wand-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.

Plain text still works

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 with AI_INVALID_INPUT.
  • measureInput(input) returns the number of characters counted against maxInputCharacters. 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, then outputSchema checks it. Throw from parseText() to reject an answer.
  • output: "object" makes the model return structured JSON, which outputSchema validates. The action must require the structured-output capability, 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.

An object action
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.

ActorRuns withCharged to
userc.get("ai").run()The site budget and the user's AI points
systemc.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.

plugins/forum/src/api/cron/tag-topics.cron.ts
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.

FieldRequiredDefaultRange
maxInputCharactersYes-1 – 1,000,000
maxOutputTokensYes-1 – 200,000
timeoutMsYes-1,000 – 600,000
maxRetriesNo00 – 3
maxStepsNo11 – 10
maxImagesNo1 with image-input, else 00 – 10
dailyLimitNonull (no limit)0 – 100,000
  • timeoutMs applies to each attempt.
  • maxRetries is extra attempts on the same model after a failed provider call.
  • maxSteps is model round trips inside one attempt (tool loops). Each step is billed as its own call.
  • dailyLimit counts 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

  1. Find the action, check the actor, validate input.
  2. Check the global AI switch and the action's switch.
  3. For users: check the role permission, then authorize().
  4. Measure the input and the number of images.
  5. Pick the model: the admin's choice, or the first configured model with every required capability. Resolve the optional fallback, skipping an incompatible one.
  6. Reserve budget for the worst case and check rate and concurrency limits, in one short database transaction.
  7. Call the provider outside any transaction. Retry up to maxRetries times, then try the fallback model once.
  8. Validate the output.
  9. Settle: charge the real cost and release the rest of the reservation.
  10. Emit ai.run.completed or ai.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.

CodeStatusWhen
AI_NOT_CONFIGURED400No ai.models are configured.
AI_INVALID_INPUT400The input failed inputSchema, or an object action was streamed.
AI_INPUT_TOO_LARGE413The input exceeds maxInputCharacters or maxImages.
AI_DISABLED503An admin switched AI off for the whole site.
AI_ACTION_DISABLED403An admin switched this action off.
AI_ACTION_UNKNOWN404No installed plugin registers this key.
AI_UNAUTHORIZED403Not signed in, no AI permission, authorize() refused, or wrong actor.
AI_MODEL_INCOMPATIBLE409No model has the required capabilities.
AI_PRICING_MISSING409The model has no pricing while a budget cap applies.
AI_USER_LIMIT_REACHED429The user's monthly AI points are used up.
AI_DAILY_LIMIT_REACHED429The user reached today's limit for this permission.
AI_RATE_LIMITED429Too many runs started in the last minute.
AI_CONCURRENCY_LIMITED429Too many runs in flight.
AI_BUDGET_EXHAUSTED503The site budget (or the system budget) for this month is used up.
AI_DUPLICATE_REQUEST409The idempotencyKey was already used.
AI_PROVIDER_FAILED502The provider returned an error.
AI_INVALID_OUTPUT502The answer was empty or failed parseText() / outputSchema.
AI_TIMEOUT504The provider took longer than timeoutMs.
AI_CANCELED408The 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:

EventPayload
ai.run.completedactionKey, actorType, runId, userId
ai.run.failedactionKey, 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.

plugins/forum/src/api/lib/ai-events.ts
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.

Learn more