Overview

Office.js-compatible editing API: an object model that batches its work, running on a server over bytes or in a page against an editor already open.

@docx-editor.dev/editor-api edits DOCX files through a supported subset of Word's JavaScript object model, including paragraphs, ranges, comments, and revisions. Use load() to queue reads and sync() to apply each batch atomically.

Run the API on a server over DOCX bytes or in the browser against an open editor. See Office.js compatibility for supported members and differences from Word.

npm install @docx-editor.dev/editor-api @docx-editor.dev/core

@docx-editor.dev/core is a peer dependency: the engine holds identity-keyed state, so your project must resolve exactly one copy of it, shared with any editor adapter you install.

Choose a host

DocumentCapabilities fieldServer runtimeBrowser runtime
documentYesYes
saveYesNo
eventsYesYes
selectionNoYes
scrollingNoYes
layoutNoYes

runtime.capabilities reports these values. The values remain fixed for the runtime's lifetime.

The current mode and document state can still refuse writes. These dynamic permissions are not capability fields.

Use these prerequisites:

TaskPrerequisite
Create a server runtimeDOCX bytes and one resolved copy of @docx-editor.dev/core
Create a browser runtimeAn attached editor from @docx-editor.dev/react, @docx-editor.dev/vue, or the core editor API
Write a comment or replyA non-empty author
Write browser review dataThe Pro review module, an editable mode, and a host that accepts the write
License and supportSee the pricing page

A refusal from sync() is authoritative.

On a server

The server runtime is headless. It accepts bytes and returns bytes.

import { readFile, writeFile } from 'node:fs/promises';
import { DocxEditor } from '@docx-editor.dev/editor-api';

const runtime = await DocxEditor.createServer(await readFile('contract.docx'), {
  author: 'Review bot',
});
try {
  await runtime.run(async (context) => {
    const matches = context.document.body.search('$50k');
    matches.load();
    await context.sync(); // one round trip: now you know what was found

    for (const match of matches.items) match.insertText('$500k', 'Replace');
    await context.sync(); // one atomic batch: all of the writes, or none
  });
  await writeFile('contract.filled.docx', await runtime.save());
} finally {
  runtime.dispose();
}

createServer completes its bounded parse before it resolves and does not retain the input Uint8Array; the caller may reuse or transfer that buffer afterward. Each save() returns a fresh, caller-owned Uint8Array. Mutating or transferring one save result cannot change the runtime or a later save.

The server runtime is detached from every live editor. To load the result into a live editor, make that replacement explicit:

const source = new Uint8Array(await editor.save());
const detached = await DocxEditor.createServer(source);
try {
  // inspect, search, and edit with detached.run(...)
  const result = await detached.save();
  editor.load(result); // the live document changes only here
} finally {
  detached.dispose();
}

To discover bookmarks without first knowing their text, enumerate the story that owns them:

await runtime.run(async (context) => {
  const bookmarks = context.document.body.bookmarks;
  bookmarks.load('items');
  await context.sync();

  for (const bookmark of bookmarks.items) bookmark.load('name');
  await context.sync();

  console.log(bookmarks.items.map(({ name }) => name));
});

document.body.bookmarks covers only the main body story. A header or footer Body has its own collection; this accessor never combines separate stories into a document-wide answer.

Handle resource limits

DocxEditor.createServer rejects with error code ResourceLimitExceeded when opening exceeds a resource cap. Catch DocxEditorError and inspect its limit field, such as xml.maxElements or zip.maxRatio. The XML reader uses the engine's default budget of 10,000,000 elements per part. Set limits.xml.maxElements to lower that budget for untrusted input or raise it for larger documents, up to the engine ceiling of 50,000,000. Set limits.xml.maxBytes alongside it. The 64 MiB per-part byte ceiling and 256-level depth ceiling still apply. These budgets do not guarantee that a document fits in the host's memory. Counts and byte budgets must be finite nonnegative integers; compression ratios may be fractional. Malformed input and invalid budget options reject with InvalidArgument.

Create tracked changes on a server

Set context.document.changeTrackingMode = 'TrackMineOnly' before calling range.insertText() or range.delete(). Configure an author when you create the runtime. The mode applies to that runtime and persists across run() calls.

Tracked edits support text within one paragraph, including table cells. Edits that touch pending revisions, structural edits, and formatting edits are rejected atomically. TrackAll and browser runtime tracking-mode control are unsupported.

A pending row insertion or deletion blocks tracked edits throughout that row, including other cells and nested tables.

For a complete example, see the server agent guide. To publish suggestions to a shared document, use DocxEditor.createCollaborative.

In the browser

The browser entry takes an editor the host already created (from @docx-editor.dev/react or a plain page) and drives it in place. Edits apply to the open document with the reader's undo stack intact, so there is no save(): the host continues to call its existing save path.

import { DocxEditor } from '@docx-editor.dev/editor-api/browser';

const runtime = DocxEditor.createBrowser(editor, { author: 'Demo Reviewer' });
await runtime.run(async (context) => {
  const heading = context.document.body.paragraphs.getFirstOrNullObject();
  heading.load('text');
  await context.sync();

  if (!heading.isNullObject) heading.font.bold = true;
  await context.sync();
});

The /browser entry includes integration with the painted engine. Import the root entry on servers to keep that browser code out of the bundle.

