Skip to main content
Wire up an LLM agent that reads and edits .docx files headlessly. Install the SDK, open a document, and run an agentic tool loop. Full working code below.
If you need real-time sync between the agent and a frontend editor, add collaboration. The SDK client joins the same Yjs room as the frontend: edits appear live.

Prerequisites

  • Node.js 18+
  • @superdoc-dev/sdk
  • An LLM provider API key (e.g., OPENAI_API_KEY)

Step 1: Install

npm install @superdoc-dev/sdk openai

Step 2: Open a document

Create an SDK client and open a .docx file. client.open() returns a document handle you’ll pass to the dispatcher.
import { createSuperDocClient } from '@superdoc-dev/sdk';

const client = createSuperDocClient();
await client.connect();

const doc = await client.open({ doc: './contract.docx' });

Step 3: Load the toolkit

One call returns the tool definitions for your provider, the matching system prompt, and a pre-bound dispatcher. This guide uses the core preset (recommended); everything can be cached — it doesn’t change between requests.
import { createAgentToolkit } from '@superdoc-dev/sdk';

const { tools, systemPrompt, dispatch } = await createAgentToolkit({
  provider: 'openai',
  preset: 'core',
});
On the legacy preset instead? Pass preset: 'legacy' (or omit it — legacy is the default for backwards compatibility). The loop below is identical; what changes is the tool surface the model sees and that results are raw operation results rather than receipts. See the legacy preset page for its behaviors.

Step 4: Run the agent loop

The agent loop sends messages to the LLM, dispatches tool calls, feeds results back, and repeats until the model is done.
import OpenAI from 'openai';

const openai = new OpenAI(); // uses OPENAI_API_KEY env var

const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
  { role: 'system', content: systemPrompt },
  { role: 'user', content: 'Find the termination clause and rewrite it to allow 30-day notice.' },
];

while (true) {
  const response = await openai.chat.completions.create({
    model: 'gpt-5.4',
    messages,
    tools,
  });

  const choice = response.choices[0];
  messages.push(choice.message);

  // Stop when the model has no more tool calls
  if (choice.finish_reason === 'stop' || !choice.message.tool_calls?.length) {
    console.log(choice.message.content);
    break;
  }

  // Execute each tool call and feed results back
  for (const toolCall of choice.message.tool_calls) {
    if (toolCall.type !== 'function') continue;

    try {
      const result = await dispatch(doc,
        toolCall.function.name,
        JSON.parse(toolCall.function.arguments),
      );
      messages.push({
        role: 'tool',
        tool_call_id: toolCall.id,
        content: JSON.stringify(result),
      });
    } catch (err: any) {
      // Return errors as tool results: the model will self-correct
      messages.push({
        role: 'tool',
        tool_call_id: toolCall.id,
        content: JSON.stringify({ error: err.message }),
      });
    }
  }
}
What’s happening:
  1. The system prompt teaches the model how to use SuperDoc tools.
  2. The while(true) loop calls OpenAI, checks for tool calls, dispatches them via the toolkit’s dispatch, and feeds results back.
  3. When the model returns finish_reason: 'stop' (no more tool calls), the loop ends.
  4. Errors are caught and returned as tool results so the model can see what went wrong and retry.

Step 5: Save and clean up

await doc.save({ inPlace: true });
await doc.close();
await client.dispose();

Full example

A complete, copy-pasteable script that opens a document, runs an agent, saves, and exits:
import OpenAI from 'openai';
import { createSuperDocClient, createAgentToolkit } from '@superdoc-dev/sdk';

// 1. Open the document
const client = createSuperDocClient();
await client.connect();
const doc = await client.open({ doc: './contract.docx' });

// 2. Load the toolkit (tools + system prompt + dispatch, core preset)
const { tools, systemPrompt, dispatch } = await createAgentToolkit({
  provider: 'openai',
  preset: 'core',
});

// 3. Build the conversation
const openai = new OpenAI();
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
  { role: 'system', content: systemPrompt },
  { role: 'user', content: 'Find the termination clause and rewrite it to allow 30-day notice.' },
];

