# 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 [#get-the-toolkit]

```bash
pnpm add @superdoc-dev/sdk
```

One call produces all three pieces:

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

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

> **Always pass a preset (warning)**
>
> `preset: 'core'` is the surface to build on. Omitting `preset` selects the older `legacy` tool surface for backward
> compatibility, which advertises a different set of tools.


`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 [#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.

```mjs
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:

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

## What each piece does [#what-each-piece-does]

| Piece          | Role                                                                     |
| -------------- | ------------------------------------------------------------------------ |
| `tools`        | Tool definitions in your provider's shape. Pass through untouched.       |
| `systemPrompt` | Documents the actions the model may call. Send it as the system message. |
| `dispatch`     | Runs 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 [#read-the-receipts]

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

| `status`  | Meaning                                                         |
| --------- | --------------------------------------------------------------- |
| `ok`      | The requested edits applied and any post-check agreed           |
| `partial` | Some applied and some did not. Never treat this as success.     |
| `failed`  | The action did not complete. Some of it may still have applied. |
| `aborted` | The 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.

**Receipt `replace`**: replacement recorded in tracked mode


## Keep the loop bounded [#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-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 [#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 [#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 [#next]

[Tools](/agents/build/tools) explains the action surface, how to narrow it, and how to replay a tool call deterministically without the model. [Safety](/agents/operate/safety) covers what to settle before an agent touches documents that matter.
