---
title: "Chapter 7: AI"
description: "Add aiFeature() to the meeting-notes editor, provide the /api/ai endpoints and keys, and describe the callout to the model with aiContext."
canonical_url: "https://skaile-ai.github.io/forge-editor/tutorial/ai"
---
# Chapter 7: AI

> Add aiFeature() to the meeting-notes editor, provide the /api/ai endpoints and keys, and describe the callout to the model with aiContext.

The meeting-notes editor can summarise, rewrite and extend text, and a sidebar can chat with the document and propose edits. All of it is one feature, `aiFeature()`. This chapter adds it, shows what the server side needs, and then does the one thing only you can do: tell the model that a „Callout“ block exists and how to write it.

This chapter has no live demo, because the model calls need a server and a provider key. Run the repository's Nuxt app (`bun run dev`, port 3056) with a key in `.env` and open `/kitchen-sink/surface` (the callout feature with the AI sidebar) or `/kitchen-sink/simple` (`SimpleEditor` with `aiFeature()`) to try it.

## Add the feature

`aiFeature()` adds a selection menu (rewrite tasks such as shorter, formal or translate, plus free prompts), the `/ai` command and a „KI“ toolbar group, in-document review of rewrites (accept or reject per change), ghost autocomplete while typing (Tab accepts, Esc dismisses), model profiles and the `ai` settings tab. The sidebar (document chat and block generation) and the quick model switch are components you place yourself; the sidebar comes from `@skaile/forge-editor/ai/vue/chat` and needs the optional peer `@skaile/forge-chat`. It needs no Typed blocks, transcript, media, mentions or Yjs; `bun run check:editor-entries` proves it with a `core + ai` fixture.

```vue
<script setup lang="ts">
import { aiFeature } from '@skaile/forge-editor/ai/vue'
import { AiSidebar } from '@skaile/forge-editor/ai/vue/chat'
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { EditorSurface, EditorToolbar } from '@skaile/forge-editor/vue'
import type { Editor } from '@tiptap/vue-3'
import { shallowRef } from 'vue'

const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature(), aiFeature()] })
const editor = shallowRef<Editor>()
const sidebarOpen = shallowRef(false)
</script>

<template>
  <EditorToolbar :editor="editor" />
  <div class="flex">
    <EditorSurface v-model="content" v-model:sidebar-open="sidebarOpen" :preset="preset" class="flex-1" @ready="editor = $event" />
    <AiSidebar v-model:open="sidebarOpen" :editor="editor" class="w-[380px]" />
  </div>
</template>
```

`aiFeature()` needs no component setup: the model profiles come from the app's editor context, and its notices (rate limits, import problems) go to the surface's notifier. Import `@skaile/forge-editor/styles/ai.css` after `core.css`. The Vue sidebar renders on `@skaile/forge-chat` (its `ChatPanel`), an optional peer of the editor package: install it, import `@skaile/forge-chat/styles/chat.css` and add `@source "…/node_modules/@skaile/forge-chat/dist";` next to the one for the library; a Nuxt host also lists `'@skaile/forge-chat/nuxt'` after `'@nuxt/ui'` in `modules`. The `AdvancedEditor` of chapter 6 already contains the feature (`ai: true`); `SimpleEditor` takes it through its `add` prop.

## The server side

The feature talks to its endpoints through the backend's `ai` port. The Nuxt module mounts the routes for you; with another transport you provide them yourself. The handlers in [`packages/editor/src/server/handlers/ai.ts`](https://github.com/skaile-ai/forge-editor/blob/main/packages/editor/src/server/handlers/ai.ts) are the reference implementation.

