Your own lesson pipeline · 01 · 26 Aug 2026 · 10 min read
Your own lesson pipeline: the spine
Clone it and run it — a standard code to a published assignment, end to end.
The tempting way to build this is a form and a prompt. A text box on the front, a frontier model in the middle, a course export on the back — and three weeks later nobody can answer why the lesson a teacher approved on Monday is different on Thursday, or which of four hundred items came from the source passage you have just discovered is wrong. The system is opaque exactly where you need to audit it. This post builds the spine instead: a request, a generation worker, an immutable block store, a gate, a review surface and one publish.
↳ This post builds every stage at its thinnest honest version, so the later posts have somewhere to land.
Worked exampleThis series uses a K–8 supplemental curriculum publisher as its running example — standards frameworks from three states, generated interactive lessons, the teachers who edit them, and the learning-management courses in two districts the platform publishes into. The pipeline itself is domain-agnostic; the content just happens to belong to third graders.
The decision
Where the model call lives decides everything else
Before any of the code, one question, and it is the one that determines whether a bad afternoon at your model provider is an operational condition or a broken product. The same request, run four ways.
one request, four ways
Generation runs…
The model provider is…
↳ Start in the handler, with the provider throttling — the August configuration. Then move the generator behind the log and change nothing else.
The API returns504 Gateway Timeout
The teacher waits30 s
She seesAn error, and no record that she asked
✕ nothing written down✕ cannot be replayed
A transient upstream problem has become a broken product during first period. Worse: nothing recorded that this lesson was wanted, so the incident is unrecoverable in the strict sense — you cannot afterwards generate what the outage ate, because nobody wrote down that anyone asked.
The request
A prompt is not a contract
Start at the edge, because everything downstream inherits its mistakes. The thing a user creates is not a prompt — it is a durable record carrying the scope the entire pipeline will run under. The prompt is one implementation detail of one generator, and it will change three times before the quarter ends. The request will not.
Scope is constructed once, at the edge. District, environment, framework version, grade band, locale and reading-level target are resolved when the request is created and travel with it. Nothing downstream re-derives them from a header or infers them from a course id a second time.
The framework version is pinned into the request. Standards frameworks get revised. A lesson generated against the 2019 revision and one generated against the 2024 revision are different claims, and if the request does not say which, neither does the artefact.
The handler does two things. Validate and enqueue. No model call, no retrieval, no write to anything a teacher is waiting on.
The response is an id, not a lesson. It comes back in milliseconds; the lesson arrives when it arrives, and the client subscribes.
src/api/requests.ts
importFastifyfrom"fastify";
import { producer } from"../stream/producer.js";
import { scopeFromClaims } from"../security/scope.js";
importtype { LessonRequest } from"../contracts/request.js";
const app = Fastify({ logger: true });
app.post("/requests", async (req, reply) => {
const scope = scopeFromClaims(req.user); // district + env, from the token, never the bodyconst body = req.bodyasPartial<LessonRequest>;
if (!body.standardCode || !body.gradeBand) return reply.code(400).send({ ok: false });
constrequest: LessonRequest = {
requestId: `req_${randomId()}`,
districtId: scope.districtId,
env: scope.env,
standardCode: body.standardCode, // "3.OA.A.1"frameworkId: scope.frameworkId, // pinned per districtframeworkVersion: scope.frameworkVersion, // "2019.1" — revisions change the claimgradeBand: body.gradeBand, // "3"readingLevelTarget: body.readingLevelTarget ?? null, // 1.0 on grade; 0.6 two years belowlocale: body.locale ?? "en-US",
requestedBy: scope.userId,
requestedAt: newDate().toISOString(), // server-stamped, never client-trusted
};
await producer.send({
topic: "lesson.requests",
messages: [{ key: `${scope.districtId}:${request.requestId}`, value: JSON.stringify(request) }],
});
return reply.code(202).send({ ok: true, requestId: request.requestId });
});
Why is generation a queue consumer and not the request handler?
Because a model call is an unbounded, retryable, externally rate-limited operation, and you must not synchronise one with a request a human is watching. A four-block lesson is four to six model calls plus retrieval; on a bad afternoon that is ninety seconds, and on a worse one it is a rejection from a provider whose capacity you do not control. Behind the log it is a different failure: backpressure shows up as consumer lag, which is a number you alert on and a queue you can drain. And the coupling is worse than the latency — an inline generator ties your ability to re-run to your ability to serve.
The pipeline
Six stages, one log
Each stage reads a topic and writes a topic. That is the entire architecture, and it is deliberate: any stage can be stopped, rewritten and restarted from its own position without coordinating with the others.
Topic
Key
Compacted
Contents
lesson.requests
districtId:requestId
no
every lesson asked for
lesson.blocks
districtId:blockHash
no
generated blocks with provenance
lesson.artifacts
districtId:lessonId
yes
current version of each lesson
lesson.reviews
districtId:lessonId
no
edits, approvals, rejections
lesson.publishes
districtId:courseId
no
what went where, and when
content.flags
districtId:blockHash
no
gate decisions and their reasons
The artefact topic is compacted on purpose. Keyed by lesson, compaction keeps the latest value per key — so the topic is the current state of every lesson, not a change feed you have to fold. A new consumer rebuilds full state by reading it from the beginning.
Blocks are never compacted. The block topic is the audit floor. When a source passage turns out to be wrong, “which blocks did it produce” is a scan of this topic, and you cannot scan history you have compacted away.
The relational store is the read side. Artefacts, the content graph and the publish ledger live there, which is also where row-level security lands in post 4. Blocks and assets live in object storage, addressed by hash.
Interaction telemetry is a separate store. Student events arrive in post 6, and they are deliberately not in the same database as the content — their retention rules, access rules and deletion rules are all different.
src/contracts/block.ts
exportinterfaceContentBlock {
blockHash: string; // sha256 of (kind, body, assets) — its identity IS its contentkind: "explanation" | "worked_example" | "practice_item" | "image" | "interactive";
body: string; // markdown or a structured item, per kindassetIds: string[]; // object-store keys; images carry their own rights recordprovenance: BlockProvenance;
}
exportinterfaceBlockProvenance {
requestId: string; // "req_5510"objectiveId: string; // "obj_7d31" — what this block is FOR, not just what it is aboutgenerator: "model" | "human" | "imported";
modelVersion: string | null; // null when a human wrote itpromptHash: string | null; // the exact prompt, hashed — re-derivable, not guessableretrievalSnapshot: string | null; // ids + versions of every source passage usedcreatedAt: string;
}
Why content-address the blocks?
Because it makes three otherwise painful things free. Deduplication: the same explanation generated twice for two districts is one object, stored once. Diffing: a lesson’s version graph is a list of hashes, so “what changed between v3 and v4” is a set difference rather than a text diff over prose. And recall: when a source passage is retracted, every block whose retrieval snapshot contains it is a query, and every lesson containing those blocks is one join away — which is the only mechanism by which a content recall is a Tuesday afternoon rather than a quarter. It also removes a class of bug by construction: a block cannot be edited in place, so “what did the teacher actually approve” has an exact answer forever.
workers/generate.py
"""Consumes lesson.requests, emits lesson.blocks. Stateless; safe to run N of these."""from lessonpipe.contracts import LessonRequest, ContentBlock, BlockProvenance
from lessonpipe.graph import objectives_for
from lessonpipe.retrieval import approved_sources
from lessonpipe.stream import consume, publish
from lessonpipe.hashing import block_hash, prompt_hash
defhandle(request: LessonRequest) -> None:
# Objectives come from the content graph, not from the model. Post 2 owns this edge.for objective in objectives_for(request.standard_code, request.framework_version):
sources = approved_sources(
district_id=request.district_id, # retrieval is scoped, always
framework_version=request.framework_version,
objective_id=objective.id,
)
prompt = render_prompt(objective, sources, request.grade_band, request.reading_level_target)
for draft in call_model(prompt): # retries and rate limits live here, not in the API
block = ContentBlock(
block_hash=block_hash(draft.kind, draft.body, draft.asset_ids),
kind=draft.kind,
body=draft.body,
asset_ids=draft.asset_ids,
provenance=BlockProvenance(
request_id=request.request_id,
objective_id=objective.id,
generator="model",
model_version=draft.model_version,
prompt_hash=prompt_hash(prompt),
retrieval_snapshot=[s.version_key for s in sources],
created_at=now_iso(),
),
)
publish("lesson.blocks", key=f"{request.district_id}:{block.block_hash}", value=block)
consume("lesson.requests", handle)
Isolation
District and environment are partition keys, not afterthoughts
Two districts are two different public bodies who happen to share your cluster, and a test environment must never leak into production. So scope is not a filter someone remembers to apply — it is in the key, the partition and the namespace.
The namespace is district plus environment. Two districts in production do not share a topic key prefix, a schema or an object-store prefix.
Every message key is prefixed with the district. One district’s bulk regeneration cannot reorder another’s, and a per-district replay is a key-range scan rather than a full-topic filter.
Every table is partitioned by district. Offboarding one is dropping partitions and an object-store prefix — a thing you want to be boring on the day it happens.
Scope is constructed once, at the edge. It travels on the request. Nothing downstream reconstructs it.
src/contracts/scope.ts
exportinterfaceTenantScope {
districtId: string; // "riverbend"env: "dev" | "prod";
}
/** The one place a scope becomes a string. Schema names, topic keys, object prefixes. */exportfunctionnamespace(scope: TenantScope): string {
return`${scope.districtId}__${scope.env}`;
}
exportfunctionrequestKey(scope: TenantScope, requestId: string): string {
return`${scope.districtId}:${requestId}`;
}
Partitioning keeps districts apart; it does not keep children safe. Publishability as a join key, row-level security bound to the executing role, and the external contractor login that makes both necessary are post 4’s job.
The gate and the publish
The thin end: one check, one course
The gate reads blocks, runs a small set of checks, and writes a decision. The publisher assembles approved blocks into a lesson and pushes it at a local mock platform. That is the minimum viable pipeline, and it is enough to prove the spine end to end.
The gate holds; it does not delete. A failed block stays addressable with its reason attached, because “why is there no practice item about arrays” needs an answer.
Held is a state, not an error. A lesson with three of six blocks held is a lesson a human should look at, not a failed job.
This publish is deliberately naive. Whole lesson, one call, no diff, no idempotency key, no fidelity measurement. It is wrong in production and correct as a teaching step.
The version graph starts here. Every approval writes a new version with the block hashes it contains; nothing is ever mutated.
src/contracts/lesson.ts
exportinterfaceLessonVersion {
lessonId: string; // "lsn_4c19" — stable across versionsversion: number; // 1, 2, 3 — monotonic per lessondistrictId: string;
env: "dev" | "prod";
standardCode: string; // "3.OA.A.1"frameworkVersion: string; // "2019.1"blockHashes: string[]; // ordered; the lesson IS this listderivedFrom: number | null; // the version this one was edited fromcreatedBy: string; // "usr_2210" — a teacher, or "system"createdAt: string;
}
Run it yourself · open-lesson-pipeline @ v1-spine
Bring up the stack, seed two districts with synthetic frameworks and courses, then watch one standard code become a published assignment — twice, once with the model provider forced to fail.
terminal
git clone https://github.com/xaviramirezcom/open-lesson-pipeline
cd open-lesson-pipeline
git checkout v1-spine
pnpm install && uv sync
pnpm lesson up # queue + postgres + object store + a mock LMS, via docker compose
pnpm lesson seed # two districts, three frameworks, synthetic courses and rosters# Ask for one lesson and follow it through every stage.
pnpm lesson request --district riverbend --standard 3.OA.A.1 --grade 3
pnpm lesson trace req_5510
# The same request, with the generator's model provider forced to fail.
pnpm lesson request --district riverbend --standard 3.OA.A.1 --grade 3 --chaos model-429
pnpm lesson trace req_5510 --show-lag
The healthy trace prints six hops: one request, three objectives resolved, fourteen blocks each with a prompt hash and a retrieval snapshot, eleven passes and three holds, a lesson at version one, and a publish. The chaos run prints something more useful: the API still returns in under fifty milliseconds, the request sits durably on the log, consumer lag climbs, and when the provider recovers the worker drains it and the lesson appears — late, but never lost. Same request, same failure, and the only thing that decided whether a teacher saw an error was where the model call lived.
Teaching-grade reference implementation, not a production courseware platform. It reproduces the ideas and the queue/warehouse integration shape; bring your own model keys, curriculum data and LMS credentials. LMS adapters run against a local mock by default. MIT-licensed. View the repo →
Explain it back
A team ships the generator inline with the request handler — the teacher clicks Generate, the handler retrieves, calls the model four times, assembles the lesson and returns it, so lessons appear “instantly” and there is no queue to operate. It works beautifully through a pilot with forty teachers. In August, six districts onboard at once, every teacher in the country builds their first unit in the same two weeks, and the product is unusable for eleven days. What went wrong architecturally, and what should the handler’s only job have been?
Reveal a model answer
They put an unbounded, externally rate-limited operation in the hot path of a human interaction. Generation latency is not a property of your code — it is a property of a provider’s capacity, and in August you are competing for that capacity with every other education company having the same August. When the provider throttles, an inline handler converts a queueing problem into an availability problem, at exactly the moment a district’s first impression is being formed.
The handler’s one job is to accept and durably record: validate the scope, stamp the time, write to the request topic, return an id. Everything slow, failable or provider-dependent belongs behind the log, where a capacity shortfall shows up as consumer lag — a number you can watch drain, and a number you can throw workers at — instead of as a spinner.
The bonus consequence is quieter and worse. With generation inline, the pipeline has no record of what was asked for — only of what came back, if anything did. So the August incident is unrecoverable in the strict sense: you cannot afterwards generate the lessons the eleven days ate, because nobody wrote down that they were wanted. A durable request log turns the same incident into a backlog you drain overnight and an apology with a delivery date in it.