# Beadkit Learning primitives for AI-native interfaces — components an LLM can emit mid-stream, rendered natively with xAPI telemetry. Registry: https://beadkit.dev/r/.json ## Install - learning-core — Shared card shell and xAPI telemetry. Every learning component depends on this; installing a component pulls it in automatically. npx shadcn@latest add https://beadkit.dev/r/learning-core.json - quiz — Inline concept check with immediate feedback and an xAPI answered statement. npx shadcn@latest add https://beadkit.dev/r/quiz.json - flashcard — Two-sided card with an xAPI experienced statement on first reveal. npx shadcn@latest add https://beadkit.dev/r/flashcard.json - sequence — Shuffled steps the learner reorders, with keyboard controls and an xAPI answered statement carrying the submitted order. npx shadcn@latest add https://beadkit.dev/r/sequence.json - mastery — Per-skill mastery bars plus a learner self-rating, emitting an xAPI rated statement. npx shadcn@latest add https://beadkit.dev/r/mastery.json - learning-stream — Parses payloads out of streaming model output and hydrates them inline. Pulls in every component it can render. npx shadcn@latest add https://beadkit.dev/r/learning-stream.json ## Source ### components/learning/LearningCard.tsx ```tsx import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface LearningCardProps { /** Eyebrow label, e.g. "Concept check". Rendered uppercase. */ label: string; /** Component type, exposed for the parser and the QA harness to find. */ type: string; children: ReactNode; className?: string; } /** * Shared shell for every learning component: surface, eyebrow, sizing. Keeping * the chrome here means a new component only writes its own interaction, and a * change to the surface applies to all of them at once. */ export function LearningCard({ label, type, children, className }: LearningCardProps) { return (
{label}
{children}
); } ``` ### components/learning/ComponentBoundary.tsx ```tsx "use client"; import { Component, type ReactNode } from "react"; interface Props { children: ReactNode; fallback: ReactNode; } /** * Isolates one learning component's render from the rest of the page. * * Validation in the registry rejects malformed payloads, but it cannot prove a * component will not throw — a deep field can still be the wrong shape, and a * host app may pass its own props. Without a boundary, one bad activity in a * streamed lesson unmounts the entire host tree. The cost of being wrong here * is somebody else's app going blank, so the boundary is not optional. * * Class component because React exposes error catching only via lifecycle. */ export class ComponentBoundary extends Component { state = { failed: false }; static getDerivedStateFromError() { return { failed: true }; } componentDidCatch(error: unknown) { // The host app owns real reporting; surfacing it beats swallowing it. console.error("Learning component failed to render", error); } render() { return this.state.failed ? this.props.fallback : this.props.children; } } ``` ### components/learning/xapi.ts ```tsx import { useCallback, useRef } from "react"; /** Verbs we emit. Add here as components need them. */ const VERB_IDS = { answered: "http://adlnet.gov/expapi/verbs/answered", experienced: "http://adlnet.gov/expapi/verbs/experienced", // Not an ADL verb — self-assessment has no ADL equivalent, and the registered // tincanapi verb is what an LRS will already recognise for it. rated: "http://id.tincanapi.com/verb/rated", } as const; export type XApiVerb = keyof typeof VERB_IDS; export interface XApiStatement { verb: { id: string; display: { "en-US": string } }; object: { id: string; definition: { name: { "en-US": string } } }; result: { response?: string; success?: boolean; completion: boolean; duration: string; }; } export interface StatementInput { verb: XApiVerb; activityId: string; /** Human-readable activity name, e.g. the quiz prompt or flashcard front. */ name: string; durationMs: number; response?: string; success?: boolean; } export function buildStatement({ verb, activityId, name, durationMs, response, success, }: StatementInput): XApiStatement { const seconds = Math.max(0, Math.round(durationMs / 1000)); return { verb: { id: VERB_IDS[verb], display: { "en-US": verb } }, object: { id: `http://beadkit.dev/activities/${activityId}`, definition: { name: { "en-US": name } }, }, result: { // Omitted rather than sent as null: an LRS treats a present-but-null // `success` as a scored attempt, which a flashcard view is not. ...(response !== undefined && { response }), ...(success !== undefined && { success }), completion: true, duration: `PT${seconds}S`, }, }; } /** * Times the learner's engagement and emits an xAPI statement. Every learning * component needs the same start-clock + emit pair, so it lives here rather * than being re-implemented per component. */ export function useTelemetry( activityId: string, name: string, onTrackTelemetry?: (statement: XApiStatement) => void ) { // Set on first render, not on mount, so time spent before hydration counts. const startedAt = useRef(Date.now()); return useCallback( (verb: XApiVerb, extra: Pick = {}) => { onTrackTelemetry?.( buildStatement({ verb, activityId, name, durationMs: Date.now() - startedAt.current, ...extra, }) ); }, [activityId, name, onTrackTelemetry] ); } // Self-check: npx tsx components/learning/xapi.ts if (process.argv[1]?.endsWith("xapi.ts")) { const quiz = buildStatement({ verb: "answered", activityId: "quiz-1", name: "HTTP Idempotency Check", durationMs: 14400, response: "B", success: true, }); console.assert(quiz.object.id === "http://beadkit.dev/activities/quiz-1", "object id"); console.assert(quiz.verb.id.endsWith("/answered"), "verb id"); console.assert(quiz.result.duration === "PT14S", "duration rounds to seconds"); console.assert(quiz.result.success === true && quiz.result.response === "B", "result fields"); const card = buildStatement({ verb: "experienced", activityId: "card-1", name: "Idempotent", durationMs: 400, }); console.assert(!("success" in card.result), "success omitted when not scored"); console.assert(!("response" in card.result), "response omitted when not scored"); console.assert(card.result.duration === "PT0S", "sub-second rounds to PT0S"); console.assert(card.verb.id.endsWith("/experienced"), "experienced verb id"); // Self-assessment carries a response but no success: the learner's own rating // is not a scored attempt, and an LRS reads `success` as one. const rating = buildStatement({ verb: "rated", activityId: "mastery-1", name: "REST fundamentals", durationMs: 9000, response: "solid", }); console.assert(rating.verb.id === "http://id.tincanapi.com/verb/rated", "rated verb id"); console.assert(rating.result.response === "solid", "rating response"); console.assert(!("success" in rating.result), "self-rating is not scored"); console.log("xapi self-check passed"); } ``` ### components/learning/QuizBlock.tsx ```tsx "use client"; import { useState } from "react"; import { LearningCard } from "./LearningCard"; import { useTelemetry, type XApiStatement } from "./xapi"; export interface QuizOption { id: string; text: string; valid: boolean; feedback: string; } export interface QuizBlockProps { activityId: string; prompt: string; options: QuizOption[]; onTrackTelemetry?: (statement: XApiStatement) => void; } export function QuizBlock({ activityId, prompt, options, onTrackTelemetry }: QuizBlockProps) { const [selected, setSelected] = useState(null); const [submitted, setSubmitted] = useState(false); const track = useTelemetry(activityId, prompt, onTrackTelemetry); const chosen = options.find((o) => o.id === selected); const handleSubmit = () => { if (!chosen || submitted) return; setSubmitted(true); track("answered", { response: chosen.id, success: chosen.valid }); }; return (

{prompt}

{options.map((opt) => { const isSelected = selected === opt.id; return ( ); })}
{selected && !submitted && ( )}
{submitted && chosen && (
{chosen.valid ? "Correct" : "Incorrect"} {chosen.feedback}
)}
); } ``` ### components/learning/FlashcardBlock.tsx ```tsx "use client"; import { useState } from "react"; import { LearningCard } from "./LearningCard"; import { useTelemetry, type XApiStatement } from "./xapi"; export interface FlashcardBlockProps { activityId: string; front: string; back: string; onTrackTelemetry?: (statement: XApiStatement) => void; } export function FlashcardBlock({ activityId, front, back, onTrackTelemetry, }: FlashcardBlockProps) { const [revealed, setRevealed] = useState(false); const [tracked, setTracked] = useState(false); const track = useTelemetry(activityId, front, onTrackTelemetry); const flip = () => { setRevealed((v) => !v); // Only the first reveal is a learning event; flipping back and forth is not // repeated engagement and would inflate the LRS record. if (!tracked) { setTracked(true); track("experienced"); } }; return (

{revealed ? "Click to flip back" : "Click to reveal the answer"}

); } ``` ### components/learning/SequenceBuilder.tsx ```tsx "use client"; import { useEffect, useRef, useState } from "react"; import { LearningCard } from "./LearningCard"; import { useTelemetry, type XApiStatement } from "./xapi"; export interface SequenceStep { id: string; text: string; } export interface SequenceBuilderProps { activityId: string; prompt: string; /** The correct order. Shown shuffled; the learner reorders back to this. */ steps: SequenceStep[]; onTrackTelemetry?: (statement: XApiStatement) => void; } /** * Deterministic shuffle: same seed, same order, every time. * * `Math.random()` here would produce one order on the server and another in the * browser, which is a hydration mismatch. Seeding from the activity id keeps the * function pure, so both sides agree. */ function shuffle(steps: SequenceStep[], seed: string): SequenceStep[] { let h = 2166136261; for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 16777619); const out = [...steps]; for (let i = out.length - 1; i > 0; i--) { h = Math.imul(h ^ (h >>> 15), 2246822507); const j = (h >>> 0) % (i + 1); [out[i], out[j]] = [out[j], out[i]]; } // A shuffle that lands on the answer hands the learner a solved activity. // Rotate rather than reshuffle: one step is enough to break it, and it always // terminates. return out.every((s, i) => s.id === steps[i].id) ? [...out.slice(1), out[0]] : out; } const btnId = (activityId: string, stepId: string, dir: "up" | "down") => `${activityId}-${stepId}-${dir}`; export function SequenceBuilder({ activityId, prompt, steps, onTrackTelemetry, }: SequenceBuilderProps) { const [order, setOrder] = useState(() => shuffle(steps, activityId)); const [submitted, setSubmitted] = useState(false); const track = useTelemetry(activityId, prompt, onTrackTelemetry); // Reordering re-renders the list under the button that was just clicked, so // focus has to be put back by hand or every move drops the keyboard user out // of the list. const refocus = useRef(null); useEffect(() => { if (!refocus.current) return; document.getElementById(refocus.current)?.focus(); refocus.current = null; }); const move = (from: number, delta: number) => { if (submitted) return; const to = from + delta; const next = [...order]; [next[from], next[to]] = [next[to], next[from]]; setOrder(next); // The button just clicked may not exist at the item's new position — an // item at the top has no "up" — so fall back to the opposite one. const up = delta < 0; const clickedStillExists = up ? to > 0 : to < next.length - 1; refocus.current = btnId( activityId, next[to].id, clickedStillExists ? (up ? "up" : "down") : up ? "down" : "up" ); }; const correct = order.every((s, i) => s.id === steps[i].id); const handleSubmit = () => { if (submitted) return; setSubmitted(true); track("answered", { response: order.map((s) => s.id).join(","), success: correct }); }; return (

