← Back To Engineering

Building an Agent-Readable Rulebook, Part 1: Collaborative Editing and Persistence

Building an Agent-Readable Rulebook, Part 1: Collaborative Editing and Persistence

Sep 24, 2026

, Senior Software Engineer

How a shared policy draft stays coherent across concurrent edits, reconnects, and database writes.

At Coral, we build AI agents that automate patient intake for healthcare providers: reading referrals, filling forms, and moving orders through a team’s queue. Every client runs that work by its own rules, and most of those rules already exist as a written policy.

A client should not have to learn your rule builder before they can automate a process they already understand.

Several AI products start from the same place, among them Ramp’s Policy Agent, Claygent Builder, Intercom’s Fin Procedures, and Sierra’s Ghostwriter. What they share is an authoring choice, and sales has a line for it:

If you want to shorten the distance between you and a new client, meet them where they’re at.

Start with what the client already knows.

That is what we set out to solve at Coral by building our own Policy Agent. This article is about the part that is easy to underestimate: once a client’s document can influence automation, it has to be dependable. That means solving what goes wrong when two people edit one document, how a CRDT resolves it on one machine, how that runs across a deployment, how the result reaches the database without losing a word, and finally how well an agent actually understands what it reads.

One rule, then the exceptions

One rule, then the exceptions

One rule, then the exceptions

Say a client wants one form field pre-filled with a predictable value instead of a person picking it each time:

If the patient comes from the Houston branch, set their Branch Code to HUS123.

In a traditional system this is a few lines of code, or two dropdowns and a text field in a rule builder:

@dataclass
class FieldRule:
when_field: str
equals: str
set_field: str
to: str

HOUSTON = FieldRule("patient.branch", "Houston", "form.branch_code", "HUS123")

Now imagine Maya, an operations manager, setting this up, and Ben in intake, who knows the exceptions that show up in real referrals.

Ben reads Maya’s rule and points out an exception:

For referrals from partner clinics, keep the branch code supplied in the referral.

Maya agrees, with a qualification:

Only when the referral supplies a code. Otherwise, use the Houston default.

The rule builder now needs another condition, a missing-value check, and a fallback.

None of this is hard for Maya and Ben to discuss. The difficulty is translating what they know into the configuration model we give them. As the exceptions accumulate, a form builder starts needing the features of a programming language. Eventually Maya is asking us to configure a policy she could have explained in a paragraph.

That was the handoff we wanted to remove.

Our Policy Agent lets clients write their operating rules in a document editor, and AutoFill, our form-filling agent, reads the published policies when it fills a field. We are not compiling prose into code: the policy is versioned context that AutoFill interprets at decision time, and the services around it decide what is saved, what is live, and what counts as a citation.

The sketch looks small: an editor, some rules, a Publish button. We believed that for about a week. The rest of this article is what it took to make that editor safe for two people, one agent, and a database.

One shared draft, not a branch for every editor

One shared draft, not a branch for every editor

One shared draft, not a branch for every editor

Our first instinct was a Git-like workflow: separate drafts, merged on submit. It almost seemed natural, since a policy change has to be reviewed anyway. But it fails quietly. Maya changes the Houston default to HUS456 and publishes; Ben, working from the old policy, adds an exception that still says HUS123. A text merge accepts both, and the new policy contradicts itself.

Must Ben update his proposal before submitting? Does the reviewer see his change or the combined result? What happens to comments on wording that has already changed?

We did not want our answer to be “please rebase your draft.”

So we replaced branch-like change requests with one rule: a policy can have at most one open draft, shared by everyone editing it. The published policy stays live while the draft is prepared. Diffs, comments, review, publication, and version history stay; branches go. A uniqueness constraint on open drafts enforces it: two simultaneous creates mean one insert and one join.

The trade-off: Maya cannot keep a private, long-running proposal while Ben prepares another change to the same policy. They coordinate what is ready to publish.

There is still a concurrency problem, though. One shared draft does not mean one copy of the document.

The later save is not necessarily the newer document

