Chapter 7: AI
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.
<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 are the reference implementation.
| Endpoint | Purpose |
|---|---|
GET /api/ai/models | { presets: ModelProfile[], roles: Record<ModelRole, string> }: the server Model presets as Model profiles (no keys) and the default Model preset per Model role (complete, rewrite, generate) |
POST /api/ai/generate | SSE (delta, done, error) for selection tasks and block generation |
POST /api/ai/chat | SSE for the sidebar's document chat with edit operations |
POST /api/ai/complete | { text } continuation for ghost autocomplete |
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 (flashLitebecomesNUXT_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 and 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 everyeditor.markdown.serialize(…)(selection text, document context, summaries) use yourrenderMarkdownfrom 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 whyeditorBlock: truematters: 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 inaiContext.blocks; the library does not guess it fromrenderMarkdown.
The callout contributes its description like this:
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:
| Field | Meaning |
|---|---|
id | The node type name (callout); doubles as the convert target id |
label | The block's name in the document's language |
description | One or two sentences on its purpose and allowed content |
syntax | The Markdown form with placeholders in angle brackets |
example | One complete Markdown example |
What happens with it:
aiContextis additive. Any number of features may contribute.defineEditormerges the contributions:blockTypesandblocksconcatenate in feature order, andstructure(the Structure mode:'flat'or'structured') may come from one feature only. A blockiddescribed twice, or two features that setstructure, is reported when the merged context is first read (aiContextOf(editor)), not when you calldefineEditor.- 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.
syntaxandexamplemust matchrenderMarkdownand the tokenizer, or the result will not parse back into the node. converttargets. The chat sends the model its list ofconverttargets: the core ids, the Block types ofaiContext.blockTypes, and eachblocksentry whose id a registered conversion provider parses. The callout'sconversionsfrom chapter 5 do, so „Umwandeln in Callout“ is also an agent operation. A block without such a provider is not aconverttarget, but the model may stilladdorupdateit 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):
| Type | Effect |
|---|---|
add | Insert block(s) after a block ID; afterId: null is the document start. For the callout, add after a body block inserts inside the callout. |
update | Replace block id; a single paragraph is diffed within the block. |
delete | Remove the block. |
convert | Change the block type, keeping the content; to must be one of the request's targets. |
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.
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 shows the same editor on React.
Chapter 6: Backend and collaboration
Register the Nuxt module, hand it EditorPorts for identity and storage, and open one document in two browsers with the collaboration session.
Chapter 8: The React surface
Run the meeting-notes editor on React: EditorContextProvider, EditorSurface, EditorToolbar, SimpleEditor and the Toaster, and where the callout would go.