Side pane summarising a LessonFolk create-course session: course title and workflow steps done so far

<h1 align="center"> <img src="assets/brand/logo.svg" alt="LessonFolk" width="320"> </h1>
LessonFolk is a collection of open-source AI courses designed to be taught by an AI. Connect your AI chat (Claude Code, Codex, Claude Desktop, Cursor…) to LessonFolk, say "let's start", and it guides you through lessons in a conversation: explaining, asking questions, adapting to your level, and saving your progress.
LessonFolk is a website and an MCP server (Model Context Protocol, a standard way for AI apps to use tools). Your AI chat connects to it: it gets the courses and the tutor's instructions, and saves your progress there. The website shows your courses, your path and your progress.
The tutor asks about your level and interests, recommends a path of courses, and teaches one short lesson at a time. Courses are grouped by theme: Understanding AI, Using AI tools, Building with AI and AI and society. The full list, in the recommended order, is courses/en/index.yaml, or ask your tutor "what can I learn?".
Pick one of two ways:
Hosted (coming soon): sign in on the hosted LessonFolk with GitHub or Google, open its Connect page and add LessonFolk to your AI chat. Your progress is saved with your account.
Self-hosted: private, on your computer, no data sent to us. With Docker running:
git clone https://github.com/CGSeb/lessonfolk.git
cd lessonfolk
docker compose up -d --build
Open http://127.0.0.1:4321 to see the dashboard. Then start your AI chat in this folder:
claude and approve the lessonfolk MCP server when asked.codex mcp add lessonfolk --url http://localhost:4321/mcp once, then codex.Then say:
Let's start learning AI.
The learner guide has the details.
LessonFolk is developed by CG Seb.
hooks/register.tsx 280 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Build, Lesson, StepId, StepStatus } from '../types'
5
6const PANE = 'course-builder'
7const TITLE = 'Course builder'
8const TOOL = 'mcp__course-builder__progress'
9
10const STEPS: { id: StepId; label: string }[] = [
11 { id: 'prepare', label: 'Prepare (read format, catalog)' },
12 { id: 'interview', label: 'Interview the author' },
13 { id: 'outline', label: 'Outline approved' },
14 { id: 'write', label: 'Write course.yaml and lessons' },
15 { id: 'register', label: 'Register in index.yaml' },
16 { id: 'validate', label: 'Validate (check:courses)' },
17 { id: 'handback', label: 'Hand back' },
18]
19
20const IDLE: Build = {
21 isActive: false,
22 steps: {
23 prepare: 'todo',
24 interview: 'todo',
25 outline: 'todo',
26 write: 'todo',
27 register: 'todo',
28 validate: 'todo',
29 handback: 'todo',
30 },
31 lessons: [],
32}
33
34const build = atom({ plugin: 'course-builder', key: 'build' } as const, IDLE)
35
36const slash = (path: string) => path.replace(/\\/g, '/')
37
38// courses/<lang>/<course>/<file>, wherever the repository sits.
39const coursePath = (path: string) => {
40 const match = /(?:^|\/)courses\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(slash(path))
41
42 return match ? { lang: match[1], course: match[2], file: match[3] } : undefined
43}
44
45const isIndex = (path: string) => /(?:^|\/)courses\/[^/]+\/index\.yaml$/.test(slash(path))
46
47const yamlField = (text: string, key: string) =>
48 new RegExp(`^${key}:\\s*(.+)$`, 'm').exec(text)?.[1]?.trim().replace(/^["']|["']$/g, '')
49
50// Marks `step` and moves every earlier unfinished step to done: steps run in order.
51const reach = (b: Build, step: StepId, status: StepStatus): Build => {
52 const at = STEPS.findIndex(s => s.id === step)
53 const steps = { ...b.steps }
54 STEPS.forEach((s, i) => {
55 if (i < at && steps[s.id] !== 'done') steps[s.id] = 'done'
56 })
57 if (steps[step] !== 'done' || status === 'done') steps[step] = status
58
59 return { ...b, steps }
60}
61
62const mergeLessons = (known: Lesson[], files: string[]): Lesson[] =>
63 files.map(file => known.find(l => l.file === file) ?? { file, isWritten: false })
64
65export const register: Register = on => {
66 on('session.start', async ($, e, next) => {
67 await $.command.register({
68 name: 'course-builder',
69 description: 'Show the course creation summary pane',
70 })
71 await $.tool.register({
72 name: 'progress',
73 description:
74 'Updates the Course builder pane while you run the create-course skill of LessonFolk. ' +
75 'Call it whenever a step of the "Create a course" procedure starts or finishes ' +
76 '(prepare, interview, outline, write, register, validate, handback), and as soon as ' +
77 'the course title, id, level, theme or lesson list is known or changes (for example ' +
78 'once the outline is proposed). Files you write under courses/ are tracked on their own.',
79 inputSchema: {
80 type: 'object',
81 properties: {
82 step: {
83 type: 'string',
84 enum: STEPS.map(s => s.id),
85 description: 'The workflow step this update is about',
86 },
87 status: {
88 type: 'string',
89 enum: ['active', 'done'],
90 description: 'Whether the step has started or is finished (default active)',
91 },
92 title: { type: 'string', description: 'Course title' },
93 courseId: { type: 'string', description: 'Course id (kebab-case)' },
94 level: { type: 'string' },
95 theme: { type: 'string' },
96 lessons: {
97 type: 'array',
98 description: 'Planned lessons in order',
99 items: {
100 type: 'object',
101 properties: {
102 file: { type: 'string', description: 'NN-slug' },
103 title: { type: 'string' },
104 },
105 required: ['file'],
106 },
107 },
108 note: { type: 'string', description: 'One short line on where things stand' },
109 },
110 },
111 })
112
113 return next(e)
114 })
115
116 on('command.run', { command: 'course-builder' }, async $ => {
117 await $.ui.open({ id: PANE, title: TITLE })
118
119 return { text: 'Course builder pane opened.' }
120 })
121
122 // The skill starting is what opens the pane and starts a fresh summary.
123 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
124 const ran = await next(e)
125 if (e.skill !== 'create-course' || ran.deny !== undefined || ran.isError) return ran
126
127 await update($, build, () => reach({ ...IDLE, isActive: true }, 'prepare', 'active'))
128 void $.ui.open({ id: PANE, title: TITLE })
129
130 return {
131 ...ran,
132 context: [
133 ...(ran.context ?? []),
134 `A "${TITLE}" pane now shows the person this course creation. Keep it current with ` +
135 `the ${TOOL} tool: call it as each step starts or finishes and when the course ` +
136 `title, id or lesson list is settled.`,
137 ],
138 }
139 })
140
141 on('tool.call', { tool: TOOL }, async ($, e) => {
142 const input = (e as unknown as { input?: Record<string, unknown> }).input ??
143 (e as unknown as Record<string, unknown>)
144 const step = input.step as StepId | undefined
145 const status = (input.status as 'active' | 'done' | undefined) ?? 'active'
146 const lessons = input.lessons as { file: string; title?: string }[] | undefined
147
148 await update($, build, b => {
149 let next: Build = { ...b, isActive: true }
150 if (step && STEPS.some(s => s.id === step)) next = reach(next, step, status)
151 for (const key of ['title', 'courseId', 'level', 'theme', 'note'] as const) {
152 if (typeof input[key] === 'string') next = { ...next, [key]: input[key] as string }
153 }
154 if (lessons) {
155 next = {
156 ...next,
157 lessons: lessons.map(l => {
158 const known = b.lessons.find(k => k.file === l.file)
159
160 return { file: l.file, title: l.title ?? known?.title, isWritten: known?.isWritten ?? false }
161 }),
162 }
163 }
164
165 return next
166 })
167 void $.ui.open({ id: PANE, title: TITLE })
168
169 return { result: 'Course builder pane updated.' }
170 })
171
172 // Files written under courses/ fill in the write step without the model's help.
173 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
174 const ran = await next(e)
175 if (ran.deny !== undefined || ran.isError || !(await read($, build)).isActive) return ran
176
177 const where = coursePath(e.file_path)
178 if (where?.file === 'course.yaml') {
179 const lessonIds = [...e.content.matchAll(/^\s*-\s*([^\s#]+\/[^\s#]+)\s*$/gm)].map(m => m[1])
180 await update($, build, b => ({
181 ...reach(b, 'write', 'active'),
182 title: yamlField(e.content, 'title') ?? b.title,
183 courseId: yamlField(e.content, 'id') ?? where.course,
184 level: yamlField(e.content, 'level') ?? b.level,
185 theme: yamlField(e.content, 'theme') ?? b.theme,
186 lessons: lessonIds.length
187 ? mergeLessons(b.lessons, lessonIds.map(id => id.split('/').pop() as string))
188 : b.lessons,
189 }))
190 } else if (where && /^\d+-.+\.md$/.test(where.file)) {
191 const file = where.file.replace(/\.md$/, '')
192 const title = yamlField(e.content, 'title')
193 await update($, build, b => {
194 const has = b.lessons.some(l => l.file === file)
195 const lessons = has
196 ? b.lessons.map(l => (l.file === file ? { ...l, title: title ?? l.title, isWritten: true } : l))
197 : [...b.lessons, { file, title, isWritten: true }]
198 const isAllWritten = lessons.every(l => l.isWritten)
199
200 return { ...reach(b, 'write', isAllWritten ? 'done' : 'active'), lessons }
201 })
202 } else if (isIndex(e.file_path)) {
203 await update($, build, b => reach(b, 'register', 'done'))
204 }
205
206 return ran
207 })
208
209 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
210 const ran = await next(e)
211 if (ran.deny !== undefined || ran.isError || !(await read($, build)).isActive) return ran
212 if (isIndex(e.file_path)) await update($, build, b => reach(b, 'register', 'done'))
213
214 return ran
215 })
216
217 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
218 const ran = await next(e)
219 if (!/check:courses/.test(e.command) || ran.deny !== undefined) return ran
220 if (!(await read($, build)).isActive) return ran
221
222 const isPassed = !ran.isError
223 await update($, build, b => ({
224 ...reach(b, 'validate', isPassed ? 'done' : 'active'),
225 validation: isPassed ? 'pass' : 'fail',
226 }))
227
228 return ran
229 })
230
231 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
232 const { Box, Text } = $.ui.resolve(e)
233 const b = await read($, build)
234
235 if (!b.isActive) {
236 return (
237 <Box flexDirection="column">
238 <Text dimColor>No course in progress.</Text>
239 <Text dimColor>Run the create-course skill to start one.</Text>
240 </Box>
241 )
242 }
243
244 const mark = (s: StepStatus) => (s === 'done' ? '✓' : s === 'active' ? '●' : '○')
245 const written = b.lessons.filter(l => l.isWritten).length
246
247 return (
248 <Box flexDirection="column">
249 <Text bold>{b.title ?? 'Untitled course'}</Text>
250 {b.courseId && <Text dimColor>{b.courseId}</Text>}
251 {(b.level || b.theme) && (
252 <Text dimColor>{[b.level, b.theme].filter(Boolean).join(' · ')}</Text>
253 )}
254 <Text> </Text>
255 <Text bold>Steps</Text>
256 {STEPS.map(s => (
257 <Text dimColor={b.steps[s.id] === 'todo'} bold={b.steps[s.id] === 'active'}>
258 {mark(b.steps[s.id])} {s.label}
259 {s.id === 'validate' && b.validation ? ` (${b.validation === 'pass' ? 'passed' : 'failed'})` : ''}
260 </Text>
261 ))}
262 {b.lessons.length > 0 && <Text> </Text>}
263 {b.lessons.length > 0 && (
264 <Text bold>
265 Lessons {written}/{b.lessons.length}
266 </Text>
267 )}
268 {b.lessons.map(l => (
269 <Text dimColor={!l.isWritten}>
270 {l.isWritten ? '✓' : '○'} {l.file}
271 {l.title ? ` · ${l.title}` : ''}
272 </Text>
273 ))}
274 {b.note && <Text> </Text>}
275 {b.note && <Text dimColor>{b.note}</Text>}
276 </Box>
277 )
278 })
279}
280types/index.d.ts 31 lines1export type StepId =
2 | 'prepare'
3 | 'interview'
4 | 'outline'
5 | 'write'
6 | 'register'
7 | 'validate'
8 | 'handback'
9
10export type StepStatus = 'todo' | 'active' | 'done'
11
12export type Lesson = { file: string; title?: string; isWritten: boolean }
13
14export type Build = {
15 isActive: boolean
16 title?: string
17 courseId?: string
18 level?: string
19 theme?: string
20 steps: Record<StepId, StepStatus>
21 lessons: Lesson[]
22 validation?: 'pass' | 'fail'
23 note?: string
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'course-builder': { build: Build }
29 }
30}
31