mrkdwn is a collaborative markdown workspace where humans and AI agents are first-class co-authors — built as one stateful Bun process on Prisma Compute, with Prisma Postgres, object storage, and Automerge CRDTs. Scroll to see how a keystroke becomes a conflict-free, durable edit — and how an agent pulls up a chair.
Most collaborative editors treat the document as a string and fight over it: locks, operational-transform servers, "someone else edited this" dialogs. mrkdwn treats every page as an Automerge document — a data structure where every character carries a permanent identity, every edit is an operation against those identities, and any two peers holding any two versions can merge with a deterministic, commutative result.
That one decision shapes the whole system. There is no authoritative "current text" to guard, so the server doesn't arbitrate writes — it just relays and persists operations. Humans type over websockets, agents write over REST, and nobody ever sees a merge conflict, because the data structure makes conflicts unrepresentable.
The complete life cycle of an edit — from the keypress to every peer's screen, through an agent's hands, and into object storage. Follow the numbers.
/sync; presence (cursors, typing flags, the agent's caret) rides the same channel.@mention resolves the agent's parked long-poll immediately.oldText → newText. Edits are validated atomically
against the settled document; misses and ambiguity come back as self-explaining 409s.persistedAt is written only after
the S3 PUT succeeds — the editing path never waits on a database.Nothing on the editing hot path waits for a database. Postgres holds what's relational (workspaces, pages, slugs, timestamps); the CRDT holds what's collaborative; S3 holds what must survive.
Here's the mechanism, not the magic. Automerge models text as a sequence of characters where each
insertion is an operation: "insert 'x' after the character with id op-1234@actorA".
Ids are (counter, actorId) pairs — globally unique, totally ordered, never reused. A delete
doesn't remove a character; it marks that id as a tombstone. Every peer applies every operation, and
because the operations reference identities instead of positions, applying them in any
order converges to the same document.
Ship it after the review
Ship it on Tuesday
Ship it on Tuesday after the review
Both peers inserted "after the same character". The tie is broken by comparing op ids — arbitrary but deterministic, so every replica picks the same winner and both insertions survive, character for character. Nothing was overwritten, no one was asked to pick a side, and the loser of the tie-break lost nothing but a few pixels of position.
The same identity trick powers everything subtle in mrkdwn. A cursor is a reference to a character's op id, so it stays attached to that character no matter how much text moves around it. Comment anchors are cursor pairs — quote text, edit above it all day, the highlight never drifts (presence.ts, notifications.ts). And because the history is never thrown away, attribution is derived, not recorded: diff each change against its parents, pin the insertions with cursors, resolve them against today's text — click any avatar and see exactly which characters they wrote (attribution.ts).
// attribution.ts — who wrote what, derived from history for (const raw of A.getAllChanges(doc)) { const change = A.decodeChange(raw); const patches = A.diff(doc, change.deps, [change.hash]); // what did THIS change do? // each inserted span → two cursors, resolved against the current doc }
mrkdwn is the kind of app that fights serverless: a websocket per browser tab, agents parked on
25-second long-polls, a background worker flushing to S3, a wasm CRDT engine holding every open page
in memory, and disk state under ./data. On Prisma Compute it's just… a process. One
Bun.serve owns HTTP, websockets, and static assets on a single port
(server.ts).
There's no message broker between browsers, because the process is the broker: Bun-native websockets feed automerge-repo's sync protocol through an ~80-line bridge (wsbridge.ts), and ephemeral presence — cursors, "✏️ writing" flags, the agent's typewriter caret — rides the same channel as document sync. Deploying is building the frontend and starting the process; state survives restarts on a volume.
# the whole production story
bun run build
NODE_ENV=production DATABASE_URL=... MRKDWN_DATA=/data bun src/server/main.ts
Not everything wants to be a CRDT. Which workspaces exist, which pages live in them, what slug a title maps to, when a page was last persisted — that's queryable, transactional, relational data. It lives in Prisma Postgres, described by a contract that is the entire schema — two models, ~20 lines. Prisma Next turns it into a fully typed client; store.ts is the only file that talks to the database.
// store.ts — the registry, fully typed from the contract const rows = await db.orm.public.Document .where({ workspaceId }) .orderBy(d => d.createdAt.asc()) .all();
The division of labor is strict: Postgres is never on the editing hot path. Page
titles typed into the editor sync back to the registry on a debounce, re-deriving slugs; the
persistedAt column is written only after a successful S3 upload. In development you don't
even set up a database — the server boots a local Prisma Postgres via
@prisma/dev and applies the
contract on start.
Automerge documents serialize to a single compact binary — content and full history. A
background worker (persist.ts)
mirrors every page to S3 as {workspaceId}/{pageId}.automerge. It dirty-tracks by document
heads and writes at most once per doc every two seconds — a burst of typing coalesces
into one PUT. Restoring a page is one GET and one Automerge.load(); the history comes back
with it.
// persist.ts — the write discipline // dirty? schedule at max(lastSave + 2s, now) → A.save(doc) → S3 PUT // only THEN touch Postgres (persistedAt) and broadcast the new heads state.entry.handle.broadcast({ type: "persisted", heads });
Those broadcast heads drive the most honest pixel in the app — the dot in the topbar. Every client compares its own document heads against the last persisted heads:
Client side: durability.ts — when the socket drops, a transaction filter swallows local edits but keeps remote reconciliation alive for the reconnect.
Joining. Hit Invite your agent on any page and paste the invite into Claude
Code, Codex — anything with a shell. The invite is scoped to that page and carries the URL, a bearer
token, and instructions. The agent names itself (X-Agent handle +
X-Agent-Name display name), announces presence, and is told to read the page for
work already waiting — unchecked to-dos and open questions count, not just @mentions
(skill.ts — the invite
doubles as an installable Claude Code skill, served at /skill.md).
Watching. Agents don't poll the document — the document comes to them. A scanner
watches every page's changes for @handle mentions (in the text and in comment
threads), keyed by Automerge cursors so an edited line doesn't re-notify
(notifications.ts).
The agent parks on a long-poll and is told to stay parked for 30 minutes past the last instruction:
# the agent's event loop — wakes early the moment a mention lands while true; do curl -s "$MRKDWN_URL/api/notifications?wait=25" -H "X-Agent: claude" ... done
Writing. Edits use the exact-match contract agents already know from their file
tools — { oldText, newText }, atomic, with 409s that explain themselves
(api.ts). And because agent
text landing as a single paste feels alien next to human keystrokes, the server doesn't apply it
atomically: a typewriter
(typewriter.ts) streams
each edit into the CRDT in 2–3 character chunks on a jittered ~35ms cadence, broadcasting the agent's
caret as it goes. Humans watch the agent write.
// typewriter.ts — typing position rides a cursor, so concurrent // human edits shift the caret instead of corrupting the text const pos = A.getCursorPosition(doc, ["content"], lastChar) + 1; handle.change(d => A.splice(d, ["content"], pos, 0, chunk), { message });
Requests queue per document, so an agent's follow-up edit always reads its own settled write. Every chunk carries the agent's name in the change message — which is how attribution can tell agents apart even though they all share the server's actor id. Click the agent's avatar: its contributions light up.
Everything above is ~3,000 lines of TypeScript in github.com/prisma/mrkdwn, Apache-2.0, with 1,000+ tests. Start with the README, then:
bun install && bun run dev is the whole setup — a local Prisma Postgres boots
itself. Then invite your agent and watch it type.