how it works
How leaf works
No daemon, no database, no build step: the server starts with the session and
stops with it. One uv script serves the page and mediates an
append-only event log; an ES-module runtime renders the widgets and injects
the comments, threads, banner, and version picker.
The loop
The two hosts carry that context through different loops. On Claude Code,
leaf wait is a background task whose completion joins the same
queue as a terminal message: an idle session takes it as a new turn, and a
working one folds it in between tool calls. On Codex, the wait runs in a
unified-exec session inside the turn that handed over the URL; the agent keeps
that turn active and polls that exact session until it returns. A detached
Codex command has no completion that can start a future turn, which is why the
loop stays active there.
After either host puts a complete, untruncated JSON batch in model context, the agent explicitly acknowledges its highest sequence before acting. A truncated batch is acknowledged not at all and retrieved again with enough room for the whole result. Until acknowledgement the next wait prints the events again. That small separation makes “the process printed it” and “the agent saw it” two different facts, so an abandoned wait cannot silently consume the user's words.
The server and the wait both go down with the session, and the page directory
is the whole state. Which is what lets a page outlive every session that works
on it: start server run from a shell of your own and it belongs
to no session, so nothing takes it down but leaf server stop.
Sessions then come and go around a page that stays up, and between them the
banner says so — no session holds this page, and it picks up again when one
does. The tab says it too: a leaf's icon wears the color of that banner's dot,
so a row of them tells you which page is working, which is waiting on you, and
which has nobody behind it, without opening any.
A command never edits the document
The agent has two ways in, and only one of them is HTML. A version is a file:
the agent writes the whole document into the page directory, an ordinary file
write with nothing mediating it. Everything it has to say goes
through the leaf command instead — a reply in a thread, a comment
it opens on a passage of its own, the changelog note that publishes a version
— and each of those appends one event to the log. The log takes a command
rather than a file write because it isn't the agent's alone: the browser
appends to it too, and one writer is what gives every event an id to reply to
and a place in the order.
So the agent's words reach the screen without the document changing. What the
user looks at is the version composed with the log, recomposed on a two-second
poll: the reply appears in a thread in the injected panel, the passage it
answers is lit by the browser's highlight registry — which paints a range and
touches no nodes — and the banner's line is whatever
leaf status last wrote. Nothing in the document was rewritten to
put any of it there, and it is the same composition that shows the user their
own edits before a version carries them.
The HTML changes once per version, and even then nothing is edited in place: a
revision is a new file beside the last one, v2.html next to
v1.html, and version publish publishes it. Until the
publication note lands the file is on disk and unreachable, and publishing
lints it first, so a broken version is never exposed. The next poll moves the
tab onto the new version, keeping the reader's place, and holds while the user
is mid-comment or mid-drag. Coming back the other way,
leaf wait starts after the last acknowledged user event, so a
comment posted between a wait and its acknowledgement remains beyond the
cursor and still lands.
What a page is made of
A version is one complete HTML document, and the required scaffold is two lines: the theme stylesheet and the runtime module. Everything between them is the agent's, drawn from the four sources below in ascending order of specialization; each earns its place only where the one before it has no way to carry what the page needs.
-
Plain semantic HTML. The theme styles bare elements, so
headings, prose, tables, code, and
detailscarry the page's voice unclassed. Most of any page is this. -
Class idioms. A small closed set the theme defines for
shapes HTML has no element for, among them
.eyebrow,.lede,.tag,.callout,.facts, andol.steps, which numbers this list. -
lf-*widgets. Custom elements that say what things are:<lf-options>for a question the reader answers,<lf-board>for work in columns. Attributes carry scalars, children carry prose, a leading<strong>is an item's title, and a JSON-Schema registry keeps the renderer, the linter, and the agent's documentation agreeing about the vocabulary. -
Bespoke presentation. A page-local
<style>and hand-drawn inline SVG, both riding the theme's tokens; the architecture diagram above is one. This is the escape hatch for presentation only this page needs, never a second palette.
Each language has one owner. HTML is the author's whole medium: pages carry
markup and never script, and interactivity is authored as markup too, so
writing <lf-board> is what adds dragging. CSS is the
theme's: it renders every widget's look, and many widgets need nothing else.
JS is the runtime's, opt-in per widget: a registry flag
(x-upgrade) names the tags whose behavior module loads, and the
behavior attaches to markup that has already rendered, so a module that fails
to load leaves a readable page rather than a blank one.
The head's meta channel carries what the page asks of its reader: one that
seeks approval declares
<meta name="lf-review" content="sign-off">, and only then
does the banner offer "✓ Looks good". The declaration versions with the
document; without it the banner offers a neutral "End leaf" for a
comments-only page.
This page is set in the plugin's own theme.css, and the widgets
below are rendering in it right now, with no script involved. Each sits in a
lf-specimen — the gutter that marks quoted material — so an
example decision never reads as one the page is asking you to make.
Whichever way that goes, which of the three jobs behind it are worth starting now?
The committed catalog runs wider: mermaid diagrams, unified diffs with per-file collapse, annotated code walkthroughs, file trees, timelines, metric rows, plans with milestones, and tabbed parallel workstreams. The examples gallery holds complete pages showing the range.
Edits are events; the document stays the state
Dragging a card or clicking an option reaches the agent as a structured action on the same channel as comments, and the next version ships with the board as you left it and your pick marked. Until it ships, the page shows the version plus your own edits; a reload replays them, and a second tab follows along live.
Declining is a version too. When the agent disagrees with an edit, it ships the next version without the change, says why in the changelog, and the page rolls back to the authored state as that version lands.
Each page owns its layer
page init vendors the whole layer (runtime, theme, registry,
widget modules) into the page's directory, overlaying the shipped defaults
with ~/.config/leaf/ and the project's .leaf/. Theme
rules cascade over the defaults; registry entries add or replace widgets;
modules replace by filename. The
customization guide shows how to scaffold each
shape. Because pages are self-contained, a version you approved can't change
under you when the defaults do.
Nothing broken goes live
Before a version is published, version check lints it
deterministically, with no browser: the HTML parses with balanced tags, the
scaffold is exactly the theme link and the runtime module, every
lf-* element validates against the page's registry along with the
head's lf-* metas, ids stay unique and every comment anchor from
the previous version survives, and nothing is wider than the readable column.
The changelog note runs the lint and refuses a failing version, so an open
browser never sees one. Before a page's URL first goes out,
version check --render also loads it in the machine's own Chrome
and refuses what only a layout engine can see — a console error, a widget
rendered into a box of no size, a sideways scroll — in both color schemes.
Served where you are
A page is served on the address its session arrived on: over SSH, the one your
client just reached the machine on; otherwise 127.0.0.1. The URL
leaf prints opens as-is either way, with no port to forward and nothing to
configure. It carries a key — the machine's, shared by every page it serves —
and the browser keeps that key in a cookie from the first request, so the open
port is still a page nobody without a link can read or comment on.
When a jump host or NAT leaves that address unroutable from your browser, say
so and the agent re-serves the page with
server run --host NAME on a hostname you do reach (a tailnet name
works). The key still gates every request, and leaf serves only networks the
machine is already on: there is no public tunnel.