Template workflow

Experimental

This feature is experimental and may change in future releases.

Fill structured Tiptap templates with AI-generated content and insert the result into the editor.

See the source code on GitHub.

How templates work

Tiptap templates are Tiptap JSON structures with special attributes that mark dynamic parts:

  • _templateSlot (string key): The entire node is replaced with AI-generated HTML content.
  • _templateIf (string key): The node is conditionally included based on a boolean value.
  • _templateAttributes (array of {key, attribute} objects): Specific node attributes are set from AI-generated values.

Here is an example template in Tiptap JSON format:

{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": { "level": 1 },
      "content": [{ "type": "text", "text": "Non-Disclosure Agreement" }]
    },
    {
      "type": "paragraph",
      "attrs": { "_templateSlot": "parties" },
      "content": [{ "type": "text", "text": "Party details will be generated here." }]
    },
    {
      "type": "paragraph",
      "attrs": { "_templateIf": "includeArbitration" },
      "content": [{ "type": "text", "text": "Arbitration clause content..." }]
    },
    {
      "type": "heading",
      "attrs": {
        "level": 1,
        "_templateAttributes": [{ "key": "sectionLevel", "attribute": "level" }]
      },
      "content": [{ "type": "text", "text": "Governing Law" }]
    }
  ]
}

The AI generates a JSON object with values for each key:

{
  "parties": "<p>This agreement is between <strong>Acme Corp</strong> and <strong>Beta LLC</strong>.</p>",
  "includeArbitration": true,
  "sectionLevel": 2
}

Tech stack

Project overview

This demo uses the AI Toolkit's template workflow to fill a legal document template (Non-Disclosure Agreement) with AI-generated content. The template has predefined sections with slots, conditional clauses, and dynamic attributes. The AI fills in the dynamic parts while the fixed legal boilerplate is preserved.

Installation

Create a Next.js project:

npx create-next-app@latest template-workflow

Install the core Tiptap packages and the Vercel AI SDK for OpenAI:

npm install @tiptap/react @tiptap/starter-kit ai @ai-sdk/openai zod

Install the Tiptap AI Toolkit:

Pro package

The AI Toolkit is a pro package. Before installation, set up access to the private NPM registry by following the private registry guide.

npm install @tiptap-pro/ai-toolkit @tiptap-pro/ai-toolkit-tool-definitions

Server setup

Create an API endpoint that uses the Vercel AI SDK to call the OpenAI model.

If your backend is in another programming language than TypeScript, see this guide.

Inside the API endpoint, convert the HTML template to a workflow configuration using createTemplateWorkflow. The function auto-extracts all template keys from the HTML, generates the system prompt, and creates the output schema.

// app/api/template-workflow/route.ts
import { openai } from '@ai-sdk/openai'
import { createTemplateWorkflow } from '@tiptap-pro/ai-toolkit-tool-definitions'
import { Output, streamText } from 'ai'

export async function POST(req: Request) {
  const { htmlTemplate, task } = await req.json()

  // Create the workflow from the HTML template.
  // It auto-extracts template keys and generates the prompt and schema.
  const workflow = createTemplateWorkflow({ htmlTemplate })

  const result = streamText({
    model: openai('gpt-5-mini'),
    system: workflow.systemPrompt,
    prompt: JSON.stringify({
      task,
      context: 'Additional background information related to the task',
    }),
    output: Output.object({ schema: workflow.zodOutputSchema }),
  })

  return result.toTextStreamResponse()
}

Client setup

On the client side, define the template as Tiptap JSON. Use the Vercel AI SDK's useObject hook to stream partial values from the server. Pass a permissive Zod schema (z.object({}).passthrough()) to accept any properties the server returns.

As partial values arrive, call templateWorkflow with hasFinished and workflowId to progressively fill the template. The workflowId enables streaming mode where the method tracks the insertion range across calls and replaces content progressively. Before calling the API, convert the template to HTML using the createHtmlTemplate method so the server can parse the template keys.

// app/template-workflow/page.tsx
'use client'

import { experimental_useObject as useObject } from '@ai-sdk/react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { AiToolkit, getAiToolkit } from '@tiptap-pro/ai-toolkit'
import { useEffect, useState } from 'react'
import { v4 as uuid } from 'uuid'
import { z } from 'zod'

// Define the template as Tiptap JSON
const template = {
  type: 'doc',
  content: [
    {
      type: 'heading',
      attrs: { level: 1 },
      content: [{ type: 'text', text: 'Non-Disclosure Agreement' }],
    },
    {
      type: 'paragraph',
      attrs: { _templateSlot: 'parties' },
      content: [{ type: 'text', text: 'Party details...' }],
    },
    // ... more template content
  ],
}

// A permissive schema that accepts any properties from the server
const templateSchema = z.object({}).passthrough()

