GuideAugust 31, 20265 min read

Automating Word documents without running Word

An object model shaped like the Word JavaScript API, over the same engine that renders the browser editor. Why every batch is a transaction, what the model refuses to guess, and how the server and browser runtimes differ.

Code that edits a .docx often uses one of two methods. You can use a low-level Office Open XML (OOXML) library and write the markup. You can also install and control Word on a server.

@docx-editor.dev/editor-api provides another method. It runs the same engine that renders the browser editor. Its object model has the shape of the Word JavaScript API that add-ins use. Code can read context.document.body.paragraphs, call load() and then sync(), and check isNullObject.

It is not Office.js. The package runs outside the Office add-in host, depends on no Microsoft package, and defines every type in its own API. It provides compatible shapes for a documented subset of the object model.

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

@docx-editor.dev/core is a peer dependency. Your project must resolve one copy of it across the editing API and any editor adapter. Server use requires Node.js ^20.16.0 || >=22.3.0.

sync() is the transaction boundary

Declare each read with load(). The runtime queues every write and applies the queue at sync().

The runtime is in-process, so batching does not reduce network requests. It provides atomicity. A partial batch can leave inconsistent terms. Therefore, everything queued between two syncs forms one ordered transaction. If any operation fails, the runtime applies no operations from the transaction.

This design requires two phases. You cannot use search results until a sync loads the values.

Use the API on a server

The root entry runs headless.

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(); // now you know what was found
 
    for (const match of matches.items) match.insertText('$500k', 'Replace');
    await context.sync(); // all of the writes, or none
  });
  await writeFile('contract.filled.docx', await runtime.save());
} finally {
  runtime.dispose();
}

Consider two ownership details when this code runs inside a larger service. createServer finishes its bounded parse before it resolves and does not retain the input array, so the caller can reuse or transfer that buffer afterward. Each save() returns a fresh array. Mutating or transferring one save result cannot reach the runtime or a later save.

The server and browser use the same preservation pipeline. The canonical tree preserves content that the engine does not model, and package payloads pass through unchanged. For more information, see How docx-editor preserves DOCX content it does not understand.

In the browser there is no save()

The /browser entry controls an editor that the host created. It applies edits to the open document and preserves the reader's undo stack. The host remains responsible for saving, so capabilities.save is false.

import { DocxEditor } from '@docx-editor.dev/editor-api/browser';
 
const runtime = DocxEditor.createBrowser(editor, { author: 'Demo Reviewer' });
await runtime.run(async (context) => {
  const paragraphs = context.document.body.paragraphs;
  const firstParagraph = paragraphs.getFirstOrNullObject();
  firstParagraph.load('text');
  await context.sync();
 
  if (!firstParagraph.isNullObject) firstParagraph.font.bold = true;
  await context.sync();
});

Import the root entry on servers. The /browser entry includes integration with the painted engine, which server bundles do not require.

What the object model refuses to guess

Reading a property you never loaded throws. A typo becomes an error at the read, rather than a wrong value that reaches the writes after it.

getFirstOrNullObject and getLastOrNullObject return an object whose isNullObject is true after the sync. An absent object and a failed operation remain separate outcomes.

Proxies belong to the run that created them. They survive every sync() inside it. To use one in a later run, track it, return it, and pass it to runtime.run(object, callback) for adoption. Nothing survives dispose().

Preflight metadata does not guarantee a write. For content-control writes you load isBound with the other properties and skip the bound ones, and sync() still re-reads the document and refuses the whole batch if a control got bound in between.

Navigation-property expansion is not supported. A non-empty LoadQueryOptions.expand throws InvalidArgument. Load the navigation object or collection explicitly, and then sync it.

Capabilities are frozen for the life of a runtime

runtime.capabilities reports the host differences. save is false in the browser. selection, scrolling, and layout are false on a server. These values do not change during the runtime lifetime, so capability checks remain valid.

Comment writes have no capability flag. Module registration, editing mode, and document state can change during a runtime. Therefore, sync() reports a typed refusal when a comment write is unavailable.

Handing a server result back to a reader

The server runtime is detached from every live editor. Its changes reach the reader's screen only when the application loads the result:

const source = new Uint8Array(await editor.save());
const detached = await DocxEditor.createServer(source);
try {
  // Document operations go here.
  const result = await detached.save();
  editor.load(result); // the live document changes only here
} finally {
  detached.dispose();
}

editor.load() replaces the document and resets the reader's position. Use the browser runtime while someone works in the file. Use the server runtime when no one has the document open.

Porting Word add-in code

The object model remains similar. The batch opens differently because Office.onReady and Word.run require the add-in host. Open a batch with DocxEditor.createServer(bytes) or DocxEditor.createBrowser(editor). You can then reuse the callback body.

The implemented subset covers the following:

  • The document, its body, paragraphs, ranges, and the collections over them.
  • Search, with matchCase and matchWholeWord.
  • Character formatting through font, and paragraph formatting.
  • Lists and list items.
  • Bookmarks, sections, and page setup.
  • Footnotes and endnotes.
  • Comments with replies and a resolved flag.
  • Revisions, with accept and reject.
  • Content controls, addressed by ID, tag, or title.

Some differences require an additional sync(). For more information, see Office.js compatibility.

What this leaves to the application

The package includes no model integration, no tool catalog, and no chat UI. The application that owns the model decides which operations to expose, how to describe them, and what to do with a refusal.

Define one tool for each document operation. Each tool performs its operation inside a run block. For example, add_comment opens a batch, anchors the comment, and syncs. If the document refuses an operation, the model receives a typed error. The transaction does not produce a partially edited file.

The browser entry drives an editor already in the page, so the document never leaves it. Your own code decides what text reaches a model.

License

Unlike the Apache 2.0 editor packages, @docx-editor.dev/editor-api uses the EigenPal Pro License. You can use and modify it in a non-production environment for evaluation. Production use requires a written commercial agreement. See the pricing page, or write to licensing@eigenpal.com.

Next steps