AI should suggest edits, not overwrite your document

Editor··13 min read

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:

1
The 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:

1
We 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.

Start with the product contract

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:

1
Human edit -> committed document transaction
2
AI edit -> tracked document transaction
3
Accept -> keep the proposal
4
Reject -> 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.

Why setContent() is the wrong abstraction

The smallest unsafe implementation looks convincing:

1
const rewrittenMarkdown = await rewrite(documentMarkdown, instruction);
2
3
editor.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:

1
t0 AI reads version D0
2
t1 collaborator creates D1
3
t2 AI returns A1, derived from D0
4
t3 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.

Give the model meaning, not positions

The next temptation is to ask the model for editor operations directly:

1
delete from position 184 to 213
2
insert "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:

1
type SemanticEdit = {
2
oldText: string;
3
newText: 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.

1
Model responsibility: decide what should change
2
Application 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.

A text diff is not an editor diff

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:

1
before: The release is scheduled for Friday.
2
after: We plan to release on Friday.

Rich text has changes with no character difference at all:

1
node type: paragraph("Release plan") -> heading("Release plan", level: 2)
2
attribute: taskItem(checked: false) -> taskItem(checked: true)
3
mark: "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:

1
import type { Slice } from 'prosemirror-model';
2
3
type 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.

Treat the AI as a disconnected collaborator

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:

1
import * as Y from 'yjs';
2
3
const aiReplica = new Y.Doc();
4
Y.applyUpdate(aiReplica, Y.encodeStateAsUpdate(liveDocument));
5
6
// The headless editor writes tracked suggestions to aiReplica.
7
8
const delta = Y.encodeStateAsUpdate(
9
aiReplica,
10
Y.encodeStateVector(liveDocument)
11
);
12
13
Y.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:

1
old snapshot vs current snapshot -> choose or overwrite

to:

1
human 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.

The second AI edit is harder than the first

Tracked changes keep both sides of a proposal in the editor. Replace slow with instant, and the underlying document contains something like this:

1
The 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.

1
Suggestion document: The editor is [delete: slow][insert: instant].
2
AI'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.

Suggestions belong inside the shared document

A suggestion rendered as a local React overlay is not collaborative state. It disappears on reload, cannot move with the content, and is invisible to everyone except the requester.

Inline suggestions should be represented as editor marks. Inserted text stays in the document with an insertion mark. Deleted text stays with a deletion mark until the proposal is resolved. Attribute changes retain enough information to restore the old value. Blocks that cannot carry text marks can store suggestion metadata in node attributes and use decorations only for rendering.

That representation gives accept and reject precise semantics:

1
proposal: [delete: Thursday][insert: Friday]
2
3
accept -> Friday
4
reject -> Thursday

Both commands are ordinary ProseMirror transactions. Through the Yjs binding they become ordinary collaborative updates, so every client sees the same resolution. No separate “accept on the AI server” endpoint needs to own the truth.

Suggestion identity is the important contract. It connects inserted and deleted fragments into one decision, survives synchronization, and lets the UI offer accept-one or accept-all without inferring relationships from nearby text.

Two reviewers can still race—one accepting while another rejects. CRDTs ensure the replicas converge, but the commands must be idempotent and define what a second resolution means after the first has removed the suggestion metadata.

Outcome: reversibility must be data, not temporary UI.

Stream the work, not the document

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.

1
SSE stream -> what the agent is doing
2
Suggestion state -> what the agent proposes changing
3
CRDT 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.

Make unsafe failure boring

The worst failure is not an exception. It is a polished edit in the wrong paragraph.

The safe outcomes are intentionally unexciting:

1
ambiguous text match -> request more context
2
invalid markdown -> reject before parsing into editor state
3
untrackable change -> roll back the logical edit
4
stale position map -> rebuild and re-read
5
concurrent 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 mental model I keep coming back to

The architecture becomes easier to reason about when each layer has one job:

1
The model decides what should change.
2
The parser decides whether the proposal is valid.
3
The diff decides what actually changed.
4
The editor makes the change reversible.
5
The CRDT merges concurrent histories.
6
The 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.

Liked this article? Share it with a friend on X, LinkedIn, or email, , or . Have a question or want to talk about realtime editors, local-first systems, or open source? Email me and I'll do my best to get back to you.

Have a wonderful day.

– Vipin

Continue exploring