export default function Page() {
  const editor = useEditor({
    immediatelyRender: false,
    extensions: [StarterKit, AiToolkit],
    content: '<p>Click "Generate" to fill the template.</p>',
  })

  const [workflowId, setWorkflowId] = useState('')

  const { submit, isLoading, object } = useObject({
    api: '/api/template-workflow',
    schema: templateSchema,
  })

  // Stream partial results as they arrive
  useEffect(() => {
    if (!editor || !object) return

    const toolkit = getAiToolkit(editor)
    toolkit.templateWorkflow({
      template,
      values: object as Record<string, unknown>,
      position: 'document',
      hasFinished: !isLoading,
      workflowId,
    })
  }, [object, workflowId, editor, isLoading])

  const generate = () => {
    if (!editor) return

    const toolkit = getAiToolkit(editor)
    const htmlTemplate = toolkit.createHtmlTemplate(template)

    setWorkflowId(uuid())
    submit({
      htmlTemplate,
      task: 'Generate an NDA between Acme Corp and Beta LLC',
    })
  }

  if (!editor) return null

  return (
    <div>
      <EditorContent editor={editor} />
      <button onClick={generate} disabled={isLoading}>
        {isLoading ? 'Generating...' : 'Generate'}
      </button>
    </div>
  )
}

End result

With additional CSS styles, the result is a polished template workflow application:

See the source code on GitHub.

Insert template in part of the document

To insert the template result into a specific region instead of replacing the entire document, set the position option. It takes either a position or a Range.

// Insert template result at a specific range
toolkit.templateWorkflow({
  template,
  values: object,
  position: { from: 10, to: 50 },
  hasFinished: !isLoading,
  workflowId,
})

Build a template editor

You can build a system where users create and edit templates in a rich text editor and define which parts the AI should fill. Use the _templateSlot attribute to mark nodes as template fields.

First, register the TemplateField extension in your editor. This extension adds the three template attributes (_templateSlot, _templateIf, _templateAttributes) as global attributes to all node types in the editor schema:

import { TemplateField } from '@tiptap-pro/ai-toolkit'

const editor = new Editor({
  extensions: [StarterKit, TemplateField],
})

TemplateField vs AiToolkit

You can use TemplateField alongside the AiToolkit extension, or on its own if you only need template field support without the full AI Toolkit functionality.

The TemplateField extension provides commands for setting and unsetting template attributes on the node at the current selection:

// Mark the selected node as a template slot
editor.commands.setTemplateSlot('intro')

// Remove the template slot
editor.commands.unsetTemplateSlot()

// Mark the selected node as conditionally included
editor.commands.setTemplateIf('showDisclaimer')

// Remove the conditional
editor.commands.unsetTemplateIf()

// Map template keys to node attributes
editor.commands.setTemplateAttributes([{ key: 'sectionLevel', attribute: 'level' }])

// Remove the attribute mappings
editor.commands.unsetTemplateAttributes()

Style template slots using the [_templateslot] CSS attribute selector. The TemplateField extension outputs lowercased template attributes in the DOM:

[_templateslot] {
  border: 2px dashed #6a00f5;
  border-radius: 4px;
  padding: 4px 8px;
  background-color: rgba(106, 0, 245, 0.05);
  position: relative;
}

[_templateslot]::after {
  content: attr(_templateslot);
  position: absolute;
  top: -10px;
  right: 4px;
  font-size: 10px;
  background: #6a00f5;
  color: white;
  padding: 0 4px;
  border-radius: 2px;
}

Persist template fields after filling

By default, the templateWorkflow method removes all _templateSlot attributes after filling the template. Set preserveSlotAttr to true to keep the attributes on the filled nodes:

toolkit.templateWorkflow({
  template,
  values: object,
  position: 'document',
  hasFinished: !isLoading,
  workflowId,
  preserveSlotAttr: true,
})

When preserveSlotAttr is enabled, each filled node retains its _templateSlot attribute. This lets you:

  • Identify AI-filled content: Inspect which parts of the document were generated by the AI.
  • Re-fill template fields: Use the filled document as a new template to regenerate specific sections.

Re-fill template fields

When preserveSlotAttr is true, the filled document still contains _templateSlot attributes. You can extract the document JSON and pass it back to templateWorkflow to re-fill specific fields:

// Extract the current document as a template
const currentDoc = editor.getJSON()

// Re-fill with new values
const toolkit = getAiToolkit(editor)
toolkit.templateWorkflow({
  template: currentDoc,
  values: newValues,
  position: 'document',
  preserveSlotAttr: true,
})

This enables iterative editing workflows where users can ask the AI to regenerate individual sections without affecting the rest of the document.

Control which template fields are required

By default, all template fields are required — the AI must fill every field. To make specific fields optional, use requiredSlots, requiredConditions, and requiredAttributes on the server side. Only listed fields are required; unlisted fields become optional:

// Server-side: only "intro" is required, other slots are optional
const workflow = createTemplateWorkflow({
  htmlTemplate,
  requiredSlots: ['intro'],
})

The AI will always fill required slots and may optionally fill other slots based on the task context.

To make all fields of a type optional, pass an empty array:

// All slots are optional, but specific conditions and attributes are required
const workflow = createTemplateWorkflow({
  htmlTemplate,
  requiredSlots: [],
  requiredConditions: ['includeDisclaimer'],
  requiredAttributes: ['headingLevel'],
})