SLOPSHOPPER

agent-canvas

A live, clickable board for each Claude Code session, drawn by headless Chrome in a pane beside the transcript.

newpaneguardcommandpromptprocess
★ 1v0.1.0MITupdated 2026-10-03sebi75/agent-canvas
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-canvas
│ ┃ Canvas ✕ › fix the failing auth test and add an audit log call │ ┃ starting Chrome▣ client module ./input.tsx │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /agent-canvas │ ┃ ⎿ agent-canvas: Canvas on: /Users/dev/.claude/agent-canvas/session │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Canvas
starting Chrome▣ client module ./input.tsx board · starting Chrome · click to use the page, Esc gives …
README

agent-canvas

A live board for each Claude Code session, in a pane beside the transcript. The agent keeps it current every reply, with diagrams, tables, charts and the state of the work. The board is a real web page drawn by headless Chrome, so you can click, type, hover and scroll in it. Nothing opens in a browser tab, and the board belongs to the session that made it.

It is a Claude Code mod: a plugin whose hooks run inside Claude Code.

The agent draws a canvas of how Express routing works, then the table it built is filtered in the pane

How it works

  • The agent writes files. While the board is on, the mod adds the rules in RULES.md to the system prompt. Each reply, the agent rewrites turn.html (this reply's news), edits the panels that changed (panels/<name>.html, kept across turns) and appends a line to log.txt. The files live in ~/.claude/agent-canvas/sessions/<session id>/.
  • The mod checks the work. When the agent tries to end a turn without writing turn.html after its last tool call, the mod blocks the Stop event and tells it why.
  • The bridge draws the page. bridge/bridge.mjs builds board.html from those files, runs headless Chrome on it over a pipe (no open port), and writes each frame as a PNG. Panels written this turn come first, marked "updated". Every turn keeps a snapshot, linked from "earlier turns".
  • The pane shows it. The mod draws the frames with Claude Code's Image element and swaps each new one in with $.ui.blit. A Client region laid over the picture sends clicks, keys and the pane size back to Chrome, and the mouse wheel scrolls the page.

Requirements

  • Claude Code 2.1.287 or later
  • A terminal that draws images for Claude Code: Ghostty, kitty or WezTerm
  • Google Chrome or Chromium
  • Node.js 18 or later

Install

From a Claude Code session:

/plugin marketplace add sebi75/agent-canvas /plugin install agent-canvas@agent-canvas

Or clone the repository and load the folder in every session by adding CLAUDE_CODE_PLUGIN_DIRS with the clone's path to the env block of ~/.claude/settings.json.

Use

commandwhat it does
/agent-canvasturns the board on and opens its pane; when it shows another page, goes back to the board
/agent-canvas offturns the board off and closes the pane
/agent-canvas <url or path>shows that page instead, for example a local dev server

Click the page to type into it. Esc gives the keyboard back to Claude Code. The board stays on across a plugin reload, and AGENT_CANVAS_OPEN=1 turns it on when a session starts.

Herdr

Claude Code turns images off when the terminal reports itself as "libghostty", which Herdr does. Its own switch turns them back on: set CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 in the shell that starts claude inside Herdr, for example in ~/.zshrc when HERDR_ENV is set.

Use Herdr 0.9 or later. On Herdr 0.9.3 an animated page measured about 42 frames per second on screen, against about 53 in plain Ghostty. Herdr 0.7 drew about 3; there, set AGENT_CANVAS_FPS=4 so frames do not queue up.

Configuration

variabledefault
AGENT_CANVAS_FPS60
AGENT_CANVAS_SCALE2 device pixels per CSS pixel
AGENT_CANVAS_CHROMEthe first Chrome or Chromium found
AGENT_CANVAS_OPENunset; 1 turns the board on at session start

Limits

  • The terminal shows pictures of the page, so text on it cannot be selected or copied.
  • The Desktop app and the VS Code extension have no Image element. The pane says so there.
  • Frames travel as PNG files. Shared-memory raw pixels would be faster if a terminal needs it.

History

agent-canvas started as herdr-canvas, which rendered the same kind of board to a static image in a Herdr pane next to the agent. Claude Code mods made it possible to put a live, clickable page inside Claude Code itself.

Test

node test/board.test.mjs

License

MIT

Source 3 files
hooks/register.tsx 222 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4// A live board for the session in a Claude Code pane. bridge/bridge.mjs runs headless Chrome on
5// the board page (or any URL) and writes frames as PNG files; an Image shows them, swapped in with
6// $.ui.blit; a Client laid over it (input.tsx) catches clicks, keys and the pane size. While the
7// board is on, the rules in RULES.md join the system prompt, and the Stop event is blocked until
8// the agent has written this turn's turn.html.
9const PANE = 'agent-canvas'
10const NO_IMAGES = 'This terminal draws no images for Claude Code. Use Ghostty, kitty or WezTerm.'
11const NO_IMAGES_HERDR = 'Images are off: this claude started without CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1. Set it in your shell for Herdr, then start claude again.'
12const isOnRef = atom({ plugin: 'agent-canvas', key: 'isOn' } as const, false)
13
14let dir = '' // this session's board files
15let rules = ''
16let home = ''
17let maxFps = 60
18let scale = '2'
19let noImages = NO_IMAGES
20let url = '' // what the pane shows: '' is the board
21
22let bridge: AsyncGenerator<unknown, unknown> | null = null
23let socket: string | null = null
24let frame: { file: string; n: number } | null = null // the newest frame the bridge wrote
25let shownN = 0
26let isBlitting = false
27let note = 'starting Chrome'
28let stats = { frames: 0, ms: 0, since: Date.now(), text: '' }
29let seen = { instance: '', seq: 0 }
30let size: { t: 'resize'; columns: number; rows: number } | null = null // the pane's last size, sent again once Chrome is up
31let chain: Promise<unknown> = Promise.resolve()
32
33let turnStart = 0 // when the current prompt arrived
34let lastWork = 0 // when the last tool call that was not a board write finished
35let isLoopTick = false // a /loop firing: a quiet tick may leave the board alone
36
37const toUrl = (s: string) => (/^[a-z]+:\/\//.test(s) ? s : `file://${s.replace(/^~/, home)}`)
38
39// Input goes to the bridge in order, one request at a time.
40function send($: EngineInterface, ev: object) {
41  const at = socket
42  if (!at) return chain
43  chain = chain.then(() =>
44    $.http.fetch('http://bridge/', { method: 'POST', body: JSON.stringify(ev), socketPath: at }).catch(() => undefined))
45  return chain
46}
47
48// One blit at a time, always of the newest frame: frames that arrive meanwhile are skipped, so a
49// slow terminal shows fewer frames instead of falling further behind.
50async function show($: EngineInterface) {
51  if (isBlitting) return
52  isBlitting = true
53  while (frame && frame.n !== shownN) {
54    const { file, n } = frame
55    const t = Date.now()
56    const r = await $.ui.blit({ requestId: PANE, key: 'view', source: { file, format: 'png', generation: n } })
57    if (r.deny) {
58      if (r.deny.includes('alt')) note = noImages
59      $.ui.invalidate('ui.render') // not mounted yet, resized, or no images: draw the tree again
60      break
61    }
62    shownN = n
63    stats.frames++
64    stats.ms += Date.now() - t
65    if (Date.now() - stats.since >= 1000) { // the footer's frame rate, redrawn once a second
66      stats = { frames: 0, ms: 0, since: Date.now(), text: `${stats.frames} fps` }
67      $.ui.invalidate('ui.render')
68    }
69    if (maxFps < 60) await $.clock.sleep(Math.max(0, 1000 / maxFps - (Date.now() - t)))
70  }
71  isBlitting = false
72}
73
74function start($: EngineInterface) {
75  if (bridge) return
76  const argv = ['node', `${$.plugin.root}/bridge/bridge.mjs`, '--board', dir, ...(url ? [url] : [])]
77  const child = $.process.spawn({ argv, env: { AGENT_CANVAS_SCALE: scale, AGENT_CANVAS_FPS: String(maxFps) } })
78  bridge = child
79  void (async () => {
80    let buf = ''
81    try {
82      for await (const chunk of child) {
83        if (chunk.stream === 'stderr') { note = chunk.text.trim().slice(0, 160); continue }
84        buf += chunk.text
85        for (let i; (i = buf.indexOf('\n')) >= 0;) {
86          const m = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1)
87          if (m.socket) { socket = m.socket; note = ''; if (size) void send($, size); $.ui.invalidate('ui.render') }
88          if (m.frame) frame = { file: m.frame, n: m.n }
89          if (m.error) note = m.error
90        }
91        void show($) // not awaited: the loop keeps reading, show() picks up the newest frame
92      }
93    } catch (err) {
94      note = `bridge failed: ${String(err).slice(0, 160)}`
95    }
96    bridge = null
97    socket = null
98    frame = null
99    $.ui.invalidate('ui.render')
100  })()
101}
102
103function stop() {
104  void bridge?.return(undefined) // leaving the stream ends the child, and the bridge closes Chrome
105  bridge = null
106}
107
108async function turnOn($: EngineInterface) {
109  await update($, isOnRef, () => true)
110  await $.ui.open({ id: PANE, title: 'Canvas' })
111  start($)
112}
113
114export const register: Register = on => {
115  on('session.start', async ($, e, next) => {
116    await $.command.register({ name: 'agent-canvas', description: 'Live board for this session (off: close it; a URL or path: show that page)' })
117    const started = await next(e)
118    home = (await $.env.get('HOME')) ?? ''
119    dir = `${home}/.claude/agent-canvas/sessions/${await $.session.id()}`
120    rules = (await $.fs.read(`${$.plugin.root}/RULES.md`)).replaceAll('{{DIR}}', dir)
121    maxFps = Number(await $.env.get('AGENT_CANVAS_FPS')) || 60
122    scale = (await $.env.get('AGENT_CANVAS_SCALE')) || '2'
123    if (await $.env.get('HERDR_ENV')) noImages = NO_IMAGES_HERDR
124    if ((await $.env.get('AGENT_CANVAS_OPEN')) === '1' || (await read($, isOnRef))) await turnOn($)
125    return started
126  })
127
128  on('command.run', { command: 'agent-canvas' }, async ($, e) => {
129    const arg = e.args.trim()
130    if (arg === 'off') {
131      await update($, isOnRef, () => false)
132      stop()
133      await $.ui.close({ id: PANE })
134      return { text: 'Canvas off.' }
135    }
136    url = arg && arg !== 'board' ? toUrl(arg) : ''
137    await send($, { t: 'navigate', url }) // a running bridge switches page; '' is the board
138    await turnOn($)
139    return { text: url ? `Canvas shows ${url}` : `Canvas on: ${dir}` }
140  })
141
142  // The rules ride in the system prompt while the board is on, so a compaction cannot lose them.
143  on('prompt.compose', async ($, e, next) => {
144    const composed = await next(e)
145    if (!(await read($, isOnRef))) return composed
146    return { sections: [...composed.sections, { id: 'agent-canvas:rules', text: rules, scope: 'session' as const }] }
147  })
148
149  on('prompt.submit', async ($, e, next) => {
150    turnStart = Date.now()
151    lastWork = 0
152    isLoopTick = /^\s*\/loop\b|<<autonomous-loop/.test(e.text)
153    if (await read($, isOnRef)) await $.fs.write(`${dir}/turn`, String(turnStart)) // the board marks panels written after this
154    return next(e)
155  })
156
157  on('tool.call', async ($, e, next) => {
158    const result = await next(e)
159    if (!JSON.stringify(e).includes(dir)) lastWork = Date.now() // board writes are not work the board must describe
160    return result
161  })
162
163  on('classic.Stop', async ($, e, next) => {
164    const result = await next(e)
165    if (!(await read($, isOnRef)) || e.stop_hook_active || isLoopTick || !turnStart) return result
166    const file = `${dir}/turn.html`
167    const written = (await $.fs.exists(file)) ? (await $.fs.stat(file)).mtimeMs : 0
168    if (written < turnStart) {
169      return { ...result, block: `The canvas is on but ${file} was not written this turn. Write this reply's turn.html, update the panels that changed and append to log.txt, as the canvas rules say, then finish.` }
170    }
171    if (written < lastWork) {
172      return { ...result, block: `${file} was written before the rest of this turn's work, so it describes the plan instead of the result. Rewrite it to say what is true now, then finish.` }
173    }
174    return result
175  })
176
177  on('ui.message', async ($, e, next) => {
178    if (e.requestId !== PANE) return next(e)
179    const data = e.data as { instance: string; events: { seq: number; t: string }[] }
180    if (data.instance !== seen.instance) seen = { instance: data.instance, seq: 0 }
181    for (const ev of data.events) {
182      if (ev.seq <= seen.seq) continue
183      seen.seq = ev.seq
184      if (ev.t === 'resize') size = ev as unknown as typeof size
185      void send($, ev)
186    }
187    await chain
188    return {}
189  })
190
191  on('ui.scroll', async ($, e, next) => {
192    if (e.requestId !== PANE) return next(e)
193    await send($, { t: 'wheel', dy: e.by })
194    return { deny: 'the page scrolls itself' }
195  })
196
197  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
198    const ui = $.ui.resolve(e)
199    if (e.surface !== 'terminal' || !('Image' in ui)) {
200      const { Text } = ui
201      return <Text>The canvas needs a terminal that draws images (Ghostty, kitty, WezTerm).</Text>
202    }
203    const { Box, Text, Image, Client } = ui
204    const columns = Math.max(20, e.props.bodyColumns)
205    const rows = Math.max(5, e.props.scroll.bodyRows - 1)
206    const where = url ? url.replace(/^file:\/\//, '') : 'board'
207    return (
208      <Box flexDirection="column">
209        <Box width={columns} height={rows}>
210          {frame
211            ? <Image key="view" source={{ file: frame.file, format: 'png', generation: frame.n }} columns={columns} rows={rows} alt={noImages} />
212            : <Text dimColor>{note || 'starting Chrome'}</Text>}
213          <Box position="absolute" top={0} left={0}>
214            <Client key="input" module="./input.tsx" props={{ columns, rows }} width={columns} height={rows} />
215          </Box>
216        </Box>
217        <Text dimColor wrap="truncate-end">{[where, note, stats.text, 'click to use the page, Esc gives the keys back'].filter(Boolean).join(' · ')}</Text>
218      </Box>
219    )
220  })
221}
222
hooks/input.tsx 44 lines
1import type { ClientModule } from 'claude-code'
2
3type Props = { columns: number; rows: number }
4type State = { columns: number; rows: number }
5type Ev = { t: string; seq: number; [k: string]: unknown }
6
7// Laid over the browser frame. Sends the pointer, keys and its own size to the hooks module.
8// A post replaces one not yet delivered in the same frame, so each post carries the last
9// 20 events with a sequence number; the hooks module skips the ones it has seen.
10const instance = Math.random().toString(36).slice(2)
11let seq = 0
12let pending: Ev[] = []
13
14function emit(post: (d: never) => void, ev: Omit<Ev, 'seq'>) {
15  const next = { ...ev, seq: ++seq } as Ev
16  const last = pending[pending.length - 1]
17  if (next.t === 'move' && last?.t === 'move') pending[pending.length - 1] = next // keep only the newest hover
18  else pending.push(next)
19  pending = pending.slice(-20)
20  post({ instance, events: pending } as never)
21}
22
23const Input: ClientModule<Props, State> = (props, surface) => {
24  const post = surface.post as (d: never) => void
25  if (surface.state === undefined) {
26    surface.onPointer(ev => {
27      if (ev.type === 'enter' || ev.type === 'leave') return
28      emit(post, { t: ev.type, x: ev.fine?.x ?? ev.x + 0.5, y: ev.fine?.y ?? ev.y + 0.5, button: ev.button ?? null })
29    })
30    surface.onKey(k => emit(post, { t: 'key', key: k.key, ctrl: !!k.ctrl, shift: !!k.shift, meta: !!k.meta }))
31  }
32  const s = surface.state
33  if (surface.columns > 0 && (s === undefined || s.columns !== surface.columns || s.rows !== surface.rows)) {
34    emit(post, { t: 'resize', columns: surface.columns, rows: surface.rows })
35    surface.setState({ columns: surface.columns, rows: surface.rows })
36  } else if (s === undefined) {
37    surface.setState({ columns: 0, rows: 0 })
38  }
39  const { Box } = surface.elements
40  return <Box width={props.columns} height={props.rows} />
41}
42
43export default Input
44
types/index.d.ts 9 lines
1/** Whether the board is on for this session: kept across a plugin reload. */
2export type IsOn = boolean
3
4declare module 'claude-code' {
5  interface PluginState {
6    'agent-canvas': { isOn: IsOn }
7  }
8}
9