pnpm install
cp .env.example .env.local
pnpm devOne LLM API key is enough to run everything. Set STANDARDS_SOURCE=example and leave
Supabase unconfigured and you need no other accounts — see the README.
Before opening a PR:
pnpm lint # eslint
pnpm build # next build — this is also the typecheckCI runs both on every PR. pnpm build matters as much as lint: it typechecks, and the
editor language server does not catch everything the compiler does.
A widget is data, not a React tree — the model emits a spec and a registry renders it. Nothing in the pipeline needs to know your kind exists.
Take drag-sort as the smallest complete example: it has a spec, a component, a catalog
entry, a generator, and a demo page, and nothing else references it.
export const yourWidgetSpec = z.object({
kind: z.literal('your-widget'),
learningComponentId: z.string().nullable(),
// …the fields the model fills in. Use .describe() generously; it is prompt text.
});
export type YourWidgetSpec = z.infer<typeof yourWidgetSpec>;Then add it in two places in the same file:
- the
widgetSpecdiscriminated union - the
widgetKindenum
Both are still hand-maintained. Deriving them from the registry would close a circular
import between schema.ts and the registry, which is why it hasn't been done.
Props are { spec, onComplete? }. If a student can be right or wrong, emit telemetry:
const telemetry = useWidgetTelemetry();
telemetry.track({
eventType: 'answer_checked',
widgetKind: spec.kind,
learningComponentId: spec.learningComponentId,
standardCode: telemetry.standardCode,
correct: allCorrect,
payload: { attempt: attempts + 1, ...(allCorrect ? {} : { misconception: spec.hint }) },
});and widget_completed once on success, guarded by a ref so a re-submit can't double-count.
This is what feeds session reports and the personalization that weights later pathways
toward what a student found hard. A widget that skips it records nothing.
registerWidgetCatalog<YourWidgetSpec>({
kind: 'your-widget',
schema: yourWidgetSpec,
component: lazy(() =>
import('@/components/widgets/YourWidget').then((m) => ({ default: m.YourWidget })),
),
plannerDescription: 'What it does, which subjects it suits, and which step purposes it fits.',
assesses: true,
coverageRule: (standard) => standard.tags.includes('your-tag'),
});Then add one import line to builtins.ts.
plannerDescriptionis prompt text. It is joined with every other widget's and becomes the menu the planner chooses from. Say what the widget is for and when not to use it — the good ones name a step purpose ("prefer practice or check, never activate").assessesis whether finishing the widget records whether the student was right. It is required, so you have to decide.falseis correct for presentational widgets; it also means the widget can't back acheckstep, and one that tries is substituted with a note.coverageRuleis optional. Omit it and the widget fits every standard.
registerWidgetGenerator({
kind: 'your-widget',
async generate(ctx) {
// ctx: { anchor, plan, step, prompt } — `prompt` is the prebuilt shared context
// Return { widget: null, note } if you can't produce something usable; the
// orchestrator falls back for you.
},
});Then add one import line to builtins.generate.ts. This is the server-only half.
Every widget has one, with a hand-written spec so it renders without a model call. It is how reviewers see your widget without spending a token.
SELF_ADVANCING_KINDSinsrc/components/pathway/PathwayWalkthrough.tsx— add your kind if finishing it is unambiguous. Leave it out and the student gets an explicit "I'm done" button, which is right for anything open-ended.pnpm mcp:buildregeneratespublic/widget-shell.html, a committed bundle that inlines every widget for MCP hosts. It does not regenerate itself, and it has been forgotten before.
Implement StandardsSource (src/lib/standards/types.ts) and add it to the REGISTRY map in
index.ts. src/lib/standards/example.ts is a complete in-memory implementation to copy.
STANDARDS_SOURCE accepts a comma-separated list tried in priority order, so a new source can
cover topics an existing one rejects.
Implement the interface in src/lib/storage/types.ts and register it in index.ts.
memory.ts is the reference implementation.
- Comments explain why, not what. The existing code is a good guide.
- Prefer a registry entry over a
switch. If you find yourself adding a case for your kind somewhere, that list probably wants to be derived instead. - Don't commit
package.jsonorpnpm-lock.yamlchanges unless the PR is about dependencies.