The later save is not necessarily the newer document

The later save is not necessarily the newer document

Maya and Ben have separate browsers, each holding its own copy of the Houston policy. With ordinary autosave, that is enough to lose a change:

Both editors load:
Houston default: HUS123

Maya saves:
Houston default: HUS456

Ben saves afterward:
Houston default: HUS123
Partner clinics: retain the supplied code

Ben’s browser never received Maya’s change, so his save carries the old default, and when it replaces the whole document HUS456 disappears. This is our ordinary last-write-wins, or LWW, save path: each save replaces the draft, and the later write wins.

“Later” is not “newer”. An older copy can arrive last over a slower connection, and timestamps would not help: choosing one whole document always discards the other person’s edit.

We needed to combine changes within the document rather than choose between copies of it.

How Yjs keeps track of edits without relying on wall clocks

How Yjs keeps track of edits without relying on wall clocks

How Yjs keeps track of edits without relying on wall clocks

We use Yjs, a conflict-free replicated data type, or CRDT. Its updates can arrive in any order, or twice, and replicas still converge once they have all of them.

If Maya inserts new before patient while Ben changes HUS123 to HUS456, offsets fail: her insertion shifts the positions his edit was written against. Yjs gives inserted content its own identity and places it relative to its neighbors, not at an offset.

Figure 2. Yjs places each edit by identity and neighbors, so both replicas converge.

A small example

A small example

A small example

A plain Y.Text, without the rich-text editor around it:

import * as Y from "yjs";

const maya = new Y.Doc(), ben = new Y.Doc();
const mayaText = maya.getText("policy"), benText = ben.getText("policy");

mayaText.insert(0, "If the patient comes from the Houston branch, " +
"set Branch Code to HUS123.");
Y.applyUpdate(ben, Y.encodeStateAsUpdate(maya));

// Independent edits on each replica.
mayaText.insert(mayaText.toString().indexOf("patient"), "new ");
const at = benText.toString().indexOf("HUS123");
ben.transact(() => { benText.delete(at, 6); benText.insert(at, "HUS456"); });

// Exchange updates; applying one twice must not duplicate it.
const mayaUpdate = Y.encodeStateAsUpdate(maya), benUpdate = Y.encodeStateAsUpdate(ben);
Y.applyUpdate(maya, benUpdate);
Y.applyUpdate(ben, mayaUpdate);
Y.applyUpdate(maya, benUpdate);

console.assert(mayaText.toString() === benText.toString());
console.log(mayaText.toString());
// If the new patient comes from the Houston branch, set Branch Code to HUS456.

Both changes survive. Neither Maya’s document nor Ben’s was picked as the winner.

There is a limit. If Maya inserts new and Ben inserts returning in the same place, both replicas agree on new returning patient. They agree; it still makes no sense.

The CRDT coordinates edits. It has no opinion about which branch code the business should use.

Why our existing WebSocket service was not enough

Why our existing WebSocket service was not enough

Why our existing WebSocket service was not enough

We already had a WebSocket service for live updates: several replicas behind the ingress, with Redis fanning events out to all of them. It looked like the obvious home for policy collaboration, and it does not work, because of where a collaboration room lives.

A room is an in-memory object inside one server process. With several replicas, Maya and Ben can open the same policy and land on different processes, each with its own room of the same name:

Maya -> WebSocket replica A -> Houston policy room A

Ben -> WebSocket replica B -> Houston policy room B

Neither room sees the other’s edits, so nothing converges, and both rooms eventually save their own full document to the same draft row: the last-write-wins overwrite again, now on the server. Sticky sessions do not help; they pin a browser to a replica, not a policy to one. We needed routing by document, not by browser.

We tried three ways to keep the existing service before giving up on it.

Route at the ingress. Hash the policy key so every socket for one policy lands on one replica. Rooms still live in a process, so every deploy or scale event reshuffles ownership mid-session, and the ingress becomes part of the correctness story.

