stetdocdocs

Build on stetdoc

View as markdown

A product can build stetdoc into its own system in four ways. All four run the same engine, give the same refs and base tokens, and fail with the same stable error codes, so a product can mix them: a server that edits through the library while an agent in a sandbox uses the CLI on the same documents.

Way inChoose it when
The Rust crate stetdoc-coreYour service is in Rust, or you want the engine with no process or runtime around it
The Node and Bun package @clauseo/stetdocYour server is in TypeScript or JavaScript and holds documents in memory between turns
The CLI as a subprocess, with --jsonAny other language, or an agent with a shell in a sandbox
MCP, with stetdoc mcpAn agent or app that takes tools, with no code of yours in between

The engine does no file I/O and reads no clock. In the library you pass the bytes, the author and the date. The CLI and the MCP server read files and take the author from --author (or a tool call's author), then STETDOC_AUTHOR, then the name a user saved with stetdoc init. A product that serves several users passes the author on every call, or sets it per session (STETDOC_AUTHOR in that session's environment, or stetdoc mcp --author). It never relies on the config file, which holds one name for the whole machine. Nothing falls back to git or the operating system.

Every example below runs against the same one-paragraph document, contract.docx, which reads "Payment is due within thirty days."

The Rust crate

stetdoc-core is one handle, Document, which opens, views, edits and saves. Add it to Cargo.toml:

toml
[dependencies]
stetdoc-core = "0.9"
rust
use stetdoc_core::{Batch, Document, Edit, Limits, OpenOptions, Stamp, ViewSpec};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let bytes = std::fs::read("contract.docx")?;
    let mut doc = Document::open(bytes, &OpenOptions::default())?;
    println!("{}", doc.view(&ViewSpec::default())?.text);

    let batch = Batch::parse(
        &format!("base: {}\n---\n@replace\nref: p1\nfind: thirty days\nwith: sixty days\n", doc.base()),
        &Limits::default(),
    )?;
    let receipt = doc.edit(Edit::ops(batch, Stamp::new("Jane Doe", "2026-10-11T09:00:00")))?;
    println!("{}", receipt.text);

    std::fs::write("contract.edited.docx", doc.save()?.bytes)?;
    Ok(())
}

The receipt shows the redline: p1 Payment is due within {--thirty--}{#0 C}{++sixty++}{#1 C} days. Every failure is a StetError with a stable ErrorCode. Keep the Document between turns, or export it to a WorkingCopy and resume it later, so the agent's refs stay valid. The backend of Claude's text editor tool is the crate stetdoc-editor. The public interface is listed in ARCHITECTURE.md (on GitHub), and why it has this shape is in decision 0001 (on GitHub).

The Node and Bun package

@clauseo/stetdoc is the engine as a native addon, with TypeScript types generated from the engine's definitions. It needs Node 22.18 or Bun 1.3.13 or later. Install it with npm install @clauseo/stetdoc or bun add @clauseo/stetdoc. The package manager picks the addon for your platform.

js
import { readFileSync, writeFileSync } from "node:fs";
import { Document, StetdocError } from "@clauseo/stetdoc";

const doc = Document.open(readFileSync("contract.docx"));
console.log(doc.view().text); // the page that an agent reads, with its refs

const receipt = doc.edit({
  base: doc.base(),
  ops: [{ op: "replace", ref: "p1", find: "thirty days", with: "sixty days" }],
  author: "Jane Doe",
  date: "2026-10-11T09:00:00",
});
console.log(receipt.text);

const { bytes } = doc.save();
writeFileSync("contract.edited.docx", bytes);

try {
  doc.edit({ base: doc.base(), ops: [{ op: "delete", ref: "p99" }], author: "Jane Doe", date: "2026-10-11T09:00:00" });
} catch (error) {
  if (error instanceof StetdocError) console.log(error.code, error.next); // not_found, and what to do about it
}

Calls are synchronous, so a busy server makes them from a worker thread. Offsets count UTF-16 code units, as JavaScript strings do. doc.textEditor(input, { author, date }) is the backend of Claude's text editor tool. The full surface is in the package's README.

The CLI as a subprocess

Every command takes --json and prints its result as JSON on stdout. A failure exits non-zero and prints {"error": {"code", "message", "next"}}. edit reads its batch from stdin, so document text never goes through arguments. In Python:

python
import json, subprocess

def stetdoc(*args, stdin=None):
    """Run stetdoc with --json and return what it prints. A failure exits non-zero and prints {"error": ...}."""
    run = subprocess.run(["stetdoc", *args, "--json"], input=stdin, capture_output=True, text=True)
    result = json.loads(run.stdout)
    if run.returncode != 0:
        error = result["error"]
        raise RuntimeError(f"{error['code']}: {error['message']} {error['next']}")
    return result

view = stetdoc("view", "contract.docx")  # the page, with the base token a batch must name
batch = f"""base: {view['base']}
---
@replace
ref: p1
find: thirty days
with: sixty days
"""
result = stetdoc("edit", "contract.docx", "-o", "contract.edited.docx",
                 "--author", "Jane Doe", "--date", "2026-10-11T09:00:00", stdin=batch)
print(result["output"], result["receipt"]["blocks"][0]["text"])

The exit status tells the class of a failure. The codes and their exit classes are in the error reference, and every command and flag is in the CLI reference. For many edits to one document, unpack it once to a working copy and edit that, so refs stay stable across calls.

MCP

stetdoc mcp serves the commands as tools on stdin and stdout: unpack, pack, view, inspect, edit, replace, comment, compare, check, lookup and skills_get. Each takes the command's flags as arguments and returns the text the command prints, with the JSON of --json in structuredContent. A receipt's text is cut to about 2 KB unless the call passes detail: true. The edit tool takes typed operations in ops, or a batch in batch. A failure is a result with isError set. The tools' descriptions are kept short, about 4,100 tokens for all of them, since a client sends them on every turn (decision 0013 (on GitHub)). A client sends one JSON-RPC message per line:

$ stetdoc mcp --root ./documents
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"me","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"view","arguments":{"input":"contract.docx"}}}

With --root, every path is confined to that folder, and --author NAME stamps every call that names no author. To register it with an agent, see Use stetdoc with your agent. To serve it to a cloud app, see Self-hosting the MCP server.

A worked case

A legal drafting product can use two of the four ways together: the CLI and the skill in the model's sandbox, and @clauseo/stetdoc in the server for its own document tools and for Claude's text editor tool, with one pinned version for both.

Before you build on it, read what stetdoc keeps exactly, feature by feature, and how it handles hostile input.