Guides

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.

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

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

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

EndpointPurpose
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/generateSSE (delta / done / error) for selection tasks and block generation
POST /api/ai/chatSSE for the sidebar's document chat with edit operations
POST /api/ai/complete{ text } continuation for ghost autocomplete

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.

Public AI configuration is inert data passed to createEditor:

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

Factory (@skaile/forge-editor/integrations/…)requiresContributes
composite-ai → compositeAiFeature()composites, ai, mediablock actions „Block füllen (KI)“ (Block types with the ai-brief 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 formatAiResult
transcript-ai → transcriptAiFeature()transcript, aiblock action „Transkript bereinigen (KI)“ and the same row in the AI selection menu
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 transcriptSegments (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. writeDerivedBody and discardDerived (the write-back of „Ableiten (KI)“) are exported too; the 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.
SurfaceWhatModel roleRoute
Ghost autocomplete (ai.autocomplete setting)Inline continuation while typingcompletePOST /api/ai/complete
AiSelectionMenu, selection modereformulate, fix, shorter, formal, casual, translate, summarize and the free prompt customrewritePOST /api/ai/generate
AiSelectionMenu, caret mode (/ai)continue („Weiterschreiben“) and the free prompt generategeneratePOST /api/ai/generate
AiSidebar quick actionsblockTasks() without the caret tasks: generate, transcript-summary, transcript-minutes, transcript-action-itemsgeneratePOST /api/ai/generate
AiSidebar chat agentAnswers with citations, proposes operations (below)generatePOST /api/ai/chat
Typed block summary (block action, composite-ai)Task summarize over the Typed block's body → descriptionrewritePOST /api/ai/generate
Fill a Typed block (block action, composite-ai)Task fill-block over the block's brief → body, as reviewgeneratePOST /api/ai/generate
Transcript cleanup (block action, transcript-ai)Cleans transcript segment textsrewritePOST /api/ai/transcript-cleanup
Readable rendition (block actions, composite-ai)Transcript with timestamps (audio/video), full text (PDF, OCR fallback) or description (image) of a Typed block that holds a Source → Rendition child with meta.derivationgenerate for OCR/description/bibliography, none for transcription and PDF textPOST /api/ai/extract
Derive (block action, composite-ai)Task fill-block over the source Typed block's Markdown → body of a new sibling Typed block with meta.derivationgeneratePOST /api/ai/generate
Literaturangaben abrufen (block action, composite-ai)extract with method: 'bibliography': Crossref by DOI, else model extraction from the first pages → meta.bibgenerate only for the model fallbackPOST /api/ai/extract
Split with AI (block actions, composite-ai)Task split-block over the Typed block's body: the model inserts ## headings, the block is split at themgeneratePOST /api/ai/generate
Gliederung bereinigen (KI) (block action on headings and ⌘K, composite-ai)Task outline-sanitize over the document's headings as JSON → the repaired outlinegeneratePOST /api/ai/generate
Block transfer with Shift menu (transferFeature)Tasks transfer-summary and transfer-bullets over the dragged Typed block's Markdown → body of a new Typed block with meta.derivationgeneratePOST /api/ai/generate

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

TaskSent byselectionpromptAnswer
split-blocksplitCompositeWithAithe Typed block's body as Markdownthe wanted section size in charactersthe same Markdown with ## headings inserted; any other change makes the split write nothing
outline-sanitizesanitizeOutlinethe document's headings as a JSON list { i, level, text, chars }emptya JSON list of the same length { i, level, text } (empty text removes the heading); anything else writes nothing
transfer-summarysummaryTransform (integrations/composite-ai)the dragged Typed block's Markdown (cut to AI_LIMITS.selection)emptya short Markdown summary, written into a new Typed block
transfer-bulletsbulletsTransformthe sameemptya Markdown list of the key statements, written into a new Typed block

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.

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

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

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

::: 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)). 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.

Copyright © 2026