Clayfold server and frontend statistics and the Claude Code instances the server runs, in a pane

A local, single-learner learning platform. You type what you want to learn; Claude Code, running headless, interviews you, collects sources, builds a knowledge map and generates interactive lessons. Every lesson step passes quality gates before you see it. Lessons come with a tutor, flashcards with spaced repetition, notes and a memory of your progress.
How lessons are built and why: docs/learning-design.md. System layout and contracts: docs/architecture.md.
claude auth status). Lessons run on your subscription or API key.bun install
bun run build # builds the UI into web/dist
bun run start # http://127.0.0.1:4317
When origin/main has new commits, the top bar shows "New version". The update pulls them, rebuilds the UI and restarts the server, either at once or after Claude finishes its current work. Claude turns that a restart cuts off run again after it. The update needs a clean checkout of main and a server started with bun run start.
The app speaks English by default; switch to Russian with the language button in the sidebar. The language also sets what Claude writes from the next run on: interviews, missions, lessons, cards and the tutor. Content written earlier keeps its language.
Data lives in data/ (SQLite database and one workspace folder per topic with MISSION.md, RESOURCES.md, GLOSSARY.md, NOTES.md, learning-records/). Back it up to keep your progress; it is not in git.
| Variable | Default | Meaning |
|---|---|---|
CLAYFOLD_PORT | 4317 | Server port |
CLAYFOLD_DATA_DIR | ./data | Database and workspaces |
CLAYFOLD_MODEL | opus | Default model for onboarding, lessons, the tutor and review; the Settings page overrides it per role |
CLAYFOLD_CRITIC_MODEL | sonnet | Default model for the critic, answer grading and narration scripts; the Settings page overrides it per role |
CLAYFOLD_MAX_BUDGET_USD | 5 | Spend ceiling per Claude run |
CLAYFOLD_CLAUDE_BIN | claude | Claude Code executable |
OPENALEX_API_KEY | none | Free OpenAlex key for paper discovery; raises the daily free budget from $0.10 to $1 |
An Exa key, added under Settings → Source search, lets Claude also search the web by meaning when it collects sources. It is optional and kept in the system keychain.
Clone the repository, open your coding agent (Claude Code, Codex, Cursor or another) in its folder, and paste this prompt:
Set up Clayfold from this repository and start it. Work from the repository root, run the steps in order, and check each result before the next step. If a check fails and you cannot fix it, stop and tell me what failed.
1. `bun --version` prints 1.3 or later. Otherwise install Bun: `curl -fsSL https://bun.com/install | bash` (Windows: `powershell -c "irm bun.sh/install.ps1|iex"`), then use a new shell.
2. `claude --version` prints 2.1.288 or later. Otherwise install or update Claude Code: `curl -fsSL https://claude.ai/install.sh | bash` (Windows: `irm https://claude.ai/install.ps1 | iex`).
3. `claude auth status` reports `"loggedIn": true`. Otherwise ask me to run `claude` once and finish the browser login, then check again.
4. `bun install` exits 0.
5. `bun run build` exits 0 and `web/dist/index.html` exists.
6. Start `bun run start` in the background. It prints `Clayfold on http://127.0.0.1:4317`. If the port is taken, set `CLAYFOLD_PORT` to a free port.
7. `curl -s http://127.0.0.1:4317/api/settings` (with your port) returns JSON with a `language` field.
8. Give me the address to open. Tell me that lessons run Claude Code on my account, each run capped at CLAYFOLD_MAX_BUDGET_USD (default 5 USD), and that the app language, English or Russian, is switched with the button in the sidebar.
Development setup, project layout, translations and the pull request process: CONTRIBUTING.md. Coding agents read AGENTS.md. Report vulnerabilities privately as described in SECURITY.md.
hooks/register.tsx 349 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ClaudeInstance, FinishedRun, Samples, Snapshot, SystemView } from '../types'
5
6const PANE = 'clayfold'
7const COMMAND = 'clayfold'
8const TOOL = 'server_state'
9const POLL_MS = 2000
10const KEPT_SAMPLES = 60
11const SHOWN_ACTIVITIES = 3
12const SHOWN_FINISHED = 3
13const SPARK_LABEL = 6
14
15const snapshot = atom({ plugin: 'clayfold-monitor', key: 'snapshot' } as const, null)
16const autoOpened = atom({ plugin: 'clayfold-monitor', key: 'autoOpened' } as const, false)
17const samples = atom({ plugin: 'clayfold-monitor', key: 'samples' } as const, { cpu: [], rssMb: [] })
18const notifiedUntil = atom({ plugin: 'clayfold-monitor', key: 'notifiedUntil' } as const, null)
19
20type Config = { port: number; vitePort: number; longRunMs: number }
21
22const mb = (v: number) => `${v.toFixed(v < 10 ? 1 : 0)} MB`
23const pct = (v: number) => `${v.toFixed(1)}%`
24const usd = (v: number) => `$${v.toFixed(v < 0.1 ? 3 : 2)}`
25
26/** "m:ss", or "h:mm:ss" from an hour on. */
27export function elapsed(ms: number): string {
28 const s = Math.max(0, Math.floor(ms / 1000))
29 const h = Math.floor(s / 3600)
30 const m = Math.floor((s % 3600) / 60)
31 const ss = String(s % 60).padStart(2, '0')
32 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
33}
34
35const since = (at: number, iso: string) => elapsed(at - Date.parse(iso))
36
37const appUrl = (port: number, path = '/') => `http://localhost:${port}${path}`
38
39/** Where an instance or a finished run lives in the app: its lesson, else its topic. */
40function pagePath(run: { lessonId: string | null; topicId: string | null }): string | null {
41 if (run.lessonId) return `/lessons/${encodeURIComponent(run.lessonId)}`
42 if (run.topicId) return `/topics/${encodeURIComponent(run.topicId)}`
43 return null
44}
45
46const instanceMeta = (it: ClaudeInstance) =>
47 [
48 `PID ${it.pid}`,
49 it.model,
50 it.rssMb !== null ? mb(it.rssMb) : null,
51 it.cpuPercent !== null ? `CPU ${pct(it.cpuPercent)}` : null,
52 it.queued ? `${it.queued} queued` : null,
53 ]
54 .filter(Boolean)
55 .join(' · ')
56
57export function statusLine(snap: Snapshot | null): string | undefined {
58 const sys = snap?.system
59 if (!sys) return undefined
60 return `clayfold: ${sys.instances.length} claude · ${mb(sys.backend.rssMb)} · cpu ${pct(sys.backend.cpuPercent)}`
61}
62
63export function finishedText(run: FinishedRun): string {
64 const what = `${run.kind} "${run.topicTitle}"`
65 const took = elapsed(Date.parse(run.finishedAt) - Date.parse(run.startedAt))
66 const cost = run.costUsd !== null ? `, ${usd(run.costUsd)}` : ''
67 if (run.cancelled) return `Clayfold: ${what} stopped after ${took}`
68 if (run.error) return `Clayfold: ${what} failed after ${took}: ${run.error.slice(0, 120)}`
69 return `Clayfold: ${what} finished in ${took}${cost}`
70}
71
72/** Runs finished after `until`, oldest first. */
73export function newlyFinished(finished: FinishedRun[], until: string): FinishedRun[] {
74 return finished.filter(run => run.finishedAt > until).reverse()
75}
76
77/** One terminal row of block characters, scaled from `min` to `max`, as RasterProps.cells. */
78export function sparkCells(values: number[], min: number, max: number, color: number): string {
79 const span = max - min || 1
80 const words = new Uint32Array(values.length * 3)
81 values.forEach((v, i) => {
82 const level = Math.round(((Math.min(max, Math.max(min, v)) - min) / span) * 7)
83 words.set([0x2581 + level, color, 0x01000000], i * 3)
84 })
85 let binary = ''
86 for (const byte of new Uint8Array(words.buffer)) binary += String.fromCharCode(byte)
87 return btoa(binary)
88}
89
90async function fetchSystem($: EngineInterface, port: number): Promise<{ system: SystemView | null; error: string | null }> {
91 try {
92 const r = await $.http.fetch(`http://127.0.0.1:${port}/api/system`)
93 if (!r.ok) return { system: null, error: `HTTP ${r.status}` }
94 const system = JSON.parse(r.text) as Partial<SystemView>
95 // A server started before /api/system gained these fields keeps answering without them until it restarts.
96 if (!Array.isArray(system.finished) || !system.instances?.every(it => Array.isArray(it.activities))) {
97 return { system: null, error: 'the server runs older code; restart it' }
98 }
99 return { system: system as SystemView, error: null }
100 } catch (err) {
101 return { system: null, error: err instanceof Error ? err.message : String(err) }
102 }
103}
104
105async function poll($: EngineInterface, config: Config): Promise<void> {
106 const [answer, viteUp] = await Promise.all([
107 fetchSystem($, config.port),
108 $.http.fetch(`http://localhost:${config.vitePort}/`).then(
109 r => r.ok,
110 () => false,
111 ),
112 ])
113 const next: Snapshot = { at: await $.clock.now(), ...answer, viteUp }
114 await update($, snapshot, () => next)
115 $.ui.status(statusLine(next))
116 const system = answer.system
117 if (!system) return
118
119 await update($, samples, (old): Samples => ({
120 cpu: [...(old?.cpu ?? []), system.backend.cpuPercent].slice(-KEPT_SAMPLES),
121 rssMb: [...(old?.rssMb ?? []), system.backend.rssMb].slice(-KEPT_SAMPLES),
122 }))
123
124 // The first answer of a session only sets the mark, so runs finished before it are not announced.
125 const until = await read($, notifiedUntil)
126 const newest = system.finished[0]?.finishedAt ?? ''
127 if (until !== null) for (const run of newlyFinished(system.finished, until)) $.ui.toast(finishedText(run), { timeoutMs: 8000 })
128 if (until === null || newest > until) await update($, notifiedUntil, () => newest)
129}
130
131async function stopRun($: EngineInterface, port: number, conversationId: string): Promise<void> {
132 try {
133 const r = await $.http.fetch(`http://127.0.0.1:${port}/api/conversations/${encodeURIComponent(conversationId)}/cancel`, {
134 method: 'POST',
135 })
136 $.ui.toast(r.ok ? 'Clayfold: stopping the run…' : `Clayfold: stop failed, HTTP ${r.status}`)
137 } catch (err) {
138 $.ui.toast(`Clayfold: stop failed: ${err instanceof Error ? err.message : String(err)}`)
139 }
140}
141
142/**
143 * Starts `bun run dev:server` detached, so it outlives this mod and this session; works on macOS, Linux and Windows.
144 * The mod lives in `.claude/skills/clayfold-monitor` of the checkout it starts.
145 */
146async function startServer($: EngineInterface, config: Config): Promise<void> {
147 const dir = `${$.plugin.root}/../../..`
148 if (!(await $.fs.exists(`${dir}/server/index.ts`))) {
149 $.ui.toast(`Clayfold: no server/index.ts in ${dir}`)
150 return
151 }
152 const script = [
153 "const { spawn } = require('node:child_process')",
154 "const fs = require('node:fs')",
155 "fs.mkdirSync('data', { recursive: true })",
156 "const log = fs.openSync('data/dev-server.log', 'a')",
157 `spawn('bun', ['run', 'dev:server'], { detached: true, windowsHide: true, stdio: ['ignore', log, log], env: { ...process.env, CLAYFOLD_PORT: '${config.port}' } }).unref()`,
158 ].join(';')
159 const ran = await $.process.run(['bun', '-e', script], { cwd: dir, timeoutMs: 15000 })
160 $.ui.toast(
161 ran.exitCode === 0
162 ? 'Clayfold: starting the server; its log is data/dev-server.log'
163 : `Clayfold: start failed: ${(ran.stderr || ran.stdout).slice(0, 160)}`,
164 )
165}
166
167export const register: Register = (on, options) => {
168 const config: Config = {
169 port: Number(options.port ?? 4317),
170 vitePort: Number(options.vitePort ?? 5173),
171 longRunMs: Number(options.longRunMinutes ?? 10) * 60_000,
172 }
173
174 on('session.start', async ($, e, next) => {
175 await $.command.register({
176 name: COMMAND,
177 description: 'Show the Clayfold server, its frontend and the Claude Code instances it runs',
178 })
179 await $.tool.register({
180 name: TOOL,
181 description:
182 'Reads the local Clayfold server state: backend process stats, frontend build, the running Claude Code ' +
183 'instances with their latest activities, and recently finished conversation turns with duration, cost and ' +
184 'error. Use it to find out why a lesson, onboarding or tutor run is slow, stuck or failed.',
185 inputSchema: { type: 'object', properties: {} },
186 })
187 void poll($, config)
188 $.clock.every(POLL_MS, () => void poll($, config))
189 // A reload fires session.start again; the pane opens unasked once per session.
190 if (!(await read($, autoOpened))) {
191 await update($, autoOpened, () => true)
192 void $.ui.open({ id: PANE, title: 'Clayfold' })
193 }
194 return next(e)
195 })
196
197 on('command.run', { command: COMMAND }, async $ => {
198 const opened = await $.ui.open({ id: PANE, title: 'Clayfold' })
199 return { text: opened.isPlaced ? 'Clayfold pane opened.' : 'Clayfold pane is waiting for a wider terminal.' }
200 })
201
202 // The tool is registered at session start, so this build's tool names do not list it for a matcher.
203 on('tool.call', async ($, e, next) => {
204 if (String(e.tool) !== `mcp__clayfold-monitor__${TOOL}`) return next(e)
205 const { system, error } = await fetchSystem($, config.port)
206 if (!system) return { result: `The Clayfold server at 127.0.0.1:${config.port} did not answer: ${error}` }
207 return { result: JSON.stringify(system, null, 2) }
208 })
209
210 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
211 const elements = $.ui.resolve(e)
212 const { Box, Text, Button, Link } = elements
213 const snap = await read($, snapshot)
214
215 if (!snap) return <Text dimColor>Connecting to 127.0.0.1:{config.port}…</Text>
216
217 const vite = (
218 <Text>
219 Vite :{config.vitePort} <Text color={snap.viteUp ? 'green' : 'gray'}>{snap.viteUp ? 'up' : 'down'}</Text>
220 </Text>
221 )
222
223 if (!snap.system) {
224 return (
225 <Box flexDirection="column">
226 <Text>
227 <Text bold>Backend</Text> <Text color="red">down</Text>{' '}
228 <Text dimColor>
229 127.0.0.1:{config.port} · {snap.error}
230 </Text>
231 </Text>
232 <Box marginTop={1}>
233 <Button key="start" hotkey="s" variant="primary" onPress={() => startServer($, config)}>
234 Start server
235 </Button>
236 </Box>
237 <Text dimColor>Runs `bun run dev:server` in the project folder.</Text>
238 <Text> </Text>
239 <Text bold>Frontend</Text>
240 {vite}
241 </Box>
242 )
243 }
244
245 const { backend: b, frontend: f, instances, finished } = snap.system
246 const history = await read($, samples)
247 const width = Math.max(1, Math.min(KEPT_SAMPLES, (e.viewport?.columns ?? 40) - SPARK_LABEL - 12))
248 const cpu = history?.cpu.slice(-width) ?? []
249 const rss = history?.rssMb.slice(-width) ?? []
250 const sparks =
251 e.surface === 'terminal' && cpu.length > 1 && 'Raster' in elements
252 ? (() => {
253 const { Raster } = elements
254 return (
255 <Box flexDirection="column">
256 <Box flexDirection="row">
257 <Text dimColor>{'cpu'.padEnd(SPARK_LABEL)}</Text>
258 <Raster key="cpu" columns={cpu.length} rows={1} cells={sparkCells(cpu, 0, Math.max(5, ...cpu), 0x5fd75f)} />
259 <Text dimColor> max {pct(Math.max(...cpu))}</Text>
260 </Box>
261 <Box flexDirection="row">
262 <Text dimColor>{'mem'.padEnd(SPARK_LABEL)}</Text>
263 <Raster key="rss" columns={rss.length} rows={1} cells={sparkCells(rss, Math.min(...rss), Math.max(...rss), 0x5fafff)} />
264 <Text dimColor> max {mb(Math.max(...rss))}</Text>
265 </Box>
266 </Box>
267 )
268 })()
269 : null
270
271 let hotkey = 0
272 return (
273 <Box flexDirection="column">
274 <Text>
275 <Text bold>Backend</Text> <Text color="green">up</Text> {since(snap.at, b.startedAt)}{' '}
276 <Link href={appUrl(config.port)} label="open app" />
277 </Text>
278 <Text wrap="truncate-end">
279 PID {b.pid} · Bun {b.bun} · :{b.port}
280 </Text>
281 <Text wrap="truncate-end">
282 {mb(b.rssMb)} (heap {mb(b.heapMb)}) · CPU {pct(b.cpuPercent)} · DB {mb(b.dbMb)}
283 </Text>
284 <Text dimColor wrap="truncate-end">
285 {b.model}, critic {b.criticModel} · {usd(b.maxBudgetUsd)} per run
286 </Text>
287 {sparks}
288 <Text> </Text>
289 <Text bold>Frontend</Text>
290 <Text wrap="truncate-end">web/dist {f.builtAt ? `built ${since(snap.at, f.builtAt)} ago` : 'not built'}</Text>
291 {vite}
292 <Text dimColor>
293 {f.streams} open event stream{f.streams === 1 ? '' : 's'}
294 </Text>
295 <Text> </Text>
296 <Text bold>Claude Code instances: {instances.length}</Text>
297 {instances.length === 0 && <Text dimColor>None running.</Text>}
298 {instances.map(it => {
299 const age = snap.at - Date.parse(it.startedAt)
300 const isLong = age > config.longRunMs
301 const path = pagePath(it)
302 const key = it.conversationId && hotkey < 9 ? String(++hotkey) : undefined
303 const shown = it.activities.slice(-SHOWN_ACTIVITIES)
304 return (
305 <Box flexDirection="column" marginTop={1}>
306 <Text wrap="truncate-end">
307 <Text color="magenta">{it.kind}</Text> <Text bold>{it.topicTitle ?? '—'}</Text>{' '}
308 <Text color={isLong ? 'yellow' : undefined} dimColor={!isLong}>
309 {elapsed(age)}
310 {isLong ? ' long run' : ''}
311 </Text>
312 </Text>
313 {shown.map((label, i) => (
314 <Text dimColor={i < shown.length - 1} wrap="truncate-end">
315 {i < shown.length - 1 ? ' ' : '▸ '}
316 {label}
317 </Text>
318 ))}
319 <Text dimColor wrap="truncate-end">
320 {instanceMeta(it)}
321 </Text>
322 {(it.conversationId || path) && (
323 <Box flexDirection="row" gap={2}>
324 {it.conversationId && (
325 <Button key={`stop-${it.conversationId}`} hotkey={key} dimColor onPress={() => stopRun($, config.port, it.conversationId!)}>
326 stop
327 </Button>
328 )}
329 {path && <Link href={appUrl(config.port, path)} label="open in browser" />}
330 </Box>
331 )}
332 </Box>
333 )
334 })}
335 {finished.length > 0 && (
336 <Box flexDirection="column" marginTop={1}>
337 <Text bold>Recently finished</Text>
338 {finished.slice(0, SHOWN_FINISHED).map(run => (
339 <Text wrap="truncate-end" color={run.error && !run.cancelled ? 'red' : undefined} dimColor={!run.error || run.cancelled}>
340 {finishedText(run).replace(/^Clayfold: /, '')} · {since(snap.at, run.finishedAt)} ago
341 </Text>
342 ))}
343 </Box>
344 )}
345 </Box>
346 )
347 })
348}
349types/index.d.ts 75 lines1// The response of the Clayfold server's GET /api/system (SystemView in shared/api.ts).
2
3export type ClaudeInstance = {
4 pid: number
5 kind: 'onboard' | 'lesson' | 'tutor' | 'review' | 'critic' | 'grading'
6 model: string
7 startedAt: string
8 conversationId: string | null
9 topicId: string | null
10 topicTitle: string | null
11 lessonId: string | null
12 /** Oldest first; the last one is current. */
13 activities: string[]
14 queued: number
15 rssMb: number | null
16 cpuPercent: number | null
17}
18
19export type SystemView = {
20 backend: {
21 pid: number
22 startedAt: string
23 bun: string
24 port: number
25 rssMb: number
26 heapMb: number
27 cpuPercent: number
28 dbMb: number
29 model: string
30 criticModel: string
31 maxBudgetUsd: number
32 }
33 frontend: { builtAt: string | null; streams: number }
34 instances: ClaudeInstance[]
35 /** Newest first. */
36 finished: FinishedRun[]
37}
38
39export type FinishedRun = {
40 conversationId: string
41 kind: 'onboard' | 'lesson' | 'tutor' | 'review'
42 topicId: string
43 topicTitle: string
44 lessonId: string | null
45 startedAt: string
46 finishedAt: string
47 costUsd: number | null
48 error: string | null
49 cancelled: boolean
50}
51
52export type Snapshot = {
53 /** Clock time of the poll, in milliseconds. */
54 at: number
55 /** Null when the server did not answer. */
56 system: SystemView | null
57 error: string | null
58 viteUp: boolean
59}
60
61/** Backend samples for the sparklines, oldest first. */
62export type Samples = { cpu: number[]; rssMb: number[] }
63
64declare module 'claude-code' {
65 interface PluginState {
66 'clayfold-monitor': {
67 snapshot: Snapshot | null
68 autoOpened: boolean
69 samples: Samples
70 /** finishedAt of the newest finished run already announced; null until the first answer. */
71 notifiedUntil: string | null
72 }
73 }
74}
75