The guided tour

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.

~3,000 lines of TypeScript, all of it linked below
The idea

The document is a CRDT, not a file

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 whole system

One keystroke, end to end

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.

Your browser CodeMirror 6 + Automerge (wasm) ops apply locally first — 0 ms 9 the dot turns green when durable editor/Editor.tsx Every other browser same merge, same result Your agent Claude Code, Codex, … GET /notifications?wait=25 POST /doc/edits names itself, reads for open asks sync relay wsbridge.ts Automerge repo repo.ts typewriter typewriter.ts mention scanner notifications.ts persist worker persist.ts ONE BUN PROCESS · PRISMA COMPUTE Prisma Postgres registry · persistedAt S3 object storage {ws}/{page}.automerge full history, restored on boot 1 2 3 4 5 6 7 8
  1. A keystroke becomes data. CodeMirror dispatches a transaction; automerge-codemirror turns it into a CRDT op carrying your actor id. It applies to the local document first — zero latency.
  2. One websocket carries everything. The change syncs to the Bun process over /sync; presence (cursors, typing flags, the agent's caret) rides the same channel.
  3. Fan-out. The server merges the op into its replica, appends it to disk, and relays it to every other peer. Each merges independently — all converge to the same text.
  4. Agents are woken, not polled-for. The mention scanner diffs each change; an @mention resolves the agent's parked long-poll immediately.
  5. The agent answers in oldText → newText. Edits are validated atomically against the settled document; misses and ambiguity come back as self-explaining 409s.
  6. The typewriter makes it human. The edit streams into the CRDT in 2–3 character chunks on a jittered ~35 ms cadence, the agent's caret broadcast with every chunk.
  7. Durability is coalesced. The persist worker dirty-tracks by document heads and writes the full Automerge file to S3 at most once per doc every two seconds.
  8. Postgres confirms, off the hot path. persistedAt is written only after the S3 PUT succeeds — the editing path never waits on a database.
  9. The loop closes on your screen. The persisted heads are broadcast; every client compares them to its own and the topbar dot turns green.

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.

Automerge

Conflicts don't get resolved. They never happen.

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.

SOREN — offline on a train
Ship it after the review
@CLAUDE — same moment, via REST
Ship it on Tuesday
MERGED — identically, on every peer
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
}
Prisma Compute

One stateful process. That's the architecture.

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
Prisma Postgres + Prisma Next

Relational where relational wins

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.

Object storage

Durability you can see

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:

green — everything durable in S3 amber — changes in flight, ≤ 2s out red — connection lost, editing locks

Client side: durability.ts — when the socket drops, a transaction filter swallows local edits but keeps remote reconciliation alive for the reconnect.

Agent collaboration

Agents don't get an API. They get a seat.

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.

Keep digging

Fork it. Break it. Ship your own.

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.