Back to Directory/Documentation

Matra

The Matra rich text editor's documentation as an MCP server: every extension, command and API.

DocumentationTypeScriptv1.1.6

Matra

A headless rich text editor framework with a first-class extension API.

  • No engine leakage — the document model is plain JSON; no ProseMirror type appears in a public signature
  • Plain objects, plain functions — no this, no classes, no inheritance chains
  • Inferred types — adding an extension adds its commands, fully typed, with no module augmentation
  • Async-safe — position mapping is built in, so a late AI response cannot corrupt the document

See DESIGN.md for the API rationale, CHANGELOG.md for what changed when, and CONTRIBUTING.md before a pull request.

Packages

PackagePurposeLicence
@matrajs/coreEngine, document model, extension API, starter kitMIT
@matrajs/reactuseEditor, useEditorState, useEditorFocus, EditorContentMIT
@matrajs/vueuseEditor, useEditorState, useEditorFocus, EditorContentMIT
@matrajs/sveltematra — a use: action, the editor, and a state storeMIT
@matrajs/solidcreateMatra — the editor, a mount ref, and a state signalMIT
@matrajs/aiStreaming edits that survive concurrent typingCommercial
@matrajs/collabAuthority, step rebasing, remote cursorsCommercial
@matrajs/versionsSnapshots, a real diff between them, restore as one undo stepCommercial

Matra is মাত্রা — the horizontal line that runs across the top of Bengali script and holds a word together. Packages live under the @matrajs scope, matching matrajs.com.

Installing a binding installs the engine with it. For a React application pnpm add @matrajs/react is the entire install: one package, and no third-party dependency arrives behind it.

Quick start

import { createEditor, starterKit } from '@matrajs/core'

const editor = createEditor({
  extensions: starterKit,
  content: '<p>Hello</p>',
})

editor.mount(document.querySelector('#editor')!)
editor.commands.toggleBold()

Every command comes from the array you passed. Nothing else is on editor.commands, and calling something that is not there is a compile error.

The packages in detail

Eight packages, one version number, released together. Every other package depends on @matrajs/core and on nothing else, so installing a binding installs the whole editor — there is no second package to remember, no @matrajs/pm to keep in step, and no peer range to resolve by hand.


@matrajs/core — MIT

The engine, and the only package that is not optional. The document model, transforms, position mapping, editor state and the editable view are written here, with zero runtime dependencies.

pnpm add @matrajs/core

Entry points

ExportWhat it is
createEditor(options)Builds an editor. The extensions array decides everything else about it.
buildSchema(extensions)The schema alone, for validating a document with no view and no DOM.
pos(…), range(…)Constructors for the two position types.
starterKitSeventeen extensions in one array — document, paragraph, text, heading, blockquote, code block, bullet/ordered/list item, horizontal rule, hard break, bold, italic, strike, code, link, history.
79 named extensionsEvery entry in Extensions, each importable on its own.
HelperstableOfContents(doc), assignIds(doc), commentRanges(doc), activeSuggestion(editor), searchEmoji(query), youtubeId(url), normalizeUrl(text), fieldsIn(doc), fillFieldsIn(doc, values), hashtagsIn(doc), parseDelimited(text), dictationSupported() — plain functions, not extensions.
toMarkdown, fromMarkdownPure string work, so they run in Node, in a worker and at the edge.
…CSS helpersplaceholderCSS, commentCSS, taskListCSS, dragHandleCSS, suggestionCSS, searchCSS, lockedCSS, fieldsCSS, columnsCSS, footnotesCSS and the rest — stylesheets to paste into an app rather than a stylesheet to import.

EditorOptions

FieldTypeNotes
extensionsreadonly AnyDef[]Declare it as const. The tuple is what makes the commands infer.
contentDocNode | stringDocument JSON, or HTML to parse.
editableboolean
autofocusboolean | 'start' | 'end'
elementHTMLElementMount as soon as the editor exists, instead of calling mount yourself.

The editor