// 4. Agent loop
while (true) {
  const response = await openai.chat.completions.create({
    model: 'gpt-5.4',
    messages,
    tools,
  });

  const choice = response.choices[0];
  messages.push(choice.message);

  if (choice.finish_reason === 'stop' || !choice.message.tool_calls?.length) {
    console.log(choice.message.content);
    break;
  }

  for (const toolCall of choice.message.tool_calls) {
    if (toolCall.type !== 'function') continue;

    try {
      const result = await dispatch(doc,
        toolCall.function.name,
        JSON.parse(toolCall.function.arguments),
      );
      messages.push({
        role: 'tool',
        tool_call_id: toolCall.id,
        content: JSON.stringify(result),
      });
    } catch (err: any) {
      messages.push({
        role: 'tool',
        tool_call_id: toolCall.id,
        content: JSON.stringify({ error: err.message }),
      });
    }
  }
}

// 5. Save and clean up
await doc.save({ inPlace: true });
await doc.close();
await client.dispose();

Other providers

AWS Bedrock

Use the toolkit with provider: 'anthropic' and convert to Bedrock’s toolSpec shape:
import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime';
import { createSuperDocClient, createAgentToolkit } from '@superdoc-dev/sdk';

const client = createSuperDocClient();
await client.connect();
const doc = await client.open({ doc: './contract.docx' });

// Get tools in Anthropic format, convert to Bedrock toolSpec shape
const { tools, systemPrompt, dispatch } = await createAgentToolkit({
  provider: 'anthropic',
  preset: 'core',
});
const toolConfig = {
  tools: tools.map((t: any) => ({
    toolSpec: {
      name: t.name,
      description: t.description,
      inputSchema: { json: t.input_schema },
    },
  })),
};

const bedrock = new BedrockRuntimeClient({ region: 'us-east-1' });
const messages = [
  { role: 'user', content: [{ text: 'Review this contract.' }] },
];

while (true) {
  const res = await bedrock.send(new ConverseCommand({
    // Bedrock uses regional inference-profile IDs, not Anthropic model
    // names — copy the exact ID from your Bedrock console's model catalog.
    modelId: 'us.anthropic.claude-sonnet-4-6',
    messages,
    system: [{ text: systemPrompt }],
    toolConfig,
  }));

  const output = res.output?.message;
  if (!output) break;
  messages.push(output);

  const toolUses = output.content?.filter((b) => b.toolUse) ?? [];
  if (!toolUses.length) break;

  const results = [];
  for (const block of toolUses) {
    const { name, input, toolUseId } = block.toolUse;
    const result = await dispatch(doc, name, input ?? {});
    const json = typeof result === 'object' && result !== null ? result : { result };
    results.push({ toolResult: { toolUseId, content: [{ json }] } });
  }
  messages.push({ role: 'user', content: results });
}

await doc.save();
await doc.close();
await client.dispose();
Auth: AWS credentials via aws configure, env vars, or IAM role. No API key needed.

Streaming generated text into a visible editor

Sometimes you don’t need a full agent loop. You just want the model to write into the document while the user watches. Stream the output through a small backend proxy and append each delta to the editor:
for await (const chunk of streamFromServer(prompt, signal)) {
  buffer += chunk;
  if (chunk.includes('\n')) flush();
  else if (!pendingFlush) pendingFlush = setTimeout(flush, 150);
}

function flush() {
  editor.doc.insert({ value: buffer, type: 'text' });
  buffer = '';
}
editor.doc.insert is the public Document API. With no target, content appends at the end. Newlines from the model become real paragraph breaks. A few things to get right:
  • Keep the model key on the server. A small Node proxy that forwards Server-Sent Events keeps the key out of client bundles.
  • Buffer deltas. Inserting on every token causes one document mutation per token, which floods the layout engine and undo stack. Flush on a timer (~150ms) or whenever a newline arrives.
  • Abort on unmount and Stop. Tie an AbortController to the fetch and call it from your cleanup. The server should also abort upstream when the client disconnects so neither side burns tokens.
Full working example: examples/ai/streaming.
  • LLM tools: tool catalog and SDK functions
  • Best practices: prompting, workflow tips, and tested prompt examples
  • Debugging: troubleshoot tool call failures
  • Collaboration: add real-time sync between agent and frontend
  • SDKs: typed Node.js and Python wrappers