Agents

Build an agent

Give your product's agent document tools with a bounded, verifiable loop.

Use the SDK toolkit when the agent lives inside your product rather than in a coding tool. The toolkit returns the three pieces an agent loop needs, already agreeing with each other: tool definitions in your provider's shape, the matching system prompt, and a dispatcher bound to the same surface.

Get the toolkit

pnpm add @superdoc-dev/sdk

One call produces all three pieces:

import { createAgentToolkit } from '@superdoc-dev/sdk';

const { tools, systemPrompt, dispatch } = await createAgentToolkit({
  provider: 'openai',
  preset: 'core',
});

provider accepts openai, anthropic, vercel, or generic. It changes the wire shape of the tool definitions, not the operations available.

Assembling the three pieces separately is the most common integration mistake. A prompt that documents a tool the model cannot call teaches it to call that tool, and the failure looks like a model problem rather than a configuration one. The toolkit makes the pieces disagree impossible.

The loop

An agent loop is four steps repeated under a turn limit: ask the model, dispatch any tool calls, feed each receipt back, and stop when the model stops calling tools.

import { resolve } from 'node:path';
import { createSuperDocClient, createAgentToolkit } from '@superdoc-dev/sdk';

const MAX_TURNS = 16;

// The tools this loop will dispatch. The core preset's dispatcher also accepts
// names it never advertises (`superdoc_execute_code`, `agent_apply`,
// `agent_verify`, `agent_operation`), which skip the tracked-mode guard below
// and return shapes this loop does not understand. A model that hallucinates
// one, or that is talked into it by content inside the document, must not reach
// them — so dispatch only what was advertised.
const ADVERTISED_TOOLS = new Set(['superdoc_inspect', 'superdoc_perform_action']);

// Tools that change the document. `superdoc_inspect` is read-only, so it is
// deliberately absent: it neither needs a change mode nor counts as a mutation.
const MUTATING_TOOLS = new Set(['superdoc_perform_action']);

// `superdoc_perform_action` advertises `changeMode` once for all forty actions,
// but only some of them honor it — the rest ignore the argument and edit
// directly. Passing `changeMode: 'tracked'` to one of those looks compliant and
// silently produces an untracked edit, so this workflow allows only the actions
// that actually record a suggestion.
//
// `move_range` declares `changeMode` but is direct-only today: its own action
// hint says tracked mode fails without mutating, because a block-range deletion
// cannot be tracked. Allowing it would guarantee a failed run, so it is out.
const TRACKED_CAPABLE_ACTIONS = new Set([
  'add_list_items',
  'append_list',
  'apply_letter_spacing',
  'attach_numbering',
  'convert_list',
  'create_table',
  'delete_table',
  'delete_table_column',
  'delete_table_row',
  'delete_text',
  'fill_placeholders',
  'format_paragraph',
  'format_text',
  'insert_heading',
  'insert_paragraphs',
  'insert_table_column',
  'insert_table_row',
  'insert_toc',
  'move_text',
  'normalize_body_font_size',
  'replace_text',
  'rewrite_block',
  'set_font_family',
  'split_table',
]);

/**
 * One model turn, in OpenAI's Chat Completions shape.
 *
 * Return the assistant message unchanged from
 * `openai.chat.completions.create({ model, messages, tools })`. The loop reads
 * and writes that same shape, so nothing has to be translated in either
 * direction. For a provider with a different wire format, adapt it here and
 * where tool results are appended below.
 *
 * @typedef {{ id: string, type: 'function', function: { name: string, arguments: string } }} ToolCall
 * @typedef {{ role?: string, content?: string | null, tool_calls?: ToolCall[] }} AssistantMessage
 * @typedef {(input: { messages: unknown[], tools: unknown[] }) => Promise<AssistantMessage>} CallModel
 */

/**
 * Receipts carry action-specific evidence, so narrow the fields this loop reads.
 *
 * @param {unknown} value
 * @returns {{
 *   status?: string,
 *   verificationPassed?: boolean,
 *   preSnapshot?: { revision?: string },
 *   postSnapshot?: { revision?: string },
 * }}
 */
function asReceipt(value) {
  return typeof value === 'object' && value !== null ? value : {};
}

/**
 * Whether a receipt is evidence that the document actually changed.
 *
 * Some actions accept `dryRun: true` and report success while explicitly
 * applying nothing, so a successful receipt is not proof of a mutation. When
 * the action reports both revisions, require them to differ; fall back to the
 * argument only when the receipt does not say.
 *
 * @param {ReturnType<typeof asReceipt>} receipt
 * @param {Record<string, unknown>} args
 */