<table>
<thead>
  <tr>
    <th>
      Endpoint
    </th>
    
    <th>
      Purpose
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        GET /api/ai/models
      </code>
    </td>
    
    <td>
      <code>
        { presets: ModelProfile[], roles: Record<ModelRole, string> }
      </code>
      
      : the server Model presets as Model profiles (no keys) and the default Model preset per Model role (<code>
        complete
      </code>
      
      , <code>
        rewrite
      </code>
      
      , <code>
        generate
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        POST /api/ai/generate
      </code>
    </td>
    
    <td>
      SSE (<code>
        delta
      </code>
      
      , <code>
        done
      </code>
      
      , <code>
        error
      </code>
      
      ) for selection tasks and block generation
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        POST /api/ai/chat
      </code>
    </td>
    
    <td>
      SSE for the sidebar's document chat with edit operations
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        POST /api/ai/complete
      </code>
    </td>
    
    <td>
      <code>
        { text }
      </code>
      
       continuation for ghost autocomplete
    </td>
  </tr>
</tbody>
</table>

API keys never reach the client. In Nuxt the module's `tiptapEditor.ai` options (Model presets, default Model preset per Model role, rate limit) and these environment variables supply the server side:

- `NUXT_TIPTAP_EDITOR_AI_API_KEY`: the shared fallback key for every preset.
- `NUXT_TIPTAP_EDITOR_AI_PRESETS_<ID>_API_KEY`: the key of one preset (`flashLite` becomes `NUXT_TIPTAP_EDITOR_AI_PRESETS_FLASH_LITE_API_KEY`).
- `NUXT_TIPTAP_EDITOR_AI_TRANSCRIPTION_API_KEY`: a Deepgram key; the repository's config enables transcription only when it is set.

Public AI configuration is inert data passed to `createEditor`, for example `createEditor({ ai: { roles: { complete: 'flashLite', rewrite: 'sonnet', generate: 'sonnet' }, transcription: false } })`: the default Model preset per Model role until the user picks one in the `ai` settings tab. The full description is in [AI configuration](https://skaile-ai.github.io/forge-editor/guides/ai-configuration) and [AI](https://skaile-ai.github.io/forge-editor/guides/ai).

## Teach the model about the callout

Without help the model sees a flat document with no custom Block types. Features adjust that through the `aiContext` hook, and a block feature uses its `blocks` list. First what the model **sees** and what it **can write**:

- **What the model sees.** `editor.getMarkdown()` and every `editor.markdown.serialize(…)` (selection text, document context, summaries) use your `renderMarkdown` from chapter 4. The chat's document view lists *text blocks* as `[<block id>] <Markdown>` lines. A wrapper block like the callout appears as a header line with the block's own ID and its Markdown opener, `[<id>] !!! info`; its body blocks follow two spaces further in, each with its own ID. This is why `editorBlock: true` matters: the model can address, update, delete, extend or convert a block by that ID.
- **What the model can write.** AI results are parsed by the block pipeline (`parseBlocks`), so Markdown in your syntax becomes your node. The model knows the syntax only if you describe it in `aiContext.blocks`; the library does not guess it from `renderMarkdown`.

The callout contributes its description like this:

```ts [app/editor/callout/feature.ts]
aiContext: () => ({
      blocks: [{
        id: 'callout',
        label: 'Callout',
        description: 'A colour-highlighted box for a note (info), a warning (warning) or something completed (success). Holds paragraphs, lists or quotes, no headings.',
        syntax: '!!! <info|warning|success>\n<content, blocks separated by a line break; a blank line ends the callout>',
        example: '!!! warning\nPlease check the figures before sending.',
      }],
    }),
```

Each `AiBlock` (a type exported from `@skaile/forge-editor/core`) describes one host block:

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Meaning
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        id
      </code>
    </td>
    
    <td>
      The node type name (<code>
        callout
      </code>
      
      ); doubles as the <code>
        convert
      </code>
      
       target id
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        label
      </code>
    </td>
    
    <td>
      The block's name in the document's language
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        description
      </code>
    </td>
    
    <td>
      One or two sentences on its purpose and allowed content
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        syntax
      </code>
    </td>
    
    <td>
      The Markdown form with placeholders in angle brackets
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        example
      </code>
    </td>
    
    <td>
      One complete Markdown example
    </td>
  </tr>
</tbody>
</table>

What happens with it:

- **aiContext is additive.** Any number of features may contribute. `defineEditor` merges the contributions: `blockTypes` and `blocks` concatenate in feature order, and `structure` (the Structure mode: `'flat'` or `'structured'`) may come from one feature only. A block `id` described twice, or two features that set `structure`, is reported when the merged context is first read (`aiContextOf(editor)`), not when you call `defineEditor`.
- **The prompts.** The chat lists the blocks in its system prompt and sends them with each request; the generate tasks (sidebar and selection menu) list them too. A result may then contain the block in your syntax, and the block pipeline turns it into your node. `syntax` and `example` must match `renderMarkdown` and the tokenizer, or the result will not parse back into the node.
- **convert targets.** The chat sends the model its list of `convert` targets: the core ids, the Block types of `aiContext.blockTypes`, and each `blocks` entry whose id a registered conversion provider parses. The callout's `conversions` from chapter 5 do, so „Umwandeln in Callout“ is also an agent operation. A block without such a provider is not a `convert` target, but the model may still `add` or `update` it through Markdown in its syntax.

### `aiBlockData`

A second hook, `aiBlockData: ({ editor, node, pos, signal }) => AiBlockDatum[] | Promise<AiBlockDatum[]>`, supplies material for one Typed block (examples, a record) before „Block füllen (KI)“ asks the model. All contributions run in parallel and concatenate in feature order. The library only collects and caps the entries; the fetch belongs to your feature, and `signal` aborts it with the editor. The callout does not need it; the repository's Epikrise host feature (`app/editor/epikrise/feature.ts`, on `/kitchen-sink/epikrise`) shows one that does.

## What an agent edit does

The model proposes changes to the document through the `edit_document` tool. The server checks them (`checkAgentOps`, from `@skaile/forge-editor/ai/protocol`) and streams the valid ones; the client shows each as an in-document review change the user accepts or rejects. There are four operation types (`AGENT_OP_TYPES`):

<table>
<thead>
  <tr>
    <th>
      Type
    </th>
    
    <th>
      Effect
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        add
      </code>
    </td>
    
    <td>
      Insert block(s) after a block ID; <code>
        afterId: null
      </code>
      
       is the document start. For the callout, <code>
        add
      </code>
      
       after a body block inserts <em>
        inside
      </em>
      
       the callout.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        update
      </code>
    </td>
    
    <td>
      Replace block <code>
        id
      </code>
      
      ; a single paragraph is diffed within the block.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        delete
      </code>
    </td>
    
    <td>
      Remove the block.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        convert
      </code>
    </td>
    
    <td>
      Change the block type, keeping the content; <code>
        to
      </code>
      
       must be one of the request's targets.
    </td>
  </tr>
</tbody>
</table>

So after this chapter the model can write `!!! warning` blocks into your notes, change an existing hint's text through its ID, or convert a paragraph into a callout. Invalid operations are dropped with a reason (`unknown`, `scope`, `field`, `duplicate`, `limit`, `target`, `inside`) and the outcome of every operation is fed back to the model with the next message. The complete tables, limits and the Typed block Markdown fence format are in [AI](https://skaile-ai.github.io/forge-editor/guides/ai).

## What you have now

An editor with AI on every level: selection rewrites, ghost autocomplete, a chat sidebar with reviewable edit operations, and a model that knows the „Callout“ block exists, how to write it, and that it can convert into it.

## Next

[Chapter 8: The React surface](https://skaile-ai.github.io/forge-editor/tutorial/react-surface) shows the same editor on React.


## Sitemap

See the full [sitemap](https://skaile-ai.github.io/forge-editor/sitemap.md) for all pages.