Share the room through Redis. Every replica keeps its own copy of the room and relays each update to the others over pub/sub, the way y-redis does. That works, but the document then depends on a second system with its own ordering and outages, and every replica ends up holding every open policy. We would pick it if a policy had hundreds of concurrent editors; ours have a handful.

Share the room through Mongo. Persist every update and let replicas read it back: every keystroke becomes a database round trip.

What we chose: a separate service with exactly one process. Policy collaboration moved out of the shared WebSocket service into its own deployment, /ws/policy, running one replica with one worker. Every policy’s room lives in that single process, so there are no copies to keep consistent. The existing /ws service keeps doing what it did:

/ws
Existing WebSocket service
Multiple replicas
Stage-event delivery

/ws/policy
Dedicated collaboration service
One replica, one worker
One in-memory authority for each policy room

Figure 3. Two rooms for one policy never converge; the fix is one room authority.

The deployment configuration is the whole trick:

# Abridged deployment configuration.
spec:
replicas: 1

strategy:
type: Recreate

template:
spec:
containers:
- name: policy-collaboration
env:
- name: WORKERS
value: "1"

replicas: 1 and WORKERS=1 mean one process. Recreate means a deploy stops the old process before starting the new one; a rolling deploy would briefly run two processes, and therefore two rooms for the same policy. Sockets drop for a few seconds on each deploy and reconnect, and we accepted that. A policy rarely has more than a handful of people in it at once, so one process is plenty; if that ever changes, the Redis bridge above is the next step. This is a limit of our choice, not of CRDTs.

One process for every room sounds like a bottleneck, so we measured it: 2, 5, and 10 clients typing into one room over loopback, plus ten clients at five edits per second.

Chart 1. Propagation latency per run: p50 under 3.5 ms, p95 under 7 ms, on loopback.

Every run converged with every token present once, and the draft in the database matched the room three to four seconds after the last edit, which is the idle debounce, not write time. Loopback numbers, no network; but the relay is the part the single-process decision affects, and it is not where the time goes.

The same policy, in three representations

The same policy, in three representations

The same policy, in three representations

Maya edits the rule, a reviewer diffs it, AutoFill reads it, and a reconnecting browser resumes from it. One representation cannot serve all four:

  • Block JSON: canonical draft content, published versions, diffs, and attribution. The source of truth.

  • Yjs document and snapshot: live collaboration and synchronization recovery.

  • Anchored Markdown: published policy text served to agents, derived from the blocks.

Each canonical block carries a stable block ID, so a rule stays the same rule when Maya moves it into a “Branch assignment” section or changes its code. The diff engine compares IDs, not positions or text:

{
"block_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"text": "For patients assigned to Houston, use HUS456."
}

For AutoFill, published blocks render to Markdown with a reserved anchor before each block, deterministically, so the agent can quote a line and we can find the block it came from:

<!-- coral:block 01ARZ3NDEKTSV4RRFFQ69G5FAV -->
For patients assigned to Houston, use HUS456.

That anchor is what Part 2’s citation verification stands on.

When the room is ahead of the database

When the room is ahead of the database

When the room is ahead of the database

The room is the live document; the database sees it on a schedule. The room converts its content to block JSON and writes through the same change-control service as ordinary saves, after three seconds of quiet or thirty seconds of accumulated changes. Between flushes the two disagree by design.

We also persist the encoded Yjs state so a restarted room resumes from it. That snapshot is recovery material and must never outrank the canonical draft, which fixes the write order:

# Schematic: capture both representations without yielding.
encoded_room_state = ydoc.get_update()
block_inputs = ydoc_to_block_inputs(ydoc)

# Save canonical content first.
await persist_canonical_blocks(block_inputs)

# Then save its corresponding collaboration snapshot.
await persist_room_snapshot(encoded_room_state)

No await between the two captures, so both describe the same document. Blocks first, snapshot second, so a failed snapshot write leaves recovery behind the draft, never ahead of it.

A snapshot is also checked against the open draft before it seeds a room and discarded on mismatch. The check exists because of an incident: a programmatic publish bypassed the service, left an older snapshot behind, and the editor showed a version 7 header over version 6 content. One keystroke in that room would have written the old content back.