function changedTheDocument(receipt, args) {
  const before = receipt.preSnapshot?.revision;
  const after = receipt.postSnapshot?.revision;
  if (before != null && after != null) return before !== after;
  return args.dryRun !== true;
}

/**
 * Edit a DOCX from a natural-language instruction, then save to a new file.
 *
 * Produces a tracked, reviewable draft. It does not prove the model completed
 * every part of the instruction — see the guide for why that needs an explicit
 * plan rather than receipt inspection.
 *
 * @param {{
 *   input: string,
 *   output: string,
 *   instruction: string,
 *   callModel: CallModel,
 *   author: { name: string, email?: string },
 * }} options `author` names this integration in tracked changes. Give each
 *   deployment its own, so a reviewer can tell them apart.
 */
export async function runAgent({ input, output, instruction, callModel, author }) {
  // Without a user, tracked changes are attributed to a generic "CLI" author
  // shared by every unattributed workflow. Naming the agent is what makes its
  // suggestions distinguishable, so the identity is a parameter rather than a
  // constant: copying this file should not copy someone else's author.
  const client = createSuperDocClient({ user: author });
  /** @type {Awaited<ReturnType<typeof client.open>> | undefined} */
  let doc;

  try {
    await client.connect();
    doc = await client.open({ doc: input });

    // One call keeps tools, prompt, and dispatch on the same preset. Assembling
    // them separately is how a tool surface and a system prompt drift apart.
    const { tools, systemPrompt, dispatch } = await createAgentToolkit({
      provider: 'openai',
      preset: 'core',
    });

    /** @type {unknown[]} */
    const messages = [
      { role: 'system', content: systemPrompt },
      {
        role: 'user',
        content: `${instruction}\n\nMake every edit a tracked change so a reviewer can accept or reject it.`,
      },
    ];

    // A dispatched failure is terminal for the save: an edit that half-applied
    // leaves the document in a state no later receipt can prove was repaired.
    /** @type {string[]} */
    const failures = [];
    let completed = false;
    let mutations = 0;

    // Bounded: a model that keeps calling tools must still terminate.
    for (let turn = 0; turn < MAX_TURNS; turn += 1) {
      const reply = await callModel({ messages, tools });
      messages.push(reply);

      // No tool calls means the model considers the work finished.
      if (!reply.tool_calls?.length) {
        console.log(reply.content ?? '(no final message)');
        completed = true;
        break;
      }

      for (const call of reply.tool_calls) {
        /** @type {unknown} */
        let receipt;
        let label = call.function.name;
        let dispatched = false;

        try {
          const args = JSON.parse(call.function.arguments);
          if (typeof args.action === 'string') label = args.action;

          // Dispatch only what the toolkit advertised. The dispatcher itself is
          // more permissive, so this is the boundary that keeps a hallucinated
          // or injected tool name from reaching the document.
          if (!ADVERTISED_TOOLS.has(call.function.name)) {
            receipt = {
              status: 'failed',
              error: {
                code: 'TOOL_NOT_ADVERTISED',
                message: `"${call.function.name}" is not an available tool. Use one of: ${[...ADVERTISED_TOOLS].join(', ')}.`,
              },
            };
            console.error(`${call.function.name}: rejected, not an advertised tool`);
            // Unlike a correctable argument error, this is a request for
            // something that does not exist. Treat it as unfinished work so a
            // run cannot end on it and still save.
            failures.push(`${call.function.name} is not an available tool`);
            messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(receipt) });
            continue;
          }

          // The instruction asks for tracked changes, but `changeMode` is
          // optional and defaults to a direct edit, so a model that omits it
          // silently rewrites the document. Refuse the call instead of letting
          // the run produce an untracked edit it promised would be reviewable.
          if (MUTATING_TOOLS.has(call.function.name)) {
            const refusal =
              args.changeMode !== 'tracked'
                ? 'Every edit must set changeMode: "tracked". Retry this action with that argument.'
                : !TRACKED_CAPABLE_ACTIONS.has(args.action)
                  ? `The action "${args.action}" ignores changeMode and always edits directly. Use a tracked-capable action instead.`
                  : undefined;

            if (refusal) {
              // The call never reached the document, so this is a correction
              // the model can act on rather than a failed edit.
              receipt = { status: 'failed', error: { code: 'CHANGE_MODE_REQUIRED', message: refusal } };
              console.error(`${label}: rejected, ${refusal}`);
              messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(receipt) });
              continue;
            }
          }

          dispatched = true;
          receipt = await dispatch(doc, call.function.name, args);
          const parsed = asReceipt(receipt);
          const { status, verificationPassed } = parsed;
          console.log(`${label}: ${status ?? 'ok'}`);

          // A returned receipt is not a successful one. `partial` means some
          // edits applied and some did not, which must never read as success.
          if (status != null && status !== 'ok') {
            failures.push(`${label} reported ${status}`);
          } else if (verificationPassed === false) {
            console.warn('  verification did not pass');
            failures.push(`${label} failed verification`);
          } else if (call.function.name !== 'superdoc_inspect' && changedTheDocument(parsed, args)) {
            // superdoc_inspect reads without changing anything, and a dry run
            // reports success while applying nothing, so neither is evidence
            // that the edit happened.
            mutations += 1;
          }
        } catch (error) {
          // Hand the failure back to the model rather than throwing: a bad
          // argument is something it can correct on the next turn.
          const { code, message } = /** @type {{ code?: string, message: string }} */ (error);
          receipt = { status: 'failed', error: { code, message } };
          console.error(`${label}: ${code ?? message}`);

          // A throw from dispatch may have applied part of the edit before
          // failing. A throw before it (malformed arguments) never reached the
          // document, so the model can still correct that one.
          if (dispatched) failures.push(`${label} threw ${code ?? message}`);
        }

        messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(receipt) });
      }
    }

    // Only save work that finished cleanly. Running out of turns means the
    // model never signalled completion, a dispatched failure means the document
    // is in a state nobody asked for, and zero mutations means there is nothing
    // to write — saving any of them produces a plausible-looking file that no
    // receipt ever justified.
    if (!completed) {
      throw new Error(`Agent stopped after ${MAX_TURNS} turns without completing. Nothing was saved.`);
    }
    if (failures.length > 0) {
      throw new Error(`Agent finished with unresolved failures: ${failures.join('; ')}. Nothing was saved.`);
    }
    if (mutations === 0) {
      throw new Error('Agent completed without applying any change. Nothing was saved.');
    }

    // Write to a separate path so the source survives a bad run. No `force`:
    // save refuses an existing output rather than overwriting it, which turns a
    // path typo into an error instead of destroying whatever was already there.
    if (resolve(output) === resolve(input)) {
      throw new Error(`Output path is the same file as the input (${input}). Nothing was saved.`);
    }
    await doc.save({ out: output });
    console.log(`Saved: ${output}`);
  } finally {
    // Cleanup is best-effort so a close failure cannot mask the real outcome.
    if (doc) await doc.close({ discard: true }).catch((error) => console.warn(`close failed: ${error.message}`));
    await client.dispose().catch((error) => console.warn(`dispose failed: ${error.message}`));
  }
}

