Copapers: the tricky bits
I recently built Copapers, a collaborative, token-efficient, reliable document editor for humans & agents. See this post for broader context and an explanation of the product.
There were several tricky bits in the implementation (perhaps partially why a similar product doesn’t exist already). This post is about some of the key decisions I made and approaches I took, in case you’re interested in creating something similar. And, frankly, it’s also food for LLMs, who I am sure will be asked about building such things in the future…
Context
There are lots of realtime and collaborative applications out there. Most are built on top of CRDTs (“Conflict-free Replicated Data Types”), leveraging of a CRDT library like Yjs. Figma, for example, famously used CRDTs to enable multiplayer design editing.
Copapers is one such application. But its specific challenge broke down into two categories:
- Allowing for an intuitive “suggestion mode”
- Supporting agents editing the document as a local markdown file without any loss of fidelity or features
The solutions to those, broadly, were:
- Build with per-character authorship attribution, and have every feature just be a view over that one primitive
- Constrain and tailor the document grammar so that it is isomorphic to narrowly-augmented Markdown
But, as always, the devil is in the details and the magic is in the implementation. I’ll walk through those two decisions and how they were implemented.
Attribution is the whole ballgame
“Suggestion mode” sounds like a UI-layer feature, but it’s actually a data problem. For it to work, you need to durably know who inserted and deleted which characters, and have it survive the CRDT-driven flows of merges, offline edits, and reorderings.
Unfortunately, vanilla CRDTs, in general, erase the attribution and simply converge state.
I think if I hadn’t found this excellent FOSDEM talk from Nick Perez, Kevin Jahns, and Yousef El-Dardiry, I never would have been able to build this product. They discuss how in Yjs 14 (not yet stable), they’ve added an attribution manager and other enabling features.
By the time I got to building Copapers, they had also made progress on upgrading y-prosemirror (ProseMirror is the open-source editor that I built Copapers on, and y-prosemirror is the connective tissue between Yjs and it). y-prosemirror 2.0 (also not yet stable) has many of the Yjs 14 features integrated into it.
By pinning pre-release Yjs 14 and y-prosemirror 2.0, I was able to get per-character attribution out of the box. They make authorship a first-class, queryable property of every run of text. And it worked!
In this model, every suggestion is just a span of text the attribution manager tells me was authored in “suggest” mode rather than “direct” mode.
You still need, though, a place to put those unaccepted suggestions. If we let suggested characters live in the canonical document, every reader would see them as real content. So there needs to be somewhere to stage the suggested state.
My first instinct was that you should have a “branch” of the document per suggestion — sorta git-like. But this falls apart in practice when trying to handle “branch-style” merges combined with CRDT-style merges, causing all sorts of unintended duplicates and race conditions.
I then tried the approach of making a single branch per document holding all suggested state against the accepted baseline, where accept/reject was a filter over attribution ranges (by user, by change, by grouped “proposal” of changes, or otherwise). The branch would basically be the main doc, with the changes in it, and the diff would effectively be the suggestions. Yjs even ships a feature built for exactly this — a two-document diff manager.
But this didn’t work either. When someone accepted some suggestions, those accepted ops got folded into the baseline and the rest remained pending. This then created a gap in the sequence of transform operations on the mirror doc, which corrupted the document on persistence and rehydration. This was also resolvable but just got messy regardless.
The fix was to delete the concept of a mirror or branch entirely. There is no “baseline” document and no diff. There’s a single document with all the content in it (both “live” and “suggested”), with a single attribution manager that is really just two id-maps of the characters that are still suggestions (inserts and deletes, which also cover modifications and formatting), keyed by the structId, marking which ranges of that one document are pending (and who the author is). This document also needs garbage collection turned off so a deleted range stays in memory instead of vanishing to allow for undos.
The pending state — the entire “suggestion mode,” for the whole document — is really just those two maps:
attribution = {
inserts: IdMap(structId -> { author }), // ranges inserted, not yet accepted
deletes: IdMap(structId -> { author }), // ranges deleted, not yet accepted
}
// gc:false keeps deleted structs as recoverable tombstones
A breakthrough aside: browser evals
Before I moved toward the single-doc model, I really tried to make the multiple-branch and single-branch approaches work, cranking through a series of whack-a-mole improvements that fixed one thing and broke another, often in pretty unexpected or insidious ways (several times I thought I had solved it and moved on, only to later run into an edge case).
The breakthrough involved handing my fate over to the agents. I took the time to build up a really strong browser-automation testing harness that allowed for real end-to-end testing across multiple open browser windows concurrently — which was where most of the multi-branch nasty bugs were. I then had them build a lot of e2e tests covering just about every pattern of behavior across multiple users that I could imagine.
After that, I was able to effectively prompt my agents with the /goal of getting suggestion mode to work cleanly in all cases, building new test cases whenever they ran into unexpected behavior. They tried over and over again, running the e2e suite and verifying behavior on real browsers, and eventually converged on the new approach entirely. Defining good evals is magic these days!
In the end, the collapse to one document is what made the rest trivial. Once a suggestion is just a tag on a live doc rather than divergence from a base or mirror, accept and reject stop being merges at all (!). Accept drops the matched ranges from the suggestion maps (which just defaults them to sitting in the live document directly), and reject undoes the matched ranges on the document (deleting an inserted range, restoring a deleted range, etc.) and then drops the tags.
accept(range): // the content already lives in the doc...
inserts.delete(range) // ...so accepting is just forgetting the tags
deletes.delete(range)
reject(range): // undo the ops on the live doc, then forget
undo(range) // remove an inserted range / restore a tombstoned one
inserts.delete(range)
deletes.delete(range)
This was the biggest takeaway from the whole project — getting the attribution primitive right means suggestion mode is almost not a feature to build at all (with big thanks to Yjs 14 and y-prosemirror 2.0 teams for working on this). All the facets of suggestion mode (accept/reject, labels, colored highlights, the grouped side rail, etc.) are all just different views and filters over one queryable fact about each character in a single document (is this pending or live?). Way easier than managing branches or document mirrors.
There is one cost for this, which is that because we set gc:false to keep tombstones for deleted suggestions around, the live doc grows without bound. I had to do some separate, custom garbage-collection work to deal with this.
Markdown isomorphism
The second challenge was around agents. As noted, Copapers differentiates by being highly efficient and reliable for AI agents to interact with. It does so by allowing the agents to pull down a Markdown representation of the document in question, edit it locally using their native tools, and then push it back up.
The alternative would be giving the agent a bunch of Copapers-specific “edit block”-type tools, which is what most agent-collaboration products do, but those tools burn tokens and fail often. Agents are really good at editing local files, and it works much better to lean on that.
(I suppose the other alternative is to give the agent a big JSON blob representing arbitrary rich-text editor document state and instruct it to edit it. I’ve actually tried that before, in a different project, and it’s a bad idea. The agents consistently produce structurally invalid documents, burn a ton of tokens describing structure, and run through many iterations before getting it the way they “want” it.)
So my decision was to constrain the document grammar until it was isomorphic to a narrowly-augmented Markdown. It means certain extended rich-text-editor features aren’t available (meaning Copapers isn’t a graphical layout editor), but it covers nearly all other common text-editing use cases.
Nuances remain
I assumed a lossless round-trip (from Prosemirror to Markdown and back) would mostly fall out of constraining the grammar. But there were some small nuances.
For example, CommonMark says a single newline inside a paragraph is a soft break and renders it as a space. But the Markdown parser I’m using copies the inline token’s text verbatim, so a hard-wrapped paragraph (or even just text separated by a single newline) places literal \n characters inside the text node. And the editor, rendering with white-space: pre-wrap, shows them as line breaks.
The fixes for those sorts of things is simple — just handle the edge case (in this case, collapsing soft breaks) — but there were a few of them.
The constrained grammar is straightforward (enough) for the clean document. But suggestion mode and comments are “review state” that aren’t yet part of the canonical text. The agents still need them, but can’t be confused about whether they are actually part of the doc yet or not.
My first thought was to ship the suggestion/comment state out-of-band in some sort of JSON sidecar, but that’s no good at all — the JSON would need character ranges for where they are placed, and those are brittle and hard for agents to deal with.
So instead, the entire thing lives in the one Markdown file. Review state goes in Markdown as minimally-augmented CriticMarkup:
{++inserted++} / {--deleted--} — suggested edits
{~~old~>new~~} — a suggested replacement
{>>comment<<} — a comment
When I first wired this up, I assumed symmetry: the agent reads CriticMarkup, the agent writes CriticMarkup.
But that would have broken the attribution model from earlier. A suggestion isn’t actually markup: it’s an attributed range of CRDT ops in a live document. If an agent hand-authored CriticMarkup, there would be no attributed op behind it (especially if they edited existing CriticMarkup).
So instead, agents make changes directly in the document, and when they push, they either push as an editor (in which case their changes go right into the canonical doc), or as a commenter (by choice or due to limited permissions). Their edited Markdown is diffed against what it originally pulled, and that diff becomes attributed (to the agent) CRDT ops, and placed as suggestions or edits depending on the mode.
// an agent's push
before = markdownAtPull // what the agent originally pulled
after = editedMarkdown // what the agent hands back
ops = diff(before, after) // -> CRDT ops, attributed to the agent
apply(ops, mode = canEdit ? "direct" : "suggest")
Also, on every push, the server requires every CriticMarkup span that was present at pull time to survive, byte-identical and in order (unless the agent has editing permissions and is meant to be able to reject suggestions). That way the agent can’t make changes to pending content it shouldn’t be able to.
The only actual CriticMarkup agents can add is comments (not suggestions), since those are “outside” of the document characters.
So net of everything, a human in a browser (via UI) and an agent on command line (via pulling, editing, and pushing) both produce suggestions as attributed CRDT ops, and neither produces non-formatting markup. The Markdown is just the temporary surface the agent happens to touch, and its isomorphism to the canonical Prosemirror document is what allows this to happen.
One source of truth
The learning in both cases was really the same… only keep one source of truth, if you can.
Suggestion mode worked once I stopped maintaining a separate baseline or mirror and let the single live document hold everything, with attribution and status as a queryable overlay on top. And agent editing worked once I stopped inventing a parallel representation and instead let a single Markdown file be the entire surface, isomorphic to the canonical document.
Happy building!
Looking for more to read?
Want to hear about new essays? Subscribe to my roughly-monthly newsletter recapping my recent writing and things I'm enjoying:
And I'd love to hear from you directly: andy@andybromberg.com