---
title: "AI"
description: "Add AI to an editor: selection menu, sidebar, ghost text, backend endpoints, integrations with Typed blocks and transcripts, agent operations and fence formats."
canonical_url: "https://skaile-ai.github.io/forge-editor/guides/ai"
---
# AI

> Add AI to an editor: selection menu, sidebar, ghost text, backend endpoints, integrations with Typed blocks and transcripts, agent operations and fence formats.

## AI on a core editor

`aiFeature()` adds the AI features to a core editor: selection menu (rewrite tasks such as shorter/formal/translate and free prompts), the `/ai` command and a „KI" toolbar group, in-document review of rewrites (accept/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, `AiSidebar` from `@skaile/forge-editor/ai/vue/chat`) and the quick model switch are components you place yourself; only the sidebar needs `@skaile/forge-chat`. `aiFeature()` and everything else in `/ai/vue` load without it. 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, AiReviewBar, ModelQuickSwitch } 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 (`createEditor()` installed as a Vue plugin; `createEditorContext()` on React), and its notices (rate limits, import problems) go to the surface's notifier. The React binding is `aiFeature()` from `@skaile/forge-editor/ai/react` with the same components and hooks (`useAiAssistant`, `useDocumentChat`, `useModelProfiles`); the framework-free `@skaile/forge-editor/ai` holds `aiFeatureCore(ui)` and the models behind the hooks (`createAiAssistant`, `modelProfiles`). Import the CSS after `core.css`:

```css
@import "@skaile/forge-editor/styles/ai.css";
```

The Vue `AiSidebar` (`@skaile/forge-editor/ai/vue/chat`) renders on `@skaile/forge-chat` (its `ChatPanel`), an **optional peer** of the editor package: install it only when you place the sidebar. Importing `/ai/vue/chat` without it throws an error that names `@skaile/forge-chat` when the module loads unbundled (Node, SSR); in a bundled host (Vite, Nuxt) the bundler's own resolve error for `@skaile/forge-chat` appears first. The entry uses a top-level `await`, so the host's build target must be ES2022 or later. The chat resources belong to the host, not to `ai.css` or the editor's Nuxt module:

```css
@import "@skaile/forge-chat/styles/chat.css";
@source "../../../node_modules/@skaile/forge-chat/dist"; /* Tailwind skips node_modules; relative path from this CSS file */
```

In a Nuxt host also add `'@skaile/forge-chat/nuxt'` to `modules` after `'@nuxt/ui'`: it transpiles the package and lets Nuxt resolve its Nuxt UI components.

