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
matchCaseandmatchWholeWord. - 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
- Programmatic document editing API: browser and server workflows with industry examples
- Editing API overview: both entries and the full lifecycle
- Office.js compatibility: the supported subset, and how it is verified
- Architecture: the engine both runtimes drive