Generative UI: the agent builds the page your task needs, writes new widgets when the project calls for them, and answers your comments and edits over a…

Experimental software; not ready for general use.
Leaf is generative UI for Claude Code and Codex. Review a plan, make a decision, or follow live work in a page you can comment on and change. The agent responds by revising the page.
<img alt="A Leaf page with an anchored comment, the agent's reply, and a revised plan" src="https://raw.githubusercontent.com/max-sixty/leaf-assets/445c6883aa8b8011849afad2340151e5fe0f9d49/demo/session-light.png">

Try Leaf on the home page, where a small demo agent responds in your private copy, or explore the examples.
You need uv, jq 1.6 or newer on PATH, and a browser that can reach the machine the agent runs on. No Leaf account or configuration is required. Leaf ships a prepared browser runtime. Users and package authors never need to build Leaf or run npm; custom widgets are ordinary browser JavaScript.
Claude Code:
/plugin marketplace add max-sixty/leaf#prepared
/plugin install leaf@leaf
Codex:
codex plugin marketplace add max-sixty/leaf --ref prepared
codex plugin add leaf@leaf
Pi (a highly experimental trial, which the rest of these docs don't cover yet):
pi install git:github.com/max-sixty/leaf@prepared
Then ask: “Use Leaf to write up the options for this change.” The explicit skill is /leaf [topic] in Claude Code, $leaf [topic] in Codex, and /skill:leaf [topic] in Pi; without a topic, it presents the work already under discussion. The result opens in a browser page; its comments return to the same agent task.
Known functional limitations and accepted visual differences in current browsers are recorded in Browser support gaps.
The first run syncs the plugin's uv environment through your configured package index. Render checks use the executable named by LEAF_BROWSER_EXECUTABLE, CHROME_PATH, or CHROME_BIN, then installed Google Chrome, then the first google-chrome, google-chrome-stable, chrome, chromium, or chromium-browser on PATH.
In Claude Code, a page messages its session when input arrives while nothing watches it, as after a turn you interrupted. A session that bypasses permissions holds that message for your approval unless Claude Code's crossSessionInbound setting is "accept".
Leaf's "Watch pages with a hooks module" option, in /config, moves that watch into a Claude Code hooks module, an early-access Claude Code feature, which goes on watching after a turn you interrupt. A Claude Code that doesn't load hooks modules keeps the default watch.
hooks/claude-code.ts 238 lines1/**
2 * Leaf's Claude Code hooks module: the session's watch between turns, kept by
3 * the module rather than by the background Stop registration in `hooks.json`.
4 * It runs only while the plugin's `hooks_module` option is on, and only in a
5 * Claude Code that loads hooks modules, an early-access API.
6 *
7 * It does for the watch what `hooks/pi.ts` does for a Pi session
8 * (`ClaudeCodeHarness` in `skills/leaf/scripts/leaf/harness.py`):
9 *
10 * - the session's start and each main-loop turn's end start the watch (`bin/leaf
11 * hook --harness claude-code --watch`), which prints a line and exits once one
12 * of the session's pages has input;
13 * - a turn the user interrupts ends with no Stop hook, so the module starts the
14 * watch with the Interrupt payload, which closes the turn. Receipt distinguishes
15 * input that entered that turn from input held outside its context;
16 * - a watch that wakes an idle session submits its line as a prompt, whose
17 * prompt hook hands the input over; one that wakes during a turn calls the
18 * prompt hook itself and appends what it returns to that turn, which reads it
19 * at its next step. The appended row joins the session's transcript at once,
20 * and stays there through an Escape (measured at 2.1.291), so the hook's
21 * confirmation holds: the input is in the session's context. Claude Code
22 * shows neither that row nor the prompt hook's context in the terminal. A
23 * wake while a turn's Stop hooks run waits for them, since Leaf's hands over
24 * what is pending and decides whether the turn goes on;
25 * - a delivery Leaf's Stop hook hands over as a turn ends, which Claude Code
26 * would print in full, goes into the session the same way, and the turn goes
27 * on with one line in its place.
28 *
29 * Once it can start the watch, each Stop payload it passes on carries `leaf_watch:
30 * "module"`, which stands the background registration beneath it down
31 * (`hooks/scripts/loop-guard.py`). A Claude Code that does not load the module, or
32 * a module that cannot start the watch, passes the payload unmarked, so that
33 * registration goes on watching. The prompt, Stop, SessionStart and
34 * SessionEnd registrations do the same work under either watch and stay in
35 * `hooks.json`.
36 *
37 * TODO: once Claude Code loads modules without an opt-in, make this module the
38 * only watcher and transport and drop the background Stop registration, `loop-guard.py`, and
39 * the interrupt half of the nudge.
40 *
41 * A module's processes inherit Claude Code's own environment, which names no
42 * session of its own, so every call states the session and Claude Code's process
43 * as a hook's environment does. That process is the parent of every process the
44 * module starts (measured at Claude Code 2.1.291). Failures are silent, as
45 * `hooks.json` makes them, but for a wake Claude Code refuses, which goes to its
46 * debug log.
47 */
48
49import type { EngineInterface, Register } from 'claude-code'
50import { WatchOwner } from './watch.ts'
51
52// `hooks.json`'s timeout for the prompt hook, inside which it confirms what it
53// hands over (`CONFIRM_WITHIN` in `hook_transport.py`).
54const HOOK_TIMEOUT_MS = 20_000
55// The line an inline delivery opens with (`INLINE_DELIVERY` in `hook_transport.py`),
56// and the line the turn goes on with once the module has appended one.
57const INLINE_DELIVERY = 'Leaf has new input for your turn.'
58const DELIVERED = "Leaf added the page's new input to your context above; answer it before you end the turn."
59
60type Payload = { hook_event_name: string; session_id: string; ended_at?: number }
61
62// Claude Code's process, read as the session starts.
63let claudePid = ''
64let watch = new WatchOwner()
65// Whether a main-loop turn is going, from its start until its Stop hooks let it end.
66let running = false
67// The Stop hooks of a turn now ending, while they run.
68let stopping: Promise<unknown> | undefined
69
70function environment(session: string) {
71 return { CLAUDE_CODE_SESSION_ID: session, CLAUDE_PID: claudePid }
72}
73
74function launcher($: EngineInterface) {
75 return `${$.plugin.root}/bin/leaf`
76}
77
78/** The context a hook's output puts in the turn (`Harness.hook_context`). */
79async function hook($: EngineInterface, payload: Payload): Promise<string | undefined> {
80 try {
81 const ran = await $.process.run([launcher($), 'hook', '--harness', 'claude-code'], {
82 env: environment(payload.session_id),
83 stdin: JSON.stringify(payload),
84 timeoutMs: HOOK_TIMEOUT_MS,
85 })
86 if (ran.exitCode !== 0 || !ran.stdout.trim()) return undefined
87 return JSON.parse(ran.stdout).hookSpecificOutput?.additionalContext || undefined
88 } catch {
89 return undefined
90 }
91}
92
93/** The watch owner serializes endings; the adapter supplies Claude Code's process
94 * and wake operations. Without its parent process, the registration keeps watch. */
95async function ensureWatch($: EngineInterface, session: string, interrupted: boolean) {
96 const ended = Date.now() / 1000
97 if (!claudePid) return
98 const owner = watch
99 await owner.ensure(interrupted, () => {
100 const payload: Payload = {
101 hook_event_name: interrupted ? 'Interrupt' : 'Stop',
102 session_id: session,
103 ended_at: ended,
104 }
105 const stream = $.process.spawn({
106 argv: [launcher($), 'hook', '--harness', 'claude-code', '--watch'],
107 env: environment(session),
108 input: JSON.stringify(payload),
109 })
110 const done = (async () => {
111 let out = ''
112 try {
113 for await (const chunk of stream) if (chunk.stream === 'stdout') out += chunk.text
114 } catch {
115 // Cancellation can abort output before the process releases its lease.
116 }
117 try {
118 return (await stream.result).code === 0 ? out.trim() : ''
119 } catch {
120 return ''
121 }
122 })()
123 return { stop: () => stream.return(undefined as never), done }
124 }, (output, live) => wake($, session, owner, output, live))
125}
126
127/** Whether the turn goes on, once any Stop hooks now running have returned. They
128 * hand over the input pending as they run, and decide whether the turn goes on,
129 * so a wake waits for them rather than carrying that input beside them. */
130async function turnGoesOn() {
131 while (stopping) await stopping.catch(() => undefined)
132 return running
133}
134
135function append($: EngineInterface, text: string) {
136 return $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
137}
138
139async function wake($: EngineInterface, session: string, owner: WatchOwner, woke: string, live: () => boolean) {
140 if (!woke) return
141 try {
142 // Claude Code refuses a plugin's prompt that starts with `/`, as the watch's
143 // line, a page's path, does.
144 const prompt = `Leaf: ${woke}`
145 const ongoing = await turnGoesOn()
146 if (!live()) return
147 if (ongoing) {
148 const context = await hook($, { hook_event_name: 'UserPromptSubmit', session_id: session })
149 if (!owner.open) return
150 // The hook has confirmed what it hands over, so that goes into the session
151 // even where the turn ended while the hook ran: an idle session reads an
152 // appended row at its next turn, which the prompt below starts.
153 if (context) await append($, context)
154 const ongoing = await turnGoesOn()
155 // Confirmed input still needs an idle turn even if another watch started;
156 // a session that ended meanwhile owns neither an append nor a new prompt.
157 if (!owner.open || (!context && !live())) return
158 if (ongoing) {
159 if (!context) await append($, prompt)
160 return
161 }
162 }
163 await $.prompt.submit({ text: prompt })
164 } catch (error) {
165 // A wake Claude Code refuses leaves the input to the next turn's prompt hook.
166 $.ui.log(`Leaf: the watch's wake was refused: ${error}`, { to: 'debug' })
167 }
168}
169
170export const register: Register = (on, options) => {
171 if (options.hooks_module !== true) return
172
173 on('session.start', async ($, e, next) => {
174 const started = await next(e)
175 const parent = await $.process.run(['/bin/sh', '-c', 'echo $PPID'])
176 claudePid = parent.stdout.trim()
177 void ensureWatch($, await $.session.id(), false)
178 return started
179 })
180
181 on('turn.start', ($, e, next) => {
182 running = true
183 return next(e)
184 })
185
186 on('classic.Stop', ($, e, next) => {
187 const ending = (async () => {
188 // Without Claude Code's process the watch cannot run, so the registration
189 // beneath keeps it.
190 const result = await next(claudePid ? Object.assign({}, e, { leaf_watch: 'module' }) : e)
191 const contexts = result.additionalContext ?? []
192 // Claude Code prints a Stop hook's context in the terminal, so a delivery
193 // Leaf's Stop hook hands over goes into the session as an appended row,
194 // which it does not show, and the turn goes on with one line.
195 const shown: string[] = []
196 for (const context of contexts) {
197 if (!context.startsWith(INLINE_DELIVERY)) {
198 shown.push(context)
199 continue
200 }
201 try {
202 await append($, context)
203 shown.push(DELIVERED)
204 } catch (error) {
205 $.ui.log(`Leaf: the Stop hook's delivery was not appended: ${error}`, { to: 'debug' })
206 shown.push(context)
207 }
208 }
209 // A Stop hook's context keeps the turn going.
210 running = shown.length > 0 || result.block !== undefined
211 return contexts.length ? Object.assign({}, result, { additionalContext: shown }) : result
212 })()
213 stopping = ending
214 const settled = () => {
215 if (stopping === ending) stopping = undefined
216 }
217 ending.then(settled, settled)
218 return ending
219 })
220
221 on('turn.complete', async ($, e, next) => {
222 const completed = await next(e)
223 if (e.agentId !== undefined) return completed
224 running = false
225 void ensureWatch($, await $.session.id(), e.isAborted)
226 return completed
227 })
228
229 on('session.end', async ($, e, next) => {
230 const owner = watch
231 await owner.close()
232 const ended = await next(e)
233 // /clear and resume keep the module loaded without another session.start.
234 if (watch === owner) watch = new WatchOwner()
235 return ended
236 })
237}
238hooks/watch.ts 83 lines1/**
2 * One session's between-turn watch. Host adapters own process creation and wakes;
3 * this owner keeps termination, replacement and completion in the same order.
4 *
5 * A Stop watch survives another Stop. Every Interrupt replaces the old watch,
6 * including an Interrupt watch, so the new ending reads fresh receipt evidence.
7 * Replacement invalidates completion immediately, but retains the old process
8 * until `done` proves it has exited and released its lease. Concurrent endings
9 * collapse to the latest request. Shutdown invalidates queued starts and wakes.
10 * `live` checks watch ownership after yielding; `open` tracks the session lifetime
11 * for a host that has already confirmed input and still owes its delivery.
12 */
13
14type Watch = { stop: () => unknown; done: Promise<string> }
15type Ending = { interrupted: boolean }
16
17export class WatchOwner {
18 private current: Watch | undefined
19 private desired: Ending | undefined
20 private pending: Promise<void> = Promise.resolve()
21 private closed = false
22 private generation = 0
23
24 get open(): boolean {
25 return !this.closed
26 }
27
28 ensure(
29 interrupted: boolean,
30 start: () => Watch,
31 wake: (output: string, live: () => boolean) => unknown,
32 ): Promise<void> {
33 if (this.closed || (!interrupted && this.desired?.interrupted === false)) return this.pending
34 const ending = { interrupted }
35 this.desired = ending
36 const generation = ++this.generation
37 return this.enqueue(async () => {
38 if (this.desired !== ending) return
39 await this.stopCurrent()
40 if (this.closed || this.desired !== ending) return
41 const current = start()
42 this.current = current
43 const live = () => !this.closed && this.generation === generation
44 void current.done.then(output => {
45 if (!live()) return
46 this.current = undefined
47 this.desired = undefined
48 return wake(output, live)
49 })
50 }).catch(error => {
51 if (this.desired === ending) this.desired = undefined
52 throw error
53 })
54 }
55
56 close(): Promise<void> {
57 this.closed = true
58 this.desired = undefined
59 ++this.generation
60 return this.enqueue(() => this.stopCurrent())
61 }
62
63 private enqueue(operation: () => Promise<void>): Promise<void> {
64 const next = this.pending.then(operation)
65 // A failed host operation is reported to its caller, without poisoning later
66 // shutdown or replacement requests.
67 this.pending = next.catch(() => undefined)
68 return next
69 }
70
71 private async stopCurrent(): Promise<void> {
72 const current = this.current
73 if (!current) return
74 try {
75 await current.stop()
76 } finally {
77 // Cancelling a host's output iterator can finish before its child exits.
78 await current.done
79 this.current = undefined
80 }
81 }
82}
83