Agents Attacking Documents with CRDTs Macro is developing a system that lets AI agents collaboratively edit structured documents using CRDTs, assigning stable IDs to nodes and allowing concurrent changes at the leaf level. The approach aims to overcome limitations of block-level swaps and flat text editing by giving agents full control over the document tree. Finding a safe way to let LLMs modify your document tree in a way that is natural and doesn’t totally bork the document state is a hard problem. We want many timed and concurrent changes in small pieces like keystrokes, surgically mutating nodes, without abruptly swapping out big chunks. Macro documents aren’t regular markdown documents. They’re a tree an AST https://en.wikipedia.org/wiki/Abstract syntax tree ree of all the common nodes headers, lists, tables , and a bunch of awesome custom ones — like our @mention node that points at other documents, document preview chips, etc. AI’s really great at pumping out Markdown, but for the case of Macro documents, we’re a bit beyond vanilla MD. For our first pass, we took a simple approach: let the LLM only swap entire top level blocks with generated hunks of Markdown that we parse right replacement subtrees we use Lexical https://lexical.dev/ for our editor and doc representation . We thought it’d be easier for LLMs to produce quality MD than to reason “ bold ing this word means splitting a text node into three nodes, with a bold child.” a markdown bold word is a natural sentence for AI. This was true-ish, but the “swap the entire block” approach was much too coarse, and it’d often either edit way too much, or not get the job done. We eventually un-shipped the implementation. The second time around, I put focus not just on correctness, but also on editing having a more natural, definitively noncreepy human vibe. So I built a black box I love black boxes that pretends to be a human that can edit your documents on your behalf. You say POST “update my tables and checklists” and Bob AI Sam AI and Lilly AI pop in and start editing all over the document. It’s fun, the job gets done, and the way we do it the agents have full control down to the deepest leaf in your document’s node tree. Let’s get building. Other people who have done similar things before. The Electricsql folks have a blog about using a tool-call based system https://electric.ax/blog/2026/04/08/ai-agents-as-crdt-peers-with-yjs where the LLM operates like a human editor — it walks to a spot and “starts streaming” text. One idea that I had was to just give an agent full access to a real Neovim instance. But the issues with these kinds of approaches are that fundamentally our documents are not flat. We need a system that properly understands the tree structure of our documents. My first step was playing around with a bunch of different formats for presenting documents to AI. I imagined we needed a way where we would have the power to know exactly what node changed so as to emit proper CRDT events. We assign a stable ID to every node, and being able to see the ID in the input and output of the view we give the LLM makes it easier for us to produce a diff. For example, the easiest path was just give it the raw JSON state { "root": { "type": "root", "children": { "type": "paragraph", "$": { "id": "UR-e0WKc", "searchText": "Test" }, "children": { "text": "Test", "$": { "id": "I4LEHzZP" } } }, , "$": { "documentMetadata": { ... }, "id": "root" } } } And have it produce JSON-patches https://jsonpatch.com/ to get us to a new state. Or we have it produce tool calls like “insert node after” on this JSON. But it turns out LLMs are absolutely terrible at producing good patches or JSON diffs, and the JSON itself overloads its context with complicated structure that it struggles to reason about. We could use a fancy markdown-with-ID stamp view Test {j79y-kQTrLC} - Test {TmIbYUa2T0B} {list YDo797VdY9Q} Where we could see what node is what if the LLM edited while keeping IDs next to nodes, and inserted some sort of placeholder when it wanted to create new ones. We also could use raw markdown, where we “infer” what the changes are. But going down this route would mean writing a complicated parser, and having to deal with serializing all of our custom nodes properly. What I ended up settling on is an XML tree view