---
title: "Feature contract"
description: "The EditorFeature, FeatureContext and EditorPreset types, what defineEditor validates and how to contribute a custom feature."
canonical_url: "https://skaile-ai.github.io/forge-editor/reference/feature-contract"
---
# Feature contract

> The EditorFeature, FeatureContext and EditorPreset types, what defineEditor validates and how to contribute a custom feature.

A feature is an object whose optional members are the contribution points. Fill only what you need. The types below come straight from `packages/editor/src/core/feature.ts`.

## `EditorFeature`

```ts [packages/editor/src/core/feature.ts]
*/
export type Dimension = 'content' | 'interaction' | 'ai'

/** One named contribution to an editor (blocks, toolbar entries, menus, settings, AI knowledge). Every member is optional but `dimension`; identity and ordering come from the feature list. */
export interface EditorFeature<C = unknown> {
  id: string
  /** The feature's primary dimension. */
  dimension: Dimension
  /**
   * The Vue-free schema-bearing extensions of this feature (no node views, no UI), collected by `schemaExtensionsOf` for
   * the server schema. Present on every feature whose nodes or marks the stored document can contain.
   */
  schemaExtensions?: () => AnyExtension[]
  /** Ids of features that must also be installed (`defineEditor` does not install them). */
  requires?: readonly string[]
  /** Backend capabilities the feature cannot work without; a surface session over a backend lacking one throws. Optional capabilities (users and tags for mentions) are not listed. */
  backend?: readonly BackendCapability[]
  extensions?: (ctx: FeatureContext) => AnyExtension[]
  blockActions?: (ctx: FeatureContext) => BlockAction[]
  /**
   * Block capabilities the feature declares (`container` and `title` are built in): descriptors of what a block is, which a
   * `BlockAction.requires` names and `capabilitiesAt` answers. Ids are unique across features. A capability installs nothing:
   * schema, extensions and node views stay with the feature that owns the mechanism.
   */
  blockCapabilities?: readonly BlockCapability[]
  /**
   * Ways to make a new block or text from a block (transcription, text extraction, summary, AI fill, AI derivation, bibliography fetch):
   * one registry across all features, each method declaring the capabilities the original must have (`requires`) and its backend
   * flags (`available`). The block actions of the methods are generated from it. Ids are unique across features and every
   * `requires` id must be a declared capability; `defineEditor` throws otherwise.
   */
  deriveMethods?: readonly DeriveMethod[]
  /**
   * The Block types the feature's editor offers: data (an id and the capabilities each type lists with their config) the capability
   * registry answers from. Additive: the contributions of all features concatenate in feature order. An editor created from the preset
   * checks them against the declared capabilities (`validateBlockTypes`): an unknown capability id or an invalid config throws. Block
   * types are per editor (the composites feature builds its defaults in the surface's language).
   */
  blockTypes?: (editor: Editor) => readonly BlockTypeData[]
  /**
   * Options of the block UI (`BlockUiOptions`: which surfaces render block actions). Set by the block-ui feature; the
   * overlay components are mounted by the surface without props, so they read it from the editor entry (`EditorEntry.blockUi`).
   */
  blockUi?: Readonly<BlockUiOptions>
  /** The feature's commands, each declared once with its toolbar and `/` placements (see `CommandDef`). */
  commands?: (ctx: FeatureContext) => CommandDef<C>[]
  /** Toolbar groups the feature declares; every command's `toolbar.group` must be declared by some feature. Ids are unique across features. */
  toolbarGroups?: ToolbarGroupDef[]
  /** Rules a command's `creates` must pass at the caret besides the schema (e.g. a Typed block's accepted children). */
```

The members at a glance:

