Skip to content

georgegally/liesel

v0.2.0MIT

HTML is the new markdown. Liesel is a board for reviewing and tuning HTML designs with your agent.

HTML is the new markdown. Your agent writes designs as HTML files; Liesel lays them out on one board in the browser, at real device widths, so you can compare versions, leave notes on any element, tune colours and spacing live, and send it all back to the agent in one go.

It works with any coding agent that can run a shell command - Claude Code, Codex, Cursor, OpenCode, Gemini CLI, Copilot CLI or anything else. There is no account, no cloud service and no API key: Liesel is a local CLI and a local web page.

Quick start

You need Node.js 22 or newer.

1. Install

npm install -g github:GeorgeGally/liesel

Liesel installs from GitHub. It is not published on npm, so never npm install liesel or npx liesel: anything under that name there is not Liesel.

2. Set up a project

cd ~/code/my-project
liesel init

liesel init makes a board/ folder with board.json and a starter design, writes LIESEL.md with the review loop, and adds a short section pointing at it to AGENTS.md and CLAUDE.md (and GEMINI.md if you have one). It only ever appends; nothing you already have is overwritten, and running it again is safe. Use --board <folder> for another folder name and --name "<board name>" to name the board.

3. Ask your agent

Read LIESEL.md, then make three versions of the pricing page on the board.

The agent opens the board (liesel board open board), writes HTML files into board/, and waits for your notes with liesel poll board. Each time you send notes it applies them, replies in the board's chat, and waits again.

Or start from the browser. With the server running, the Liesel home page (the address liesel board open prints, without the /board/... part) shows your open boards as cards, newest first: a live preview of each board's first design, its name, its folder (shortened just enough to tell same-named boards apart), when it last changed, and how many agent replies you have not read yet. New board opens a small form: give it a name and a folder, typed or picked with Browse. A missing folder is created; a folder that already holds HTML files opens with each one on the board. Drop more HTML files onto the board to add them. From inside a board, Liesel in the top bar goes back to the home page, and the + beside it opens New board or Open project there; closing that form without creating anything returns to the board. Click the board's name in the top bar to rename it: Enter or clicking away saves, Escape keeps the old name. The new name shows on the home page, in Settings and in the tab title; the board's address does not change.

Or open a project. Open project on the home page reviews a real app instead of copies of it: pick the project's folder (typed or with Browse), or paste a GitHub URL and Liesel clones it into ~/Liesel/<repo>. The board lives in the project's own .liesel/ folder (board.json, the chat history, dropped images and history snapshots), which Liesel keeps out of git by adding /.liesel/ to .git/info/exclude; your .gitignore is never touched. The project shows on the home page under its repository's name (until you rename the board), and its agent works in the repository root.

