Quickstart: AI

Wire model tool calls to the DOCX editor: one API route, one page. The assistant reads the document, adds comments, and suggests tracked changes in the browser.

This page adds an assistant panel to a Next.js App Router page. The model can read document text through tools, add comments, and propose tracked changes that the user accepts or rejects. Only chat messages, tool names, arguments, and tool results reach your route; the DOCX file stays client-side.

Install

npm install @eigenpal/docx-editor-react @eigenpal/docx-editor-agents ai @ai-sdk/react @ai-sdk/openai

Set OPENAI_API_KEY in the environment that runs your Next.js app, or swap @ai-sdk/openai for another AI SDK provider.

The API route

The tools ship without execute handlers: the AI SDK forwards every call to the client, which runs it against the live editor.

// app/api/chat/route.ts
import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from 'ai';
import { openai } from '@ai-sdk/openai';
import { getAiSdkTools } from '@eigenpal/docx-editor-agents/ai-sdk/server';

export async function POST(req: Request) {
  const { messages } = (await req.json()) as { messages: UIMessage[] };
  return streamText({
    model: openai('gpt-4o'),
    system: 'You are a careful document assistant. Locate paragraphs before editing.',
    messages: await convertToModelMessages(messages),
    tools: getAiSdkTools(),
    stopWhen: stepCountIs(12), // without this the loop ends after one tool call
  }).toUIMessageStreamResponse();
}

The page

useDocxAgentTools bridges tool calls to the live editor; agentPanel mounts the chat next to the pages with the shipped AgentChatLog and AgentComposer components.

'use client';

import { useMemo, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
import { type DocxEditorRef } from '@eigenpal/docx-editor-react';
import '@eigenpal/docx-editor-react/styles.css';
import {
  AgentChatLog,
  AgentComposer,
  useDocxAgentTools,
  getToolDisplayName,
  type EditorRefLike,
} from '@eigenpal/docx-editor-agents/react';
import { toAgentMessages } from '@eigenpal/docx-editor-agents/ai-sdk/react';

// Client-only import; see /docs/1.x/installation for the SSR recipe.
const DocxEditor = dynamic(
  () => import('@eigenpal/docx-editor-react').then((m) => ({ default: m.DocxEditor })),
  { ssr: false }
);

export default function Page() {
  const editorRef = useRef<DocxEditorRef>(null);
  const [file, setFile] = useState<File | null>(null);
  const [input, setInput] = useState('');

  const { executeToolCall } = useDocxAgentTools({
    // RefObject is invariant; DocxEditorRef satisfies EditorRefLike.
    editorRef: editorRef as React.RefObject<EditorRefLike | null>,
    author: 'Assistant',
  });

  // Tool results route back through a ref set after useChat returns.
  const chatRef = useRef<{ addToolResult: (args: unknown) => Promise<void> } | null>(null);
  const chat = useChat({
    transport: new DefaultChatTransport({ api: '/api/chat' }),
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
    onToolCall: ({ toolCall }) => {
      const result = executeToolCall(
        toolCall.toolName,
        (toolCall.input ?? {}) as Record<string, unknown>
      );
      void chatRef.current?.addToolResult({
        tool: toolCall.toolName,
        toolCallId: toolCall.toolCallId,
        output:
          typeof result.data === 'string'
            ? result.data
            : (result.error ?? JSON.stringify(result.data)),
      });
    },
  });
  chatRef.current = chat as unknown as typeof chatRef.current;

  const messages = useMemo(
    () => toAgentMessages(chat.messages, chat.status),
    [chat.messages, chat.status]
  );
  const loading = chat.status === 'streaming' || chat.status === 'submitted';

  return (
    <div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
      <div style={{ padding: 8 }}>
        <input
          type="file"
          accept=".docx"
          onChange={(event) => setFile(event.target.files?.[0] ?? null)}
        />
      </div>
      <DocxEditor
        ref={editorRef}
        documentBuffer={file}
        agentPanel={{
          title: 'Assistant',
          render: () => (
            <>
              <AgentChatLog
                messages={messages}
                loading={loading}
                error={chat.error?.message}
                humanizeToolName={getToolDisplayName}
              />
              <AgentComposer
                value={input}
                onChange={setInput}
                onSubmit={() => {
                  if (!input.trim() || loading) return;
                  chat.sendMessage({ text: input });
                  setInput('');
                }}
                disabled={loading}
              />
            </>
          ),
        }}
      />
    </div>
  );
}

Pick a .docx, then ask it to "find every passive sentence and suggest a rewrite." The suggested edits appear as tracked changes in the editor, where the user can accept or reject them.

The AI editing tutorial explains the tool loop, selection context, tool filtering, and the Vue equivalent.

Next steps

On this page