---
title: How it works
description: A page is a tree of components in a CRDT, edited through a document-style surface, kept in your browser and synced between its tabs, and published as static HTML. Each technology and what it beat, how comments and AI work with no server, and what it costs to run.
---

# How it works

A page is a tree: each node names a component from the app's source, with
its props, its styles and its text. The tree lives in a CRDT, so anyone can
edit it at once and every copy ends the same. The editor draws the tree
with the real components, so the page works while you edit it, and an edit
only ever changes the tree, never the code.

- [Our own format, nobody's new engine](#our-own-format-nobodys-new-engine)
- [The technologies](#the-technologies), each with what it beat
- [No server: privacy, comments and AI](#no-server-privacy-comments-and-ai)
- [What it costs to run](#what-it-costs-to-run)
- [Open](#open)

## Our own format, nobody's new engine

Should this be built from scratch, with a format of its own? Partly. The
**schema** is ours: what a node is, which styles exist, how a component
declares its props. The **engines** are not: the CRDT, the text editor and
the components are mature open source, and the text form is markdown with
tags.

The schema has to be ours because no existing format is all of these at
once:

- **Data, never code.** HTML carries scripts and handlers; MDX carries
  imports and expressions; the AI builders' format *is* code.
- **A tree whose nodes keep their identity through moves.** HTML and
  markdown have no ids; ProseMirror's JSON and Puck's are trees, but their
  collaborative bindings store children in lists, so a move is a delete
  and an insert, and a concurrent edit to the moved block is lost.
- **Styles as data with a fixed list of properties.** Webstudio and
  GrapesJS store full CSS, which is the right power for one designer and
  the wrong one for a stranger's edit.
- **Components from the app's own source.** Puck gets this right, and its
  data model is the nearest to ours: a component name and its props.

What a format of our own costs: we write its migrations, its text form's
parser and its binding to the editor, and nobody else's tools read it. The
text form is markdown so that people and git can, and the schema is small
so that the rest stays cheap.

### A page in three forms

- **The doc** (Loro) is the truth: the tree, every edit, who made it, and
  every version. It merges in any order.
- **The text form** is what people, git and the source view read: markdown
  for prose, tags for components, with literal props and a stable id on
  each node; no imports, no expressions, nothing that runs. These pages are
  written in it.
- **Node JSON** is a flat snapshot of one version, defaults stripped,
  checked against each component's schema; it is all a renderer reads,
  live or published.

A node is one LoroTree node with a map: `type` (a component's name),
`props` (checked against the component's schema), `style` (property to
value, from the allowed list) and, for nodes holding words, `text`, a
LoroText with marks for bold, links and the like.

The text form is markdown for headings and paragraphs and a tag for every
other component, with an id on every block: a trailing `{#12@345}`, or an
`id` on the tag. Marks are toggles (`**`, `_`, a backtick, `[…](…)`), so
any overlap of them prints and parses back as it was. An id on every
line costs some reading; matching blocks by their words instead would
turn a move made in git into a delete and an insert, losing what
`move/keeps-edits` keeps.

The source view shows the text form, printed from the doc as edits arrive.
Editing it is harder than showing it: a changed line is diffed against the
text it was printed from, and the difference becomes the edits a person
would have made, so it merges with everyone else's. That diff is the same
one an edit made in git needs, so the two are built once.

## The technologies

### Loro, for the doc
Loro is a CRDT in Rust with a WebAssembly build. It has the one thing this
needs that the others don't: a **movable tree**, where moving a node is
one operation, so a section dragged while someone types in it keeps their
typing. It also has rich text with marks (Peritext's rules), undo that
undoes only your own edits, presence, shallow snapshots, version history
and forks, and it can read an update's authors before importing it, so an
update can be refused whole.

Chosen over:
- **Yjs**, the most used CRDT, with the most editor bindings. It has no
  move: a move is a delete and an insert.
- **Automerge**. Well designed, with Ink & Switch's Keyhive work on
  encryption and permissions, but no movable tree yet.
- **Our own CRDT.** Years of work that Loro has done.

Its cost is size: 3.4 MB of WebAssembly, 790 KB over the wire (loro-crdt
1.16.4, measured 2026-10-17). So a page paints first as static HTML,
readable at once, and the editor loads behind it.

### ProseMirror, for typing
Typing is where an editor feels right or broken: selection, input methods,
spellcheck, paste, and undo all have to behave as they do everywhere else.
ProseMirror handles them, and its schema is a list of what a document may
contain, which suits a format that may contain only data.

We write its binding to the doc ourselves. The existing one,
loro-prosemirror (0.4.4), stores children in lists, which would lose the
movable tree. Its cursor and undo plugins are the model for ours.

Chosen over:
- **Tiptap**, ProseMirror with a friendlier API and extensions. Its
  collaboration is built on Yjs, and its hosted parts are paid; we'd use
  ProseMirror underneath anyway.
- **Lexical**. Fast and small, but its collaboration is Yjs too, and it
  assumes React.
- **contenteditable from scratch.** Every editor that tried this rebuilt
  ProseMirror, slower.

### Preact, for components
A component is a function from props to markup, built from the app's
source, with a schema of its props beside it. Preact is 4 KB and runs
React's components, so most of the React world's components can be
registered.

Chosen over:
- **React**, ten times the size for the same model.
- **Web components.** No framework, but their shadow DOM hides elements
  from the style pencil and from the page's tokens.
- **Svelte or Solid.** Smaller output, smaller ecosystem to register from.

### CSS tokens and one stylesheet, for styles
Tokens are CSS custom properties (`--accent`, `--space-2`). A node's style
map becomes rules in one constructed stylesheet, keyed by the node's id,
rebuilt as edits arrive. Nothing is written into `style` attributes, so the
page's CSP can forbid inline styles, and a value is checked against the
allowed list before it reaches the sheet.

### The browser's own channel and storage, for sync
Each tab is a peer with its own copy of the doc. Tabs send each other
updates over a BroadcastChannel, which the browser delivers between tabs
of one site and nowhere else, and the doc is kept in IndexedDB, so a page
outlives its tabs. A tab that opens late, or was cut off, sends its
version and gets back only what it is missing. One tab at a time, chosen
by a Web Lock, writes the compacted snapshot.

The editor sees none of that: it hands a transport bytes to send and takes
the bytes it receives. The browser's channel is the first transport; your
devices are the next one ([Sharing](#sharing-tabs-first-then-your-devices)).

Chosen over:
- **A relay in the cloud** (a Durable Object per page). It would reach
  other devices today, but it means storing everyone's pages, sealing them
  so we can't read them, and running a service; your.os does all of that
  already.
- **Browsers connecting directly** (WebRTC). It still needs a server to
  introduce them and, on about one network in five, a relay of its own.
  That is your.os's job too.
- **One shared copy that tabs take turns writing.** Simpler, but then two
  tabs aren't two peers, and two tabs are the cheapest real test of the
  merging everything else depends on.

### TypeScript and esbuild, for the editor and the build
The editor is large enough that types pay for themselves; esbuild builds
it in milliseconds with no configuration. The build is also the gate for
code: a component reaches the library only through it.

### Docker and Playwright, for the tests
One image (Playwright 1.63 on Ubuntu, with wrangler) builds the site,
serves it locally exactly as it ships, and drives real Chromium against
it, several tabs at once for the editing tests. `npm run e2e` is the whole thing, so a laptop and CI run the same.

### This site
Each page in `docs/` is rendered by `site/build.mjs` to static HTML, with
the page's markdown beside it for agents. It is the publish path the editor
will use, in its first form.

## No server: privacy, comments and AI

There is no server for pages. The site serves the editor's files, the same
to everyone, and after they load, a page is edited, stored and shared
inside your browser.

### What anyone else sees
The site sees that someone loaded the editor, from which IP address, as
any website does. It never sees a page, a comment or an edit, because none
is ever sent; a test proves no request is made while editing.

### Comments
A comment is part of the page's data, in the same doc. It is anchored to a
node id, or to a stable cursor into a node's text (Loro keeps these
through edits), so it follows what it's about. A comment from someone on
another device arrives the way their edits do, once your.os carries them.

### AI
With no server, an AI runs from your browser, as you: when you ask, it
sends the part you selected to the provider you chose (Anthropic or
OpenAI), with your own key. The provider reads what you sent, the way a
person you showed it to would, and nothing else leaves. The editor's CSP
lets it reach those two and nowhere else.

The AI doesn't get to write code, or even text the page would parse: it
gets tools for the same moves a person makes (insert a component, set a
prop, set a style from the allowed list, move a block, change text), and
its edits land on a copy of the page as a suggestion. Someone accepts
them, and they merge as the AI's own edits, under its name, undoable on
their own. Edits that are data are what make an AI safe to let in: the
worst it can do is a bad edit you can see and refuse.

A model running inside the browser (WebGPU) would let the page never leave
at all. Today such models are small enough to rename and restyle, not to
build a page; it's an option for later, not the default.

### Sharing: tabs first, then your devices
Two tabs in one browser need no coordination beyond the browser: no
accounts, no addresses, no network. That is enough to build and prove all
of the editing, because two tabs are two peers, merging the same way two
devices would.

Between devices, the editor will run inside your.os, which already holds
the hard parts: a key per device, sealed sessions between them, a direct
connection where the network allows one, and a relay that can't read
where it doesn't. your.os becomes one more transport, and who may edit a
page becomes one of its grants. Until then, a page moves between devices
as a file.

What waits for that: editing with someone on another device, and their
comments.

## What it costs to run

Nothing, except the AI. Cloudflare's prices, checked 2026-10-17:

- **The site, the editor and its WebAssembly** are static files, and
  requests to static assets are "free and unlimited" on any plan. There is
  no other service to run, and no pages to store.
- **The AI** is the one real cost. On Cloudflare's own models, a mid-size
  model (Llama 3.3 70B) is $0.29 per million tokens read and $2.25 per
  million written, after 10,000 free "neurons" a day for the whole account,
  which is a few dozen edits. A free tier for everyone would be paid by us,
  so it is bring your own key. A shared allowance would need a server
  holding our key, which nothing else here needs.

## Open

- **Undo for a source edit made while someone else edited.** It is made on
  a copy of the page as it was printed and merged, so it arrives as
  another peer's edit, and Ctrl+Z doesn't take it back. One made with
  nobody editing is undone like any other.
- **A version that brings back a deleted block** brings it back with a new
  id: Loro can't undelete a tree node. The page renders the same; a
  comment on the old block stays with the old id.
- **One ProseMirror over the page, or one per text block.** It is one per
  block for now: components stay out of ProseMirror's way, Enter splits a
  block and Backspace joins two. What it lacks is a selection across
  blocks, as in a document; whether that needs one ProseMirror is open.
- **A component whose code changes its schema.** Its existing pages need a
  migration, applied as edits, so browsers on the old build keep working.
- **Storage the browser may clear.** Browsers evict a site's storage
  under pressure unless asked to keep it, and some ask the user. How hard
  to nudge toward saving a file is open.
- **What your.os's transport needs from the editor**: a way to say which
  peer an update came from, so a grant can refuse it whole. Loro can read
  an update's authors before importing it.
