Tiptap Edit hooks

Tiptap Edit hooks allow you to intercept operations before they are applied to the document. You can accept or reject each operation based on custom logic, and optionally modify the content or operation type when accepting.

Experimental feature

Tiptap Edit hooks are currently an experimental feature and their API may change in the future.

Use cases

  • Content moderation: Filter or sanitize AI-generated content before it reaches the document
  • Custom validation: Ensure operations meet business requirements
  • Logging and analytics: Track what changes the AI is making
  • Access control: Prevent modifications to certain parts of the document

The beforeOperation hook

The beforeOperation hook is called before each Tiptap Edit operation is applied. It receives context about the operation and returns an action to take.

Hook context

The hook receives a BeforeOperationContext object with these properties:

PropertyTypeDescription
operationTiptapEditOperationThe operation being executed, containing type, target, and content
operationIndexnumberIndex of this operation in the batch (0-based)
docNodeThe document at the current point in the transaction
deleteContentFragment | nullThe content that will be deleted (null for insertBefore/insertAfter)
insertContentFragmentThe content that will be inserted (as a ProseMirror Fragment)
range{ from: number; to: number }The position range where the operation will be applied

To access the editor instance inside your hook, capture it in a closure when defining the hook.

Hook return values

The hook must return one of these actions:

// Accept the operation unchanged
{ action: 'accept' }

// Accept with a modified fragment (overrides the content to insert)
{ action: 'accept', fragment: modifiedFragment }

// Accept with a modified operation type
{ action: 'accept', operationType: 'insertAfter' }

// Accept with both modifications
{ action: 'accept', fragment: modifiedFragment, operationType: 'replace' }

// Skip this operation and record an error
{ action: 'reject', error: 'Reason for rejection' }

The fragment property allows you to override the ProseMirror Fragment that will be inserted. The operationType property allows you to change the operation type ('replace', 'insertBefore', or 'insertAfter').

When an operation is rejected, the remaining operations in the batch continue to execute.

Usage

With executeTool

import { getAiToolkit } from '@tiptap-pro/ai-toolkit'
import { log } from './lib/logger'

const toolkit = getAiToolkit(editor)

const result = toolkit.executeTool({
  toolName: 'tiptapEdit',
  input: {
    operations: [['replace', 'abc123', '<p>New content</p>']],
  },
  tiptapEditHooks: {
    beforeOperation: (context) => {
      // Log every operation
      log(`Operation type: ${context.operation.type}`)

      // Example: reject operations that delete too much content
      if (context.deleteContent && context.deleteContent.size > 1000) {
        return {
          action: 'reject',
          error: 'Content to delete is too large',
        }
      }

      return { action: 'accept' }
    },
  },
})

With streamTool

const result = toolkit.streamTool({
  toolCallId: 'call_123',
  toolName: 'tiptapEdit',
  hasFinished: true,
  input,
  tiptapEditHooks: {
    beforeOperation: (context) => {
      // Change all replacements to insertAfter operations
      if (context.operation.type === 'replace') {
        return {
          action: 'accept',
          operationType: 'insertAfter',
        }
      }

      return { action: 'accept' }
    },
  },
})

With tiptapEditWorkflow

const { content } = toolkit.tiptapRead()

const operations = await callApiEndpoint({ content, task: 'Improve the writing' })

const result = toolkit.tiptapEditWorkflow({
  operations,
  workflowId: 'edit-123',
  tiptapEditHooks: {
    beforeOperation: (context) => {
      // Only allow replacements, not insertions
      if (context.operation.type !== 'replace') {
        return {
          action: 'reject',
          error: 'Only replace operations are allowed',
        }
      }

      return { action: 'accept' }
    },
  },
})