A process crash between flushes can still lose recent edits; draining on shutdown and on the last disconnect shrinks that window.

The fallback saver became a second writer

The fallback saver became a second writer

The fallback saver became a second writer

A browser that loses its socket keeps an ordinary autosave path so people can still save. Without coordination, that path and the room are two full-document writers against one row:

Maya disconnects.
Her browser retains its last document snapshot.

Ben adds the partner-clinic exception.
The room now contains newer content.

Maya's fallback autosave runs.
Her older document replaces the draft.

The room flushes.
Its document replaces Maya's save.

Both requests can succeed. One of the writers still loses. Measured with the guard off, twenty trials, each waiting until the room’s edit was in the database before the stale save arrived: twenty of twenty, the stale save replaced the draft and the room’s edit vanished, then the next flush replaced it again and the offline edit vanished too.

Chart 2. Guard off: both writers lose in 20 of 20. Guard on: every stale save refused.

Merging the two documents is not a fix. A block absent from the room and present in the older save is either a deliberate deletion or an update that never arrived, and two snapshots cannot tell which. So we coordinate which path may persist, not what it persists, with three mechanisms:

Wait before falling back. A socket drop waits eight seconds before autosave, and a reconnect that completes sync first cancels it.

Know who is in the room. Presence is tracked per editor connection ID, not per user, since one user can hold a live tab and a stale one. A save from outside the live room is refused with POLICY_ROOM_LIVE; a participant’s save carries its connection ID through.

Hold the door on the way out. When the last member leaves, a __finalizing__ marker holds the room until the final flush lands.

With the guard on, the same twenty trials refused every stale save and left the draft untouched, and the marker covered the final flush in ten of ten.

The editor saved a blank page nobody had deleted

The editor saved a blank page nobody had deleted

The editor saved a blank page nobody had deleted

Hours after the first collaboration release, two production drafts were wiped. The editor binding emitted an empty paragraph before the stored document was seeded into the room; our initialization logic took it for content, and the first flush saved a blank document.

An editor’s onChange event is not proof that a person changed anything. It also fires on binding initialization, remote updates, and internal normalization. Fix: no change event enters the save pipeline until the first sync and seed pass completes, and cold-room seeding checks that the bound document is actually blank. An emergency backend guard that refused textless saves also blocked a deliberate select-all-and-delete, since the server sees the same payload either way; it went once the boundary existed.

Related: a stored block with props: {} came back from the editor as:

{
"backgroundColor": "default",
"textColor": "default",
"textAlignment": "left"
}

Same rule, different object: phantom edits and noisy diffs. We strip materialized defaults in storage and on both sides of every comparison. Otherwise opening a document looks like proposing a policy change.

Takeaways: before you put a document in front of an agent

Takeaways: before you put a document in front of an agent

Takeaways: before you put a document in front of an agent

Maya and Ben can now edit the Houston policy together, and nothing they type can be overwritten by a writer the server cannot identify.

The lessons that cost the most were not about CRDTs. They were about the boundary between the editor and the database, and they apply to any document allowed to influence automation.

Count the writers. The room’s flush, the fallback autosave, a programmatic publish, the editor’s own initialization: each either carries an identity the server can check, or it is a way to lose someone’s work.

Decide which representation wins. Canonical blocks are the truth; the collaboration snapshot is recovery material. Until the write order said so, a stale snapshot could become a writer.

A change event is not a change. Editors fire them on initialization, normalization, and remote updates. Two drafts were wiped before we drew that line.

Normalize before you compare. Materialized defaults turned an untouched document into a proposed change.

The Houston draft now says HUS456, and AutoFill still fills HUS123. Turning the draft into the instruction the agent follows is a different set of problems, about decisions rather than edits.

Part 2: Review, Version Control, Verified Citations, and Evals.

Originally published on Ayush Singh’s Substack.

ASMOne, Inc ©️ . All rights reserved

ASMOne, Inc ©️ . All rights reserved