Documentation

AI

Field suggestions

Add an AI suggestion button to a Content Engine text or textarea field in the VitNode AdminCP. Point the field's ai option at a registered AI action, and editors review each suggestion before it is used.

Give a Content Engine text or textarea field an ai option and the AdminCP form shows an AI button next to it. The button runs a registered AI action with other fields of the item as input, shows the suggestion for review, and writes nothing until the editor accepts it. You write no route, no button and no form code.

On this page the newsroom plugin suggests a story teaser (excerpt) from the story's title and reference code. It is the real code from the newsroom plugin.

Before you begin

  • A language model with the text capability in ai.models. See AI setup.
  • A Content Engine content type with a text or textarea field to fill, and the fields it should be written from. The example uses newsroom.story; see Create a content type to make one.

Add AI to a field

Define the action

The field sends one string per source field, plus locale for the language being edited. Match inputSchema to that shape:

plugins/newsroom/src/api/ai/actions.ts
import { defineAiAction } from '@vitnode/core/api/lib/ai/action'
import { languageName } from '@vitnode/core/api/lib/ai/language-name'
import { checkStaffPermission } from '@vitnode/core/api/lib/check-staff-permission'
import { z } from 'zod'

import { storyContentType } from '@/content/story'
import { manifest } from '@/manifest.gen'

export const storyExcerptAiAction = defineAiAction({
  authorize: async ({ c }) =>
    await checkStaffPermission(c, {
      module: storyContentType.permissionModule,
      permission: 'can_edit',
      plugin: manifest.id,
      type: 'admin',
    }),
  buildPrompt: (input, { instructions }) => ({
    prompt: `Title: ${input.title}\nReference code: ${input.code}`,
    system: [
      'You write a one-sentence teaser for a newsroom story.',
      `Write it in ${languageName(input.locale)}, under 200 characters.`,
      'Answer with the teaser only, without quotes.',
      ...(instructions ? [instructions] : []),
    ].join('\n'),
  }),
  defaults: {
    maxInputCharacters: 400,
    maxOutputTokens: 120,
    timeoutMs: 20_000,
  },
  description: 'ai_actions.newsroom.story_excerpt.description',
  icon: 'sparkles',
  id: 'story.excerpt',
  title: 'ai_actions.newsroom.story_excerpt.title',
  inputSchema: z.object({
    code: z.string().max(100),
    locale: z.string().min(2).max(16),
    title: z.string().min(1).max(200),
  }),
  output: 'text',
  outputSchema: z.string().min(1).max(500),
  parseText: (text) => text.trim().replace(/^["ā€œ](.*)["ā€]$/su, '$1'),
  permission: { defaultGranted: true, key: 'story.excerpt' },
  promptVersion: 1,
  requiredCapabilities: ['text'],
})

authorize asks for the story's own can_edit, because the AI permission never replaces access to the content. languageName turns the locale code into a language name, because models write better for "Polish" than for "pl".

Add the title and description

title and description are message keys. Add their text under ai_actions and the plugin id, next to the plugin's existing messages:

plugins/newsroom/src/locales/en.json (excerpt)
{
  "ai_actions": {
    "newsroom": {
      "story_excerpt": {
        "title": "Suggest story teaser",
        "description": "Suggests a teaser for a newsroom story."
      }
    }
  }
}

Register the action

plugins/newsroom/src/config.api.ts (excerpt)
import { storyExcerptAiAction } from '@/api/ai/actions'

export const newsroomApiPlugin = () =>
  buildApiPlugin({
    manifest,
    aiActions: [storyExcerptAiAction], 
    // ...blocks, modules and the rest stay unchanged
  })

Point the field at the action

Use the canonical key, <pluginId>:<id>. The newsroom plugin's id is newsroom:

plugins/newsroom/src/content/story.ts (excerpt)
excerpt: field.textarea({
  ai: {
    action: 'newsroom:story.excerpt',
    mode: 'suggestion',
    sourceFields: ['title', 'code'],
  },
  maxLength: 500,
  nullable: true,
}),

Give editors access

A root role can use the button right away. Any other editor's role needs the action's permission and an AI points allowance. Open Users → Roles, edit the role and use its Artificial intelligence (AI) tab. With defaultGranted: true the permission is on until a role says otherwise, but the default monthly points are 0. See AI access.

Check the result

Restart the dev server, open the newsroom stories in the AdminCP and create or edit a story. Fill in the title and the code, then click the AI button next to the excerpt field.

  1. Generate sends the source fields in the language being edited. While a source field is empty it stays disabled and names the field: "Fill in … first."
  2. The suggestion appears in a review panel with Discard, Try again and Use suggestion.
  3. Use suggestion puts the text into the field, in the edited language only. Nothing is saved until you save the form, which still goes through validation and revisions.

The panel warns before stale text replaces anything:

  • If the sources changed since the suggestion was made, it says "The text it was written from changed since. Try again for an up-to-date suggestion."
  • If the editor typed into the field after asking, the accept button becomes Replace my newer text.

The AI button appears only when this admin may start the action. That means AI is on, the action is enabled, a configured model can run it, and the admin's roles grant its permission. Each run shows up in Debug Panel → AI logs. Core records an accept or discard on the run for statistics only, and it never changes what was charged. Each error code has its own message, such as "The AI provider failed. Nothing was charged to you."

The ai field option

Prop

Type

ai is plain JSON on the field definition, so it reaches the browser too. Nothing AI-specific is stored with the content.

Mistakes fail early

  • When the content type is defined, defineContentType() throws if the key is not <pluginId>:<localId>, mode is not "suggestion", a source field does not exist, or the field lists itself as a source.
  • At boot, VitNode throws if no installed plugin registers the action, or the action is system-only (actors: ["system"]).

The input shape is not checked at boot. A mismatch between sourceFields and inputSchema shows up as AI_INVALID_INPUT on the first click.