MemberSignature
commandsCommandsOf<T> & CoreCommandsOnly what the extensions you passed provide. Anything else is a compile error.
cansame shapeAsks instead of does, so a button can be disabled rather than dead.
batch(run)=> booleanSeveral commands, one undo step. Rolls back entirely if any returns false.
isActive(name, attrs?)=> booleanMarks first, then nodes · isActive('heading', { level: 2 }) reads naturally.
getJSON()=> DocNode
getHTML()=> stringAnswers without a DOM.
getText()=> string
setContent(content)=> void
selectionSelection
editable / setEditable(v)
on(event, fn)=> () => voidchange, focus, blur, selectionChange. Returns its own unsubscribe.
extensionState<S>(name)=> S | undefinedHow a toolbar reads a character count or a collab version without a global.
mount(el) / destroy()
unsafe{ view, state, schema }Excluded from semver. Needing it means the public API has a gap — open an issue.

Core commands, present whatever you pass: select, insert, replace, remove, moveBlock, focus. insert and replace accept blocks at a caret inside a paragraph and split the paragraph around them, which is what a rule or a table asked for at the caret means.

What an extension may declare, beyond commands, keys and input rules:

FieldOnWhat it does
attributesextensionAdd attributes to nodes and marks defined elsewhere · [{ types: ['paragraph', 'heading'], attrs: { indent: { default: 0, render, parse } } }]. How textAlign, indent and uniqueId work without the paragraph knowing about them.
handlePaste(ctx, { html, text, files })extensionClaim a paste before the editor parses it. Return true to keep it.
handleDrop(ctx, { html, text, files, pos })extensionThe same for something dropped from outside. Block drags inside the editor never reach it.
filterChange(ctx)extensionVeto a change before it lands. Return false and the document, the selection and the undo history stay as they were · how locked() refuses a keystroke, a paste and a drag alike. editor.can asks it too.
nodeViewsextensionRender nodes defined elsewhere with your own DOM · { image: ({ node, getPos, editor }) => … }. How imageResize() puts a handle on the stock image.
decorations(ctx)extensionDraw over the document · highlights, widgets, a class on the current block.
stateextensionReduced on every transaction · read with editor.extensionState(name).
codenodeWhitespace inside is literal, so a pasted function keeps its line breaks.
listItemnodeEnter splits, Tab nests, Backspace at the start lifts.
marksnodeWhich marks the text may carry · '' for none.
nodeViewnodeRender with your own DOM and keep it across edits.

@matrajs/react — MIT

pnpm add @matrajs/react
ExportSignature
useEditor(options)Editor<T> — created lazily on first render, destroyed on unmount.
useEditorState(editor, select)S — a useSyncExternalStore subscription to change and selectionChange.
useEditorFocus(editor)boolean
EditorContent{ editor } plus every div attribute.
import { starterKit } from '@matrajs/core'
import { EditorContent, useEditor, useEditorState } from '@matrajs/react'

export function Notes() {
  const editor = useEditor({ extensions: starterKit, content: '<p>Hello</p>' })
  const bold = useEditorState(editor, (e) => e.isActive('bold'))

  return (
    <>
      <button onClick={() => editor.commands.toggleBold()} aria-pressed={bold}>
        Bold
      </button>
      <EditorContent editor={editor} className="prose" />
    </>
  )
}

Options are read once. Changing them later does not recreate the editor, because tearing down a live document on a prop change loses the user's work — use the commands instead. The mount is guarded on unsafe.view, so StrictMode's double invoke cannot leave two views fighting over one element.


@matrajs/vue — MIT

The same four names as React, returning refs.

pnpm add @matrajs/vue
ExportSignature
useEditor(options)Editor<T>, markRawped · works in a component or a bare effect scope.
useEditorState(editor, select)Readonly<Ref<S>>
useEditorFocus(editor)Readonly<Ref<boolean>>
EditorContentComponent with an editor prop.
<script setup lang="ts">
import { starterKit } from '@matrajs/core'
import { EditorContent, useEditor, useEditorState } from '@matrajs/vue'

const editor = useEditor({ extensions: starterKit })
const bold = useEditorState(editor, (e) => e.isActive('bold'))
</script>

<template>
  <button :aria-pressed="bold" @click="editor.commands.toggleBold()">Bold</button>
  <EditorContent :editor="editor" />
