Agent Framework

Live AI Agent in a React DOCX Editor

Wire useDocxAgentTools + useChat into a running <DocxEditor>. Tool calls run in the browser against the live document. Interactive demo.

The inline transport for @eigenpal/docx-editor-agents. The agent runs in the browser, executing tool calls against a live <DocxEditor> ref. Comments, tracked changes, and scroll updates render in the editor as the agent operates on the document.

Demo

Roastmaster is a sample agent wired to a live <DocxEditor>. The agent calls read_document once to fetch the paragraphs (each tagged with its paraId), picks the three to five paragraphs with the most material to comment on, fires add_comment for each (anchored to a unique phrase via the search argument), and writes a one-paragraph summary in the chat panel. The Vercel AI SDK streams tokens; tool calls execute in the browser through useDocxAgentTools. Comments and the chat summary land on the live document while the agent loop runs.

The demo is scoped to read + comment only (the server route exposes a subset of the tool catalog, so even a deviating model cannot edit text). Open the panel and pick a prompt.

Quick start

UI primitives (<AgentPanel>, <AgentChatLog>, <AgentComposer>) come from @eigenpal/docx-js-editor. Agent primitives (useDocxAgentTools, getAiSdkTools, toAgentMessages) come from @eigenpal/docx-editor-agents.

Client (app/page.tsx):

'use client';
 
import { useEffect, useMemo, useRef, useState } from 'react';
import {
  DocxEditor,
  AgentChatLog,
  AgentComposer,
  type DocxEditorRef,
} from '@eigenpal/docx-js-editor';
import {
  useDocxAgentTools,
  getToolDisplayName,
  type EditorRefLike,
} from '@eigenpal/docx-editor-agents/react';
import { toAgentMessages } from '@eigenpal/docx-editor-agents/ai-sdk/react';
import { useChat } from '@ai-sdk/react';
import {
  DefaultChatTransport,
  lastAssistantMessageIsCompleteWithToolCalls,
} from 'ai';
 
export default function Page({ buffer }: { buffer: ArrayBuffer }) {
  const editorRef = useRef<DocxEditorRef>(null);
  const { executeToolCall, getContext } = useDocxAgentTools({
    editorRef: editorRef as React.RefObject<EditorRefLike | null>,
    author: 'Assistant',
  });
 
  // AI SDK v6 quirk: `chat` isn't in scope inside onToolCall, so route
  // addToolResult through a ref that we set right after useChat returns.
  const chatRef = useRef<{ addToolResult: (args: unknown) => Promise<void> } | null>(null);
 
  const chat = useChat({
    transport: new DefaultChatTransport({
      api: '/api/chat',
      prepareSendMessagesRequest: ({ messages }) => ({
        body: { messages, context: getContext() },
      }),
    }),
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
    onToolCall: ({ toolCall }) => {
      const result = executeToolCall(
        toolCall.toolName,
        (toolCall.input ?? {}) as Record<string, unknown>,
      );
      const output =
        typeof result.data === 'string'
          ? result.data
          : (result.error ?? JSON.stringify(result.data));
      void chatRef.current?.addToolResult({
        tool: toolCall.toolName,
        toolCallId: toolCall.toolCallId,
        output,
      });
    },
  });
 
  useEffect(() => {
    chatRef.current = chat as unknown as typeof chatRef.current;
  }, [chat]);
 
  const messages = useMemo(
    () => toAgentMessages(chat.messages, chat.status),
    [chat.messages, chat.status],
  );
  const isLoading = chat.status === 'submitted' || chat.status === 'streaming';
 
  const [input, setInput] = useState('');
 
  return (
    <DocxEditor
      ref={editorRef}
      documentBuffer={buffer}
      agentPanel={{
        title: 'Assistant',
        render: () => (
          <>
            <AgentChatLog
              messages={messages}
              loading={isLoading}
              humanizeToolName={getToolDisplayName}
            />
            <AgentComposer
              value={input}
              onChange={setInput}
              onSubmit={() => {
                chat.sendMessage({ text: input });
                setInput('');
              }}
              disabled={isLoading}
            />
          </>
        ),
      }}
    />
  );
}

Server (app/api/chat/route.ts):

import { getAiSdkTools } from '@eigenpal/docx-editor-agents/ai-sdk/server';
import { streamText, stepCountIs, convertToModelMessages } from 'ai';
 
export async function POST(req: Request) {
  const { messages, context } = await req.json();
  const result = streamText({
    model: 'openai/gpt-5.4-mini',
    system: `You are reviewing a DOCX. ${JSON.stringify(context)}`,
    messages: await convertToModelMessages(messages),
    tools: getAiSdkTools(),
    stopWhen: stepCountIs(12),
  });
  return result.toUIMessageStreamResponse();
}

The server registers schemas only (no execute). The client owns execution because the editor lives there. AI SDK forwards each tool call to onToolCall, which delegates to executeToolCall.

React API

Both hooks live at @eigenpal/docx-editor-agents/react. Pick one.