<table>
<thead>
  <tr>
    <th>
      Member
    </th>
    
    <th>
      Contributes
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        extensions(ctx)
      </code>
    </td>
    
    <td>
      TipTap extensions (nodes, marks, plugins)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        commands(ctx)
      </code>
      
      , <code>
        toolbarGroups
      </code>
      
      , <code>
        insertGuards(ctx)
      </code>
    </td>
    
    <td>
      <code>
        CommandDef[]
      </code>
      
       declared once with <code>
        toolbar
      </code>
      
       and <code>
        slash
      </code>
      
       placements (toolbar, <code>
        /
      </code>
      
       menu, ⌘K palette, enabled/visible state via <code>
        commandState
      </code>
      
      ), the groups they name, and guards a <code>
        creates
      </code>
      
       command must pass (e.g. a Typed block's Accepted children)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        transactionGuards(ctx)
      </code>
    </td>
    
    <td>
      <code>
        TransactionGuard[]
      </code>
      
      : <code>
        (tr, state) => true | string
      </code>
      
      , run in feature order for every transaction that changes the document (not for selection-only ones). The first string refuses the whole transaction and is shown as the notice. For rules no schema expresses (a locked paragraph, a required document shape); <code>
        insertGuards
      </code>
      
       only gate commands
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        blockActions(ctx)
      </code>
    </td>
    
    <td>
      block actions (shown by <code>
        block-ui
      </code>
      
      : hover bar, selected-block toolbar or both, per <code>
        BlockUiOptions.actions
      </code>
      
      ); <code>
        ability
      </code>
      
       unset = every block; <code>
        placement: 'hover' | 'toolbar' | 'both'
      </code>
      
       (default <code>
        'both'
      </code>
      
      ) narrows the surface; <code>
        target: 'block' | 'typedBlock'
      </code>
      
       (default <code>
        'block'
      </code>
      
      ): with <code>
        'typedBlock'
      </code>
      
       the toolbar evaluates the action against the Typed block around the selected block (<code>
        ctx.node
      </code>
      
      , <code>
        ctx.pos
      </code>
      
       are the Typed block's), absent when none encloses it
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        overlays
      </code>
    </td>
    
    <td>
      framework components (<code>
        EditorFeature<C>
      </code>
      
      ; Vue: <code>
        VueFeature
      </code>
      
      ) mounted beside the content, receiving <code>
        { editor }
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        rowBadges(ctx)
      </code>
    </td>
    
    <td>
      <code>
        RowBadge[]
      </code>
      
      : chips after the title of a Typed block in the composites chrome, additive in feature order (<code>
        of(editor, row)
      </code>
      
       returns <code>
        { label, tone } | null
      </code>
      
      ; optional <code>
        subscribe
      </code>
      
       for changes outside the editor's transactions). The Prüfung feature draws „Geprüft“ / „Prüfung veraltet“ this way
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        documentBadges(ctx)
      </code>
    </td>
    
    <td>
      <code>
        DocumentBadge[]
      </code>
      
      : chips of the whole document, drawn after the „Dokument“ heading of the inspector and on the open document's row of the Block outline in the Document tree (<code>
        of(editor)
      </code>
      
      , optional <code>
        subscribe
      </code>
      
      ). The Prüfung feature draws the Freigabestatus this way
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        inspectorSections(ctx)
      </code>
    </td>
    
    <td>
      <code>
        InspectorSection<C>[]
      </code>
      
      : sections of the Typed block inspector and details card below the block's fields, additive in feature order (<code>
        { id, title, component, appliesTo? }
      </code>
      
      ; ids unique). The Prüfung feature adds „Prüfstand“
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        setup(ctx)
      </code>
    </td>
    
    <td>
      per-surface side effect when the surface mounts (runtime, listener, <code>
        <head>
      </code>
      
       element); returns an optional <strong>
        teardown
      </strong>
      
       the surface runs when it unmounts
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        settingsTabs
      </code>
    </td>
    
    <td>
      factory <code>
        (ctx: EditorContext) => SettingsTab<C>[]
      </code>
      
       for the tabs of <code>
        EditorSettingsPanel
      </code>
      
      /<code>
        EditorSettingsModal
      </code>
      
       (<code>
        SettingsTab<C>
      </code>
      
      : <code>
        label
      </code>
      
       is already translated, the <code>
        component
      </code>
      
       slot is the framework's component type)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        metadataDefaults(ctx)
      </code>
    </td>
    
    <td>
      built-in <code>
        @
      </code>
      
      /<code>
        #
      </code>
      
      -style lists
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        blocks
      </code>
      
      , <code>
        conversions
      </code>
    </td>
    
    <td>
      block pipeline stages, „Umwandeln in …“ providers
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        anchorResolver
      </code>
      
      , <code>
        aiContext
      </code>
      
      , <code>
        formatAiResult
      </code>
    </td>
    
    <td>
      collaboration positions, what AI knows about the document, AI result formatting
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        aiBlockData
      </code>
    </td>
    
    <td>
      material per Typed block for „Block füllen (KI)“ (additive; the host owns the fetch)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        requires
      </code>
    </td>
    
    <td>
      ids of features that must also be listed
    </td>
  </tr>
</tbody>
</table>

## `FeatureContext`

Every factory (`extensions`, `commands`, `blockActions`, …) receives the `FeatureContext` of the surface it runs on. It is created when a surface is set up and never shared between editors.

```ts [packages/editor/src/core/feature.ts]
stopUndoCapture: (state: EditorState) => boolean
}

/** Per-surface dependencies of a feature's factories. Created when a surface is set up; never shared between editors. */
export interface FeatureContext {
  /** The app context the surface belongs to (shared by all editors of the app): app-level models key on it. */
  readonly app: EditorContext
  /** The app's shared settings (see `createEditorContext`): read with `get()`, write with `update()`, observe with `subscribe()`. */
  readonly settings: Store<EditorSettings>
  readonly metadata: MetadataRegistry
  /** Chrome of the editor this context belongs to (settings, sidebar). */
  readonly ui: EditorUi
  /** Shows a notice through the surface's framework (Nuxt UI toast, sonner). */
  readonly notify: Notifier
  /** The app's UI language (`createEditorContext({ locale })`). */
  readonly locale: Locale
  /** Renders a catalog message in the app's language; every label a feature contributes goes through it. */
  readonly t: Translate
  /** The data layer of the app (`createEditorContext({ backend })`): uploads, mention lookups, themes, AI. */
  readonly backend: EditorBackend
  /** Inert public AI configuration of the app context; absent when none was supplied. */
  readonly ai: EditorAiConfig | undefined
  /** Document whose package stores uploads (media feature). */
  readonly documentId: string | undefined
  readonly collaboration: CollaborationSession | undefined
  /** The collaboration-aware position mapping of the preset's `anchorResolver` feature; null means local mapping only. */
  readonly anchorResolver: AnchorResolver | null
  /** Host options for the ability extension (`abilities` prop). */
  readonly abilities: Partial<AbilitiesOptions>
  /** The final command list: the features' commands after the host's `commands` transformer. The `/` menu lists the ones with a `slash` placement. */
  readonly commands: () => CommandDef[]
```

## `EditorPreset`

```ts [packages/editor/src/core/feature.ts]
/**
   * Refusals of whole transactions: every transaction that changes the document runs through the guards of all
   * features, in feature order; the first one answering a string refuses it and shows that string as a notice. For rules
   * about the document as a whole that no schema expresses (a locked paragraph, a required document shape).
   */
  transactionGuards?: (ctx: FeatureContext) => TransactionGuard[]
  /** Components mounted beside the editor content; they receive `{ editor }`. `C` is the component type of the framework layer that renders them. */
  overlays?: readonly C[]
  /**
   * Chips after the title of a Typed block in the composites chrome, additive in feature order (one per `RowBadge`).
   * The factory runs once per surface; the chrome reads the result from the editor entry (`EditorEntry.rowBadges`).
   */
```

## What `defineEditor` validates

`defineEditor({ features })` keeps the given order, neither deduplicates nor installs missing dependencies, and throws for:

- a feature without a `dimension`;
- a duplicate feature `id`;
- a `requires` entry that is not installed;
- a toolbar group declared by two features (`toolbarGroups`);
- a singleton hook contributed twice (`parseMarkdown`, `segment`, `structure`, `migrateContent`, `anchorResolver`, `formatAiResult`);
- an importer id declared twice (`importers`).

A command naming a toolbar group no feature declares needs the feature context to build the commands: `toolbarOf` throws it. `normalize` hooks chain left to right. `aiContext` contributions merge (`blockTypes` and `blocks` concatenated in feature order, `structure` from one feature only); `aiBlockData` contributions are additive too. The doc comment of the function:

```ts [packages/editor/src/core/define-editor.ts]
const fail = (message: string): never => failWith(message, 'defineEditor')

/**
 * Validates `features` and resolves their pipeline and conversion providers. Keeps the given order; neither
 * deduplicates nor installs missing dependencies. Throws on duplicate ids, a `requires` entry that is not installed,
 * a toolbar group declared by two features (`toolbarGroups`), a block capability declared twice or with a built-in id (`blockCapabilities`), a derive method declared twice, with neither `run` nor `menu` or requiring an undeclared capability (`deriveMethods`), an importer id declared twice (`importers`) and a singleton hook (`parseMarkdown`, `segment`, `structure`, `migrateContent`, `anchorResolver`,
 * `formatAiResult`) contributed twice. A command naming a toolbar group no feature declares needs the feature context
 * to build the commands: `toolbarOf` throws it. `normalize` hooks chain left to right. `aiContext` contributions merge (`blockTypes` and `blocks`
 * concatenated in feature order, `structure` from one feature only); their conflicts surface when the merged `aiContext` reads the editor.
```

## Example: a `notes` feature

```ts
// notes/feature.ts
import { command } from '@skaile/forge-editor/core'
import type { VueFeature } from '@skaile/forge-editor/vue'
import SettingsNotesTab from './SettingsNotesTab.vue'

export function notesFeature(): VueFeature {
  return {
    id: 'notes',
    dimension: 'interaction', // 'content' when it adds nodes or marks, 'ai' for AI features
    // requires: ['block-ui'], // fail at composition time when a dependency is missing
    toolbarGroups: [{ id: 'notes', order: 100 }],
    // One declaration per command: the toolbar button and the `/strich` entry share label, icon and gating.
    commands: () => [
      command({
        id: 'dash',
        toolbar: { group: 'notes' },
        slash: { id: 'strich' },
        icon: 'i-lucide-minus',
        label: 'Gedankenstrich',
        command: chain => chain.insertContent('\u2013'),
      }),
    ],
    // Runs in every mounted surface's setup and returns its teardown. Several surfaces of one app share state: dedupe per app
    // inside and reference-count the release (the theme runtime keys a WeakSet by `ctx.settings`). Report problems with `ctx.notify({ title, kind })`.
    setup: (ctx) => {
      const onKey = (event: KeyboardEvent) => {
        if (event.key === 'F2') ctx.ui.openSettings('notes')
      }
      document.addEventListener('keydown', onKey)
      return () => document.removeEventListener('keydown', onKey)
    },
    // Tab ids must be unique across the preset; the panel renders nothing when no feature contributes a tab.
    settingsTabs: ctx => [{ id: 'notes', label: ctx.t('notes.tab'), icon: 'i-lucide-notebook', component: SettingsNotesTab }],
  }
}
```

```vue
<!-- notes/SettingsNotesTab.vue -->
<script setup lang="ts">
import USwitch from '@nuxt/ui/components/Switch.vue'
import { SettingsSection } from '@skaile/forge-editor/vue'
import { ref } from 'vue'

const shout = ref(false) // local state; see the settings note below for persisted values
</script>

<template>
  <SettingsSection title="Notizen">
    <div class="py-3">
      <USwitch v-model="shout" label="Hervorheben" />
    </div>
  </SettingsSection>
</template>
```

Compose it with the advanced list (or any list) and mount it with the same preset the toolbar and settings UI receive:

```ts
import { defineEditor } from '@skaile/forge-editor/core'
import { advancedEditor } from '@skaile/forge-editor/vue/advanced'
import { notesFeature } from './notes/feature'

const preset = defineEditor({ features: [...advancedEditor().features, notesFeature()] })
```

## Things that bite

- Items gated by an own `ability` (`CommandDef.ability`, `BlockAction.ability`) stay inert until the ability exists at the caret. Grant custom abilities at root through the surface prop: `:abilities="{ root: [...ROOT_ABILITIES, MY_ABILITY] }"` (`ROOT_ABILITIES` from `/core/schema`).
- The hover bar of `BlockAction`s is right-aligned 8px inside the editor's right edge for every block and is hidden while the editor is read-only. A block that draws its own icons in its top-right corner marks them with `data-block-corner` (`BLOCK_CORNER_ATTR` from `/block-ui`): the bar then docks immediately left of them, centred on their row, instead of covering them.
- A feature that adds a **node** must be known to every schema that touches the document. The Nuxt module's collab route builds its schema from `advancedSchemaExtensions()` plus the `schema` option of `provideEditorServer({ ports, schema })`, so register the Vue-free node file there (`schema: () => [...advancedSchemaExtensions(), YourNode]`, [tutorial chapter, Backend and collaboration](https://skaile-ai.github.io/forge-editor/tutorial/backend-and-collaboration)); a custom Hocuspocus server builds `getSchema([...advancedSchemaExtensions(), YourNode])` itself. Standalone editors (JSON `v-model`) need none of this. `editorBlock: true` (via `NodeFlags`) gives an own node block IDs, also on the server.
- A tab component can bind only to fields that exist in `EditorSettings`: `parseEditorSettings` drops unknown keys, so new keys are neither stored locally nor sent to the server. The tab component runs inside the app, so it can call `useEditorSettings()` (from `/vue`: `{ settings, ready, synced }` as refs; from `/react`: the same names as values).
- Factories (`extensions`, `commands`, `blockActions`, ...) receive the `FeatureContext`: `app`, `settings`, `metadata`, `ui`, `notify`, `backend`, `ai`, `documentId`, `collaboration`, `getEditor()`. `backend` is the `EditorBackend` of the app (uploads, lookups, themes, AI: `ctx.backend.assets?.upload(...)`); features never call `fetch`. `settings` is a framework-free store: read `ctx.settings.get()`, change it with `ctx.settings.update(s => { ... })` (or `set`), observe it with `ctx.settings.subscribe(listener)`. `ctx.notify({ id?, title, description?, kind?, icon?, duration? })` shows a user-facing notice (Nuxt UI toast or sonner toast, per surface; features never import a toast library). `ctx.app` is the app context that app-level models key on. Menu commands and block-action menus return core `MenuItem[]` (`type?: 'label' | 'separator'`, `label`, `description`, `icon`, `disabled`, `onSelect`). `getEditor()` is `undefined` while extensions are constructed; use it in event handlers only. Components in `overlays` and `component` commands receive `{ editor }` as props.

Complete worked example with node, node view, conversions and AI description: the tutorial chapter [The callout as a feature](https://skaile-ai.github.io/forge-editor/tutorial/own-feature-path-b) and the runnable [`app/editor/callout/`](https://github.com/skaile-ai/forge-editor/tree/main/app/editor/callout). Lighter path without a feature (`extensions` prop and `commands` transformer): [The callout through props](https://skaile-ai.github.io/forge-editor/tutorial/own-block-path-a).


## Sitemap

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