SLOPSHOPPER

course-builder

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

newpaneguardcommandtool
v0.1.0NOASSERTIONupdated 2026-10-07CGSeb/lessonfolk/.claude/skills/course-builder
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · course-builder
│ ┃ Course builder ✕ › fix the failing auth test and add an audit log call │ ┃ No course in progress. │ ┃ Run the create-course skill to start one. ⏺ 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 │ │ › /course-builder │ ⎿ course-builder: Course builder pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Course builder
No course in progress. Run the create-course skill to start one.
README

<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.

How it works

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?".

Quick start

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 Code: run claude and approve the lessonfolk MCP server when asked.
  • Codex: run codex mcp add lessonfolk --url http://localhost:4321/mcp once, then codex.
  • Other apps: follow the dashboard's Connect page.

Then say:

Let's start learning AI.

The learner guide has the details.

Documentation

License

LessonFolk is developed by CG Seb.

  • Code (dashboard, tooling, tutor instructions): MIT
  • Course content (courses/): CC BY 4.0. You may share and adapt the courses, including commercially, as long as you give appropriate credit.
Source 2 files
hooks/register.tsx 280 lines
1import { 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}
280
types/index.d.ts 31 lines
1export 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