AI should suggest edits, not overwrite your document
An AI edit is almost always based on an old document.
The delay may be three seconds, but that is enough. Someone can correct a date, resolve a comment, or keep typing while the model is still deciding what to write.
Imagine a release note that begins like this:
1The release is scheduled for Thursday. The migration is safe.
You ask the AI to make it clearer. At the same time, another collaborator changes Thursday to Friday after the schedule moves.
The AI, still working from the old text, returns:
1We plan to release on Thursday after completing the migration.
Both edits are reasonable. Only one contains the current fact.
If the editor calls setContent() with the model response, Thursday comes back and the collaborator's correction disappears. If the editor rejects every response produced from an old version, AI edits fail whenever the document is active. If it merges the response silently, nobody knows which words came from the model.
This is the real AI-editor problem. Generating prose is the easy part. Admitting probabilistic output into a document that humans are editing concurrently is the hard part.
The design that has made the most sense to me is to treat AI like a collaborator whose changes arrive late and require review. It can propose edits, but it does not get to replace the document.
Before choosing models, tools, or diff libraries, define what the user should be able to trust.
The AI may change one sentence or reorganize an entire section. Either way, its work should appear as insertions, deletions, and modifications inside the shared document. Other collaborators should see the same proposal. Accept and reject should be reversible editor operations, not private buttons attached to a chat response.
The contract is compact:
1Human edit -> committed document transaction2AI edit -> tracked document transaction3Accept -> keep the proposal4Reject -> restore the previous content
This immediately rules out a common architecture: storing an AI response beside the editor and applying it later as a fresh snapshot. A snapshot knows the desired ending, but it has forgotten how the document got there.
In a single-user textarea, that may be acceptable. In a collaborative rich-text editor, history is part of correctness.
Outcome: model output is a proposal, not document state.
The smallest unsafe implementation looks convincing:
1const rewrittenMarkdown = await rewrite(documentMarkdown, instruction);23editor.commands.setContent(rewrittenMarkdown);
It works in a demo because the document is frozen while the request runs. Real documents are not frozen.
Tiptap and ProseMirror do not store “some text.” They store a tree. A heading has a node type. Bold text has a mark. A task item has a checked attribute. A comment may be anchored to a range that moves as transactions are applied. Yjs is concurrently tracking another representation of that tree.
Replacing the root document turns all of those differences into one giant operation. The editor can no longer answer useful questions: Was this paragraph deleted? Did its type change? Which part belongs to the AI? Can one change be rejected without reverting the others?
It also creates a stale-write problem:
1t0 AI reads version D02t1 collaborator creates D13t2 AI returns A1, derived from D04t3 setContent(A1) replaces D1
The bug is not that the model was slow. The bug is that a version-derived result was treated as a version-independent command.
Outcome: never apply an AI snapshot to a live collaborative document.
The next temptation is to ask the model for editor operations directly:
1delete from position 184 to 2132insert "We plan to release on Friday" at position 184
Those coordinates are already unstable. A collaborator inserting one character near the top of the page shifts every position after it. A model can also produce a syntactically valid ProseMirror step that is invalid for the current schema, or a Yjs update that is impossible to audit safely.
The model should work in a language suited to language models. For prose-heavy documents, markdown is a useful boundary. It preserves enough structure to express headings, lists, links, and code while remaining easy to search and transform.
A model-facing edit can be as simple as:
1type SemanticEdit = {2oldText: string;3newText: string;4};
The model reads a clean markdown view, finds the passage it needs, and proposes an exact oldText -> newText change. It never receives permission to manufacture CRDT items or mutate an editor at an absolute position.
That boundary also makes failure useful. If oldText appears twice, the operation is ambiguous. If it no longer appears, the model must read again. If newText cannot be parsed by the editor's schema, validation rejects it before the shared document changes.
1Model responsibility: decide what should change2Application responsibility: decide where and how it changes
This is the same reason databases separate a query from their storage engine. A useful instruction should not need to know the physical layout of the data it changes.
Outcome: let the model address content; let the editor own coordinates.
After applying the semantic edit to markdown, we have two clean documents: the document before the proposal and the document after it. Now we need to explain the difference to ProseMirror.
For plain text, insertions and deletions are enough:
1before: The release is scheduled for Friday.2after: We plan to release on Friday.
Rich text has changes with no character difference at all:
1node type: paragraph("Release plan") -> heading("Release plan", level: 2)2attribute: taskItem(checked: false) -> taskItem(checked: true)3mark: "important" -> strong("important")
A document-aware diff must notice text, marks, node types, and attributes. With an attribute-aware encoder, libraries such as prosemirror-changeset can provide the change ranges, but application-specific nodes still need application-specific interpretation.
The result should describe editor meaning, not bytes:
1import type { Slice } from 'prosemirror-model';23type TrackedSuggestion =4| { kind: 'insertion'; id: string; content: Slice }5| { kind: 'deletion'; id: string; content: Slice }6| { kind: 'modification'; id: string; before: unknown; after: unknown };
One logical edit should receive one suggestion identity. If changing a heading also changes its text, accepting it should feel like one decision—not six unrelated character edits and one attribute toggle.
Positions need discipline during dispatch. Applying a change near the beginning shifts later coordinates, so replacement plans are safest when dispatched from the end of the document toward the beginning.
And the conversion must fail closed. If a proposed operation cannot be represented as a reversible suggestion, drop it. An untracked fallback would make the model's most dangerous change the least visible one.
Outcome: diff document semantics, then group them by user intent.
We still have a concurrency problem. The AI produced its suggestions from D0, but the live document may now be D1.
The useful CRDT mental model is not “merge this string into the current string.” It is “another replica edited independently and has now reconnected.”
At the start of the run, clone the current Y.Doc into an isolated AI replica. Load that replica into a headless Tiptap editor. Turn the validated diff into tracked transactions there while human collaborators continue changing the live document.
When the AI finishes, send only the CRDT operations the live document does not already know:
1import * as Y from 'yjs';23const aiReplica = new Y.Doc();4Y.applyUpdate(aiReplica, Y.encodeStateAsUpdate(liveDocument));56// The headless editor writes tracked suggestions to aiReplica.78const delta = Y.encodeStateAsUpdate(9aiReplica,10Y.encodeStateVector(liveDocument)11);1213Y.applyUpdate(liveDocument, delta);
The state vector is a compact description of what the live replica has observed. encodeStateAsUpdate() uses it to produce the missing operations—the AI's new work—rather than serializing a replacement document.
This changes the merge from:
1old snapshot vs current snapshot -> choose or overwrite
to:
1human operations + AI operations -> CRDT convergence
Concurrent human edits stay in the live replica because nothing replaces it. The AI update joins the shared history like an update from a client that was temporarily offline.
CRDT convergence does not promise beautiful prose. If a human and the AI rewrite the same sentence, the merged proposal may still need judgment. Convergence preserves both histories; tracked suggestions preserve the ability to decide what belongs.
Outcome: merge AI operations, never AI snapshots.
Tracked changes keep both sides of a proposal in the editor. Replace slow with instant, and the underlying document contains something like this:
1The editor is [delete: slow][insert: instant].
That is exactly what a reviewer needs. It is terrible input for the model's next tool call. A raw serialization may become slowinstant, which is neither the old sentence nor the proposed sentence.
The AI needs a clean view that answers one question:
What would this document look like if the suggestions made so far were accepted?
Building that view requires simple but important rules. Deletion-marked content disappears. Inserted content remains, but its suggestion metadata is removed. A modification exposes the proposed attribute value.
1Suggestion document: The editor is [delete: slow][insert: instant].2AI's clean view: The editor is instant.
Every later read, search, and replacement runs against the clean view. After each successful tool call, it advances to the new proposed document while the real editor keeps the full reversible history.
Now the system has two coordinate spaces: clean content for reasoning and marked content for review. A position map bridges them. If it cannot map a range confidently, the agent should re-read instead of guessing.
This is the subtle bug that makes multi-step AI edits compound into nonsense. The first edit can be perfect; the second fails because the model is reading implementation details instead of the document humans see.
Outcome: AI reasons over the accepted future; humans review the reversible present.
Users should see that the AI is reading, thinking, and editing. That does not mean every model token belongs in ProseMirror.
Half a markdown link is invalid. Half a table has the wrong shape. A streaming rewrite can mention a block before the model later decides to remove it. Sending those partial states through the collaborative document creates flicker, noisy history, and changes that cannot yet be validated.
Use streaming for progress: status, explanation, and completed tool activity. Commit to the document only when a logical edit parses, diffs, and tracks successfully.
1SSE stream -> what the agent is doing2Suggestion state -> what the agent proposes changing3CRDT update -> how that proposal reaches collaborators
This separation makes cancellation less magical too. Stopping the model prevents future work. It does not need to pretend that already committed suggestions never existed. If the product removes them, that removal must be another safe document operation.
Outcome: stream activity eagerly; mutate shared state deliberately.
The worst failure is not an exception. It is a polished edit in the wrong paragraph.
The safe outcomes are intentionally unexciting:
1ambiguous text match -> request more context2invalid markdown -> reject before parsing into editor state3untrackable change -> roll back the logical edit4stale position map -> rebuild and re-read5concurrent AI run -> use an independent replica and suggestion ids
The document should remain untouched whenever the pipeline cannot prove where a change belongs or how to reverse it.
This also defines what to observe in production. Keep the markdown the model read, the markdown it proposed, the structural diff, and the final suggestion document. Each layer answers a different question. A model can produce the right sentence while the diff groups it badly; the diff can be correct while the editor rejects a transaction; the editor can be correct while synchronization never persists the update.
Logging only the prompt and final response hides the part of the system most likely to lose trust: the translation between language and document operations.
Outcome: if safety is uncertain, preserve the document and explain the failure.
The architecture becomes easier to reason about when each layer has one job:
1The model decides what should change.2The parser decides whether the proposal is valid.3The diff decides what actually changed.4The editor makes the change reversible.5The CRDT merges concurrent histories.6The human decides what becomes final.
The model never writes raw Yjs structures. The CRDT never decides whether prose is good. A suggestion mark never performs authorization. Each boundary turns a vague AI rewrite into something more precise.
That is the useful shift: AI inside an editor is not a text-generation feature. It is a new participant in the document protocol.
Once the AI is treated as a late, fallible collaborator, the right architecture follows. Give it a stable language for expressing intent. Validate the result. Diff rich-text structure rather than plain strings. Create tracked changes in an isolated replica. Merge operations into the live CRDT. Let humans accept or reject the result through the same editor they already trust.
The interesting outcome is not that the AI can write. It is that the document remains understandable while the AI, the editor, and several humans are all changing it at once.