AI
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:
| 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 |
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.defineEditormerges the contributions, concatenatingblockTypesandblocksin feature order;structure(the Structure mode for generated content,'flat'|'structured') may be set by one feature only. A blockiddescribed by two features, or a second feature settingstructure, throws when the merged context is read (aiContextOf(editor), which returns{ blockTypes, blocks, structure }), not whendefineEditorruns.blockTypes— the Block types of Typed blocks the model may build (AiContextBlockType:id,label,fieldsas Property definitions,purpose?). The composites feature contributes them.blocks— Host blocks asAiBlock { id, label, description, syntax, example }(exported from@skaile/forge-editor/core):idis the node type name and theconverttarget id,labelthe German name,descriptionits purpose and allowed content,syntaxthe Markdown form with placeholders in angle brackets,exampleone 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 asconverttarget 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); thecomposite-aiintegration (below) contributes the Typed block form for Structure mode. At most one feature may contribute it;defineEditorthrows 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, anddefineEditorconcatenates 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.signalaborts 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'sepikriseFeature()(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/…) | requires | Contributes |
|---|---|---|
composite-ai → compositeAiFeature() | composites, ai, media | block 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, ai | block 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-briefcapability (its config is the AI brief,BlockTypeAi). It sends the block's brief (compositeAiBrief(editor, pos): Block type andpurpose, titles of the enclosing Typed blocks aspath, title, description, non-empty property values withselectoption labels, the instruction — a non-emptymeta[ai.instructionsField]replacesai.instructions— and the AI content template (ai.contentTemplate) with{key}replaced bymeta[key], the current body as Markdown) plus the document as context to the taskfill-block(generateModel role,POST /api/ai/generate, rejected with 400 whenblockis missing). The result opens the in-document diff review over the block's body; title and properties stay untouched.fill-blockis offered nowhere else (blockTasks()skips tasks withneedsBlock). - Material from features. Before the request,
fillCompositeruns the Editor preset's mergedaiBlockDatafor the Typed block (above). The entries travel asblock.datain order, at most 10, eachtextcut toAI_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
summarizetask (rewriteModel role) fromPOST /api/ai/generateand 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
sourcecapability, i.e. holds a Source (the built-indocument,recording,paper,book,figure) and whose Source media block (audio|video|image|filein the body, found bysourceOf) is a stored asset; for an audio/video source only when the app's public AI configuration hastranscription: true(createEditor({ ai: { transcription } })). It callsbackend.ai.extract(HTTP:POST /api/ai/extract,method: 'auto',bibliographywhen the Block type has abibliographyproperty) and adds a Rendition block (Block typerendition) right after the Source media block: timestampedtranscriptSegments (with the transcript feature; else paragraphs) for a recording, the structured full text for a PDF (chapters become nestedsectionTyped 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“, andmeta.derivationrecords 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 emptymeta.bib(never itstitle) and an empty Typed block title; serverwarnings(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 (outputHashdiffers) 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 typedocumentwith the former flatmeta.transcriptare migrated into such a Readable rendition child on load (also for documents with stored collaboration state). Your server provides the endpoint;extractPostinpackages/editor/src/server/handlers/ai.tsis 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>withmeta.derivation = { from: { block }, method: 'ai:<kind>', inputHash }, generates its body from the source's Markdown with thefill-blockbrief (generateCompositeBody, the request part offillComposite) 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 abibliographyproperty and that has a DOI (meta.doiormeta.bib.DOI, read bydoiOf) or a stored PDF source. It callsbackend.ai.extractwithmethod: 'bibliography'(Crossref by DOI, else model extraction from the first pages) and stores the CSL result inmeta.bib; an empty Typed block title takes the work's title. - AI results in Structure mode. With the
structuredstructure setting (Structure mode), an AI result that carries a heading (sidebar „block generation“, selection/menu tasks with a heading) is formatted as asectionfence (::: sectionwith the heading and the result inside), so the generic pipeline inserts it as asectionTyped block. In Flat mode (flat), or without a heading, the result is unchanged from the base AI behaviour. The feature is the preset's onlyformatAiResultcontributor — 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)(withCompositeBodyResult),summarizeComposite(editor, pos, flow)(flowis anAiFlowContext:{ 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(aiisbackend.ai). Notices go through the surface'snotifyin the language oft(ctx.t), the model throughmodelProfiles(app). - Splitting and outline cleanup.
splitCompositeWithAi(editor, pos, mode, flow, { targetChars, defaultBlockType })(mode:'children'or'siblings'; the model only inserts##headings,bodyUnchangedguards that the text is unchanged,splitByInsertedHeadingsis the pure grouping) andsanitizeOutline(editor, flow)(the repaired outline in one transaction;collectHeadings,parseOutlineResponse,applyOutlineare the pure parts,OutlineHeadingandOutlineEntrythe types). Both are exported from@skaile/forge-editor/integrations/composite-aiand explained, with the manual and length splits, in Content tree mechanics.writeDerivedBodyanddiscardDerived(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 toPOST /api/ai/transcript-cleanupand 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. WithouttranscriptAiFeature()neither the action nor the menu row exists. - Server contract.
@skaile/forge-editor/integrations/transcript-ai/protocolis Vue-free and server-importable:TRANSCRIPT_CLEANUP_LIMITS,transcriptCleanupRequestSchema,transcriptCleanupMessages,transcriptCleanupChunks,parseTranscriptCleanupand 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(itssetupteardown aborts a running cleanup),createTranscriptCleanup(modelRef, ai, notify, t)(aiisbackend.ai) →{ running, cleanup, abort },hasTranscriptSegments. - Speaker-aware transformations that normalization already covers —
**Name:** textlines becoming segments in imported or AI-written Markdown — stay with thetranscriptfeature'ssegmentstage; the integration adds only the cleanup.
Reference: AI surfaces, agent operations and Typed block fences
In-block AI versus block generation
- In-block AI (
rewriteModel 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 owncompleteModel role. - Block generation (
generateModel role) produces, replaces, converts or removes whole blocks: the tasksgenerate,continueandtranscript-*, plus the chat agent operations.fill-block(block action „Block füllen (KI)“) is agenerateModel role task too, but only reachable through that action.
| Surface | What | Model role | Route |
|---|---|---|---|
Ghost autocomplete (ai.autocomplete setting) | Inline continuation while typing | complete | POST /api/ai/complete |
AiSelectionMenu, selection mode | reformulate, fix, shorter, formal, casual, translate, summarize and the free prompt custom | rewrite | POST /api/ai/generate |
AiSelectionMenu, caret mode (/ai) | continue („Weiterschreiben“) and the free prompt generate | generate | POST /api/ai/generate |
AiSidebar quick actions | blockTasks() without the caret tasks: generate, transcript-summary, transcript-minutes, transcript-action-items | generate | POST /api/ai/generate |
AiSidebar chat agent | Answers with citations, proposes operations (below) | generate | POST /api/ai/chat |
Typed block summary (block action, composite-ai) | Task summarize over the Typed block's body → description | rewrite | POST /api/ai/generate |
Fill a Typed block (block action, composite-ai) | Task fill-block over the block's brief → body, as review | generate | POST /api/ai/generate |
Transcript cleanup (block action, transcript-ai) | Cleans transcript segment texts | rewrite | POST /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.derivation | generate for OCR/description/bibliography, none for transcription and PDF text | POST /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.derivation | generate | POST /api/ai/generate |
Literaturangaben abrufen (block action, composite-ai) | extract with method: 'bibliography': Crossref by DOI, else model extraction from the first pages → meta.bib | generate only for the model fallback | POST /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 them | generate | POST /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 outline | generate | POST /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.derivation | generate | POST /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.
| Task | Sent by | selection | prompt | Answer |
|---|---|---|---|---|
split-block | splitCompositeWithAi | the Typed block's body as Markdown | the wanted section size in characters | the same Markdown with ## headings inserted; any other change makes the split write nothing |
outline-sanitize | sanitizeOutline | the document's headings as a JSON list { i, level, text, chars } | empty | a JSON list of the same length { i, level, text } (empty text removes the heading); anything else writes nothing |
transfer-summary | summaryTransform (integrations/composite-ai) | the dragged Typed block's Markdown (cut to AI_LIMITS.selection) | empty | a short Markdown summary, written into a new Typed block |
transfer-bullets | bulletsTransform | the same | empty | a 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.
| Type | Fields | Effect |
|---|---|---|
add | afterId: string | null (required), markdown | Insert 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. |
update | id, markdown | Replace 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. |
delete | id | Remove the block (or the smallest list item, list or quote that would be invalid without it). |
convert | id, to | Change 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 areferencechip orreferenceproperty 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, aTags: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:
metakeeps only that Block type's property keys, plusderivation,bib,basketandtitleHidden(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 thesectionpresentation.
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.