</template>

The mount is guarded, so a <KeepAlive> remount does not attach a second view.


@matrajs/svelte — MIT

Svelte already has the right shape — an action runs when the element exists and is told when it goes away — so the binding is thin on purpose. Written with stores rather than runes, so it behaves identically on Svelte 4 and 5.

pnpm add @matrajs/svelte
ExportSignature
matra(options){ action, editor, state }
editorState(editor)Readable<Editor<T>> — republishes on change and selection.
<script>
  import { starterKit } from '@matrajs/core'
  import { matra } from '@matrajs/svelte'

  const { action, editor, state } = matra({ extensions: starterKit })
</script>

<button aria-pressed={$state.isActive('bold')} onclick={() => editor.commands.toggleBold()}>
  Bold
</button>
<div use:action></div>

The editor exists before the element does, so commands, content and getJSON() all work before anything is on screen — which is what a server render and a test both need.


@matrajs/solid — MIT

Solid's reactivity is not a render loop, so there is no useSyncExternalStore shape to reach for: a signal that bumps on every change is enough.

pnpm add @matrajs/solid
ExportSignature
createMatra(options){ editor, mount, state } — bound to the component's lifetime.
import { starterKit } from '@matrajs/core'
import { createMatra } from '@matrajs/solid'

const { editor, mount, state } = createMatra({ extensions: starterKit })

return (
  <>
    <button aria-pressed={state().isActive('bold')} onClick={() => editor.commands.toggleBold()}>
      Bold
    </button>
    <div ref={mount} />
  </>
)

state() returns the editor itself rather than a copy: a toolbar asks isActive at render time, and cloning a document to answer that would be the expensive way to do nothing.


@matrajs/ai — Commercial

Streaming edits that survive concurrent typing. The range being rewritten is re-resolved against the current document on every chunk, so a user who keeps typing while the model streams does not end up with a corrupted paragraph.

pnpm add @matrajs/ai
ExportWhat it is
ai(options)The extension. { stream, onStatus? }.
AiStream(request: AiRequest) => AsyncIterable<string> — yours to implement.
AiRequest{ text, instruction, signal }
AiSession{ id, status, range, received, error? }
AiStatus'idle' | 'streaming' | 'done' | 'error' | 'cancelled'

Commands: askAi(instruction), cancelAi(), acceptAi(), rejectAi().

import { createEditor, starterKit } from '@matrajs/core'
import { ai } from '@matrajs/ai'

const editor = createEditor({
  extensions: [
    ...starterKit,
    ai({
      async *stream({ text, instruction, signal }) {
        const response = await fetch('/api/rewrite', {
          method: 'POST',
          body: JSON.stringify({ text, instruction }),
          signal,
        })
        for await (const chunk of response.body!.pipeThrough(new TextDecoderStream())) yield chunk
      },
      onStatus: (session) => setSpinner(session.status === 'streaming'),
    }),
  ] as const,
})

editor.commands.askAi('make this shorter')

stream runs in your application, so the model key stays on your server. The extension never talks to us.


@matrajs/collab — Commercial

Step exchange, rebasing and presence, with no CRDT dependency. Another client's work rebases over unsent local work without either being lost.

pnpm add @matrajs/collab
ExportWhat it is
collab(options)The extension. { clientId, version? }.
AuthorityThe server side · receive(version, steps) and since(version). Transport-agnostic.
sendableSteps(editor)Sendable | null — what to put on the wire.
getVersion(editor)number
remoteCursors()The presence extension.
colorFor(clientId)A stable colour per client.
remoteCursorCSSThe stylesheet the cursor decorations expect.
CollabStep, Presence, Sendable, CollabStateWire types.

Command: receiveCollabSteps(steps) — steps this client sent are skipped, and a step that no longer applies is dropped rather than thrown, because one bad message from a peer must not take the editor down.

import { createEditor, starterKit } from '@matrajs/core'
import { collab, remoteCursors, sendableSteps } from '@matrajs/collab'

const editor = createEditor({
  extensions: [...starterKit, collab({ clientId: 'me' }), remoteCursors()] as const,
})

