{"slug": "agents-attacking-documents-with-crdts", "title": "Agents Attacking Documents with CRDTs", "summary": "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.", "body_md": "Finding a safe way to let LLMs modify your document *tree* in a way that is\nnatural and doesn’t totally bork the document state is a hard problem. We want\nmany timed and concurrent changes in small pieces like keystrokes, surgically\nmutating nodes, without abruptly swapping out big chunks.\n\nMacro documents aren’t regular markdown documents. They’re a tree (an\n[ AST](https://en.wikipedia.org/wiki/Abstract_syntax_tree)ree) of all the\ncommon nodes (headers, lists, tables), and a bunch of awesome custom ones —\nlike our @mention node that points at other documents, document preview chips,\netc. AI’s really great at pumping out Markdown, but for the case of Macro\ndocuments, we’re a bit beyond vanilla MD.\n\nFor our first pass, we took a simple approach: let the LLM only swap entire top\nlevel blocks with generated hunks of Markdown that we parse right replacement\nsubtrees (we use [Lexical](https://lexical.dev/) for our editor and doc\nrepresentation). We thought it’d be easier for LLMs to produce quality MD than\nto reason “**bold** ing this word means splitting a text node into three nodes,\nwith a bold child.” `a markdown **bold** word`\n\nis a natural sentence for AI.\nThis was true-ish, but the “swap the entire block” approach was much too\ncoarse, and it’d often either edit way too much, or not get the job done. We\neventually un-shipped the implementation.\n\nThe second time around, I put focus not just on correctness, but also on editing\nhaving a more natural, definitively noncreepy human vibe. So I built a black box\n(I love black boxes) that pretends to be a human that can edit your documents on\nyour behalf. You say POST “update my tables and checklists” and `Bob (AI)`\n\n`Sam (AI)`\n\nand `Lilly (AI)`\n\npop in and start editing all over the document. It’s fun,\nthe job gets done, and the way we do it the agents have full control down to the\ndeepest leaf in your document’s node tree. Let’s get building.\n\nOther people who have done similar things before. The Electricsql folks have a\nblog about using a [ tool-call based\nsystem](https://electric.ax/blog/2026/04/08/ai-agents-as-crdt-peers-with-yjs)\nwhere the LLM operates like a human editor — it walks to a spot and “starts\nstreaming” text. One idea that I had was to just give an agent full access to a\nreal Neovim instance. But the issues with these kinds of approaches are that\nfundamentally our documents are not flat. We need a system that properly\nunderstands the tree structure of our documents.\n\nMy 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.\n\nFor example, the easiest path was just give it the raw JSON state\n\n```\n{\n  \"root\": {\n    \"type\": \"root\",\n    \"children\": [ {\n        \"type\": \"paragraph\", \"$\": { \"id\": \"UR-e0WKc\", \"searchText\": \"Test\" },\n        \"children\": [ { \"text\": \"Test\", \"$\": { \"id\": \"I4LEHzZP\" } } ]\n\t  }, ],\n    \"$\": { \"documentMetadata\": { ... }, \"id\": \"root\" }\n  }\n}\n```\n\nAnd have it produce [JSON-patches](https://jsonpatch.com/) to get us to a new\nstate. Or we have it produce tool calls like “insert node after” on this JSON.\nBut it turns out LLMs are absolutely terrible at producing good patches or JSON\ndiffs, and the JSON itself overloads its context with complicated structure that\nit struggles to reason about.\n\nWe could use a fancy markdown-with-ID stamp view\n\n```\n# Test {j79y-kQTrLC}\n\n- Test {TmIbYUa2T0B}\n{list YDo797VdY9Q}\n```\n\nWhere we could see what node is what if the LLM edited while keeping IDs next to\nnodes, and inserted some sort of placeholder when it wanted to create new ones.\nWe also could use *raw* markdown, where we “infer” what the changes are. But\ngoing down this route would mean writing a complicated parser, and having to\ndeal with serializing all of our custom nodes properly.\n\nWhat I ended up settling on is an XML tree view\n\n```\n<doc>\n  <p id=\"UR-e0WKc\">\n    <t id=\"I4LEHzZP\">Test</t>\n  </p>\n</doc>\n```\n\nIt’s easy, the LLM can understand the structure, and it’s natural for AI to think about things in XML. The main drawback here is that XML balloons pretty quickly, and context is expensive $$\n\nWhat I realized is that we don’t *need* the input format for the LLM to match\nits output! My initial approach was to just have it edit some serialized *view*\nand tell it to go crazy with `sed`\n\nand `python`\n\n, but instead I got more\ncreative.\n\nClaude Code recently added “dynamic workflows” recently, which is a feature where a primary agent writes a script to orchestrate a complicated flow of tasks.\n\nRather than have the AI read some input and modify it, it got me thinking: maybe\nit’d most natural if the *view* is more of a “*lens*” that helps the agent\nfigure out what it needs to do to edit the document, and it isn’t the one to\nmake the changes. It can have small “editors” write code against a fake library\nthat it believes actually modifies the AST. My thesis: *having many small agents\ncomplete hyperspecific tasks produces a better result than having a single giant\nagent do everything at once because the semantics of document structure are\ndelegated.*\n\nFor the library I expose to these coders, I wanted it **thin**: it doesn’t\nliterally act on our (Lexical) tree, but instead accumulates intended edits, so\nthat I could do post-processing on the edit events to simulate animation, and\nmore easily write tests.\n\nRather than literal imperative mutations\n\n``` js\nimport { $getNodeByKey, $createTextNode } from 'lexical';\n\neditor.update(() => {\n  const para = $getNodeByKey('UKTNFKdd');\n  if (!para) throw new Error('node gone');\n  para.append($createTextNode('Hello, world!'));\n});\n```\n\nIt writes simple mutations.\n\n```\neditor.setText('UKTNFKdd', 'Hello, world!');\n```\n\nSo that the “editors” can be cheap models that don’t need much cognitive overhead to make real changes. These changes nest into a nice list of “operations,” like\n\n```\n[ { \"kind\": \"setText\", \"node\": \"UKTNFKdd\", \"text\": \"Hello, world!\" } ]\n```\n\nAnd we can “chop” it up with an animation post-processing framework to make it look human.\n\n```\n[\n  { \"kind\": \"awareness\", \"x\": { \"type\": \"cursor\", \"node\": \"UKTNFKdd\", \"at\": 0 } },\n  { \"kind\": \"pause\", \"ms\": 120 },\n  { \"kind\": \"edit\", \"y\": { \"kind\": \"insertText\", \"node\": \"UKTNFKdd\", \"at\": 0, \"text\": \"Hel\" } },\n  { \"kind\": \"edit\", \"y\": { \"kind\": \"setText\", \"node\": \"UKTNFKdd\", \"text\": \"Hello, world!\" } }\n]\n```\n\nOur animation “framework”:\n\nA giant match on every op. Another unintended upside of going this route: we\ncan run the generated code in a cheap [quickjs](https://bellard.org/quickjs/)\nsandbox with no libraries, and print out a JSON serialization super easily.\n\nI ended up breaking up the task of editing and planning the edits.\n\nOur magic trio of agents looks like:\n\nThe supervising agent doesn’t have to think at all about *how* the\ndocument is edited. It just reasons out a list of tasks for writers “insert\nparagraph next to node ID X that says Y,” “set the cell (2,3) in table ID Z to\nbe blank,” etc.\n\nOne more fun trick: the supervisor doesn’t actually generate prose. It\ngenerates “snippet specifications.” We let our coders reference prose that\ndoesn’t exist yet off of a magic `snippets`\n\nobject, like\n`snippets.frogPoemParagraph`\n\n. As they write code to edit the document, we have\n*n* agents generate the snippets necessary to reassemble things. Eventually,\nthese snippets could be streamed and properly hook into our animation\nframework so that mutli-coder-agent sessions can begin displaying aas!\n\nOkay enough of the details. Now we’re both experts, let’s go through a real example here…\n\nLet’s edit a document with our new AI editing system!\n\nThe first step to kicking off the flow is asking our Macro agent to edit your document. Let’s try a creative request\n\n“write hello world in my document”\n\nThis will get it to call the document edit tool…\n\nThe user is asking me to edit the @Frog Report document. This is a request to edit an existing document. I will use my DocumentEdit tool to dispatch the edit.\n\nWhich will kick off the system. Now the interpreter agent will understand what you want…\n\n```\n# Interpretation\n1. Literal ask: Write \"Hello, world!\" into the document.\n2. Underlying goal: A minimal smoke-test of the editing pipeline.\n3. End state: One <p> containing the text \"Hello, world!\".\n4. Scope: Single paragraph. No structural changes.\n5. Style: Plain text. No format to conform to.\n6. Success checklist:\n   - Paragraph holds the literal string (no placeholder).\n   - No extra nodes introduced.\n```\n\nThe supervisor reads the interpretation and dispatch the “editors” (potentially more than one at once!) to get the job done.\n\n```\n**dispatch** — 1 edit(s)\n1. In paragraph id='UKTNFKdd', set its text to snippets.text.\n```\n\nEach editor writes a tiny bit of code, using our custom library,\n\n``` js\nconst snippets = {\n  text: \"Hello, world!\"\n};\neditor.setText('UKTNFKdd', snippets.text);\n```\n\nAnd we can now easily use a small Lexical translation layer to map the setText to the real document. But instead we pre-process the edits into a broken-up animation sequence and push them into a queue that will stream the events to everyone looking at the document…\n\n…meanwhile, the supervisor will get a “report” back of how applying the code went:\n\n```\n1. ✓ APPLIED\n\n+ Hello, world!\n```\n\nAnd then report back to the main Macro agent to conclude the tool call\n\n```\nDone! The paragraph now contains \"Hello, world!\" as a single text node.\n```\n\nWhich will then have it tell you that the job has been done. Now, the document is left being typed out by real looking editors.", "url": "https://wpnews.pro/news/agents-attacking-documents-with-crdts", "canonical_source": "https://404wolf.com/posts/AgentsAttackTheDocument/", "published_at": "2026-07-09 14:50:53+00:00", "updated_at": "2026-07-09 15:06:53.837346+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "artificial-intelligence"], "entities": ["Macro", "Lexical", "Electricsql", "Yjs", "Neovim"], "alternates": {"html": "https://wpnews.pro/news/agents-attacking-documents-with-crdts", "markdown": "https://wpnews.pro/news/agents-attacking-documents-with-crdts.md", "text": "https://wpnews.pro/news/agents-attacking-documents-with-crdts.txt", "jsonld": "https://wpnews.pro/news/agents-attacking-documents-with-crdts.jsonld"}}