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 agent writes version files and runs leaf commands, both reaching the browser through interact.py's loopback server; leaf wait carries a comment back into the agent's context. agent session version files · commands interact.py loopback port · event log your browser comments, drags, picks leaf wait carries a comment into the agent's context

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.

  1. Plain semantic HTML. The theme styles bare elements, so headings, prose, tables, code, and details carry the page's voice unclassed. Most of any page is this.
  2. Class idioms. A small closed set the theme defines for shapes HTML has no element for, among them .eyebrow, .lede, .tag, .callout, .facts, and ol.steps, which numbers this list.
  3. 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.
  4. 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.

effort: highrisk: medRewrite the importer A clean slate on the new schema; the old importer keeps running until parity. effort: lowrisk: highShim the old schema Fastest to ship; every future field pays the translation tax. effort: medrisk: lowMigrate in stages New fields land on the new schema while reads fall back, table by table.

Whichever way that goes, which of the three jobs behind it are worth starting now?

Backfill last month's rows Contract tests against the old schema A read fallback behind a flag
The form follows the options, and nothing declares it: options that argue their own case render as cards, bare labels as rows — a question about the page, where each row can name the section that makes its case. In a live leaf either form takes your pick by click, the next version ships with your choice marked, and a box under the group takes anything the options don't cover.
Flaky auth test Fails one run in twenty on CI only. Dark-mode contrast pass Rate-limit the export Tokenize the palette
In a live leaf, cards drag between columns by a grip, and each drop reaches the agent as a structured move.

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.