Components: `AiSidebar` (`editor`; `v-model:open`), `ModelQuickSwitch` (`slot`: the Model role, optional `size` and `editor`; the choice applies to the whole Model role; `editor` makes „Modelle verwalten…" open that editor's settings when the switch sits outside an `EditorSurface`), `AiReviewBar` (`editor`). `AiSelectionMenu` and `AiReviewBar` are overlays the surface mounts for the feature; they are exported for hosts that render their own editor chrome.

### Backend contract and configuration

The feature talks to its endpoints through the backend's `ai` port (`EditorContext.backend.ai`; the default HTTP backend calls the routes below, same origin; a host with another transport passes its own `EditorBackend`). You provide the endpoints; the handlers in `packages/editor/src/server/handlers/ai.ts` (mounted by `installEditorServer`, route table `packages/editor/src/server/routes.ts`) are the reference implementation and import the shared protocol from the package:

<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>

The framework-free protocol (`AI_TASKS`, `AiTask`, `AiInput`, `ModelRef`, `ModelProfile`, `modelRefSchema`, `aiBlockSchema`, `AGENT_OP_TYPES`, `checkAgentOps`, `CHAT_LIMITS`, `AI_COMPLETE_LIMITS`, `AI_LIMITS`, …) is exported from `@skaile/forge-editor/ai` (and `/ai/protocol`), so a server may import the same constants and Zod schemas. A 429 from any endpoint shows one German „Zu viele KI-Anfragen, bitte kurz warten“ toast instead of an error.

Without a server these endpoints are replaced by `createDirectAi` (`@skaile/forge-editor/adapters/direct-ai`): an `ai` backend that runs `generate`, `complete`, `models` and `transcriptCleanup` against an OpenAI-compatible endpoint from the browser with the same protocol modules (`runGenerate`, `runCompletion`, `runTranscriptCleanup`). The key then lives in the page (the sentence below about keys holds for the server routes only), no rate limit applies, and the document chat and `extract` answer 501. See [Browser-direct AI](https://skaile-ai.github.io/forge-editor/guides/storage-adapters#browser-direct-ai).

Public AI configuration is inert data passed to `createEditor`:

```ts
app.use(createEditor({ ai: { roles: { complete: 'flashLite', rewrite: 'sonnet', generate: 'sonnet' }, transcription: false } }))
```

`roles` are the default Model preset IDs per Model role until the user chooses one in the `ai` settings tab (stored in `settings.ai.roles`). API keys never reach the client: user-added profiles with their own endpoint/key are kept in this browser's `localStorage` only. In Nuxt the module's `tiptapEditor.ai` options (Model presets, default Model preset per Model role, rate limit) and the env keys `NUXT_TIPTAP_EDITOR_AI_API_KEY` / `NUXT_TIPTAP_EDITOR_AI_PRESETS_<ID>_API_KEY` supply the server side.

### Extending what AI knows about the document

By default the AI sees a flat document with no custom Block types and no Host blocks. Features adjust that through three optional hooks on `EditorFeature`:

- `aiContext: (editor) => ({ blockTypes?, blocks?, structure? })` — what the model may build and how. It is **additive**: any number of features may contribute. `defineEditor` merges the contributions, concatenating `blockTypes` and `blocks` in feature order; `structure` (the Structure mode for generated content, `'flat'` | `'structured'`) may be set by one feature only. A block `id` described by two features, or a second feature setting `structure`, throws when the merged context is read (`aiContextOf(editor)`, which returns `{ blockTypes, blocks, structure }`), not when `defineEditor` runs.

  - `blockTypes` — the Block types of Typed blocks the model may build (`AiContextBlockType`: `id`, `label`, `fields` as Property definitions, `purpose?`). The composites feature contributes them.
  - `blocks` — Host blocks as `AiBlock { id, label, description, syntax, example }` (exported from `@skaile/forge-editor/core`): `id` is the node type name and the `convert` target id, `label` the German name, `description` its purpose and allowed content, `syntax` the Markdown form with placeholders in angle brackets, `example` one complete example. The document chat and the generate tasks (sidebar and selection menu) list them in the system prompt and send them with the request, so the model can write them; the chat offers a block as `convert` target when a registered conversion provider parses its id. See [tutorial chapter on AI](https://skaile-ai.github.io/forge-editor/tutorial/ai) for the callout's contribution.
- `formatAiResult: (editor, markdown, heading?) => string` — formats an AI result's Markdown (with the optional task heading) before the generic parse/normalize/structure pipeline. Without a contributor the result gets the normalized heading (`withHeading`); the `composite-ai` integration (below) contributes the Typed block form for Structure mode. At most one feature may contribute it; `defineEditor` throws on a second one.
- `aiBlockData: ({ editor, node, pos, signal }) => AiBlockDatum[] | Promise<AiBlockDatum[]>` — material a feature supplies for one Typed block before „Block füllen (KI)“ asks the model (`AiBlockDatum { label, text }`; the three types are exported from `@skaile/forge-editor/core`). It is **additive**: every contributor runs for the Typed block in parallel, and `defineEditor` concatenates their entries in feature order (`aiBlockDataOf(editor)` returns the merged provider; without contributors it resolves to no data). The library only collects: the fetch, the ranking and what the entries mean belong to your feature. `signal` aborts when the editor is destroyed. A contributor that throws or rejects cancels the fill with the notice „Daten für den Block fehlen“, and no generate request is sent. The kitchen sink's `epikriseFeature()` (`app/editor/epikrise/feature.ts`, on `/kitchen-sink/epikrise`) fetches two example paragraphs from its own route for the reason-for-admission Block type.

AI results are parsed with `parseBlocks(editor, markdown)`: inline replacements use the pre-segmentation `parsed` content, block insertion uses `blocks`.

### Positions and collaboration

Selection menu, sidebar and review capture ranges as document anchors. Without an `anchorResolver` anchors map through local transactions only, and the review writes with a `closeHistory` step so rewriting does not merge into typing. A collaboration feature contributes an `AnchorResolver` that keeps anchors valid across remote edits and wraps the review's undo step; the AI feature imports no Yjs code either way.

## AI integrations: Typed block AI and transcript AI

`aiFeature()` knows neither Typed blocks nor transcripts. Two integration features connect it to them; each imports only the features it names, no parent feature imports the integration, so `core + ai`, `core + composites` and `core + transcript` bundles stay free of them (`bun run check:editor-entries`: fixtures `composite-ai` and `transcript-ai` allow exactly their features plus the integration, and the `ai`, `composites` and `transcript` fixtures forbid any integration module).

<table>
<thead>
  <tr>
    <th>
      Factory (<code>
        @skaile/forge-editor/integrations/…
      </code>
      
      )
    </th>
    
    <th>
      <code>
        requires
      </code>
    </th>
    
    <th>
      Contributes
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        composite-ai
      </code>
      
       → <code>
        compositeAiFeature()
      </code>
    </td>
    
    <td>
      <code>
        composites
      </code>
      
      , <code>
        ai
      </code>
      
      , <code>
        media
      </code>
    </td>
    
    <td>
      block actions „Block füllen (KI)“ (Block types with the <code>
        ai-brief
      </code>
      
       capability), „Zusammenfassung erzeugen (KI)“, “Create readable rendition” (English label), „Neu erzeugen (ersetzen)“, „Ableiten (KI)“, „Literaturangaben abrufen“, „Mit KI in Unterblöcke teilen“ / „Mit KI in Blöcke teilen“ and „Gliederung bereinigen (KI)“ (also a ⌘K command); the single <code>
        formatAiResult
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        transcript-ai
      </code>
      
       → <code>
        transcriptAiFeature()
      </code>
    </td>
    
    <td>
      <code>
        transcript
      </code>
      
      , <code>
        ai
      </code>
    </td>
    
    <td>
      block action „Transkript bereinigen (KI)“ and the same row in the AI selection menu
    </td>
  </tr>
</tbody>
</table>

```ts
import { aiFeature } from '@skaile/forge-editor/ai/vue'
import { compositeFeature } from '@skaile/forge-editor/composites/vue'
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { compositeAiFeature } from '@skaile/forge-editor/integrations/composite-ai'
import { compositeMediaFeature } from '@skaile/forge-editor/integrations/composite-media'
import { transcriptAiFeature } from '@skaile/forge-editor/integrations/transcript-ai'
import { mediaFeature } from '@skaile/forge-editor/media/vue'
import { mentionsFeature } from '@skaile/forge-editor/mentions'
import { transcriptFeature } from '@skaile/forge-editor/transcript/vue'

// The integrations are framework-free and fit both surfaces; on React take the bindings from `/<x>/react`.
const preset = defineEditor({
  features: [
    coreContentFeature(), coreUiFeature(), mediaFeature(), transcriptFeature(), compositeFeature(), mentionsFeature(), aiFeature(),
    compositeMediaFeature(), compositeAiFeature(), transcriptAiFeature(),
  ],
})
```

Install each integration after the features it requires (the order of the list is the order of toolbar/menu entries; actions carry their own `order`). `defineEditor` throws `feature "composite-ai" requires "ai", which is not installed` (likewise for `composites`, `media`, and for `transcript-ai` without `transcript` or `ai`) — it never installs a missing dependency. `bun run check:editor-composition` loads the real modules and asserts every one of these rejections, the accepted full composition and the single-`formatAiResult` rule. CSS: the stylesheets of the features in the preset (`core.css`, `media.css`, `composites.css`, `transcript.css`, `ai.css`); the integrations bring none.

**compositeAiFeature()**

- **Fill a Typed block.** „Block füllen (KI)“ appears on a Typed block whose Block type has the `ai-brief` capability (its config is the AI brief, `BlockTypeAi`). It sends the block's brief (`compositeAiBrief(editor, pos)`: Block type and `purpose`, titles of the enclosing Typed blocks as `path`, title, description, non-empty property values with `select` option labels, the instruction — a non-empty `meta[ai.instructionsField]` replaces `ai.instructions` — and the AI content template (`ai.contentTemplate`) with `{key}` replaced by `meta[key]`, the current body as Markdown) plus the document as context to the task `fill-block` (`generate` Model role, `POST /api/ai/generate`, rejected with 400 when `block` is missing). The result opens the in-document diff review over the block's body; title and properties stay untouched. `fill-block` is offered nowhere else (`blockTasks()` skips tasks with `needsBlock`).
- **Material from features.** Before the request, `fillComposite` runs the Editor preset's merged `aiBlockData` for the Typed block (above). The entries travel as `block.data` in order, at most 10, each `text` cut to `AI_LIMITS.block.data` (20000) characters; the task prompt prints each as `<label>:` followed by the quoted text.
- **Typed block summary.** „Zusammenfassung erzeugen (KI)“ appears on a Typed block whose body has text (readable renditions included). It streams a `summarize` task (`rewrite` Model role) from `POST /api/ai/generate` and replaces the block's description with it; the details popover shows it as the summary. The description is anchored when the request starts, so edits made while it streams do not misplace the result.
- **Readable rendition.** “Create readable rendition” appears on a Typed block whose Block type has the `source` capability, i.e. holds a Source (the built-in `document`, `recording`, `paper`, `book`, `figure`) and whose Source media block (`audio|video|image|file` in the body, found by `sourceOf`) is a stored asset; for an audio/video source only when the app's public AI configuration has `transcription: true` (`createEditor({ ai: { transcription } })`). It calls `backend.ai.extract` (HTTP: `POST /api/ai/extract`, `method: 'auto'`, `bibliography` when the Block type has a `bibliography` property) and adds a Rendition block (Block type `rendition`) right after the Source media block: timestamped `transcriptSegment`s (with the transcript feature; else paragraphs) for a recording, the structured full text for a PDF (chapters become nested `section` Typed blocks in Structure mode; page boundaries become italic „S. N“ paragraphs), the description for an image. Its title is „Transkript“, „Volltext“, „Volltext (OCR)“ or „Beschreibung“, and `meta.derivation` records asset, method, the model that wrote the content (the speech-to-text model of a transcription, the chat model for OCR and a description, none for the PDF text layer), the asset hash (`inputHash`), the hash of the written body's Markdown (`outputHash`) and the stored raw output. Bibliographic data the server found fills an empty `meta.bib` (never its `title`) and an empty Typed block title; server `warnings` (`ocr-unavailable`, `ocr-truncated`, `crossref-unavailable`, `pages-truncated`, `bibliography-failed`) show as a notice. A second run adds another version. „Neu erzeugen (ersetzen)“ on an asset-derived readable rendition replaces it in place and keeps its block id; when its body was edited since (`outputHash` differs) a warning notice offers „Trotzdem ersetzen“ first. The Typed block is anchored through its description when the request starts, so edits while it runs do not misplace the result. Existing Typed blocks of type `document` with the former flat `meta.transcript` are migrated into such a Readable rendition child on load (also for documents with stored collaboration state). Your server provides the endpoint; `extractPost` in `packages/editor/src/server/handlers/ai.ts` is the reference.
- **Derive.** „Ableiten (KI)“ (a menu of every Block type with an AI brief, `derivableBlockTypesOf`) appears on a Typed block with a block ID (`isDerivableSource`) wherever a Typed block of that Block type may follow it (`canDeriveAfter`). It inserts a sibling Typed block, a Derived block, titled `<Block type>: <source title>` with `meta.derivation = { from: { block }, method: 'ai:<kind>', inputHash }`, generates its body from the source's Markdown with the `fill-block` brief (`generateCompositeBody`, the request part of `fillComposite`) and writes it directly; an empty or failed result removes the new Typed block again. On a Rendition block it produces a translation or a summary as a rendition of a rendition.
- **Literaturangaben abrufen.** The block action „Literaturangaben abrufen“ (and the bibliography editor's „DOI abrufen“, through the editor command `fetchCompositeBibliography`) appears on a Typed block whose Block type has a `bibliography` property and that has a DOI (`meta.doi` or `meta.bib.DOI`, read by `doiOf`) or a stored PDF source. It calls `backend.ai.extract` with `method: 'bibliography'` (Crossref by DOI, else model extraction from the first pages) and stores the CSL result in `meta.bib`; an empty Typed block title takes the work's title.
- **AI results in Structure mode.** With the `structured` structure setting (Structure mode), an AI result that carries a heading (sidebar „block generation“, selection/menu tasks with a heading) is formatted as a `section` fence (`::: section` with the heading and the result inside), so the generic pipeline inserts it as a `section` Typed block. In Flat mode (`flat`), or without a heading, the result is unchanged from the base AI behaviour. The feature is the preset's only `formatAiResult` contributor — the base AI feature stays free of Typed blocks.
- **Exports** (`@skaile/forge-editor/integrations/composite-ai`): `compositeAiFeature`, `fillComposite(editor, pos, flow)`, `generateCompositeBody(editor, pos, flow, signal)` (with `CompositeBodyResult`), `summarizeComposite(editor, pos, flow)` (`flow` is an `AiFlowContext`: `{ ai, modelRef, notify, t }`, exported too), `createRendition(editor, pos, flow, options?: RenditionOptions)` (`{ replace? }`), `regenerateRendition(editor, pos, flow)`, `canCreateRendition(editor, node, transcription)`, `canRegenerateRendition(editor, node, pos, transcription)`, `deriveComposite(editor, pos, kindId, flow)`, `canDeriveAfter`, `derivableBlockTypesOf`, `isDerivableSource`, `fetchBibliography(editor, pos, flow, documentId?)`, `canFetchBibliography`, `doiOf`, `compositeSourceText`, `COMPOSITE_BODY_START` (`ai` is `backend.ai`). Notices go through the surface's `notify` in the language of `t` (`ctx.t`), the model through `modelProfiles(app)`.
- **Splitting and outline cleanup.** `splitCompositeWithAi(editor, pos, mode, flow, { targetChars, defaultBlockType })` (`mode`: `'children'` or `'siblings'`; the model only inserts `##` headings, `bodyUnchanged` guards that the text is unchanged, `splitByInsertedHeadings` is the pure grouping) and `sanitizeOutline(editor, flow)` (the repaired outline in one transaction; `collectHeadings`, `parseOutlineResponse`, `applyOutline` are the pure parts, `OutlineHeading` and `OutlineEntry` the types). Both are exported from `@skaile/forge-editor/integrations/composite-ai` and explained, with the manual and length splits, in [Content tree mechanics](https://skaile-ai.github.io/forge-editor/guides/content-tree#splitting-a-typed-block). `writeDerivedBody` and `discardDerived` (the write-back of „Ableiten (KI)“) are exported too; the [transfer](https://skaile-ai.github.io/forge-editor/guides/content-tree#block-transfer) transforms reuse them.

**transcriptAiFeature()**

- **Transcript cleanup.** „Transkript bereinigen (KI)“ is a block action on a speaker segment or a block (Typed block, quote, list) holding segments, and „Transkript bereinigen“ is a row of the AI selection menu over a selection that overlaps segments. It sends `{ id, speaker, text }` per segment to `POST /api/ai/transcript-cleanup` and opens the cleaned texts as an in-document AI review, one change per segment. The model returns only new texts keyed by block ID, so speakers, order and segment count cannot change; over-long, stale and unusable segments are skipped with a German notice. Without `transcriptAiFeature()` neither the action nor the menu row exists.
- **Server contract.** `@skaile/forge-editor/integrations/transcript-ai/protocol` is Vue-free and server-importable: `TRANSCRIPT_CLEANUP_LIMITS`, `transcriptCleanupRequestSchema`, `transcriptCleanupMessages`, `transcriptCleanupChunks`, `parseTranscriptCleanup` and the request/response types. Your endpoint validates the request with the schema, cleans in chunks and answers `{ cleaned: [{ id, text }], failed }`.
- **Exports** (`@skaile/forge-editor/integrations/transcript-ai`): `transcriptAiFeature` (its `setup` teardown aborts a running cleanup), `createTranscriptCleanup(modelRef, ai, notify, t)` (`ai` is `backend.ai`) → `{ running, cleanup, abort }`, `hasTranscriptSegments`.
- Speaker-aware transformations that normalization already covers — `**Name:** text` lines becoming segments in imported or AI-written Markdown — stay with the `transcript` feature's `segment` stage; the integration adds only the cleanup.

## Reference: AI surfaces, agent operations and Typed block fences

### In-block AI versus block generation

- **In-block AI** (`rewrite` Model role) works on text inside one block or a selection and is applied through the review diff: the selection tasks, the Typed block summary and transcript cleanup. Ghost autocomplete is in-block too but has its own `complete` Model role.
- **Block generation** (`generate` Model role) produces, replaces, converts or removes whole blocks: the tasks `generate`, `continue` and `transcript-*`, plus the chat agent operations. `fill-block` (block action „Block füllen (KI)“) is a `generate` Model role task too, but only reachable through that action.

<table>
<thead>
  <tr>
    <th>
      Surface
    </th>
    
    <th>
      What
    </th>
    
    <th>
      Model role
    </th>
    
    <th>
      Route
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Ghost autocomplete (<code>
        ai.autocomplete
      </code>
      
       setting)
    </td>
    
    <td>
      Inline continuation while typing
    </td>
    
    <td>
      <code>
        complete
      </code>
    </td>
    
    <td>
      <code>
        POST /api/ai/complete
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        AiSelectionMenu
      </code>
      
      , selection mode
    </td>
    
    <td>
      <code>
        reformulate
      </code>
      
      , <code>
        fix
      </code>
      
      , <code>
        shorter
      </code>
      
      , <code>
        formal
      </code>
      
      , <code>
        casual
      </code>
      
      , <code>
        translate
      </code>
      
      , <code>
        summarize
      </code>
      
       and the free prompt <code>
        custom
      </code>
    </td>
    
    <td>
      <code>
        rewrite
      </code>
    </td>
    
    <td>
      <code>
        POST /api/ai/generate
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        AiSelectionMenu
      </code>
      
      , caret mode (<code>
        /ai
      </code>
      
      )
    </td>
    
    <td>
      <code>
        continue
      </code>
      
       („Weiterschreiben“) and the free prompt <code>
        generate
      </code>
    </td>
    
    <td>
      <code>
        generate
      </code>
    </td>
    
    <td>
      <code>
        POST /api/ai/generate
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        AiSidebar
      </code>
      
       quick actions
    </td>
    
    <td>
      <code>
        blockTasks()
      </code>
      
       without the caret tasks: <code>
        generate
      </code>
      
      , <code>
        transcript-summary
      </code>
      
      , <code>
        transcript-minutes
      </code>
      
      , <code>
        transcript-action-items
      </code>
    </td>
    
    <td>
      <code>
        generate
      </code>
    </td>
    
    <td>
      <code>
        POST /api/ai/generate
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        AiSidebar
      </code>
      
       chat agent
    </td>
    
    <td>
      Answers with citations, proposes operations (below)
    </td>
    
    <td>
      <code>
        generate
      </code>
    </td>
    
    <td>
      <code>
        POST /api/ai/chat
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Typed block summary (block action, <code>
        composite-ai
      </code>
      
      )
    </td>
    
    <td>
      Task <code>
        summarize
      </code>
      
       over the Typed block's body → description
    </td>
    
    <td>
      <code>
        rewrite
      </code>
    </td>
    
    <td>
      <code>
        POST /api/ai/generate
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Fill a Typed block (block action, <code>
        composite-ai
      </code>
      
      )
    </td>
    
    <td>
      Task <code>
        fill-block
      </code>
      
       over the block's brief → body, as review
    </td>
    
    <td>
      <code>
        generate
      </code>
    </td>
    
    <td>
      <code>
        POST /api/ai/generate
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Transcript cleanup (block action, <code>
        transcript-ai
      </code>
      
      )
    </td>
    
    <td>
      Cleans transcript segment texts
    </td>
    
    <td>
      <code>
        rewrite
      </code>
    </td>
    
    <td>
      <code>
        POST /api/ai/transcript-cleanup
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Readable rendition (block actions, <code>
        composite-ai
      </code>
      
      )
    </td>
    
    <td>
      Transcript with timestamps (audio/video), full text (PDF, OCR fallback) or description (image) of a Typed block that holds a Source → Rendition child with <code>
        meta.derivation
      </code>
    </td>
    
    <td>
      <code>
        generate
      </code>
      
       for OCR/description/bibliography, none for transcription and PDF text
    </td>
    
    <td>
      <code>
        POST /api/ai/extract
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Derive (block action, <code>
        composite-ai
      </code>
      
      )
    </td>
    
    <td>
      Task <code>
        fill-block
      </code>
      
       over the source Typed block's Markdown → body of a new sibling Typed block with <code>
        meta.derivation
      </code>
    </td>
    
    <td>
      <code>
        generate
      </code>
    </td>
    
    <td>
      <code>
        POST /api/ai/generate
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Literaturangaben abrufen (block action, <code>
        composite-ai
      </code>
      
      )
    </td>
    
    <td>
      <code>
        extract
      </code>
      
       with <code>
        method: 'bibliography'
      </code>
      
      : Crossref by DOI, else model extraction from the first pages → <code>
        meta.bib
      </code>
    </td>
    
    <td>
      <code>
        generate
      </code>
      
       only for the model fallback
    </td>
    
    <td>
      <code>
        POST /api/ai/extract
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Split with AI (block actions, <code>
        composite-ai
      </code>
      
      )
    </td>
    
    <td>
      Task <code>
        split-block
      </code>
      
       over the Typed block's body: the model inserts <code>
        ##
      </code>
      
       headings, the block is split at them
    </td>
    
    <td>
      <code>
        generate
      </code>
    </td>
    
    <td>
      <code>
        POST /api/ai/generate
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Gliederung bereinigen (KI) (block action on headings and ⌘K, <code>
        composite-ai
      </code>
      
      )
    </td>
    
    <td>
      Task <code>
        outline-sanitize
      </code>
      
       over the document's headings as JSON → the repaired outline
    </td>
    
    <td>
      <code>
        generate
      </code>
    </td>
    
    <td>
      <code>
        POST /api/ai/generate
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Block transfer with Shift menu (<code>
        transferFeature
      </code>
      
      )
    </td>
    
    <td>
      Tasks <code>
        transfer-summary
      </code>
      
       and <code>
        transfer-bullets
      </code>
      
       over the dragged Typed block's Markdown → body of a new Typed block with <code>
        meta.derivation
      </code>
    </td>
    
    <td>
      <code>
        generate
      </code>
    </td>
    
    <td>
      <code>
        POST /api/ai/generate
      </code>
    </td>
  </tr>
</tbody>
</table>

A task's Model role comes from `AiTask.slot` (`AI_TASKS`); `ModelQuickSwitch` in the selection menu and the sidebar changes the Model role's profile in place. Inserted generation results (sidebar quick actions, `/ai` at the caret, agent `add` operations) go through the Editor preset's `structure` stage: with the composites feature and the `blocks.structure` setting `'structured'` (Structure mode), headings are grouped into nested `section` Typed blocks; with `'flat'` (Flat mode) Typed blocks are flattened into headings starting at `##`. A change of the setting applies to the next insertion. Rewrites (`replace-selection`, agent `update`) are never restructured.

### Content tree tasks

Four tasks serve the [content tree mechanics](https://skaile-ai.github.io/forge-editor/guides/content-tree). They are in `AI_TASK_IDS` and `AI_TASKS`, all in the `generate` Model role, all with `AiTask.programmatic: true`: only a block action, a command or a drop sends them, and `blockTasks()` leaves them out of the sidebar's quick actions. Each has its own prompt; a Host configures nothing but the `generate` Model role's model.

<table>
<thead>
  <tr>
    <th>
      Task
    </th>
    
    <th>
      Sent by
    </th>
    
    <th>
      <code>
        selection
      </code>
    </th>
    
    <th>
      <code>
        prompt
      </code>
    </th>
    
    <th>
      Answer
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        split-block
      </code>
    </td>
    
    <td>
      <code>
        splitCompositeWithAi
      </code>
    </td>
    
    <td>
      the Typed block's body as Markdown
    </td>
    
    <td>
      the wanted section size in characters
    </td>
    
    <td>
      the same Markdown with <code>
        ##
      </code>
      
       headings inserted; any other change makes the split write nothing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        outline-sanitize
      </code>
    </td>
    
    <td>
      <code>
        sanitizeOutline
      </code>
    </td>
    
    <td>
      the document's headings as a JSON list <code>
        { i, level, text, chars }
      </code>
    </td>
    
    <td>
      empty
    </td>
    
    <td>
      a JSON list of the same length <code>
        { i, level, text }
      </code>
      
       (empty <code>
        text
      </code>
      
       removes the heading); anything else writes nothing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        transfer-summary
      </code>
    </td>
    
    <td>
      <code>
        summaryTransform
      </code>
      
       (<code>
        integrations/composite-ai
      </code>
      
      )
    </td>
    
    <td>
      the dragged Typed block's Markdown (cut to <code>
        AI_LIMITS.selection
      </code>
      
      )
    </td>
    
    <td>
      empty
    </td>
    
    <td>
      a short Markdown summary, written into a new Typed block
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        transfer-bullets
      </code>
    </td>
    
    <td>
      <code>
        bulletsTransform
      </code>
    </td>
    
    <td>
      the same
    </td>
    
    <td>
      empty
    </td>
    
    <td>
      a Markdown list of the key statements, written into a new Typed block
    </td>
  </tr>
</tbody>
</table>

They run through the normal `POST /api/ai/generate` route and `createDirectAi` (no new endpoint); `transferRequest(payload, blocks, model, task)` builds the body of the two transfer tasks.

### Chat agent operations

The model proposes edits through the `edit_document` tool. The server checks them (`checkAgentOps`, `@skaile/forge-editor/ai/protocol`) and streams the valid ones as an `ops` event: `{ ops: AgentOp[], dropped: AgentOpProblem[], retries }`. Each operation gets a conversation-wide `op: 'op<n>'` id.

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

<tbody>
  <tr>
    <td>
      <code>
        add
      </code>
    </td>
    
    <td>
      <code>
        afterId: string | null
      </code>
      
       (required), <code>
        markdown
      </code>
    </td>
    
    <td>
      Insert block(s) after <code>
        afterId
      </code>
      
       (in the document or a Typed block's body). <code>
        afterId
      </code>
      
       must be present: an explicit <code>
        null
      </code>
      
       is the document start (not allowed with a selection scope, where <code>
        afterId
      </code>
      
       must be a selected block); a forgotten key is rejected, it never becomes the start. Several <code>
        add
      </code>
      
      s after the same block are fine and keep the order of the answer.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        update
      </code>
    </td>
    
    <td>
      <code>
        id
      </code>
      
      , <code>
        markdown
      </code>
    </td>
    
    <td>
      Replace block <code>
        id
      </code>
      
      . A single paragraph, or one block of the same type and attributes, is diffed within the block. On a Typed block, a <code>
        ::: kind
      </code>
      
       fence (exactly one Typed block) replaces the whole Typed block; unfenced markdown replaces only its body.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        delete
      </code>
    </td>
    
    <td>
      <code>
        id
      </code>
    </td>
    
    <td>
      Remove the block (or the smallest list item, list or quote that would be invalid without it).
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        convert
      </code>
    </td>
    
    <td>
      <code>
        id
      </code>
      
      , <code>
        to
      </code>
    </td>
    
    <td>
      Change the type of the block, keeping the content. <code>
        to
      </code>
      
       is one of the request's <code>
        targets
      </code>
      
      : the targets the installed conversion providers advertise (<code>
        paragraph
      </code>
      
      , <code>
        heading1
      </code>
      
      –<code>
        heading3
      </code>
      
      , <code>
        bulletList
      </code>
      
      , <code>
        orderedList
      </code>
      
      , <code>
        blockquote
      </code>
      
      , <code>
        codeBlock
      </code>
      
      , plus <code>
        transcriptSegment
      </code>
      
       and <code>
        typedBlock:<block type id>
      </code>
      
       with those features).
    </td>
  </tr>
</tbody>
</table>

Validation problems (`AgentOpProblemReason`): `unknown` (the id isn't in the document), `scope` (the id is outside the selection, or an `add` at the document start with a selection scope), `field` (a field is missing or invalid, e.g. empty `markdown`, more than `AGENT_LIMITS.markdown` = 8000 characters, or an `add` without its `afterId` key), `duplicate` (the same block is updated, converted or deleted twice), `limit` (more than `AGENT_LIMITS.ops` = 50 operations), `target` (`to` isn't among the request's `targets`), `inside` (an operation on a block *inside* a block that another operation of the same list replaces (`update`) or deletes: the operation on the outer block wins; to change the inner block too, put the change into the outer `markdown`). Which blocks lie inside which is read from the indentation of the document text (`readBlockLines`). On problems the model is asked to retry (the retry text lists each problem and says that for `inside` the outer operation applies), up to `AGENT_LIMITS.retries` = 2 times. Whatever is still invalid after that, or left out of a corrected list, is reported in `dropped`.

**Review flow.** The client re-checks each operation against the scope the request was sent with and shows valid ones as in-document review changes the user accepts or rejects. An operation that can't be shown gets an outcome instead: `blocked` (outside the scope), `stale` (its block was deleted in the browser while the model was answering; „Block gelöscht“ in the chat), `missing`, `unchanged`, `overlap` or `empty` (`AGENT_OP_OUTCOMES`). Every operation's outcome (`accepted`, `rejected`, `partial`, `invalid`, `pending`, …) is sent back as `feedback` with the next user message; the model reads it as „Entscheidungen zu deinen letzten Vorschlägen: op1 (update Block X) nicht angezeigt: Block wurde inzwischen gelöscht; …“ (`agentFeedbackText`).

**Reading tools.** Besides `edit_document` the chat can call read-only tools a host registers (`tools: [...]` in `installEditorServer` or `provideEditorServer`, a `ToolProvider` each: `{ name, tool, handle(ctx, input) }`, `@skaile/forge-editor/server`). The server runs such a call itself, feeds the result back and lets the model answer again (at most 5 turns with up to 8 calls); every call reaches the client as a `tool` event `{ name, input, output }`, which the chat keeps on the assistant turn and sends back, so later messages replay the calls and results. The system prompt lists the registered tools. `createWorkspaceTools()` provides two: `find_blocks(query, limit?)` (Block index search over the Documents the user may read) and `read_block(documentId, blockId)` (one Block as Markdown). Both need the `index` port and stay inside the user's readable Documents; an unreadable or unknown address answers the same refusal. A Block of another Document is cited by naming its Document and block id, not with a `#block-` link (those resolve in the open document only). `edit_document` cannot be registered or replaced.

**Other Documents (change set).** An operation may carry `documentId` and then aims at another Document of the workspace (block IDs are unique per Document only); without it, or with the open Document's own ID, it targets the open Document. `edit_document` offers the field when the server has a `DocumentMutator` and the host provides the `changeSets` port (`EditorPorts.changeSets`, a `ChangeSetStore`; `createJsonAdapter` provides it on `.change-sets.json`), and only Documents the user may read **and** edit are accepted: the server checks the block IDs against each Document's current content (live content of an open one) and reports a Document it does not know or the user may not edit as an `AgentOpProblem` with reason `document`. Operations for the open Document go to the in-document review as before. The others are stored as a `ChangeSet` (`core/change-set.ts`: one hunk per operation, `pending` until decided) and streamed to the client as the SSE event `change-set`. The user reviews them per Document in the `ChangeSetPanel` (`@skaile/forge-editor/change-sets/vue`, `/react`), a hunk at a time or all of a Document: `GET /api/editor/change-sets`, `POST /api/editor/change-sets/:id/accept` and `…/reject` with `{ hunks: ['op3', …] }` (the port is `changeSets`). Accepting applies the hunk through the `DocumentMutator`, so it reaches an open Document as an ordinary edit and the Block index re-projects it; a hunk whose block is gone answers `409` and stays visible as `stale` with the server's message, a refusal with `403`, `404` or `422` as `invalid`. Decisions reach the model like the in-document review's: `AgentOpFeedback` with `documentId`, sent with the next message. Share one model between the chat and the panel: `useChangeSetModel({ backend })` and `<AiSidebar :change-sets="model">`.

**Document text** (`documentChatText(editor)`): one line per block, `[<id>] <markdown on one line>`. List and quote markers form the prefix (`- `, `1. `, `> `). A Typed block is a header line `[<id>] ::: kind {meta} | Title | Description | Tags: #a #b` (meta only when non-empty, empty parts omitted), followed by its body lines indented by two spaces and a closing line `  :::` without an id. A code block keeps its own lines: the first carries the id, the others start at the left edge. A table (`tablesFeature`) does the same with its pipe-table Markdown: one block under the table's id, no line per cell, so the model edits the table as a whole. The text is truncated at `CHAT_LIMITS.document` = 120000 characters (the notice is `TRUNCATED_LINE`). The format has one owner, `block-lines` of `@skaile/forge-editor/ai/protocol`: the writer helpers (`blockLine`, `compositeLineHeader`, …), `readBlockLines(text)` (the ids and which blocks lie inside which; lines inside fenced code are code, never block ids, so a code line like `[ghost] text` does not become one), `stripBlockLineEcho` / `stripListMarkerEcho` (remove a `[<id>] ` or list/quote marker prefix the model echoes into its Markdown) and the prompt texts (`BLOCK_LINE_INTRO`, `BLOCK_LINE_IDS_HINT`, …). The browser writes the text and the server reads it with the same module.

**The same loop in a host's own chat** (`@skaile/forge-editor/ai/actions`, optional peer `@skaile/forge-app-actions`). A host whose chat is not the editor's (a Nuxt app with an AI SDK route and forge-chat client tools) gets the agent operations as two forge actions: `documentActions({ editor: () => editor, review, scope?, targets? })` returns `doc.read` (the result of `documentChatText(editor)`) and `doc.propose_ops` (the `edit_document` input: `ops` with the same fields as the table above; the input schema is as loose as the server's tool, so the type, the limits and the block rules come from `checkAgentOps` and a bad operation never refuses the whole call). `review` is the host's `createAgentOpReview({ notify, t })`; `scope` returns the current `selectionScope(editor.state)` (default: the whole document) and `targets` lists the block types `convert` may use (default: none, so a `convert` is a `target` problem). The action runs in the browser: it checks the operations with `checkAgentOps` against the block lines of the document as it is now, gives the valid ones `op<n>` ids from the review's own counter (`nextOp`, `advanceOp`: the numbering continues across turns, and next to the editor's own document chat when both share the review: pass it as `review` to `createDocumentChat`, which then renumbers the operations of an answer when they arrive, in case another proposer took the ids its request was sent with), shows them with `review.propose` and returns `{ proposed, outcomes, problems, hint }`: `outcomes` has one `{ op, type, target, outcome }` per valid operation in input order (`outcome` is `pending` while it waits for the user, else why nothing is shown: `unchanged`, `missing`, `overlap`, `stale`, `empty`, `blocked`), `proposed` counts only the `pending` ones, and each problem's `index` is the 1-based position of the operation in the input. Operations that fail the check are not retried by the action: the `problems` go back to the model, which sends corrected operations. There is no approval dialog; the in-document review (`AiReviewBar`, with the `AiReview` extension in the editor) is the approval. The capability names are `app.doc.read` and `app.doc.propose_ops`; pass the actions to `useChatActions` of `@skaile/forge-app-actions/chat`.

### Typed block Markdown fence format

```text
::: kind {meta-json}
## Title
> Description
Tags: #a #b
body blocks…
:::
```

- The meta JSON is written only when it is non-empty (`::: section`). The parser requires a JSON object; otherwise the line stays plain text. A Typed block that a `reference` chip or `reference` property points at carries its block id in the opener, `::: kind #<block id> {meta}`; the parser restores it as the block id, other fences have no `#<id>`.
- Fences nest. Every closer is a bare `:::` and closes the innermost open fence. An opener without a matching closer stays plain text.
- Inner text: the first `#`…`######` heading becomes the title, a blockquote directly after it the description, a `Tags:` line the tags (lower-cased, de-duplicated), the rest the body (recursively). A missing title gives an empty title; an empty body gives one empty paragraph.
- Known Block type: `meta` keeps only that Block type's property keys, plus `derivation`, `bib`, `basket` and `titleHidden` (the Derivation, the Bibliographic record, the Basket and the hidden-title flag survive every Block type). Unknown Block type: the id and meta are kept as given and the view falls back to the `section` presentation.

Used by `editor.getMarkdown()` (the `typedBlock` node's `renderMarkdown`), by AI results applied through the composites feature's `parseMarkdown` stage (`blocksFromMarkdown`; task results with a heading are wrapped in a `section` fence in Structure mode (`'structured'`), else titled with `##`, by the `composite-ai` integration's `formatAiResult`) and by the agent's `add`/`update` Markdown and system prompt.

```text
::: task {"status":"doing","due":"2026-11-01"}
## Angebot schreiben
> Für Kunde Müller
Tags: #vertrieb
- Preise prüfen
- Entwurf an Ben
:::
```

### Stored settings

`EditorSettings` (`@skaile/forge-editor/settings`) is one version-2 JSON object shared by all editors of an app: `editing.{headingConversionScope ('container'), authorColors (false), authorColorStyle ('background'), commentsLayout ('margin')}`, `ai.{autocomplete (false), roles ({}), translateLanguage ('Englisch')}`, `blocks.{defaultBlockType ('section'), structure ('structured'), treeRoot ('folders'), blockTypeIcon ('always'; 'hover' shows the Typed blocks' Block type icon only on hover, focus or open details), dragHandle ('hover'; 'always' shows the drag handle of blocks without a Block type icon permanently), splitTargetChars (1500; target size of a split by length or with AI, 200 to 20000: `SPLIT_TARGET_CHARS_MIN`, `SPLIT_TARGET_CHARS_MAX`)}` and `appearance.{theme (null)}` (the Obsidian theme id, `null` = standard look; see [Themes (Obsidian)](https://skaile-ai.github.io/forge-editor/guides/themes)). `parseEditorSettings(input)` deep-merges any input over those defaults (unknown keys dropped, invalid values replaced; a stored `blocks.metaVisibleDefault` of an older version is just dropped) and migrates the legacy flat per-browser shape `{ headingConversionScope }`; `PUT /api/editor/settings` instead requires the complete, valid shape (`editorSettingsSchema`). Settings tabs are contributed by features (`editing` by composites, `authors` and `comments` by their features, `ai` by AI, `appearance` by themes), so a preset without a contributing feature has no settings UI. A tab only edits its own fields; the other sections are written back unchanged, which is why one account can use a simple and an advanced editor without erasing the other's preferences.


## Sitemap

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