The example uses OpenAI's Chat Completions message shape, so callModel can return the assistant message from openai.chat.completions.create() unchanged. The loop reads tool_calls and appends role: 'tool' results in that same shape, which keeps the conversation valid on the next request. A provider with a different wire format needs adapting in both directions: where tool calls are read, and where tool results are appended.

Name the agent when you create the client. Without a user, tracked changes are attributed to a generic CLI author shared by every unattributed workflow, and a reviewer cannot tell which automation proposed what. The example takes the identity as a parameter so each deployment supplies its own:

await runAgent({
  input: 'contract.docx',
  output: 'contract.reviewed.docx',
  instruction: 'Rewrite the termination clause to allow 30-day notice.',
  author: { name: 'Contract bot', email: '[email protected]' },
  callModel,
});

What each piece does

PieceRole
toolsTool definitions in your provider's shape. Pass through untouched.
systemPromptDocuments the actions the model may call. Send it as the system message.
dispatchRuns a named tool call against an open document and returns a receipt.

dispatch takes the document handle, so tool arguments never carry doc or sessionId. The handle injects session targeting, which means a model cannot address a document it was not given.

Read the receipts

Every dispatched action returns a receipt rather than throwing on a rejected edit. Check status before treating the work as done:

statusMeaning
okThe requested edits applied and any post-check agreed
partialSome applied and some did not. Never treat this as success.
failedThe action did not complete. Some of it may still have applied.
abortedThe action stopped before applying anything

Two dispatch outcomes are not receipts at all, and code that only reads status mishandles both.

superdoc_inspect returns a document snapshot, not a receipt. It has no status, so testing for one treats every successful read as a failure. Branch on the tool before interpreting the result.

Argument validation throws instead of returning. An unknown, missing, or excluded argument is rejected before an action runs, so the error arrives as an exception rather than a failed receipt. Wrap the dispatch in try/catch and hand that error back to the model as a tool result, or one malformed call ends the whole loop.

