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.

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.