Tiptap Engine
June 3, 2026 · View on GitHub
Complete protocol reference for port developers. Every command, event, and response is documented here with payload shapes and JSON examples.
All communication is JSON strings over the platform-specific bridge (WKWebView on iOS, Chromium-based WebView on Android, jsdom in Node.js for testing). Messages fall into three categories:
- Commands (Port to Engine): carry a unique
id, return a response - Responses (Engine to Port): correlated to commands by
id - Events (Engine to Port): pushed asynchronously, not correlated to commands
Table of Contents
Message Format
Command (Port to Engine)
{
"type": "command",
"id": "unique-string",
"name": "commandName",
"payload": {}
}
Every command must have a unique id. The engine returns a response with the same id. Use any string — UUIDs, incrementing counters, timestamps, anything unique within the session.
Response (Engine to Port)
{
"type": "response",
"id": "unique-string",
"success": true,
"payload": {},
"error": { "code": "ERROR_CODE", "message": "Human-readable message" }
}
payload is present on success for query commands. error is present when success is false.
Event (Engine to Port)
{
"type": "event",
"name": "eventName",
"payload": {}
}
Events have no id. They are pushed whenever state changes.
Commands
Lifecycle Commands
init
Create the editor. Must be the first command sent. Emits schemaReady, ready, and stateChanged events before the response.
The engine loads a fixed extension set — Tiptap v3 StarterKit plus the Image node — with default options. The extension set is not selectable or configurable from the port; changing it is a build-time change to the engine.
Payload:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
content | object or string | No | Empty document | Initial content as Tiptap JSON or HTML string |
editable | boolean | No | true | Whether the editor starts in editable mode |
Example:
{
"type": "command",
"id": "1",
"name": "init",
"payload": {
"content": "<p>Hello <strong>world</strong>!</p>",
"editable": true
}
}
Response: { "success": true }
Errors:
ALREADY_INITIALIZED— The engine is already initialized. Calldestroyfirst.
destroy
Tear down the editor and clean up all resources. After this, the engine accepts a new init command.
Payload: None (empty object).
{ "type": "command", "id": "2", "name": "destroy", "payload": {} }
Response: { "success": true }
setEditable
Toggle read-only mode.
Payload:
| Field | Type | Required | Description |
|---|---|---|---|
editable | boolean | Yes | Whether the editor should be editable |
{
"type": "command",
"id": "3",
"name": "setEditable",
"payload": { "editable": false }
}
Response: { "success": true }
Content Commands
setContent
Replace the entire document.
Payload:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
content | object or string | Yes | — | New content as Tiptap JSON or HTML string |
emitUpdate | boolean | No | true | Whether to emit a stateChanged event |
{
"type": "command",
"id": "4",
"name": "setContent",
"payload": {
"content": "<h1>New Title</h1><p>New paragraph</p>"
}
}
Response: { "success": true }
getContent
Request the current document content in a specific format.
Payload:
| Field | Type | Required | Description |
|---|---|---|---|
format | "json" or "html" or "text" | Yes | Output format |
{
"type": "command",
"id": "5",
"name": "getContent",
"payload": { "format": "html" }
}
Response:
{
"success": true,
"payload": {
"content": "<p>Hello <strong>world</strong>!</p>"
}
}
For format: "json", content is a Tiptap JSON document object. For format: "html" and format: "text", content is a string.
Errors:
INVALID_FORMAT— Unknown format value.
insertContentAt
Insert content at a specific position or range.
Payload:
| Field | Type | Required | Description |
|---|---|---|---|
position | number or { "from": number, "to": number } | Yes | Where to insert |
content | object or string | Yes | Content to insert (JSON, HTML, or plain text) |
{
"type": "command",
"id": "6",
"name": "insertContentAt",
"payload": {
"position": 5,
"content": "<strong>inserted</strong>"
}
}
Response: { "success": true }
Text Input Commands
insertText
Insert text at the current selection or a given range. This is the primary command for committed keystrokes from the native input system.
Payload:
| Field | Type | Required | Description |
|---|---|---|---|
text | string | Yes | The text to insert |
range | { "from": number, "to": number } | No | Optional range to replace. If omitted, inserts at current selection. |
{
"type": "command",
"id": "7",
"name": "insertText",
"payload": { "text": "Hello" }
}
With a range (e.g., after IME composition commit):
{
"type": "command",
"id": "8",
"name": "insertText",
"payload": {
"text": "replaced",
"range": { "from": 1, "to": 6 }
}
}
Response: { "success": true }
deleteRange
Delete content in a range or at the cursor (simple character deletion).
Payload:
| Field | Type | Required | Description |
|---|---|---|---|
range | { "from": number, "to": number } | No | Range to delete. If omitted, deletes one character before the cursor. |
Simple character deletion (no range):
{ "type": "command", "id": "9", "name": "deleteRange", "payload": {} }
Explicit range:
{
"type": "command",
"id": "10",
"name": "deleteRange",
"payload": { "range": { "from": 5, "to": 10 } }
}
Response: { "success": true }
Note: For user-initiated backspace actions, prefer the
backspacecommand instead. It runs ProseMirror's full keybinding chain which handles structural operations like joining blocks and lifting list items.deleteRangewithout a range only performs simple single-character deletion.
Generic Execution
exec
Execute any Tiptap command by name. This is the gateway for all formatting, structural, and utility commands. The engine calls editor.chain().focus()[commandName](args).run().
Payload:
| Field | Type | Required | Description |
|---|---|---|---|
command | string | Yes | The Tiptap command name |
args | object | No | Arguments to pass to the command |
Toggle bold (no args):
{
"type": "command",
"id": "11",
"name": "exec",
"payload": { "command": "toggleBold" }
}
Set heading level (with args):
{
"type": "command",
"id": "12",
"name": "exec",
"payload": { "command": "setHeading", "args": { "level": 2 } }
}
Insert an image:
{
"type": "command",
"id": "13",
"name": "exec",
"payload": {
"command": "setImage",
"args": { "src": "https://example.com/image.png", "alt": "Example" }
}
}
Response:
{ "success": true, "payload": { "executed": true } }
Errors:
UNKNOWN_EXEC_COMMAND— The command name is not available on the editor.
See Command Names for exec for the full list.
Selection Commands
setTextSelection
Set a cursor or text range selection.
Payload:
| Field | Type | Required | Description |
|---|---|---|---|
anchor | number | Yes | The fixed side of the selection |
head | number | No | The moving side. If omitted, equals anchor (cursor). |
Cursor at position 5:
{
"type": "command",
"id": "14",
"name": "setTextSelection",
"payload": { "anchor": 5 }
}
Range selection from 1 to 10:
{
"type": "command",
"id": "15",
"name": "setTextSelection",
"payload": { "anchor": 1, "head": 10 }
}
Response: { "success": true }
setNodeSelection
Select an entire node at a position (e.g., an image or horizontal rule).
Payload:
| Field | Type | Required | Description |
|---|---|---|---|
position | number | Yes | Position of the node to select |
{
"type": "command",
"id": "16",
"name": "setNodeSelection",
"payload": { "position": 5 }
}
Response: { "success": true }
selectAll
Select the entire document.
Payload: None.
{ "type": "command", "id": "17", "name": "selectAll", "payload": {} }
Response: { "success": true }
focus
Set logical focus on the editor.
Payload:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
position | "start" or "end" or "all" or number | No | Current position | Where to place the cursor on focus |
{
"type": "command",
"id": "18",
"name": "focus",
"payload": { "position": "end" }
}
Response: { "success": true }
blur
Remove logical focus from the editor.
Payload: None.
{ "type": "command", "id": "19", "name": "blur", "payload": {} }
Response: { "success": true }
Keyboard Action Commands
These commands simulate real keypresses through ProseMirror's keybinding infrastructure. They give ports correct structural behavior without needing to understand the document model.
Ports should prefer these over deleteRange (for backspace) and exec('splitBlock') (for enter) when handling user keyboard input, because the keybinding chains handle context-sensitive behavior that single commands miss.
backspace
Execute ProseMirror's full Backspace command chain, which handles:
- Deleting the current selection (if non-empty)
- Joining the current block with the previous one (e.g., merging two paragraphs)
- Lifting list items out of their parent list
- Selecting and deleting atomic nodes (images, horizontal rules)
- Deleting a single character before the cursor (when no structural operation applies)
Payload: None.
{ "type": "command", "id": "20", "name": "backspace", "payload": {} }
Response: { "success": true }
When to use backspace vs deleteRange:
- Use
backspacefor user-initiated backspace actions (hardware keyboard, on-screen keyboard backspace button). It handles all the structural edge cases automatically. - Use
deleteRangewith an explicit range when the port knows the exact range to delete (e.g., programmatic deletion, clearing a specific span of text).
enter
Simulate an Enter keypress through TipTap's full Enter keybinding chain, which handles:
- Inserting a newline inside code blocks
- Creating a paragraph next to non-text blocks (images, horizontal rules)
- Lifting out of empty blockquotes and list items
- Splitting list items correctly (creating a new list item, not a new paragraph)
- Splitting blocks (the default paragraph split behavior)
Payload: None.
{ "type": "command", "id": "21", "name": "enter", "payload": {} }
Response: { "success": true }
When to use enter vs exec('splitBlock'):
- Use
enterfor user-initiated enter/return actions. It handles all block types correctly (lists, code blocks, blockquotes, etc.). - Use
exec('splitBlock')only if you specifically want a plain block split regardless of context (rare).
Query Commands
getState
Request a full state snapshot. Returns the same payload shape as the stateChanged event.
Payload: None.
{ "type": "command", "id": "22", "name": "getState", "payload": {} }
Response:
{
"success": true,
"payload": {
"doc": { "type": "doc", "pos": 0, "end": 16, "content": [...] },
"selection": { "type": "text", "anchor": 1, "head": 1, "from": 1, "to": 1, "empty": true },
"activeMarks": [],
"activeNodes": [{ "type": "paragraph", "attrs": {} }],
"commandStates": { "toggleBold": { "canExec": true, "isActive": false }, ... },
"decorations": [],
"storedMarks": [],
"editable": true
}
}
isActive
Check if a mark or node type is active at the current selection.
Payload:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Mark or node type name |
attrs | object | No | Attributes to match (e.g., { "level": 2 } for heading) |
{
"type": "command",
"id": "23",
"name": "isActive",
"payload": { "name": "bold" }
}
With attribute matching:
{
"type": "command",
"id": "24",
"name": "isActive",
"payload": { "name": "heading", "attrs": { "level": 1 } }
}
Response:
{ "success": true, "payload": { "active": true } }
canExec
Check if a command can execute in the current state.
Payload:
| Field | Type | Required | Description |
|---|---|---|---|
command | string | Yes | The command name to check |
args | object | No | Optional arguments for the check |
{
"type": "command",
"id": "25",
"name": "canExec",
"payload": { "command": "toggleBold" }
}
Response:
{ "success": true, "payload": { "canExec": true } }
getAttributes
Get attributes of a mark or node type at the current selection.
Payload:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Mark or node type name |
{
"type": "command",
"id": "26",
"name": "getAttributes",
"payload": { "name": "heading" }
}
Response:
{ "success": true, "payload": { "attrs": { "level": 2 } } }
Events
schemaReady
Emitted once after init, before ready. Contains the full schema introspection payload for the loaded extension set (StarterKit + Image). This event is the authoritative source for which nodes, marks, and commands are available — ports should read it rather than hardcoding the list.
{
"type": "event",
"name": "schemaReady",
"payload": {
"nodes": [
{
"name": "paragraph",
"contentExpression": "inline*",
"group": "block",
"attrs": [],
"isLeaf": false,
"isInline": false,
"isBlock": true
}
],
"marks": [
{
"name": "bold",
"attrs": []
},
{
"name": "link",
"attrs": [
{ "name": "href", "default": null },
{ "name": "target", "default": null }
]
}
],
"commands": [
{
"name": "toggleBold",
"type": "toggle-mark",
"associatedType": "bold",
"args": [],
"group": "formatting",
"extensionName": "bold"
},
{
"name": "setHeading",
"type": "set-node",
"associatedType": "heading",
"args": [{ "name": "level", "required": true }],
"group": "blocks",
"extensionName": "heading"
}
]
}
}
Command types in the commands array:
| Type | Meaning | UI Hint |
|---|---|---|
toggle-mark | Toggles an inline mark on/off | Toggle button |
toggle-node | Toggles a block node type | Toggle button |
set-node | Sets a block node type (no toggle) | Dropdown option |
wrap | Wraps selection in a node | Button |
lift | Lifts content out of a wrapping node | Button |
action | One-shot action (undo, redo, etc.) | Button |
ready
Emitted once after init, after schemaReady. Signals the engine is ready for commands.
{ "type": "event", "name": "ready", "payload": {} }
stateChanged
Emitted after every transaction. The primary event ports use to re-render. Contains the complete editor state.
{
"type": "event",
"name": "stateChanged",
"payload": {
"doc": {
"type": "doc",
"pos": 0,
"end": 16,
"content": [
{
"type": "paragraph",
"pos": 1,
"end": 15,
"content": [
{ "type": "text", "pos": 2, "end": 8, "text": "Hello " },
{
"type": "text",
"pos": 8,
"end": 13,
"text": "world",
"marks": [{ "type": "bold" }]
},
{ "type": "text", "pos": 13, "end": 14, "text": "!" }
]
}
]
},
"selection": {
"type": "text",
"anchor": 5,
"head": 5,
"from": 5,
"to": 5,
"empty": true
},
"activeMarks": [],
"activeNodes": [{ "type": "paragraph", "attrs": {} }],
"commandStates": {
"toggleBold": { "canExec": true, "isActive": false },
"undo": { "canExec": true, "isActive": false, "depth": 3 },
"redo": { "canExec": false, "isActive": false, "depth": 0 }
},
"decorations": [],
"storedMarks": [],
"editable": true
}
}
contentChanged
Emitted only when the document content changes (not on selection-only changes). Useful for debounced auto-save.
{
"type": "event",
"name": "contentChanged",
"payload": {
"doc": { "type": "doc", "pos": 0, "end": 16, "content": [...] }
}
}
selectionChanged
Emitted on selection-only changes (no document mutation). Lighter than stateChanged — omits the document tree.
{
"type": "event",
"name": "selectionChanged",
"payload": {
"selection": { "type": "text", "anchor": 5, "head": 5, "from": 5, "to": 5, "empty": true },
"activeMarks": ["bold"],
"activeNodes": [{ "type": "paragraph", "attrs": {} }],
"commandStates": { ... }
}
}
error
Emitted when an error occurs in the engine.
{
"type": "event",
"name": "error",
"payload": {
"code": "COMMAND_FAILED",
"message": "Something went wrong",
"commandId": "42"
}
}
commandId is present when the error was caused by a specific command.
extensionEvent
Generic passthrough for extension-specific events. The engine does not interpret these — it forwards them to the port. The fixed extension set does not currently emit any such events; this remains in the protocol so that ports and future engine builds have a defined channel for extension-defined messages.
{
"type": "event",
"name": "extensionEvent",
"payload": {
"extensionName": "someExtension",
"eventName": "someEvent",
"data": {}
}
}
Responses
Every command gets exactly one response with the matching id.
Success (no payload)
Most mutation commands return a bare success:
{ "type": "response", "id": "1", "success": true }
Success (with payload)
Query commands include their result:
{
"type": "response",
"id": "5",
"success": true,
"payload": { "content": "<p>Hello</p>" }
}
Error
{
"type": "response",
"id": "99",
"success": false,
"error": {
"code": "NOT_INITIALIZED",
"message": "Engine is not initialized. Send an init command first."
}
}
Error Codes
| Code | Meaning |
|---|---|
NOT_INITIALIZED | Command sent before init or after destroy |
ALREADY_INITIALIZED | init sent when the engine is already running |
UNKNOWN_COMMAND | Unrecognized command name |
UNKNOWN_EXEC_COMMAND | The command passed to exec doesn't exist on the editor |
INVALID_FORMAT | Unknown format in getContent |
COMMAND_FAILED | The command threw an exception during execution |
Data Types
AnnotatedNode
Every node in the document JSON carries position annotations.
{
"type": "paragraph",
"pos": 1,
"end": 15,
"attrs": { },
"content": [ ... ],
"marks": [{ "type": "bold", "attrs": {} }],
"text": "Hello"
}
| Field | Type | Present | Description |
|---|---|---|---|
type | string | Always | Node type name |
pos | number | Always | ProseMirror position where this node's content starts |
end | number | Always | ProseMirror position where this node ends |
attrs | object | When non-default attributes exist | Node attributes |
content | AnnotatedNode[] | On nodes with children | Child nodes |
marks | Mark[] | On text nodes with marks | Applied marks |
text | string | On text nodes | Text content |
Position rules:
- For block nodes:
posis after the opening token,endis after the closing token - For text nodes:
posis the first character,end - posequals the text length - The document node starts at
pos: 0 - A child's
posequals its parent'spos + 1(for the first child of a block node)
SelectionState
{
"type": "text",
"anchor": 5,
"head": 10,
"from": 5,
"to": 10,
"empty": false
}
| Field | Type | Description |
|---|---|---|
type | "text" or "node" or "all" or "gapcursor" | Selection kind |
anchor | number | Fixed side of the selection |
head | number | Moving side of the selection |
from | number | Start of selection range (min of anchor, head) |
to | number | End of selection range (max of anchor, head) |
empty | boolean | True when from equals to (cursor, no range) |
CommandState
{ "canExec": true, "isActive": false, "depth": 3 }
| Field | Type | Description |
|---|---|---|
canExec | boolean | Whether the command can execute in the current state |
isActive | boolean | Whether the associated mark/node is active at the selection |
depth | number (optional) | Stack depth for undo/redo only |
Mark
{ "type": "bold" }
{
"type": "link",
"attrs": { "href": "https://example.com", "target": "_blank" }
}
| Field | Type | Present | Description |
|---|---|---|---|
type | string | Always | Mark type name |
attrs | object | When non-default attributes exist | Mark attributes |
ActiveNode
{ "type": "heading", "attrs": { "level": 2 } }
Represents a node type active at the current selection, with its attributes.
Command Names for exec
The engine loads a fixed extension set (Tiptap v3 StarterKit plus Image), so the commands available through exec are fixed. The tables below list them.
The authoritative, machine-readable list for any given engine build is the commands array in the schemaReady event — ports should read that rather than relying on this table, which is maintained by hand and documents the current set for convenience.
Formatting
| Command | Args | Description |
|---|---|---|
toggleBold | — | Toggle bold mark |
toggleItalic | — | Toggle italic mark |
toggleStrike | — | Toggle strikethrough |
toggleCode | — | Toggle inline code |
toggleUnderline | — | Toggle underline |
setLink | { href, target? } | Apply link mark |
unsetLink | — | Remove link mark |
toggleLink | { href, target? } | Toggle link mark |
Block Types
| Command | Args | Description |
|---|---|---|
setParagraph | — | Convert to paragraph |
toggleHeading | { level } | Toggle heading (level 1-6) |
setHeading | { level } | Set heading without toggle |
toggleCodeBlock | { language? } | Toggle code block |
toggleBlockquote | — | Toggle blockquote wrapping |
Lists
| Command | Args | Description |
|---|---|---|
toggleBulletList | — | Toggle bullet list |
toggleOrderedList | — | Toggle ordered list |
sinkListItem | — | Indent list item |
liftListItem | — | Outdent list item |
splitListItem | — | Split a list item |
Insert
| Command | Args | Description |
|---|---|---|
setHorizontalRule | — | Insert horizontal rule |
setHardBreak | — | Insert hard break (shift+enter) |
setImage | { src, alt?, title? } | Insert image |
History
| Command | Args | Description |
|---|---|---|
undo | — | Undo last change |
redo | — | Redo last undone change |
Selection
| Command | Args | Description |
|---|---|---|
selectAll | — | Select entire document |
focus | { position? } | Focus the editor |
blur | — | Blur the editor |