editor.on('change', () => {
  const sendable = sendableSteps(editor)
  if (sendable) socket.send(JSON.stringify(sendable))
})

socket.onmessage = (event) => editor.commands.receiveCollabSteps(JSON.parse(event.data))

Authority is a plain class with no server attached — run it in a WebSocket handler, a Durable Object, or a test.


@matrajs/versions — Commercial

Snapshots, a real diff between them, and restore as one undo step.

pnpm add @matrajs/versions
ExportWhat it is
versions(options)The extension. { now?, idleMs?, keep?, onChange?, store? }.
versionList(editor)Version[]
localVersionStore(key)A VersionStore on localStorage.
diffDocs(a, b)DocDiff — block-level changes between two documents.
diffWords(a, b)WordRun[]
blockStarts, sizeOf, textOfThe primitives the diff is built from.
versionClasses, versionDiffCSSClass names and the stylesheet for preview decorations.
Version{ id, label, at, doc, size }

Commands: snapshotVersion(label?), restoreVersion(id), previewVersion(id | null), forgetVersion(id).

import { createEditor, starterKit } from '@matrajs/core'
import { localVersionStore, versionList, versions } from '@matrajs/versions'

const editor = createEditor({
  extensions: [
    ...starterKit,
    versions({
      idleMs: 30_000,
      keep: 50,
      store: localVersionStore('doc-42'),
      onChange: (state) => render(state.versions, state.diff),
    }),
  ] as const,
})

editor.commands.snapshotVersion('before the rewrite')
editor.commands.previewVersion(versionList(editor)[0].id)

idleMs: null turns automatic snapshots off and leaves them to snapshotVersion. A version per keystroke is not history, it is a keylogger with a nicer name. now is injected rather than reached for, so a test does not have to sleep to make two versions differ.


Security

Document JSON, pasted HTML and collaborative steps are all treated as hostile, and the rendering path is the gate they all pass through: executable attributes are never set, URL attributes are scheme-checked, undeclared attributes are dropped, and commands report failure rather than throwing. See SECURITY.md.

Development

pnpm install
pnpm dev         # playground at localhost:5173
pnpm test        # vitest
pnpm typecheck   # tsc, including the type-level tests
pnpm check       # biome, and prettier for .astro
pnpm build       # tsup, all packages
pnpm size        # the bundle ladder the site quotes
pnpm bench:check # the performance ratchet, against the recorded baseline
pnpm links       # no dead internal links on the site
pnpm packaging   # every built package imports and requires (run after build)
pnpm wiring      # every script on the site finds the markup it asks for
pnpm exercise        # drive every extension through the built package in a DOM: every command, rule and paste
pnpm install:matrix  # pack every package, npm-install it into fresh Vite apps for each framework, build and run them
pnpm facts       # the counts the site prints — tests, adversarial tests, extensions

Status

1.0 — the Matra engine, end to end. Document model, transforms, position mapping, editor state and the editable view are written from scratch, with zero runtime dependencies. 779 tests, 68 of them adversarial, and every package is installed with plain npm into a fresh React, Vue, Svelte, Solid and vanilla Vite app and built there before a release (pnpm install:matrix).

An app on the starter kit bundles 31 kB gzipped, because nothing arrives that the editor does not use — seventy-nine extensions ship in the package and none of them is in the bundle until it is in the array. The whole ladder, from an empty extension array upwards, is measured by pnpm size and checked in CI. It was 25 kB at 0.16; what the five kilobytes bought is listed in CHANGELOG.md.

Drag and drop landed in 0.9.0: blocks drag with a handle, a line shows where they will land, and the move is one undo step.

The view passes its tests but has not yet met real IME users on iOS Safari or Android Chrome. See ENGINE.md for where the risk actually sits, and harness/ime for the page that checks it on a real device.

Extensions

Everything in the box, and everything free unless marked.