useDocxAgentTools(options)

Recommended. Returns { tools, executeToolCall, getContext }. Accepts custom tools via the tools option (consumer wins on name collision) and scope restriction via include / exclude allow/block lists.

OptionTypeDescription
editorRefRefObject<EditorRefLike | null>DocxEditor ref.
author?stringDefault author for comments / tracked changes. Default 'AI'.
tools?Record<string, AgentToolDefinition>Custom tools. Same name as a built-in replaces it.
include?readonly string[]Allow-list of built-in tool names.
exclude?readonly string[]Block-list (applied after include).

Returns:

FieldTypeDescription
toolsOpenAI tool schemasPass to your AI provider.
executeToolCall(name, args) => AgentToolResultHand to AI SDK's onToolCall.
getContext() => AgentContextSnapshot{ selection, currentPage, totalPages } for system-prompt injection.

useAgentChat(options)

Simpler hook. Returns { executeToolCall, toolSchemas }. Use it when you don't need custom tools or scope filtering.

EditorBridge methods (manual)

To skip the hook, createEditorBridge(editorRef, author) from @eigenpal/docx-editor-agents/bridge returns the same surface the tools call into.

MethodPurpose
getContentAsText(opts?)[paraId] text lines for LLM prompts.
getContent(opts?)Structured blocks.
findText(query, opts?)Locate text across paragraphs.
getSelection()Current cursor / selection.
getComments(filter?)List comments.
getChanges(filter?)List tracked changes.
addComment({ paraId, text, search? })Add a comment.
replyTo(commentId, { text })Reply to a comment.
resolveComment(commentId)Mark comment as done.
proposeChange({ paraId, search, replaceWith })Suggest a tracked change.
scrollTo(paraId)Scroll the editor.
onContentChange(listener)Subscribe to edits. Returns unsubscribe.
onSelectionChange(listener)Subscribe to selection moves. Returns unsubscribe.

Recipes

Restrict the agent to read-only with include

Production agents often need scope limits. include returns only the named built-ins. Useful for a review-only assistant that can read but not mutate.

const { tools, executeToolCall } = useDocxAgentTools({
  editorRef,
  include: ['read_document', 'find_text', 'read_comments', 'read_changes'],
});

Add your own tool alongside the built-ins

const { tools, executeToolCall } = useDocxAgentTools({
  editorRef,
  author: 'Assistant',
  tools: {
    fetch_clause: {
      name: 'fetch_clause',
      description: 'Fetch a clause template by name from your library.',
      inputSchema: {
        type: 'object',
        properties: { name: { type: 'string' } },
        required: ['name'],
      },
      handler: async (input) => ({
        success: true,
        data: await fetchTemplate(input.name as string),
      }),
    },
  },
});

Suggestion chips wired to the chat

<AgentSuggestionChip> pairs with chat.sendMessage. Pre-canned prompts that read the user's selection on the first tool call:

import { AgentSuggestionChip } from '@eigenpal/docx-js-editor';
 
<div style={{ display: 'flex', gap: 8 }}>
  <AgentSuggestionChip
    label="Review for grammar"
    onClick={() => chat.sendMessage({ text: 'Review the selected text for grammar.' })}
  />
  <AgentSuggestionChip
    label="Tighten this paragraph"
    onClick={() => chat.sendMessage({ text: 'Make the selected paragraph more concise.' })}
  />
</div>

What does failure look like

executeToolCall always resolves to AgentToolResult, never throws — failures come back as { success: false, error: string }. Common cases: the agent passes a paraId that no longer exists (user deleted the paragraph), the search phrase doesn't anchor in the target paragraph, or proposeChange runs against an already-deleted range.

const result = executeToolCall('add_comment', {
  paraId: 'ABCD1234',
  text: 'Tighten this.',
  search: 'phrase that is not in this paragraph',
});
 
if (!result.success) {
  // result.error: "search phrase not found in paragraph ABCD1234"
  // Forward as the tool result so the model can retry with a different anchor.
  void chatRef.current?.addToolResult({
    tool: 'add_comment',
    toolCallId,
    output: result.error,
  });
}

The model usually recovers on its own — feed result.error back as the tool output and the next step typically re-reads the paragraph and picks a different anchor. Don't throw; let the loop handle it.

React to live edits

bridge.onContentChange fires after every PM transaction. Use it to keep your UI or your server in sync with what the agent and the user have done:

import { createEditorBridge } from '@eigenpal/docx-editor-agents/bridge';
 
const bridge = createEditorBridge(editorRef.current!, 'Assistant');
 
const unsubscribe = bridge.onContentChange((event) => {
  console.log('comments:', event.commentCount, 'changes:', event.changeCount);
});

Try the live wiring on this site: /editor?agent=roastmaster opens the editor with the Roastmaster agent panel already on. Provider details (Vercel AI SDK, OpenAI, Anthropic, LangChain) on Bring your own agent. Runnable example: agent-chat-demo on GitHub.