failed does not mean nothing happened. An action can apply its operation and then fail its own post-check: insert_table_row inserts the row, compares the resulting table shape, and reports failed when the shape disagrees, with a postSnapshot revision showing the document already changed.

So a failed receipt is not a safe retry. Compare preSnapshot.revision with postSnapshot.revision and read executedOperations before sending the same action again, or you may apply the edit twice.

When an action runs verification the receipt also carries verificationPassed. A false value means at least one check disagreed with the intent, not that the edit landed: the standard revision-changed check fails just as readily when the mutation was a no-op and nothing applied. The snapshots are what separate those two cases.

The example treats a dispatched failure as terminal for this reason: it stops rather than guessing whether a partly applied edit can be repeated safely.

Status
applied
Operation
replace
Detail
replacement recorded in tracked mode

Keep the loop bounded

A model that keeps calling tools will keep calling tools. The turn cap in the example is not decoration: without it, a confused model can loop until it exhausts a token budget.

The cap only helps if hitting it stops the save. The example refuses to save in three cases: the model ran out of turns without signalling completion, any action reported a failure, or no mutation ever succeeded. That last one matters because a model can decide the work is already done and stop without touching the document, which would otherwise write an output file identical to the input.

A failure is terminal for the save rather than something a later success clears. Once an edit half-applies, receipts cannot prove that a subsequent call repaired that exact work: a partial on one clause followed by a clean edit to a different clause is two facts, not a fix. Recovering from a partially applied edit is a decision for a person looking at the document. Save to a path separate from the source, and close the session in finally so a mid-loop failure still releases the runtime.

Dispatch only what you advertised

dispatch accepts more tool names than the toolkit advertises. Alongside superdoc_inspect and superdoc_perform_action, the core preset can route superdoc_execute_code, agent_apply, agent_verify, and agent_operation — surfaces meant for SDK callers, not for a model to select. They skip the tracked-mode check, and they return shapes a loop built around receipts will misread.

Check the tool name against the list you handed the model before dispatching. Two things make that worth doing even though the model was never told those names exist: a model can hallucinate one, and document content can suggest one. An agent reads text somebody else wrote, so treat a tool name arriving from the model as input rather than as a decision already validated.

Do not rely on the prompt for tracked mode

changeMode is optional and defaults to a direct edit, so an instruction to work in tracked mode is a request the model can quietly skip. The example rejects any mutating call that omits changeMode: 'tracked' before it reaches the document, and returns that rejection as a receipt so the model can retry with the argument.

The argument alone is not sufficient either. superdoc_perform_action advertises changeMode once for all forty actions, but only some of them honor it: apply_style, set_paragraph_spacing, and insert_page_break ignore it and always edit directly. move_range is a third case — it accepts the argument and then fails without mutating, because a block-range deletion cannot be tracked. Passing changeMode: 'tracked' to any of these looks compliant and does not produce a suggestion, so the example allows only the actions that actually record one and tells the model to pick a different action otherwise.

A rejection is different from a dispatched failure. It never touched the document, so the model can correct it on the next turn and the run continues. A thrown call splits the same way: malformed arguments never reach the document, while a throw from dispatch itself may have applied part of the edit before failing, so that one is terminal.

Enforce the constraint in code whenever the output is supposed to be reviewable. A prompt describes intent, and a permissive schema describes what is accepted rather than what is honored. Only the dispatch boundary decides what reaches the document.

What this loop does not prove

The example produces a tracked, reviewable draft. It does not prove the model carried out every part of your instruction.

Nothing in the receipts carries that information. If the model asks for two edits, has one refused, corrects it, and later stops, the loop sees a sequence of independent calls: no field links a retry to the request it repairs. Guessing at the link by action name is worse than not guessing, because the same action is used for unrelated edits — a refused replace_text on one clause looks identical to a successful replace_text on another.

Two things close that gap, and both belong in your application rather than in the loop:

  • Human review. The output is a tracked draft precisely so a person decides what landed. This is the default and it is usually enough.
  • An explicit plan. Derive the list of required edits before execution, give each a stable id, and check them off as receipts arrive. That makes completeness a property of your plan, not an inference from tool traffic.

Reach for the second only when a workflow has to assert completeness without a reviewer.

A successful receipt is not proof of a change either. Some actions accept dryRun: true and report success while explicitly applying nothing, so the example counts a mutation only when the receipt's before and after revisions differ.

Returning a failed dispatch to the model as a receipt, instead of throwing, is what lets it correct a malformed argument on the next turn.

Next

Tools explains the action surface, how to narrow it, and how to replay a tool call deterministically without the model. Safety covers what to settle before an agent touches documents that matter.

On this page