Textbold, italic, strike, code, underline, highlight, subscript, superscript, link, text style, kbdcolour, background, font family and size, as one mark
Blocksparagraph, heading, blockquote, code block, horizontal rule, hard break, image, callout, detailsa Notion callout and a collapsible toggle
EmbedsYouTube, any embed page in a sandboxed frame, image resize with a handleallowlisted hosts only; the width lands in the HTML
Templateslocked blocks, fields, snippetsa contract with fixed clauses, a mail merge with no editor, words that expand as typed
Layoutcolumns, page break, line height, text directiontwo to six columns, a real break in print, right-to-left detected from the text
Scholarlyfootnotes, math inline and displaynumbered by position; KaTeX or MathJax plug in, or the source shows
Listsbulleted, ordered, task lists with real checkboxes
Tablesinsert, delete, header rows, colspan and rowspan, add and remove rows and columns, Tab between cellsspanning cells widen rather than split
Writingplaceholder, character count, text align, indent, typography, emoji shortcodes, autolink, clear formatting, text case, invisible characters, selection highlight, typewriter scrolling, autosave, smart paste, hashtagssmart quotes, dashes, arrows · :tada: · URLs link as you type · tab-separated text becomes a table
Findingsearch and replaceincremental: typing rescans one paragraph
Codesyntax highlighting as decorationsa built-in tokeniser, or plug in Shiki, Prism or lowlight
Structuretable of contents, unique block ids, focus class, trailing nodederived from the document, never stored beside it
InterchangeMarkdown in and out, with no DOMruns on a server
Draggingblock drag and drop, drag handle, drop cursor, files dropped or pastedthe drop cursor is in the engine, not an extension
Reviewthreaded comments anchored to rangesfree here · Tiptap's Comments needs a subscription
Menus@ mentions and / commands, detection only, bubble and floating menus for your elementthe popup is yours
Assistanceghost text completion from any source, dictation through the browser's recogniserTab takes the suggestion; nothing is sent anywhere the browser does not already send it
PaidAI streaming, collaboration with remote cursors, version history

Tiptap 3 moved most of its old Pro extensions to MIT — a table of contents, unique ids, the drag handle, the file handler, emoji, details, invisible characters and mathematics are all free there now, and it is worth saying so rather than repeating a comparison that was true of Tiptap 2. What is still behind a Tiptap subscription is comments, snapshots and version history, the AI toolkit, track changes, DOCX import and export, and pagination.

Of those, comments are free here. Version history, collaboration and AI are the three packages this project charges for, and the shape is deliberate: the things that take a week are free and drive adoption, and the ones that took months are what you pay for.

Adding one, step by step

Every extension follows the same four steps. Search and replace, as the example:

  1. Import it from @matrajs/core — the binding you installed already depends on it, so there is nothing to add to package.json.
  2. Put it in the array. Extensions that take options are functions; the rest are plain objects.
  3. Call its commands. They are on editor.commands, typed from the array, so a typo is a compile error.
  4. Paste its CSS if it has any. Extensions that draw something export a …CSS string; the editor ships no appearance of its own.
import { createEditor, search, searchCSS, starterKit } from '@matrajs/core'

const editor = createEditor({ extensions: [...starterKit, search()] as const })

editor.commands.setSearch({ query: 'colour', wholeWord: true })
editor.commands.nextMatch()              // selects it, so the view scrolls there
editor.commands.replaceMatch('color')
editor.commands.replaceAllMatches('color')   // one undo step
editor.extensionState('search')          // { matches, current, query, … } for a panel

document.head.appendChild(Object.assign(document.createElement('style'), { textContent: searchCSS }))

The same shape for the rest: textStyle then editor.commands.setColor('#c00'); callout then toggleCallout('warning'); ...detailsKit then insertDetails(); youtube then insertYoutube({ src: url }); fileHandler({ accept: ['image/'], onDrop }) then upload in onDrop and insert at marker.map(pos); ...tableKit then insertTable(3, 3) and addRowAfter(). Each is one row in the directory on matrajs.com/extensions, with the line you would write.

toMarkdown and fromMarkdown are pure string work rather than a trip through HTML, so they run in Node, in a worker, and at the edge. Turning a document into Markdown on a server does not need a DOM polyfill.

Against the alternatives

Measured, not asserted — see BENCHMARKS.md for the method and what the numbers are not.

