Not ready for general use. Watch this space, and hopefully there'll be more to say soon.
A Claude Code and Codex plugin for collaborating with your agent in HTML. The agent presents plans, write-ups, and work in flight as a web page rather than a wall of terminal text. Select any line to comment on it like a shared doc; your comment reaches the session, and the agent ships a revised version. It comments back the same way — in the margin, on the passage in question, rather than in the terminal. A page tracking work in progress keeps up with it: items go from planned to done as the agent works through them, and the browser follows each new version on its own.
docs/ is the site, published at https://leaf.page/:
index.html is the tour (what it does, and one session end to end), examples.html lists
the example pages, how-it-works.html covers the mechanism, and customizing.html
covers themes and project widgets. Each uses leaf's own theme, so they double as
specimens, and each opens the same from a checkout as from the web.
Claude Code:
/plugin marketplace add max-sixty/leaf
/plugin install leaf@leaf
Codex:
codex plugin marketplace add max-sixty/leaf
codex plugin add leaf@leaf
No config or account is required. Leaf needs
uv on PATH (interact.py declares its dependencies
in a PEP 723 header) and a browser on the same machine as the session.
Then ask the agent for a page. The explicit skill is /leaf [topic] in Claude Code
and $leaf [topic] in Codex; with no argument it presents whatever the session is
currently about.
Project customizations live in .leaf/; user customizations live in
~/.config/leaf/. A short theme file cascades over the defaults, and a widget
scaffold adds a registry entry, CSS, and optionally an ES module:
leaf customize theme
leaf customize widget lf-callout
Add --upgrade to the widget's first scaffold command when it needs browser
behavior.
The next leaf page init <page-dir> vendors the merged layer. The
customization guide covers the file contracts and the
project/user precedence.
examples/ holds a complete page for each kind of write-up, including a
dashboard meant to change as work finishes. gallery.html puts them on one page as tabs
(generated by scripts/gallery.py; edit the examples, not it). Serve one against the
shipped layer to try it:
plugins/leaf/bin/leaf page init /tmp/demo
cp examples/triage-board.html /tmp/demo/versions/v1.html
plugins/leaf/bin/leaf version publish /tmp/demo --version 1 --text "demo"
plugins/leaf/bin/leaf server run /tmp/demo
scripts/site.py assembles https://leaf.page/ into .tmp/site,
and .github/workflows/publish-site.yaml runs it on every push to main that touches
the pages, the examples, or the layer. The docs pages are copied with their three
checkout-relative paths substituted (the theme, a link into the payload, and a link to
an example), and each
example is exported through the shipped version export, so what a visitor reads is
Chrome's own drawing of the page with the comment layer removed. The build resolves
every local link it wrote and refuses a site holding one that reaches nothing.
scripts/site.py
The suite is integration tests over the real thing. test_interact.py exercises the
lint, vendoring, publishing, catalog, export, thread-markup validation, and the anchors
leaf comment writes by reading a version file. test_render.py
loads the shipped examples in a real browser (both color schemes) and asserts what a
static lint can't reach: every widget upgrades into a box with usable size, the document
and the comment panel scroll in separate regions, the comment box grows without any
script sizing it, and neither pressing a control nor news arriving on its own moves the
controls beside it. One journey test drives the whole loop through the real UI
(select a passage, comment, drag a card, follow the next version, find the comment still
anchored) and pins the event log it leaves. test_product_page.py holds the pages under
docs/ to the shipped theme and widget registry, and test_site.py builds the site and
reads it back: the theme it serves is the shipped file, each exported example stands up
with its scripts gone, both palettes reach the site's own layer, and no page scrolls
sideways on a phone. Playwright attaches to the Chrome
already installed (channel="chrome"), so there is no browser download and still no
build step. Driving a page by hand to check a change works the same way: run page init
for the directory, then serve it from interact.handler_for(page_dir, token) in-process
as the fixtures do, opening the page with that key in the query (?t=…). server run instead puts a live page behind the session, and the
loop's hooks then hold it to watching that page.
The suite runs in the environment pyproject.toml names and uv.lock pins. That is the
developer's environment only: leaf declares what it needs in interact.py's PEP 723
header, which is what installs it with no build step, and the project file leaves that
alone. The tests need the same set anyway, because they load interact.py by path.
uv run pytest tests
Two minutes rather than eleven, because pyproject.toml shards it across eight workers.
That is the whole command: no variable in front of it and no step before it. The fixtures
move the two XDG directories leaf reads (config_home, state_home) and leave the
rest of the home alone, so every leaf the suite shells out to finds the uv cache the
developer already has, and a fresh checkout fills it the way any other run would.
One resolution sits outside uv.lock: the Playwright bin/leaf supplies to
version export on top of the script's header (it says why), which uv asks the index for
whenever its cached answer has gone stale — a second or so, once. On a machine with no
network, hold the whole run to the cache instead:
UV_OFFLINE=1 uv run pytest tests
Ruff and prettier run from .pre-commit-config.yaml, which says what each covers
and why. wt merge runs that set and then the suite as pre-merge hooks
(.config/wt.toml), and refuses a tree that doesn't pass; .github/workflows/ci.yaml
runs both again on main and on every pull request. Before then:
pre-commit run --all-files
CI is also the only place either gate meets a platform that isn't macOS, and what the two
disagree about is what a browser test measures: how wide a system font sets a word,
whether a scrollbar takes a gutter out of the window. scripts/linux-suite.sh runs the
suite where CI runs it, in a container carrying the runner's Chrome and its fonts, so a
failure reported there is one to reproduce rather than one to guess at. It takes pytest's
arguments, and needs a Docker daemon that can run linux/amd64:
scripts/linux-suite.sh
Code blocks are colored in the browser from
plugins/leaf/skills/leaf/assets/vendor/highlight.esm.js, which upstream
doesn't ship in a form a page can import — so it is bundled here.
scripts/vendor-highlight.sh rebuilds it, reading the language list out of the
registry's $languages.names so the bundle can't offer a language the lint rejects. Add a
language there, then rerun the script.
Thread messages render their Markdown in the browser too, from
vendor/marked.esm.js — upstream ships that one as a single dependency-free ESM
file, so scripts/vendor-marked.sh is a copy at a pinned version, not a build.
scripts/record-demo.sh drives a session through the shipped server and Chrome, then
writes the resulting animation to docs/demo.gif.
notes/comparisons.md reads the nearby projects against
leaf, and covers where leaf is the wrong choice.
MIT. See LICENSE.