The optional author has the same meaning as it does for createServer. It supplies the identity for comments. Server runtimes also require it for tracked text edits.

Delete comments and Undo

Deleting a top-level Comment removes its entire thread and story anchors. Deleting a CommentReply removes only that reply and preserves its parent and siblings. Queue multiple deletions before one sync() when they should be one atomic browser Undo unit:

const comments = context.document.comments;
comments.load('items');
await context.sync();

for (const comment of comments.items.slice(0, 2)) comment.delete();
await context.sync(); // one transaction; editor Undo restores both threads

The server runtime supports the same object-model calls without a browser module.

Programming model

  • Reading a property you did not load() throws. This catches typos before they affect later writes.
  • Navigation-property expansion is not supported. A non-empty LoadQueryOptions.expand is rejected with InvalidArgument; load the navigation object or collection explicitly instead.
  • sync() is the only round trip. Everything queued between two syncs is one ordered transaction. If any operation in it is refused, none of them happened.
  • Objects are proxies into a document the runtime owns. They remain valid across sync() calls within one run. To carry one into a later run, track it and pass it to runtime.run(object, callback) for adoption. No proxy remains valid after dispose().
  • getFirstOrNullObject and getLastOrNullObject return an object whose isNullObject is true after the sync, which is the difference between an absent heading and a thrown error.

For content-control writes, load isBound with the other properties and skip controls where it is true. That boolean is safe preflight metadata, not a write guarantee: sync() always checks the current document again and atomically refuses the batch if a control is bound by then.

Entries

EntryUse when
@docx-editor.dev/editor-apiServers, workers, build scripts: bytes in, bytes out
@docx-editor.dev/editor-api/browserA page, driving an editor the host already created

Both export the same vocabulary (the lifecycle types, the object model, the error type) so consumer code compiles against either. They differ by one member: createBrowser.

Integrate with an application model

This package ships no model integration, tool catalog or chat UI. The application that owns the model defines which operations to expose, how to describe them and how to handle refusals. A tool such as add_comment performs its document work inside a run block. Keep its chat UI with the application's other chrome.

Expose focused tools to an agent

Give each tool one document task. Validate its input before the tool reaches the document. Keep the complete read or write inside one run callback.

This AI SDK example exposes one read tool and two small writing tools:

import type { DocxEditorRuntime } from '@docx-editor.dev/editor-api';
import { tool } from 'ai';
import { z } from 'zod';

export function createDocumentTools(runtime: DocxEditorRuntime) {
  return {
    read_document: tool({
      description: 'Read the current document text.',
      inputSchema: z.object({}),
      execute: async () =>
        runtime.run(async (context) => {
          const body = context.document.body;
          body.load('text');
          await context.sync();
          return { text: body.text };
        }),
    }),

    append_paragraph: tool({
      description: 'Add one paragraph to the end of the document.',
      inputSchema: z.object({
        text: z.string().min(1),
      }),
      execute: async ({ text }) =>
        runtime.run(async (context) => {
          context.document.body.insertParagraph(text, 'End');
          await context.sync();
          return { inserted: true };
        }),
    }),

    replace_exact_text: tool({
      description: 'Replace one exact phrase when it occurs once.',
      inputSchema: z.object({
        search: z.string().min(1),
        replacement: z.string(),
      }),
      execute: async ({ search, replacement }) =>
        runtime.run(async (context) => {
          const matches = context.document.body.search(search, {
            matchCase: true,
          });
          matches.load('items');
          await context.sync();

          if (matches.items.length !== 1) {
            return {
              replaced: false,
              reason: `Expected one match, found ${matches.items.length}.`,
            };
          }

          matches.items[0].insertText(replacement, 'Replace');
          await context.sync();
          return { replaced: true };
        }),
    }),
  };
}

Return structured results instead of free-form status text. Refuse ambiguous writes instead of choosing a match for the agent. Use separate tools for comments, tracked proposals, tables, and content controls.

For the full walkthrough, see Build a DOCX agent. For a complete tool catalog, see the writer agent example.

Choose one revision text projection

The runtime uses the allMarkup text projection by default. Select one projection when you create the runtime:

ContentallMarkuporiginal
Ordinary textVisible and searchableVisible and searchable
Pending deletionVisible and searchableVisible and searchable
Pending insertionVisible and searchableHidden and not searchable
Pending replacementShows both sidesShows the deleted original only

original matches Word's Original review view.

const runtime = DocxEditor.createBrowser(editor, {
  revisionTextView: 'original',
});

await runtime.run(async (context) => {
  const body = context.document.body;
  body.load('text');
  const matches = body.search('original phrase');
  matches.load('items');
  await context.sync();

  console.log(body.text);
});

revisionTextView is a DocxEditor runtime option. It does not belong to the Office.js object model. Office.js provides display controls through document.activeWindow.view.revisionsFilter. It does not provide a text-projection API for the values returned by text reads.

A range returned by a search uses the runtime's text projection. Its text property and nested searches use the same projection. The range endpoints remain model offsets, so insertText(), insertComment(), and select() target the text that the search returned.

Selection does not change the insertion rule. range.select() selects the full range, so the reader's next text insertion replaces that phrase. Use range.select('End') to collapse the caret and insert after the phrase.

License

This package is licensed under the EigenPal Pro License, and you can compare and buy license and support levels on the pricing page.

Next steps

On this page