Package counts are what npm resolves for a React install of each, measured by scripts/rivals.mjs on 2026-09-07 against Tiptap 3.31.3, Lexical 0.50.0 and Slate 0.126.2, with React and @types/* left out of the count.

MatraTiptapLexicalSlate
Bundle, gzipped31 kB117 kB~35 kB~50 kB
Packages installed2503412
Of those, third-party022108
Engine types in your codenoneProseMirrorLexicalSlate
Command typesinferredmodule augmentationmanualmanual
Async position safetybuilt inmanualmanualmanual
Vue bindingfirst-classfirst-classcommunitycommunity
Svelte and Solid bindingsfirst-classcommunitycommunitycommunity
Commentsfreesubscriptionbuild itbuild it
Runtime licence check or phone-homenevernonen/an/a

Rows that used to be here and are no longer true: Tiptap 3 publishes its table of contents, unique ids, drag handle, file handler, emoji, details, invisible characters and mathematics extensions as MIT, and @tiptap/markdown parses and serialises Markdown in bare Node. Tiptap also ships an official Vue binding, which an earlier version of this table called community.

Where the alternatives win, and it is worth saying so: ProseMirror's ecosystem is a decade deep and Tiptap inherits all of it, Lexical has been hardened by Meta's traffic, and both have met far more real IME users than this has. If you need a mature extension for something exotic today, they have it and this does not.

Releasing

One registry, and an order that matters. See RELEASING.md. Every release is recorded in CHANGELOG.md.

Licence

The core is MIT and stays that way. @matrajs/core and every framework binding — @matrajs/react, @matrajs/vue, @matrajs/svelte and @matrajs/solid — the engine, the document model, the extension API, the starter kit, tables, comments, every mark and node that ships in the box. No open-core asterisk on any of it, no feature removed later to sell back.

AI, collaboration and version history are paid. @matrajs/ai, @matrajs/collab and @matrajs/versions are source-available under the Matra Commercial License: free to evaluate, develop against, test, teach with, and use in personal projects and small internal tools; paid per developer in production. They are the things here that took months rather than days — streaming edits that survive concurrent typing, rebasing another client's work over unsent local work without losing either, and a real diff between two snapshots of a document.

Nothing phones home and there is no runtime licence check. Your editor never talks to us, in development or in production, and a lapsed subscription cannot switch anything off in an app you already shipped.

There is no download gate either. The source is in this repository and the packages install from public npm — the licence is the boundary, as with the Business Source Licence. What a subscription buys is the right to run them in production, plus updates and support.

Versions up to 0.5.0 shipped under MIT, including ai and collab, and that grant cannot be withdrawn. Anyone already on 0.5.0 may stay there under MIT forever. The commercial licence starts at 0.6.0.

Installation

Source-derived launch command. Check the maintainer’s required arguments and credentials before running:

bash
npx -y @matrajs/mcp

Set up in your AI client

Merge this template into ~/Library/Application Support/Claude/claude_desktop_config.json. Keep existing servers. Add any arguments, credentials, and permissions required by the maintainer; this template has not been install-tested.

json
{
  "mcpServers": {
    "com-matrajs-matra": {
      "command": "npx",
      "args": [
        "-y",
        "@matrajs/mcp"
      ]
    }
  }
}

Restart Claude Desktop completely for changes to take effect. Confirm the server appears connected in the client’s tool list, then try a read-only example from its documentation.

Claude Desktop setup reference

Package

@matrajs/mcpnpm

Compatible MCP Clients

Matra works with any MCP-compatible client. Copy the config snippet from the Configuration section above and add it to the file shown for your client, then restart the application.

  • Claude Desktop~/Library/Application Support/Claude/claude_desktop_config.jsonRestart Claude Desktop completely for changes to take effect.
  • Cursor~/.cursor/mcp.jsonRestart Cursor for changes to take effect.
  • VS Code.vscode/mcp.jsonReload VS Code window for changes to take effect.
  • Windsurf~/.codeium/windsurf/mcp_config.jsonRestart Windsurf for changes to take effect.
  • Claude Code.mcp.jsonSave at the project root, then start Claude Code in that project and review the MCP server approval prompt. Keep real credentials out of shared files.

Learn More