{prompt}

    {order.map((step, i) => (
  1. {i + 1} {step.text} {!submitted && ( // Rendered only when the move is possible. A dimmed disabled // arrow would sit below 4.5:1 and fail the contrast check, and an // invisible one still takes tab focus. {i > 0 && ( )} {i < order.length - 1 && ( )} )}
  2. ))}
{!submitted && ( )}
{submitted && (
{correct ? "Correct" : "Not quite"} {correct ? "That is the right sequence." : `The right order is: ${steps.map((s) => s.text).join(" → ")}`}
)}
); } ``` ### components/learning/MasteryMeter.tsx ```tsx "use client"; import { useState } from "react"; import { LearningCard } from "./LearningCard"; import { useTelemetry, type XApiStatement } from "./xapi"; export interface MasterySkill { id: string; label: string; /** The model's assessment of the learner, 0 to 1. */ level: number; } export interface MasteryMeterProps { activityId: string; title: string; skills: MasterySkill[]; onTrackTelemetry?: (statement: XApiStatement) => void; } const RATINGS = [ { id: "shaky", label: "Still shaky" }, { id: "getting-there", label: "Getting there" }, { id: "solid", label: "Solid" }, ]; /** Segments per bar. Discrete reads as a level; a continuous fill reads as a * loading state, which is the wrong idea for a mastery figure. */ const SEGMENTS = 5; export function MasteryMeter({ activityId, title, skills, onTrackTelemetry, }: MasteryMeterProps) { const [rated, setRated] = useState(null); const track = useTelemetry(activityId, title, onTrackTelemetry); const overall = skills.reduce((sum, s) => sum + s.level, 0) / skills.length; const rate = (id: string) => { if (rated) return; setRated(id); // No `success`: a self-rating is the learner's own read, not a scored // attempt, and an LRS treats a present `success` as one. track("rated", { response: id }); }; return (

{title}

{Math.round(overall * 100)}%
    {skills.map((skill) => { const filled = Math.round(skill.level * SEGMENTS); return (
  • {skill.label} {Math.round(skill.level * 100)}%
    {Array.from({ length: SEGMENTS }, (_, i) => ( ))}
  • ); })}
How solid does this feel to you?
{RATINGS.map((r) => ( ))}
{rated && (

Noted — your tutor will weight the next questions accordingly.

)}
); } ``` ### components/learning/parse.ts ```tsx import { registry, type LearningType } from "./registry"; const OPEN = ""; const CLOSE = ""; export type Segment = | { kind: "text"; text: string } /** Validated payload, ready to render. */ | { kind: "component"; type: LearningType; data: Record; activityId: string } /** Tag closed but the payload was malformed or of an unknown type. */ | { kind: "invalid"; activityId: string } /** Tag opened and the stream has not closed it yet. */ | { kind: "pending" }; /** * If the text ends mid-tag ("... 0; i--) { if (text.endsWith(OPEN.slice(0, i))) return text.slice(0, -i); } return text; } /** The protocol forbids code fences inside the tags, but models add them anyway. */ function stripFences(raw: string): string { return raw .replace(/^\s*```(?:json)?\s*/i, "") .replace(/\s*```\s*$/, "") .trim(); } function classify(raw: string, activityId: string): Segment { let data: unknown; try { data = JSON.parse(stripFences(raw)); } catch { return { kind: "invalid", activityId }; } if (typeof data !== "object" || data === null || Array.isArray(data)) { return { kind: "invalid", activityId }; } const record = data as Record; const type = record.type; if (typeof type !== "string" || !(type in registry)) { return { kind: "invalid", activityId }; } const entry = registry[type as LearningType]; // Validate now so the stream never hands a broken payload to a component. if (entry.parse(record, activityId) === null) { return { kind: "invalid", activityId }; } return { kind: "component", type: type as LearningType, data: record, activityId }; } /** * Splits accumulated LLM output into prose and learning components. * * Takes the full text so far rather than a delta, so it is pure and idempotent: * calling it on every chunk cannot drift out of sync with the stream the way an * incremental state machine can. Text after an unclosed tag is withheld as * `pending` — the payload is still arriving and cannot be trusted yet. */ export function parseLearningStream(text: string): Segment[] { const segments: Segment[] = []; const pushText = (t: string) => { if (t.trim()) segments.push({ kind: "text", text: t.trim() }); }; let rest = text; let index = 0; for (;;) { const openAt = rest.indexOf(OPEN); if (openAt === -1) { pushText(trimPartialOpenTag(rest)); return segments; } pushText(rest.slice(0, openAt)); const after = rest.slice(openAt + OPEN.length); const closeAt = after.indexOf(CLOSE); if (closeAt === -1) { // Still streaming. Anything after an unclosed tag is part of this payload. segments.push({ kind: "pending" }); return segments; } // Ordinal, not a content hash: components are only ever appended, so an // earlier component keeps its id as more of the stream arrives, which keeps // xAPI statements pointing at a stable activity across re-renders. segments.push(classify(after.slice(0, closeAt), `streamed-${index}`)); index++; rest = after.slice(closeAt + CLOSE.length); } } // Self-check: npx tsx components/learning/parse.ts if (process.argv[1]?.endsWith("parse.ts")) { const kinds = (t: string) => parseLearningStream(t).map((s) => s.kind).join(","); const quiz = JSON.stringify({ type: "quiz", prompt: "Which is idempotent?", options: [ { id: "A", text: "POST", valid: false, feedback: "no" }, { id: "B", text: "GET", valid: true, feedback: "yes" }, ], }); const wrap = (json: string) => `${OPEN}${json}${CLOSE}`; console.assert(kinds("just prose") === "text", "plain text"); console.assert(kinds("") === "", "empty stream yields nothing"); console.assert(kinds(`Intro ${wrap(quiz)} outro`) === "text,component,text", "text around"); console.assert(kinds(`${wrap(quiz)}${wrap(quiz)}`) === "component,component", "back to back"); // Cut-off mid-payload: withheld, never rendered half-parsed. console.assert(kinds(`Intro ${OPEN}{"type":"qu`) === "text,pending", "unclosed tag pends"); console.assert(kinds(`Intro ${OPEN}`) === "text,pending", "bare open tag pends"); // A partially-arrived open tag must not leak to the reader as prose. console.assert(kinds("Intro s.kind === "invalid"), `prefix ${i} produced invalid`); const text = segs .filter((s): s is { kind: "text"; text: string } => s.kind === "text") .map((s) => s.text) .join(" "); console.assert(!text.includes(" s.kind === "component").length === 2, "final"); // Every registry demo must survive its own validator. This is what keeps the // documented schemas in PROMPT.md honest: the demos are the worked examples, // so a validator tightened without updating them fails here. for (const [type, e] of Object.entries(registry)) { const payload = JSON.stringify({ type, ...e.demo }); console.assert(kinds(wrap(payload)) === "component", `registry demo "${type}" fails its parser`); } console.log("parse self-check passed"); } ``` ### components/learning/registry.tsx ```tsx import type { ComponentType, ReactNode } from "react"; import { FlashcardBlock } from "./FlashcardBlock"; import { MasteryMeter } from "./MasteryMeter"; import { QuizBlock } from "./QuizBlock"; import { SequenceBuilder } from "./SequenceBuilder"; import type { XApiStatement } from "./xapi"; type Track = (statement: XApiStatement) => void; /** Props every learning component accepts. */ export interface LearningComponentProps { activityId: string; onTrackTelemetry?: Track; } interface EntryInput

{ /** Showcase heading. */ title: string; /** One or two sentences for the showcase. */ description: string; Component: ComponentType

; /** Example payload. Doubles as the showcase demo and a parser fixture. */ demo: P; /** * Validates one streamed `` payload into props, or null * if it does not conform. This is a trust boundary: the input is model * output, so every field is checked rather than assumed. * * `activityId` is supplied by the parser because the LLM schema has no id * field, but xAPI statements need a stable activity to point at. */ parse: (data: Record, activityId: string) => P | null; } const isNonEmptyString = (v: unknown): v is string => typeof v === "string" && v.trim() !== ""; /** * Binds a component to its demo payload while the prop type is still concrete. * Iterating the registry widens entries to a union, at which point TypeScript * can no longer tell that a given demo belongs to a given component — so the * pairing is resolved into `render` here instead of being cast at the call site. */ const entry =

(e: EntryInput

) => ({ title: e.title, description: e.description, Component: e.Component, demo: e.demo, parse: e.parse, render: (onTrackTelemetry?: Track): ReactNode => ( ), /** * Validates and renders one streamed payload, or returns null if it does not * conform. Combined into one step for the same union-widening reason as * `render`: only here is P concrete enough to pass parsed props to Component. */ parseAndRender: ( data: Record, activityId: string, onTrackTelemetry?: Track ): ReactNode | null => { const props = e.parse(data, activityId); return props ? : null; }, }); /** * Single source of truth for the library. Adding a component means adding one * entry here: the showcase page and the `` stream parser * both read from this map rather than keeping their own lists. * * Keys are the `type` field of the JSON an LLM emits. */ export const registry = { quiz: entry({ title: "Quiz", description: "An inline concept check with immediate feedback. Emits an xAPI “answered” statement on submit — open the console to see it.", Component: QuizBlock, parse: (data, activityId) => { if (!isNonEmptyString(data.prompt) || !Array.isArray(data.options)) return null; // A quiz with no options, or with exactly one, is not answerable. if (data.options.length < 2) return null; const options = data.options.map((o: unknown) => { const opt = o as Record; if (!isNonEmptyString(opt?.id) || !isNonEmptyString(opt?.text)) return null; return { id: opt.id, text: opt.text, valid: opt.valid === true, feedback: typeof opt.feedback === "string" ? opt.feedback : "", }; }); if (options.some((o) => o === null)) return null; const valid = options as NonNullable<(typeof options)[number]>[]; // Without a correct answer the feedback and the xAPI `success` flag are // both meaningless, so treat it as malformed rather than render it. if (!valid.some((o) => o.valid)) return null; return { activityId, prompt: data.prompt, options: valid }; }, demo: { activityId: "http-idempotency-check", prompt: "Which HTTP method should be safely designed as idempotent?", options: [ { id: "A", text: "POST", valid: false, feedback: "POST creates new resources and is typically non-idempotent.", }, { id: "B", text: "GET", valid: true, feedback: "Correct! Multiple GET calls return the same data without altering state.", }, ], }, }), flashcard: entry({ title: "Flashcard", description: "A term on the front, the explanation on the back. Emits an xAPI “experienced” statement the first time it is revealed.", Component: FlashcardBlock, parse: (data, activityId) => isNonEmptyString(data.front) && isNonEmptyString(data.back) ? { activityId, front: data.front, back: data.back } : null, demo: { activityId: "idempotency-definition", front: "What does it mean for an HTTP method to be idempotent?", back: "Making the same request many times has the same effect as making it once.", }, }), sequence: entry({ title: "Sequence builder", description: "Steps arrive shuffled and the learner orders them. Reordering is keyboard-driven, and submitting emits an xAPI “answered” statement carrying the submitted order.", Component: SequenceBuilder, parse: (data, activityId) => { if (!isNonEmptyString(data.prompt) || !Array.isArray(data.steps)) return null; // Two steps is a coin flip, not an ordering task — and the anti-identity // rotation in the component would make the answer the reverse every time. if (data.steps.length < 3) return null; const steps = data.steps.map((s: unknown) => { const step = s as Record; if (!isNonEmptyString(step?.id) || !isNonEmptyString(step?.text)) return null; return { id: step.id, text: step.text }; }); if (steps.some((s) => s === null)) return null; const valid = steps as NonNullable<(typeof steps)[number]>[]; // Duplicate ids would make the submitted order ambiguous in the xAPI // response and break React's keys. if (new Set(valid.map((s) => s.id)).size !== valid.length) return null; return { activityId, prompt: data.prompt, steps: valid }; }, demo: { activityId: "http-request-lifecycle", prompt: "Order the steps of an HTTP request.", steps: [ { id: "dns", text: "Resolve the hostname via DNS" }, { id: "tcp", text: "Open a TCP connection" }, { id: "tls", text: "Negotiate TLS" }, { id: "send", text: "Send the request" }, { id: "recv", text: "Receive the response" }, ], }, }), mastery: entry({ title: "Mastery meter", description: "The tutor's read on where the learner stands across a topic, plus a self-rating that emits an xAPI “rated” statement — the signal for what to teach next.", Component: MasteryMeter, parse: (data, activityId) => { if (!isNonEmptyString(data.title) || !Array.isArray(data.skills)) return null; if (data.skills.length === 0) return null; const skills = data.skills.map((s: unknown) => { const skill = s as Record; if (!isNonEmptyString(skill?.id) || !isNonEmptyString(skill?.label)) return null; // Out-of-range levels would overflow the bar and print figures like // "340%". Reject rather than clamp: a model that far off the schema is // probably wrong about the content too. const level = skill.level; if (typeof level !== "number" || !Number.isFinite(level) || level < 0 || level > 1) { return null; } return { id: skill.id, label: skill.label, level }; }); if (skills.some((s) => s === null)) return null; const valid = skills as NonNullable<(typeof skills)[number]>[]; if (new Set(valid.map((s) => s.id)).size !== valid.length) return null; return { activityId, title: data.title, skills: valid }; }, demo: { activityId: "rest-fundamentals", title: "REST fundamentals", skills: [ { id: "methods", label: "HTTP methods", level: 0.8 }, { id: "idempotency", label: "Idempotency", level: 0.6 }, { id: "status", label: "Status codes", level: 0.4 }, { id: "caching", label: "Caching headers", level: 0.2 }, ], }, }), }; export type LearningType = keyof typeof registry; ``` ### components/learning/LearningStream.tsx ```tsx "use client"; import { ComponentBoundary } from "./ComponentBoundary"; import { parseLearningStream } from "./parse"; import { registry } from "./registry"; import type { XApiStatement } from "./xapi"; const Unavailable = () => (

An activity could not be displayed.

); export interface LearningStreamProps { /** Everything the model has emitted so far, not just the latest chunk. */ text: string; onTrackTelemetry?: (statement: XApiStatement) => void; } /** * Renders a model's output, hydrating `` payloads into live * components as they arrive. Components render inline in the host layout — no * iframe, no post-processing pass. */ export function LearningStream({ text, onTrackTelemetry }: LearningStreamProps) { const segments = parseLearningStream(text); return (
{segments.map((segment, i) => { switch (segment.kind) { case "text": return (

{segment.text}

); case "component": return ( // Keyed by activity id, not array index: the id is stable as more // of the stream arrives, so a learner's in-progress answer is not // reset by later chunks remounting the component.
}> {registry[segment.type].parseAndRender( segment.data, segment.activityId, onTrackTelemetry )}
); case "invalid": return ; case "pending": return (
); } })}
); } ``` ## Protocol # LLM protocol Paste the block below into your backend's system prompt (OpenAI, Anthropic, or an LMS-specific MCP server). It is what makes a model emit payloads this library can hydrate. The schemas here are enforced by the validators in `components/learning/registry.tsx`. Those validators are the source of truth; the self-check in `components/learning/parse.ts` asserts that every worked example below still passes them, so this file cannot silently drift. ## What the parser tolerates Models do not follow instructions perfectly, so the parser is deliberately forgiving of the failures that actually happen in production: | Model behaviour | What the parser does | |---|---| | Payload still streaming | Renders a placeholder; never shows half-parsed JSON | | Stream cut off mid-tag | Same — the partial tag is withheld from the prose | | Wraps the JSON in ```json fences | Strips the fences and parses anyway | | Emits malformed JSON | Renders "An activity could not be displayed"; the surrounding lesson still renders | | Emits an unknown `type` | Same as malformed | | Emits a quiz with no correct option | Rejected — `success` in the xAPI statement would be meaningless | What it will not do is render a component from a payload that failed validation. ## The system prompt block ```text You are an expert AI tutor. While teaching the user, you must periodically test their understanding using custom interactive elements. When you want to issue an interactive checkpoint, stop writing standard text and output exactly one JSON object wrapped inside structural tags. CRITICAL RULES: 1. Do not include markdown code block syntax (like ```json) inside the tags. 2. The payload must strictly validate against the JSON schemas provided below. 3. Keep explanation strings concise, objective, and supportive. 4. Continue the lesson in prose after the closing tag. [SCHEMA: QUIZ] { "type": "quiz", "prompt": "The conceptual question being asked.", "options": [ { "id": "A", "text": "Option text", "valid": true, "feedback": "Why this is right." }, { "id": "B", "text": "Option text", "valid": false, "feedback": "Correction or hint." } ] } Requires at least two options, and at least one with "valid": true. [SCHEMA: FLASHCARD] { "type": "flashcard", "front": "Term, question, or snippet to memorize.", "back": "The answer, explanation, or core concept definition." } Both fields are required and must be non-empty. [SCHEMA: SEQUENCE] { "type": "sequence", "prompt": "What the learner is ordering.", "steps": [ { "id": "dns", "text": "Resolve the hostname via DNS" }, { "id": "tcp", "text": "Open a TCP connection" }, { "id": "send", "text": "Send the request" } ] } List the steps in the CORRECT order — the component shuffles them for display. Requires at least three steps with unique ids. [SCHEMA: MASTERY] { "type": "mastery", "title": "The topic being assessed.", "skills": [ { "id": "methods", "label": "HTTP methods", "level": 0.8 }, { "id": "idempotency", "label": "Idempotency", "level": 0.6 } ] } "level" is your assessment of the learner from 0 to 1, based on the conversation so far. Ids must be unique. Emit this at the end of a topic, not mid-explanation. [EXAMPLE IN-LINE OUTPUT] That wraps up how REST endpoints work. Let's see if you can spot the difference here: { "type": "quiz", "prompt": "Which HTTP method should be safely designed as idempotent?", "options": [ { "id": "A", "text": "POST", "valid": false, "feedback": "POST creates new resources and is typically non-idempotent." }, { "id": "B", "text": "GET", "valid": true, "feedback": "Correct! Multiple GET calls return the same data without altering state." } ] } What thoughts do you have on how this maps to database actions? ``` ## Wiring it up ```tsx import { LearningStream } from "@/components/learning/LearningStream"; // `text` is everything the model has emitted so far, not just the latest chunk. sendToLRS(s)} />; ``` `LearningStream` is pure with respect to `text`: re-parsing the full buffer on every chunk is what keeps it from drifting out of sync with the stream. Components are keyed by a stable activity id, so a learner's in-progress answer survives later chunks arriving. ## Telemetry Every interaction emits an xAPI statement to `onTrackTelemetry`, ready to forward to a Learning Record Store. There is no network call built in — the host app decides where it goes. ```json { "verb": { "id": "http://adlnet.gov/expapi/verbs/answered", "display": { "en-US": "answered" } }, "object": { "id": "http://beadkit.dev/activities/streamed-0", "definition": { "name": { "en-US": "Which HTTP method should be safely designed as idempotent?" } } }, "result": { "response": "B", "success": true, "completion": true, "duration": "PT14S" } } ``` A flashcard emits `experienced` with no `response` or `success`: those fields are omitted rather than sent as null, because an LRS reads a present-but-null `success` as a scored attempt. Flipping a card back and forth emits one statement, not one per flip. The `actor` field is intentionally absent. Identity belongs to the host app, which should merge it in before forwarding to its LRS.