Live pages are the project's running site, shown in the same device frames as any design through a local proxy, so notes, Controls, History, device sizes and the agent loop all work on the real pages:

  • Opening a project for the first time shows Pages: a checklist of the project's pages with All pages and a small live preview of each. Liesel finds them in the project's files (Next.js app and pages routers, SvelteKit, Astro, Remix and React Router, Nuxt, a single-page app's absolute client routes, and plain .html files) and, once the site is running, in its sitemap and the links on its home page. A route with a changing part, such as /blog/[slug], is offered with an example the site links to, or left out and named under the list. Home starts ticked. What is ticked when you press Show becomes the board's pages. Pages in the top bar opens the same picker later, to add or take away pages; a path typed under the list (such as /store) adds any page by hand. A page taken away can be brought back with Undo, notes and all.
  • The project's server runs by itself in the background. Liesel reuses a server already running from the project's folder; otherwise it runs the dev command from package.json (dev, then start, then serve, with the project's package manager), installing the dependencies first when node_modules is missing, and on a free port when the usual one is taken. A folder with no dev script but HTML files is served by Liesel itself, with nothing installed. A server that stops by itself is started again, a few times, a little later each time. The frames show a quiet spinner until the real pages answer, and nothing about the server while it works; only when it truly fails do they say so in one sentence, with Retry. Settings → Project → Advanced (folded) has the server's state, Start, Stop and Restart, the command, the port and the output. Type another command there, or give only the port of a dev server you run yourself. A Stop holds when you reopen the board, until you press Start or Liesel restarts; the server keeps running while the board is closed and reopened, and closing Liesel stops a server it started.
  • A note on a live page carries the page's path and the element's selector, so the agent edits the project's own source files. The page hot-reloads through the dev server as files change; a page that does not reload itself is reloaded by the board after a save. Each settled change is kept as a History round (the page as rendered, without scripts).
  • One dev server per project, on this machine only (localhost), with no login flows. The dev server command and port are stored on this machine, in the state folder, never in the repository.

Reviewing a board

  • Cmd - tap to turn notes on; click any element to pin a note to it; tap again to turn notes off.
  • Option - tap to turn the controls selector on; click an element to tune its colour, type, spacing and more in the sidebar's Controls tab.
  • Space - hold and drag to move around the board. Two-finger scroll pans, pinch or Cmd+scroll zooms.
  • Tab - switch the sidebar between Chat and Controls.
  • Drop an image onto a picture in a design to replace it, or drop an HTML file onto the board to add it as a new design.
  • History - in the top bar, step back through earlier builds of a design and compare one side by side with the current version.
  • Duplicate (Cmd-D) - copy a design to try changes on it and compare: from its label, or the top bar for the design whose label you last clicked.
  • Send sends every queued note to the agent at once; Accept next to a version tells the agent which one you chose.

How the agent loop works

Any agent that can run a shell command can work the loop; LIESEL.md spells it out for the project:

liesel board open board                                   # prints the board URL
liesel poll board                                         # waits, in the foreground, for the reviewer's notes
liesel poll board --agent-reply "Tightened the header."   # answer, then wait again
liesel reply board --agent-reply "Done for now."          # answer without waiting

Every command ends its output with a next_step that tells the agent exactly what to do next, so LIESEL.md and the skill below stay short and never go stale.

Optional: the Liesel skill

For agents that load Agent Skills (Claude Code, for example), install the skill so the agent reaches for Liesel on its own:

npx skills add GeorgeGally/liesel --skill liesel

It is a short stub that sends the agent to liesel --help, liesel board --help, liesel design and liesel playbook for current instructions. Add -g to install it for every project. liesel setup hooks (session-start context for Claude Code, Codex, OpenCode and Copilot CLI) and liesel setup plugin (VS Code, Cursor and Copilot CLI) are further opt-in integrations.

From source

git clone https://github.com/GeorgeGally/liesel.git liesel
cd liesel
pnpm install --frozen-lockfile
pnpm run build
pnpm link --global

Settings

Liesel's server listens on 127.0.0.1:4747 and keeps its state in ~/.liesel. LIESEL_PORT, LIESEL_HOST and LIESEL_STATE_DIR change them; every other setting is a LIESEL_* variable described below. LIESEL_NO_OPEN=1 stops Liesel opening a browser window.

Board mode

A board is a folder your agent fills, not a design tool: board.json plus one HTML file per item. Every item renders as whole-page artboards - desktop 1440, iPad 834, phone 390, and an ultrawide 3440 (a 3440x1440 monitor) - at those real viewport widths, each as tall as the page's own content. Name files NN-name-today.html and NN-name-proposed.html (more proposals as -proposed-b, -proposed-c) and the board shows them side by side under a short explanation taken from each file's <meta name="liesel-caption"> (or liesel-explain, plus an optional liesel-title). The All view shows one design per row, without the ultrawide until Show ultrawide in Settings adds it (remembered per board in this browser); Ultrawide, Desktop, iPad and Phone show that device only, in a device frame, packed in rows, and scrolling over a device scrolls its page. A note records the width it was made at, so a note on the ultrawide reaches the agent with viewport 3440. Beside each version's name, Accept (or Keep today) tells the agent at once which one you chose and at which size, marks it with a check, and Take back withdraws it. A new version grown from an existing one sits in a row directly under it: name its parent with <meta name="liesel-parent" content="02-why-card-today">, or the board picks the version with the same NN- prefix whose name it shares most. <meta name="liesel-changed" content="..."> adds a What changed line under the explanation.

The board follows the folder while you look at it: when a file changes, only that design's frames reload, the camera stays put, and the design is marked Updated just now until it has been on screen for a moment. The file's own bytes decide that, so a rewrite that puts the old modification time back still reloads. An agent reply links to the designs that changed since the previous reply, and clicking a link frames that design. Each item carries a line of builds under its name: one dot per change to its file, and a labelled tick when the file declares <meta name="liesel-build" content="Round 2">. History in the top bar (for the design whose label was last clicked, else the first on the tab) or any dot opens a strip of thumbnails along the foot of the board, oldest first and the current file last, each with its time and the notes that led to it. Clicking one lays that build out beside the current version at the same device widths, read-only, so the two can be compared in any device view; Continue from here hands the agent the snapshot to build on, so a round is a point on one item's line rather than another object on the board. Snapshots, the stylesheets they linked, and their thumbnails live in .liesel-builds/ inside the board folder. Thumbnails need a local Chrome, found the same way as for a pin's picture below.

Duplicate on a design's label (always shown, beside its width), in the top bar (for the design whose label was last clicked), or Cmd-D (with that design picked, or from inside a focused design, never while typing in a field) copies its file to <name>-copy.html (then -copy-2, ...) in the board folder and adds it as its own item labelled " copy". The copy keeps the design's tune values, element tunes and image drops, but not its notes or history; it stands right beside the original on the same tab and is locked to it, so in a device view the two scroll together, and they zoom together with the board. Changes to the copy never touch the original, and a note on it reaches the agent naming the copy's own item id. Only a copy shows Rename (or double-click its name) and Delete; Delete moves its file into .liesel-trash/ inside the board folder, and Undo in the message that follows puts the file and the item back.

The board's conversation is part of the folder too: every note and every agent reply is mirrored into .liesel-history.json beside board.json, so reopening a board days later still shows the full thread and the answered state that retires its pins, even after a restart.

An agent that is about to make a new version can say so with liesel reply <board> --agent-reply "On it." --pending <item-id> (or a [[pending: <item-id>]] line in the reply, which never shows): a holder with a loader and your note appears under that item until a file with that parent appears, and [[pending-cancel: <item-id>]] removes it.

A pin note tells the agent what it points at: the element's visible text, the heading of its section, and a picture of that spot (the element plus a 40px margin, at the device's width) delivered as an image attachment path. The picture needs a local Chrome (found in the usual places, or set LIESEL_CHROME); without one, notes go out without it.

The notes panel is a conversation. Each note is a thread headed by its pin number, design and a small picture of the spot; the agent's answers sit under the note they answer, a "replying" line shows until one arrives, and replies appear without a reload (with a count on Notes while the sidebar is hidden, or on Chat while Controls is showing). A note's stage reads Sent, Received, Applied in that order, and Applied only once the item's file has changed since the agent read it - a reply alone is an answer, not a fix. In a thread, Reply adds to the note, Edit note corrects it and Delete note withdraws it after one confirmation; deleting removes the instruction from the board and never reverts the change it asked for, which the confirmation says. Each reaches the agent as a prompt carrying the thread id and followup: reply | edit | deleted, and the same actions open from a sent note's pin. Accept and Keep today form a thread per design. An agent reply joins the threads of the notes it settles; to answer one thread, liesel reply <board> --agent-reply "..." --thread <thread> (also on poll). Set LIESEL_AGENT_NAME to name the agent in the panel.

A note the agent has answered leaves the design entirely: its pin moves out of the artwork into the board gutter just left of the frame, at the height of what it was about, dimmed to a grey that never covers the work. Pins at one height stack downward instead of on top of each other, and in a device view a pin whose spot has scrolled out of the screen waits until it is back. It stays in the thread list and still opens its thread from the gutter, so what was answered and where is still findable. Show old notes in Settings hides or shows these parked pins, remembered per board in this browser; open notes always stay on the design.

Nobody has to keep liesel poll running for a board to get answered. Open Settings (the gear at the end of the top bar; a full page over the board, with Back to board, Escape or the browser's Back to return) and pick the agent that answers this board's notes: Claude Code (claude -p), Codex (codex exec), OpenCode (opencode run) or Gemini CLI, whichever are installed on this machine (only those are offered), or a Custom command. Pick the working folder too; it defaults to the board's own folder, and the agent can edit files there. From then on, every send starts that agent headless in that folder with a prompt holding the notes (each note's item and its file, pin, selector, text and image paths) and the instruction to edit the items' HTML. When it exits, its final message is posted in Chat as the reply and the notes it handled read Done. One run per board at a time: notes sent during a run wait for the next one, which starts as soon as the run ends. Chat shows a line while the agent works or after it fails, its Output link opens Settings, which shows the run's state and its live output, Stop ends a run, and a failed or stopped run puts its notes back on the queue for Run again, the next send, or a poll. A custom command runs through /bin/sh -c in the working folder with the notes on stdin and in the file named by LIESEL_PROMPT_FILE, plus LIESEL_BOARD, LIESEL_BOARD_DIR, LIESEL_BOARD_JSON, LIESEL_BOARD_ID and LIESEL_NOTE_COUNT; what it prints becomes the reply. Note text never reaches a shell line: named agents are started from an argument list with the notes on stdin or as one argument. The choice is kept per board in ~/.liesel/board-runners.json (or LIESEL_STATE_DIR/board-runners.json) on this machine, never in board.json, so a shared board folder cannot bring a command with it, and nothing runs until you pick an agent. A run is stopped after 20 minutes (LIESEL_RUNNER_TIMEOUT_MS changes that). An agent already waiting in liesel poll on the board still gets the notes first, so agents that drive their own loop work as before.

Chat apps and agents that speak the Model Context Protocol can use liesel mcp, a stdio MCP server. Register liesel mcp as the server command; its tools are liesel_board_open, liesel_board_status, liesel_poll (waits up to timeout_seconds, default 50, and returns waiting when nothing arrived, so call it again) and liesel_reply. Run liesel mcp --help.

To hear about board feedback without keeping a poll open, start the server with LIESEL_NOTIFY_CMD set to a command, or put {"notify_cmd": "<command>"} in ~/.liesel/config.json (or LIESEL_STATE_DIR/config.json), which a running server reads on each note, so no restart is needed. Every note queued on a board (sent from the composer, a pin, tune Send, an image drop, the queue, Accept or Keep today) runs it after the note is queued, with the board name, the board.json path and a submission id as its three arguments and as LIESEL_BOARD, LIESEL_BOARD_JSON and LIESEL_SUBMISSION_ID, plus LIESEL_BOARD_DIR (the board's directory), LIESEL_BOARD_ID (its session key) and LIESEL_NOTE_COUNT (notes waiting for the agent). The note text is never passed. It runs at most once per board every 3 seconds: the first note runs it at once and a burst after it runs it once more when the 3 seconds are up. The board never waits for it, a failure only reaches server.log, and liesel poll still collects the feedback. Unset, nothing runs.

Four gestures own the pointer over a design, and only one at a time: hold Space for the hand and drag the board, tap Option to turn the controls selector on (a click then loads the element under the pointer into the sidebar's Controls tab), tap Cmd to turn notes on (a click then leaves a note), and with both off the click belongs to the page itself. Notes and the controls selector are toggles: a lone press and release turns one on, the next one turns it off, and turning one on turns the other off. Escape turns either off once no note or history is open. Cmd or Option used in a shortcut (Cmd+C, Cmd+Z, Cmd+click, Cmd+scroll, Option+drag, or a system one such as Cmd-Shift-3), joined by any other modifier, or held longer than a quick tap never flips a mode, and switching apps (Cmd-Tab) neither flips nor sticks one. The mode that is on shows in the top bar, with its own cursor over the designs. The keys work wherever focus is, including inside a design after a click into it, and switch the moment they are let go, with no pointer move needed; a space typed into a design's field or the composer stays a space. Two-finger scroll pans, pinch or Cmd+scroll zooms around the pointer, and double-clicking a design zooms to 100% there (again to fit). Fit (0) and 100% (1) sit in the top bar.

liesel board open hero-directions --name "Hero directions"
liesel board add hero-directions v5.html --label "Hero · E"
liesel board place hero-directions v5 --x 640 --y 0
liesel board pair hero-directions v1 v3     # locked: they move together
liesel poll hero-directions                 # the agent waits here

board.json carries only where an item sits, which tab it is on, what you said about it, its tune values, the images dropped onto it, and the line of builds of its file - never markup or a copy of an item's file:

{
  "format": 1,
  "name": "hero directions",
  "tabs": [{ "id": "hero", "label": "Hero" }],
  "items": [
    {
      "id": "v1",
      "label": "Hero · A, dense",
      "file": "v1.html",
      "tab": "hero",
      "x": 0,
      "y": 0,
      "w": 420,
      "h": 320,
      "status": "open",
      "notes": [],
      "tune": { "radius": "12" },
      "elementTune": {},
      "imageDrops": [],
      "builds": [{ "at": "2026-10-31T09:12:00.000Z", "hash": "41bfbf6b2c61", "label": "" }],
      "versions": []
    }
  ],
  "pairs": [{ "a": "v1", "b": "v3", "locked": true }]
}

Tap Cmd to turn notes on: hovering a design outlines the element under the pointer, and a click drops a numbered pin there with a note card. Send now (Enter) sends that note at once, Queue keeps it as a draft in the notes panel to send later with Send, and clicking away keeps a written note as a draft and discards an empty one. Remove appears only on a note that was already saved. Paste (Cmd+V) or drop PNG, JPEG or WebP images into a note or the message box; they show as thumbnails and reach the agent as local file paths with the note. Drag an image file from Finder straight onto a picture in a design and it replaces that picture: an <img>, an <img> inside a card, carousel or link, or an element painting a CSS background. The element keeps its size, crop and object-position, so nothing on the design moves. The file is copied into the board folder under assets/drops/ and recorded on the item in board.json, so the swap survives a reload; Undo in the line that appears puts the original source back in one click, and the agent gets a note naming the item, the selector, the old source and the new file so it can carry the change into the real build. A file dropped where there is no image changes nothing.

The notes panel down the right lists what you sent, grouped under the design it is about, with the agent's replies in between. A note reads Not read yet until the agent's poll has taken it, then nothing. Each note reaches the agent with the item, the pin number, and the viewport width it was made at; a sent pin is filled, a draft pin is an outline.

Tuning

An item declares its own controls in a application/liesel-tune JSON array, binding each control to a CSS custom property the page already uses:

<script type="application/liesel-tune">
  [
    { "id": "radius", "label": "Card radius", "type": "number", "css": "--radius", "min": 0, "max": 24, "step": 1, "value": 6, "unit": "px" },
    { "id": "accent", "label": "Accent", "type": "color", "css": "--accent", "value": "#b7872f" },
    { "id": "density", "label": "Density", "type": "select", "css": "--density", "options": ["cozy", "compact"], "value": "cozy" },
    { "id": "spring", "label": "Spring", "type": "spring", "css": "--spring", "value": { "stiffness": 180, "damping": 20, "mass": 1 } },
    { "id": "curve", "label": "Easing", "type": "easing", "css": "--curve", "value": "cubic-bezier(0.25, 0.1, 0.25, 1)" },
    { "id": "position", "label": "Position", "type": "pad", "css": "--position", "value": { "x": 0, "y": 0 } },
    { "id": "progress", "label": "Progress", "type": "timeline", "css": "--progress", "min": 0, "max": 1, "value": 0, "duration": 2 }
  ]
</script>

The tune controls live in the sidebar, which has two tabs at its top, Chat and Controls. Click a tab or press Tab to switch (in a text field Tab keeps its usual meaning); tuning something switches to Controls and opens a hidden sidebar, and sending a note or a reply switches back to Chat, with a count on Chat for replies that arrived meanwhile. Controls writes those properties live, saves named versions with thumbnails, and Send hands the agent only the properties you changed, as a note on that design - the same channel annotations use. Copy still produces the CSS itself. Controls names what it tunes in plain type, with hairline sliders, right-aligned tabular values, and a colour swatch that opens the picker; Escape clears it. The spring and XY pad write JSON objects to their custom properties; page scripts can parse them from getComputedStyle(document.documentElement).getPropertyValue(...). The board dispatches an liesel:tune event on the item window after applying values, so scripts can respond live; its detail maps CSS property names to values. The easing editor writes a CSS cubic-bezier(...) value. The timeline plays or scrubs a linear value from min to max over duration seconds and writes the current value, with an optional unit. These controls use DialKit's vanilla editors and timeline engine locally. Every item has a tune button. When it declares no controls, Controls is built from the page's own CSS custom properties: colours become swatches and pickers, lengths, radii, spacing and font sizes become sliders, grouped and labelled from the property names.

Tap Option to turn the controls selector on, then click any element in a design to tune that element itself: colour, background, type size, weight, tracking, line height, padding, margin, width, height, radius, border and shadow, read from the page and written back as inline style. Only properties that can show on that element are offered, and Copy returns only the values that differ from the page's own. What you tune is saved on the item per selector, so clearing Controls or reloading the board keeps the design as you left it; Reset puts the element back to the file's own values, and Send notes the agent with only the properties that changed.

A breadcrumb at the top of Controls runs from Page down to the clicked element; click a crumb to tune that element, and hover one to outline it in the design. Page offers the whole page's background, base font, base size, max width and side padding. Below the element's own controls, collapsed Inherited from groups (nearest first) hold the inherited properties - font, size, weight, style, line height, tracking, colour, alignment, case - that an element it sits in sets and it still takes; a change there tunes that element, live, saved and undoable like any other. A box's text control notes the text inside that sets its own value, so the change will not reach it, with a jump to that element.

An element that repeats - the same block in every card of a rail - offers This one or All like this (N), outlined in the design; All is the default, and Match the others takes one element's own changes off so it looks like the rest again. A change shows at All sizes by default, live in every device frame; only keeps it to the frame it was picked in, and the panel notes a value a size keeps of its own. Send and Copy write a size-only change as a media query for that device's width range. A pick whose selector would name another element tunes nothing and says so.

With the controls selector on, Up or [ widens what a click picks to the element it sits in and Down or ] narrows it back, with the outline following; near a section's edge the selector prefers the section. With the selector off, the same keys move the tuned element along its breadcrumb.

The selector stays on for the next element until Option or Escape turns it off. The pick is read from the press itself, and the frame swallows an Option press outright: a link is never followed, downloaded or selected instead of tuned.

How It Works


While Controls holds a design or an element, **Cmd-Z** undoes the last tune change and **Cmd-Shift-Z** redoes it, one step per finished drag, colour pick or select change (an inherited, shared or size-only change, **Reset** and **Match the others** included), from the sidebar, the board or inside a design; a text field keeps its own undo.
┌───────────────┐
│ Agent writes  │
│ artifact.html │
└───────┬───────┘
        ▼
┌────────────────────────┐
│ liesel <file_path> │
│ opens local browser UI │
└───────┬────────────────┘
        ▼
┌────────────────────────┐
│ Human annotates text   │
│ or elements, sends     │
│ chat, or queues layout │
│ issues from the inbox  │
└───────┬────────────────┘
        ▼
┌────────────────────────┐
│ liesel poll waits  │
│ and returns prompts    │
│ the user queued        │
└────────────────────────┘
  • File-path identity - Sessions are keyed by the canonical HTML file path, so agents do not need opaque IDs.
  • Portable artifacts - The artifact runs in a sandboxed iframe while Liesel injects a small SDK for annotations, snapshots, feedback controls, and render-time layout checks. Author-defined links and popups can open in top-level tabs, while artifact documents remain sandboxed without same-origin access. Liesel does not inject any design system, so the saved HTML file renders identically whether you open it through liesel or directly in a browser. Run liesel design for the single source of agent-facing design guidance, including optional CDN snippets and the whiteboard (Mermaid) opt-in snippet.
  • Self-paint warning - liesel <html-file>, export, and share run a render-free check for artifacts missing an explicit page background and return a one-line self_paint_warning. The check fails open - any stylesheet link, @import, Tailwind runtime script, color-scheme, or html/body/:root background signal suppresses it - and it never blocks the open.
  • Open-time layout gate - The browser chrome masks an artifact only while the real in-iframe audit waits for fonts and final geometry. The first completed client-side check reveals the artifact, whatever it found and even if reporting that check to the server fails; the gate never holds the review hostage waiting for a repair or a network round-trip. The user can click Show anyway, and a bounded safety timeout fails open from every gate state. If the review cannot load at all - the chrome's own script never runs, or the server does not answer the artifact's load request after several retries - the mask names the problem and offers Check and reload without removing the independent Show anyway escape. A review already loaded in another browser tab is named the same way, with a Take over here button that loads the artifact in the current tab, because Liesel loads an artifact in one tab at a time. The takeover warning reads "Unsent answers stay in the tab or window where you entered them; taking over in another window does not bring them along."
  • Layout issues inbox - Detection is passive. After fonts and finite animations settle, the injected SDK confirms severe failures from direct rendered evidence such as materially escaped meaningful content or required controls, clipped text fragments, viewport reachability, or near-total semantic occlusion. Explicit ellipsis and line clamp, standard visually hidden accessibility text, intentional scrollers or masks, parent overhang, generic element scroll geometry, decorative overlap, and uncertain motion do not produce findings by themselves. Proven failures are filed in a Layout issues button in the top bar, which is hidden while nothing is unresolved and otherwise shows the unresolved count. Its drawer lists each issue with severity, a plain-language explanation, the affected viewport, the target/component identity, when it was last seen, and its lifecycle state, plus per-issue Reveal (highlight it in the artifact) and Dismiss actions. Nothing is selected by default. The user picks issues (or Select all) and Queue selected fixes turns that whole group into one ordinary queued prompt, tagged layout-warnings, that reaches the agent through the normal feedback path when they send. Detection never returns liesel poll and never wakes an agent; only the user queueing a fix does. The one exception is a fatal artifact_failures response, for failures that make the review itself unusable, such as the artifact document or one of its own local assets failing to load.
  • Layout issue lifecycle - Each issue is identified by a stable fingerprint of the diagnostic rule, the normalized target identity, and the viewport class, so repeat detections update one record instead of inflating the count. Open means the latest completed check for its viewport still detects it. Queued for fix means the user asked for a repair - it stays unresolved and counted, and cannot be queued again while that request is outstanding. Resolved requires a newer successful artifact load plus a complete check at the same viewport that no longer detects it; it then leaves the count but keeps a bounded history. Still present (recurring) means a queued issue survived a newer revision, so it is selectable again with its earlier attempt retained. Unverified means a reload or check failed or was incomplete, so the prior issue was preserved rather than cleared. Returned means a resolved issue came back on a later revision. Dismissal applies only to the current artifact revision; a later revision surfaces the issue again if it is still detected. A check at one viewport never clears an issue found at another, and a viewport removed from the configured diagnostic set (LIESEL_DIAGNOSTIC_VIEWPORTS, default all) is marked obsolete with an explicit reason rather than reading as fixed.
  • Revisions legend - Opt-in. After a round of feedback the agent regenerates the whole file, and a one-sentence edit is invisible on a long artifact. The agent can declare what it changed in the artifact itself: one <script type="application/json" data-liesel-revisions> array of { id, label, timestamp, summary } entries, plus data-liesel-revision="<id>" on each block it edited or added. Run liesel design for the snippet. A Revisions button then appears in the top bar with a legend listing each round - colour swatch, label, time, and the agent's summary - and Reveal steps through that round's changed blocks, flashing them one at a time. Each revision's swatch carries a distinct border style and fill pattern as well as a colour, so the rounds stay distinguishable without colour. Artifacts with no registry show no button and are otherwise unaffected, a malformed registry is ignored rather than surfaced as an error, and at most 6 revisions are listed - one per distinguishable swatch. Liesel never marks up the artifact for this: the legend lives in the browser chrome, so the served page stays identical to the saved file, and the registry and marks travel with the file through export.
  • Live tab title and icon - Opt-in. The browser tab adopts the artifact's <title> and first <link rel="icon"> when it loads. A long-lived artifact can keep the tab current afterwards with window.liesel.setDocumentMeta({ title, icon }): a string sets the field (a title that is blank after trimming restores the load-time title), null restores the load-time value, and an omitted field is left alone. The title is trimmed to 120 characters and shown as <title> · Liesel; the icon must be a data:image/svg+xml URL of at most 8192 characters. Anything else is ignored. A value stays until the next call or until the tab itself reloads, so call again after each page update. The call is a no-op when the page is not open in Liesel, so feature-detect with window.liesel?.setDocumentMeta?.(...).
  • Local assets - Copy local images, CSS, fonts, and scripts next to the HTML artifact and reference them with relative paths from that directory; root-prefixed paths such as /assets/logo.png will not resolve through Liesel's artifact route.
  • Export and sharing - liesel export writes <name>.export.html by inlining local assets only, stripping the annotation SDK, and leaving remote CDN/font references as links that still need network access. liesel share publishes the same local-inlined HTML to ht-ml.app, a third-party hosting service not part of Liesel. Publishing sends the artifact to ht-ml.app's servers, public by default, or private and password-protected with --private (Liesel generates the password and returns it once, in the command output and in the browser publish dialog) or --password <pw> (one you supply, never echoed back). A generated password is a shared secret: give it to whoever should read the page, and expect it to appear in your agent's transcript, because the agent has to relay it. Liesel does not store it. The response includes a secret update_key shown once. Keep it to republish the same URL later with --site <site_id> --update-key <key>, which replaces the HTML in place and leaves the password alone unless you pass --private (rotate to a new generated one) or --password <pw> (set one). Locking a page that was public is not instant - it can keep answering from ht-ml.app's CDN cache for minutes after the password is set - while a page that was already private has no such cached copy. A page's password cannot be removed: ht-ml.app accepts a request to clear one and silently ignores it, so Liesel offers no way to make a private page public rather than telling you it did something the host did not do. Republish as a new page if you need a public URL. ht-ml.app has no delete endpoint, so --unpublish --site <site_id> --update-key <key> replaces the page with a short placeholder and locks it behind a discarded random password rather than removing it: the URL still resolves and the host still holds what was published. The content swap is immediate, but the lock is not: a page that was public can keep serving the placeholder to uncredentialed visitors from ht-ml.app's CDN cache for minutes afterwards. The update_key plus --private republishes real content behind a new password you can share. Bundling never fetches remote URLs, Liesel itself does not set a CSP, local reads stay confined and size-capped, and absolute file:// paths outside safe inlined asset references are redacted before output. Per-asset and per-bundle inline caps default to 10 MB and 25 MB, overridable with LIESEL_EXPORT_MAX_ASSET_BYTES and LIESEL_EXPORT_MAX_BUNDLE_BYTES. Unresolved local assets or export notices such as author-set CSP meta tags and redacted file URLs are surfaced in command or browser output. Use --token or LIESEL_HTML_APP_TOKEN for an optional bearer token when publishing a new page (a republish or --unpublish authorizes with the update_key instead and rejects --token); set LIESEL_HTML_APP_API_URL to override the ht-ml.app API base and point share at a backend you control (see Self-hosting the share backend for the contract it must implement).
  • Live reload - Liesel watches the HTML artifact file by default and preserves review context across reloads: the artifact iframe scroll position, an open annotation card's unsent text, and answers to data-liesel-question controls (application-owned form state is left alone). Supported review state and the unsent queue are saved in that tab's sessionStorage and survive a full reload of the review page in the same tab when browser storage is available. They are not shared with another tab or window; closing the owning tab can lose work that has not been sent. While a queued layout-issue batch is outstanding, closely spaced saves coalesce so one batch of fixes costs one refresh. To also reload on sibling asset changes, add data-liesel-live-reload-root to the root element or <meta name="liesel-live-reload" content="root">. If the element an unsent annotation was attached to is gone from the artifact for good, Liesel cannot reopen that card, and if the artifact replaces a queued note while you are editing it, the replacement is kept as the artifact sent it. Either way, Liesel writes your edited text into the conversation panel under Unsent annotation - selectable, never written over anything you have typed, and kept there across reloads; no note is ever dropped to make room for a newer one, and a note the browser refuses to store says so where it is shown.
  • Feedback controls - Native controls (radios, checkboxes, inputs, selects, buttons, labels, disclosure summaries, contenteditable) and custom widgets with an interactive ARIA role (button, checkbox, combobox, menuitem, menuitemcheckbox, menuitemradio, option, radio, switch, tab, treeitem) are interactive automatically, so they do not need data-liesel-action. A link (<a href>) stays annotatable when it carries one of these roles or sits inside such a widget. Artifacts can start in explore mode with data-liesel-annotate="off" on the root <html> element so stray clicks do not open annotation cards. Annotation stays on by default (or with "on"); unknown values default to on. The toolbar reflects the page default and can override it; the reviewer's choice persists across artifact reloads within that review page. See liesel playbook input for author guidance. For reversible choices, let option clicks update local state, then queue exactly one final answer from a per-question submit or Queue answer button with window.liesel.queuePrompt(). If someone may open a decision artifact as a standalone HTML file, liesel playbook input provides an optional Copy all answers control they can use to paste their form answers back into chat. Mark only custom (non-native) clickable elements with data-liesel-action so Liesel does not annotate them, and use data-liesel-question or queueKey when pre-send updates for the same question should replace each other. Everything you send is part of the conversation: a note on an element, a text selection, a table cell, a diagram node, a whiteboard, a layout-issue batch, or a composer message each becomes a bubble on your side of the Conversation panel, with an anchor line naming what it was attached to (the element tag, text selection, table cell, diagram node, whiteboard, or layout issues), a quoted excerpt, and, when available, the selector on hover. A note you have queued but not sent sits at the end of the conversation as a dashed bubble labelled Queued with edit and remove controls. Clicking the bubble or its edit control opens its words for editing in place and scrolls the artifact to the element it points at; clicking that element again in annotate mode opens the same note rather than a fresh card. Saving a note with no words and no images removes it, and Send delivers an open edit as typed. While its batch is in flight it reads Sending and can no longer be edited, and once the server accepts it, it settles in place as a sent bubble in every tab of the session. The panel keeps the newest stored transcript up to 5 MiB; older bubbles drop from view, but an accepted note still settles and is not sent twice. Agent replies render a small Markdown subset - paragraphs, line breaks, headings as bold lead lines, bullet and numbered lists, fenced and inline code, bold, italic, and http(s) links - while tables, images, and raw HTML stay as the text the agent wrote; your own messages keep their line breaks and are never parsed. On wider screens, the conversation and the queued notes share one scrollable Conversation panel above a sticky composer, so long feedback queues do not push the text box or send controls off screen. The sidebar button in the top bar hides that panel so the artifact takes the full width, and shows it again; while it is hidden the button counts queued notes or marks an agent reply you have not seen, and the choice is remembered in this browser for every review. The browser chrome keeps editing actions in the overflow menu (copy path, reload artifact, copy DOM snapshot, export standalone HTML, publish link, end session), while the composer exposes Send & End beside Send to Agent to submit queued prompts and user-ended attribution together. Once Send & End starts, Liesel pauses new feedback, waits up to 5 seconds for feedback preparation already underway, keeps any open annotation draft intact until delivery succeeds, then holds that exact terminal batch. A completed reservation survives a page reload, including when its prompts were already delivered and only the pending end remains; incomplete preparation is not restored. If preparation times out, Liesel keeps the review open and its existing queue editable with a visible error. A transient delivery failure permits only retrying the same terminal batch through Send & End until it succeeds. If a snapshot makes the request too large (HTTP 413), Liesel retries that exact batch once without the optional snapshot. An actionable attachment rejection (HTTP 400), recoverable layout-selection conflict (HTTP 409), or unrecoverable 413 instead cancels the terminal reservation without ending the session, keeps the prompts queued, and restores editing, removal, Send to Agent, Send & End, and End session so the reviewer can revise or remove stale feedback before submitting again. Unsent queued notes belong to the tab where you entered them, not to a server-shared draft. Empty Send to Agent reports "Nothing queued in this tab." followed by the same message: "Unsent answers stay in the tab or window where you entered them; taking over in another window does not bring them along." Annotate mode alone does not queue a note, so use the annotation card's Queue action first. Only feedback accepted by Send is stored on the server for delivery to the agent. Composer feedback is visibly queued before the text box clears and stays queued until acknowledged. Send waits up to 5 seconds for the artifact's DOM snapshot, then delivers without that optional context if the frame no longer answers; a late snapshot cannot submit the batch twice. A failed or still-unacknowledged POST keeps the queue and shows persistent recovery guidance unless a transcript sync proves the server already accepted that feedback.
  • Reviewing on a phone - Below 860px wide, the artifact takes the whole screen above a Conversation dock, and the conversation opens as a bottom sheet over it: tap the dock, swipe it up, or press the chevron to raise it; tap the dimmed artifact, swipe the sheet down, press the chevron, or press Escape to lower it. The dock reports what matters while the sheet is down - how many prompts are queued, a reply that arrived while you were reading, or whether the agent is listening - and the sheet stays open across a reload of the review page. The sheet sizes itself to the visible viewport and respects safe-area insets; if the keyboard or attachments leave little room, conversation content yields or scrolls while the send actions remain pinned above the bottom edge. In landscape the sheet covers the top bar as well. Wider screens keep the side-by-side layout.
  • Keyboard shortcuts - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline. In the annotation card, Enter queues the annotation, Shift+Enter inserts a newline, and Ctrl+Enter (Cmd+Enter on macOS) queues it and sends all queued prompts immediately. Escape closes the card, same as Cancel, but only while it is empty (no text, no attachment); with unsent text or an attachment present, Escape does nothing rather than risk discarding it. While editing a queued note, Enter saves it, Shift+Enter inserts a newline, and Escape discards the edit. Cmd+I or Ctrl+I toggles between annotate and explore mode from either the browser chrome or the artifact iframe, including while focus is in a textarea or control. In annotate mode a plain click on a link annotates it; Ctrl+click (Cmd+click on macOS) follows the link instead, opening it in a new tab as the browser normally would, without leaving annotate mode. Off macOS, Meta+click (the Windows or Super key) annotates like a plain click.
  • Agent presence - The browser shows when no agent is listening and keeps human feedback actions available while the agent is working. Feedback accepted by Send is queued on the server for the next successful liesel poll delivery even across reloads; never-sent notes remain in the originating tab. An agent reply concludes delivered work and returns presence to waiting (see the CLI Reference for reply and poll commands). A supervisor-owned process listener is shown as an external listener rather than an idle captain turn.
  • Delivery receipts - Every note you have sent carries three emoji under it that light up in turn: 👀 Seen when a liesel poll takes the note, 🛠️ Working when the artifact file changes after that, and ✅ Done when the agent replies. A step that has not happened yet is the same emoji greyed out. All three come from what the server observes; the agent does nothing extra. Until 👀 lights, the row says why: no agent is listening, or the agent is busy and the note is delivered on its next poll. A note answered without touching the artifact goes Seen to Done with Working still grey. A reply marks every note the agent has seen as Done, not notes still waiting for a poll. Each emoji names its step and time on hover and to screen readers. Notes sent before receipts existed show no receipt row. In a Herdr-managed pane, set LIESEL_HERDR_CHIME=1 to show a Herdr request notification and play its chime once an indefinite poll enters the waiting state. Immediate queued feedback and timed debug polls do not trigger a notification. The integration is off unless both LIESEL_HERDR_CHIME and Herdr's HERDR_ENV session marker equal 1; leaving either unset or using any other value opts out. Notification delivery runs without blocking feedback: if Herdr is unavailable, rejects the notification, stalls, or fails, the poll continues unchanged. Closing the last review tab while a poll is active starts a reconnect grace period, 10 seconds by default. A reload or quick reconnect keeps an active poll waiting; if every review stays disconnected, the poll returns browser_disconnected without ending the resumable session, and the agent asks whether to reopen or end it instead of acting uninvited. A phone that locks its screen or backgrounds the tab drops the connection too, so set LIESEL_DISCONNECT_GRACE_MS on the server to a longer millisecond budget, or to 0 or off so a disconnect never ends the poll (it then waits for feedback, an end, or its own timeout). Empty or invalid values keep the 10-second default. The no-timeout poll always writes an immediate stderr banner so it is visibly not hung; it adds the periodic stderr wait ticks only in an interactive terminal, so when stderr is piped (as under agent harnesses) the captured output carries no tick noise. Stdout always stays reserved for the final response; if the poll is interrupted or times out before feedback arrives, re-run it because feedback remains queued until delivery. Poll delivery consumes the response, so read the complete response before truncating or filtering it. Codex-specific guidance keeps that poll attached to the active turn instead of hiding it in a background task, because completed background tasks may not resume the agent.
  • Session end etiquette - Liesel tracks who ended a session: a human clicking End session (or Send & End) in the browser is a user-initiated end, while liesel end <html-file> is agent-initiated. When either side ends the session, every open review tab becomes visibly read-only and disables its feedback controls; feedback submitted after the end is refused instead of being accepted without an agent to receive it. A plain liesel <html-file> after a user-initiated end refuses to reopen the browser and returns guidance instead; pass --reopen only when the user asks for further review or something important needs their visual attention. Agent-initiated ends keep reopening normally, same as before. liesel poll's ended response and the feedback response for the final batch before an end both carry next_step guidance telling the agent to stop polling and deliver remaining updates in chat instead of reopening.
  • Precise targets - Text annotations include selected text plus range anchors, and text selections carry those anchors only. Clicking an element inside a table also carries the cell's visible row and column names alongside the exact CSS locator, so filtered or sorted rows do not make feedback look misdirected. When merged cells make either name ambiguous, Liesel leaves that name out rather than guessing; an explicit <th scope="row"> remains authoritative even when a rowspan makes the row's position ambiguous. The CSS locator still points at the exact element you clicked, so an annotation with an omitted name is only less descriptive, never mislabelled.
  • Image attachments - Attach reference images (PNG, JPEG, WebP) in either the Conversation composer or an annotation card by pasting, drag-dropping, or using its image picker; each image shows a thumbnail chip with upload, remove, retry, and error states. If a sent image is later evicted under the attachment storage limits, its transcript-history thumbnail becomes an Image expired placeholder. A paste containing both text and images preserves the text while attaching the images (a copied file's own name or path is treated as placeholder and not pasted). Conversation messages may contain text and images together or images only. Images are stored under the state dir and the queued prompt carries a server-generated absolute path and content-hash id (plus mime and dimensions) - never the raw bytes - so liesel poll hands the agent a local file path to open. Limits are LIESEL_MAX_ATTACHMENT_BYTES (default 10 MiB per image), LIESEL_MAX_ATTACHMENTS_PER_PROMPT (default 4), and LIESEL_MAX_PROMPT_ATTACHMENT_BYTES (default 25 MiB per prompt); if any image is missing or any prompt breaches a count or byte cap, the entire send batch is rejected, the queue is preserved, and the reason is surfaced in the composer rather than silently dropping images. As a browser-side abuse guard, each chrome page allows 30 upload attempts per rolling minute, 4 uploads in flight at once, and 256 MiB of attempted image bytes over its lifetime; rejected uploads stay visible for retry or removal. Unreferenced attachments expire after LIESEL_ATTACHMENT_TTL_MS (default 7 days; 0/off disables); LIESEL_MAX_ATTACHMENT_DISK_MB (default 512 MiB; 0/off disables) caps total attachment disk and may evict older unreferenced images sooner. The cap is enforced when an image is uploaded, not just periodically: the upload first reclaims unreferenced files (oldest first, and never one added within the last hour), and if it still would not fit, that upload is refused with a storage-full error on its chip instead of discarding an image the user is about to send.
  • Mermaid diagrams - Whiteboards are an opt-in: agents author a diagram as Mermaid only when you ask for an editable whiteboard, and hand-authored inline SVG illustrations are the default figure medium otherwise. In the Liesel browser, every rendered Mermaid diagram in a .mermaid container becomes an embedded editable Excalidraw whiteboard. Click a diagram to unlock editing, and use its Fullscreen action to edit it over the whole viewport. Whiteboard scenes autosave locally. If a live reload changes the Mermaid source, an unmodified whiteboard silently re-converts to the new diagram. If the reviewer had edited the scene, reopening it lets them re-convert and discard the saved edits or keep editing the saved scene. Use Queue feedback to add a bounded edit summary plus local .excalidraw scene and PNG preview paths to the Conversation panel, then click Send to Agent to deliver it. The agent updates the artifact's Mermaid source, which remains authoritative. Flowchart, sequence, class, ER, and state diagrams convert to editable shapes; other diagram types are images that reviewers can draw and annotate. Liesel changes only the browser view, so saved, standalone, and exported artifacts still render plain Mermaid.
  • Whiteboard libraries - Copy Excalidraw library files (.excalidrawlib, for example from libraries.excalidraw.com) into ~/.liesel/excalidraw-libraries/, or LIESEL_STATE_DIR/excalidraw-libraries/ when set. Every whiteboard's library panel lists their items, and an item you drop on the canvas reaches the agent by name in the edit summary. Agents run liesel libraries to find item refs and can show a flowchart node as one of those icons by adding a %% liesel-icon <node-id> <library-id>/<item name> comment to the Mermaid source. Mermaid ignores the comment, so saved, exported, and shared copies, and whiteboards missing that library, show the plain node. Liesel only reads that directory and never downloads libraries. Files over 10 MB, more than 25 MB or 5,000 items in total, and unreadable files are skipped; liesel libraries lists each skip with its reason. Each library carries its author's terms (AWS Architecture Icons, for example, are covered by AWS's own usage terms), so check them before using one.
  • Server cleanup - The detached server stops after the last session ends when nothing is connected, or after LIESEL_IDLE_TIMEOUT_MS (default 30 minutes) with no browser or poll connections. Set LIESEL_IDLE_TIMEOUT_MS=0 or off to disable idle self-shutdown.
  • Server upgrades - One background server serves every session, so upgrading liesel while reviews are open makes the next liesel <html-file> replace that server. Only a newer CLI replaces an older server: a CLI older than the running server uses it as is, so two installed versions on one machine do not restart each other's server. Releases up to and including 0.1.86 still replace a server of a different version, so this turn-by-turn restart stops only when both installed versions contain this fix. Only the review page for the artifact being opened reloads itself once the replacement answers - and not even that one while you have unsent annotation text open, which gets the same banner instead so the reload is yours to make. Every other open review page keeps working and shows a banner reading "Liesel was updated. This page is running the previous version.", with a Check and reload button and a Dismiss button, so no page you are reading reloads on its own. After liesel stop those pages say Liesel was stopped and to reload after you start it again, and a restart that only picks up a local build says that rather than claiming an update. An open review page whose server simply goes away - idle self-shutdown, a crash, a machine that slept - shows that same banner once reconnecting has failed for a few seconds, rather than retrying a dead server silently while looking fine; it clears itself if the server comes back, and dismissing it keeps it dismissed until then. A page still using the legacy event stream reloads once when it reconnects to the replacement server, moving itself onto the current transport without retaining an HTTP connection. If unsent annotation text is open, it shows the neutral server-restarted banner instead and waits for you to reload. Every Check and reload control asks the server whether it is running before it navigates; while nothing answers, the page stays where it is and says so, and a check that gets no answer at all says that instead of guessing. In-flight liesel poll commands end with an interrupted-poll error and are safe to re-run; server-accepted feedback remains queued until delivery, while never-sent notes and supported annotation drafts survive a same-tab reload as described under Live reload. A page waits for the replacement rather than reloading into a port nothing is listening on, and tells the user to restart Liesel if it never returns.
  • Local-first state - Session and board state stays under ~/.liesel/ by default, or LIESEL_STATE_DIR when set. For one release, an existing ~/.easel/ is used until ~/.liesel/ exists, EASEL_* and LAVISH_AXI_* settings are read as fallbacks, and the CLI also answers to easel.
  • Diagnostic viewports - LIESEL_DIAGNOSTIC_VIEWPORTS sets which viewport classes the layout-issue inbox tracks (mobile, compact, desktop; comma-separated, default all). Warnings whose class leaves the set are marked obsolete with an explicit reason instead of silently reading as fixed.
  • Server port - Set LIESEL_PORT to choose the server port; it defaults to 4747.
  • Network binding - With Tailscale running, the review server automatically listens only on loopback (127.0.0.1) and this machine's Tailscale IPv4 address - never on 0.0.0.0 or every interface. The generated session link uses the machine's MagicDNS name, so it is the phone-ready URL to open from another device on the same tailnet. When Tailscale is absent or down, Liesel silently falls back to loopback-only and prints no phone URL. If Tailscale is running but MagicDNS is unavailable, Liesel visibly warns that phone access is unavailable and remains loopback-only. Binding beyond loopback exposes an unauthenticated server that can read and serve arbitrary local files to devices that can reach it, so the tailnet should be trusted. Any explicit LIESEL_HOST overrides automatic Tailscale binding; wildcard values are restricted to loopback, while a non-wildcard value selects that concrete bind address, and the server listens on loopback as well so every local CLI finds it whatever its own LIESEL_HOST says. An address that cannot be bound - the tailnet address while another process holds it, while Tailscale restarts, or an explicit hostname that temporarily does not resolve - is reported as network_warning in liesel <file> output and logged to server.log; session links point at loopback meanwhile unless LIESEL_LINK_HOST overrides them, and the server keeps retrying that address in the background and immediately on the next CLI invocation. Once it binds, the warning clears and new session links use it again, with no restart. If no listener can bind, startup fails rather than claiming a reachable server. LIESEL_LINK_HOST controls the link host when automatic Tailscale binding is disabled.
  • One server per port - Agents on one machine can run with different LIESEL_HOST settings, or none, and still share one server: before starting one, the CLI looks for a running server on its configured host, loopback, and every other local address. A running server that was never asked to listen on the address a CLI's LIESEL_HOST names is replaced once by a server that listens on that address plus every address the old one served, keeping all sessions. Upgrade and network-change restarts keep every address the old server served in the same way, except an address that is no longer on this machine. Switching to an address the running server already serves, including loopback, reuses it, so run liesel stop first to drop an address. Another server of the same installation (the same state directory) found on the same port at a different address is shut down through its own shutdown endpoint, after the kept server is made to serve that address too, so review links already handed out there keep working. A server belonging to another state directory that reports its installation identity is never used, replaced, or stopped: when it holds loopback or the configured address, the CLI fails with an error naming its state directory, so pick another LIESEL_PORT. Older servers that cannot report an identity are treated as belonging to this installation only at those control addresses, for upgrade compatibility.
  • Allowed hosts - To defend against DNS rebinding, the server rejects (403) any request whose Host header is missing or not one it answers to: loopback names plus the concrete Tailscale IPv4 address and MagicDNS name when the Tailscale listener is successfully bound. If you configure a reverse proxy or another intentional hostname, list it in LIESEL_ALLOWED_HOSTS (whitespace-separated). Behind a reverse proxy, the forwarded X-Forwarded-Host is validated against the same list, so add the public hostname there and have the proxy send it together with X-Forwarded-Proto. Set LIESEL_ALLOWED_HOSTS to * to disable the check entirely, only when the server sits behind your own authentication or proxy. Mutating routes also reject a present foreign Origin or Referer (403); header-less CLI control requests remain allowed where supported.
  • Browser opening - Set LIESEL_NO_OPEN=1, equivalent to --no-open, to create or resume a session without launching a browser window.

CLI Reference

CommandDescription
lieselShow current sessions and usage guidance.
liesel init [dir]Set a project up for review: a board folder with a starter design, LIESEL.md with the agent loop, and a pointer section appended to AGENTS.md / CLAUDE.md. Never overwrites a file.
liesel updatePrint how to update. Liesel installs from GitHub (npm install -g github:GeorgeGally/liesel), never from npm, where Liesel is not published.
liesel <html-file>Open or resume a Liesel session, with the open-time layout gate enabled by default. Unresolved layout issues from earlier in the session are preserved. Refuses to reopen a session the user explicitly ended from the browser unless --reopen is passed.
liesel poll <html-file>Long-poll until the user sends feedback, ends the session, or leaves every review disconnected past the reconnect grace period; detected layout issues arrive only when queued. On browser_disconnected, ask before reopening or ending the still-resumable session. On ended, stop polling and do not reopen uninvited.
liesel reply <html-file>Post an agent reply and exit 0 only after the server accepts it, so the review page stops showing Working. Exits non-zero if the server does not confirm within 10 seconds. Use this when handing a result back without starting a long-poll. poll --agent-reply still posts a reply and then waits.
liesel end <html-file>End a session as the agent; unlike a user-initiated end from the browser, this still allows a plain reopen later.
liesel export <html-file>Write a portable copy of the artifact: one HTML file with its local assets inlined, so it opens with no server and no sibling files. Remote CDN/font references are left as links.
liesel share <html-file>Publish the artifact (local assets inlined) to ht-ml.app, a third-party host not part of Liesel, and print a visitable URL plus a secret update key; shares are public by default, --private locks one behind a generated password, and the same command republishes or unpublishes an existing page with --site/--update-key.
liesel stopShut down the background server.
liesel playbook [id]List focused artifact guidance or show one playbook; agents must open each matching playbook before writing HTML.
liesel librariesList the Excalidraw libraries in the whiteboard library directory; --search <text> or --library <id> lists item refs for %% liesel-icon nodes.
liesel designShow agent-facing design guidance, including optional CDN snippets and the whiteboard (Mermaid) opt-in snippet.
liesel setup hooksInstall or repair optional SessionStart hooks for Claude Code, Codex, OpenCode, and GitHub Copilot CLI; restart the agent session afterward.
liesel setup pluginRegister the installed package as an Agent Plugin in VS Code, Cursor, and GitHub Copilot CLI; opt-in, idempotent, no marketplace involved. Reload each client afterward.
liesel serverRun the local Liesel server.

Known playbook IDs: diagram, table, comparison, plan, code, input, explanation, slides. One artifact often combines several playbooks, such as a plan that includes a comparison and a diagram, so agents must match against each use_when trigger and open every matching playbook before writing HTML. For flows, architecture, state, or sequence diagrams, open the diagram playbook for the recommended tooling and SVG guidance.

Flags

CommandFlagDescription
liesel <html-file>--no-openEnsure the server/session exists without opening another browser window.
liesel <html-file>--no-gateSkip the open-time layout curtain for this browser open.
liesel <html-file>--reopenReopen a session the user explicitly ended from the browser; without it, a plain open refuses and explains why instead of reopening uninvited.
liesel export--out <path>Write the export to a specific path instead of <name>.export.html next to the source.
liesel share--privateMake the page private behind a password Liesel generates and returns once; hand it to viewers as a shared secret.
liesel share--password <pw>Make the third-party ht-ml.app page private with a password you supply; viewers must supply the password. An empty or whitespace-only value (an unquoted $PW that is unset) is refused rather than publishing a public page.
liesel share--site <site_id>Republish an existing page in place (with --update-key) instead of creating a new one; the URL does not change.
liesel share--update-key <key>The secret returned when the page was published; required to republish or unpublish it.
liesel share--unpublishReplace a published page with a locked placeholder (ht-ml.app cannot delete); takes --site and --update-key and no file.
liesel share--token <t>Attach an optional bearer token (LIESEL_HTML_APP_TOKEN) when creating a page; never required, and rejected on a republish or --unpublish, where the update_key is the credential.
liesel poll--owner <label>Identify the process listening for this session; the non-empty label appears in the home session listing. none is reserved for sessions without a listener. A second concurrent poll is refused instead of silently returning waiting.
liesel poll--takeoverExplicitly displace the current poll listener. The displaced poll exits with typed LISTENER_REPLACED; without this flag, the new poll fails with typed LISTENER_ACTIVE.
liesel poll--agent-reply "..."Show a concise agent reply in the existing browser chat, conclude delivered work (every note the agent has seen is marked Done), and return presence to waiting before polling again. Keep replies concise by default.
liesel poll--agent-reply-file <path>When a longer reply is genuinely necessary, read Markdown from a file (- for stdin) so newlines survive quoting. Use blank-line paragraphs, - / 1. lists, and ## headings so the Conversation panel renders it scannably. The Markdown subset is under Feedback controls. Cannot be combined with --agent-reply.
liesel poll--timeout-ms <ms>Test/debug escape hatch only; agents should normally omit it and leave the long poll running.
liesel reply--agent-reply "..."Concise reply text. Required unless --agent-reply-file is set. The command exits 0 only after the server accepts it. Empty text is refused. Prefer this over poll --agent-reply when you are not about to long-poll.
liesel reply--agent-reply-file <path>Read the reply from a file (- for stdin) so newlines survive quoting. Cannot be combined with --agent-reply. Same Markdown guidance as poll --agent-reply-file.
liesel stop--port <port>Shut down a server running on a non-default port.
liesel server--verboseLog session and watcher events to stderr; can also be enabled with LIESEL_DEBUG=1. Detached server output is appended with UTC timestamps to ~/.liesel/server.log (or LIESEL_STATE_DIR/server.log) for startup, shutdown-cause, and crash diagnostics.
liesel server--also-listen <host>Also listen on this concrete address (repeatable). The CLI passes it when it replaces a running server, so the replacement keeps every address the old one served; see One server per port.

Telemetry

Liesel sends no telemetry. The CLI can report anonymous command counts to an Umami server only when both LIESEL_UMAMI_HOST and LIESEL_UMAMI_WEBSITE_ID are set at build time or in the environment, and never sends arguments, file paths, artifact content or prompts. LIESEL_TELEMETRY=0 turns it off even then.

Development

pnpm run check          # Run all verification commands
pnpm run build          # Bundle the publishable CLI, chrome, and design assets
pnpm run build:skill    # Regenerate the installable liesel skill
pnpm test               # Run node:test tests
pnpm run lint           # Run ESLint
pnpm run format:check   # Check Prettier formatting
pnpm run typecheck      # Run TypeScript checkJs validation

Licence

MIT. See LICENSE.