Documentation

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.

Why not call generateText directly?

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 text capability in ai.models of your API config. See AI setup.
  • The @vitnode/forum plugin 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:

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 { 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,
)
  • id is local to the plugin. Core stores the action as @vitnode/forum:topic.summarize.
  • permission is the switch admins flip per role. defaultGranted: true keeps it on until a role says otherwise.
  • authorize() asks whether this admin may view forum topics. topicContentType.permissionModule is topic, derived from the type id forum.topic.
  • aiActionRef() builds the canonical key and carries the input and output types, so run() 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:

plugins/forum/src/locales/en.json (excerpt)
{
  "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:

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

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

plugins/forum/src/api/modules/admin/ai/ai.admin.module.ts
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:

plugins/forum/src/api/modules/admin/admin.module.ts (excerpt)
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.

  1. Open Artificial Intelligence (AI) → Actions. A Summarize topic row appears with its description and icon. Nothing has been spent yet.

  2. Add a temporary button that calls the route with fetcher. Create src/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 as src/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.

  3. Open Debug Panel → AI logs. The newest row is @vitnode/forum:topic.summarize with status succeeded, 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.