Framework-agnostic engine for structured courses. A provider — a coach, trainer, teacher or clinician — builds a course of sections, lessons and blocks and enrolls their own clients, who work through it at their own pace. The engine decides what each client can open (prerequisites, drip timers, provider approval), keeps locked lessons away from clients until they open, tracks progress across devices and scores quizzes. You choose where courses are kept: your own backend, memory, or Hiyve user files from the /hiyve entry.
npm install @hiyve/courses
import {
addLesson,
addSection,
completeLesson,
computeLessonStates,
createCourse,
createMemoryCourseBackend,
createMemoryCourseStorage,
createProgress,
enrollClient,
} from '@hiyve/courses';
const backend = createMemoryCourseBackend();
const provider = createMemoryCourseStorage({ userId: 'coach@example.com', backend });
const client = createMemoryCourseStorage({ userId: 'client@example.com', backend });
// Build a course: Week 1 is open at once; Week 2 opens seven days after enrollment.
let course = createCourse({ title: 'Strength basics', ownerId: provider.userId });
const week1 = addSection(course, { title: 'Week 1' });
const warmUp = addLesson(week1.course, week1.section.id, { title: 'Warm-up' });
course = warmUp.course;
const week2 = addSection(course, { title: 'Week 2' });
course = addLesson(week2.course, week2.section.id, {
title: 'Progressions',
access: { drip: { afterEnrollmentDays: 7 } },
}).course;
course = await provider.createCourse(course);
// Enroll a client; lessons they can have now are opened to them.
const { enrollment } = await enrollClient(provider, course, { userId: 'client@example.com', userName: 'Sam' });
// The client works through the course.
let progress = createProgress(enrollment);
progress = completeLesson(progress, warmUp.lesson.id);
progress = await client.saveProgress(progress);
computeLessonStates(course, enrollment, progress);
// { [lessonId]: { status: 'completed' | 'available' | 'locked' | 'awaiting-approval', reasons, … } }
A course is a tree: Course → Section → Lesson → Block. The course holds the outline and each lesson's unlock rules; a lesson's blocks are kept separately (LessonContent) so they reach a client only once the lesson opens to them.
Block type |
What it is | Completes when |
|---|---|---|
'video', 'audio' |
Stored media (media: MediaRef) |
Played to the end, or past completeAt (default DEFAULT_MEDIA_COMPLETE_AT, 0.9) of media.durationSec — see isMediaWatched |
'file' |
A document or other stored file | Your app marks it complete |
'rich-text' |
Formatted text (TipTap / ProseMirror JSON) | Your app marks it complete |
'embed' |
A page from an allowed site — see isEmbedUrlAllowed |
Your app marks it complete |
'quiz' |
Questions, scored automatically or by the provider | Submitted; with a passMark, once passed (a quiz with no right answers set has nothing to pass, so submitting completes it) — see isQuizAttemptComplete |
'live-session' |
A standing room (roomName) to join |
Your app marks it complete |
'submission' |
A prompt the client answers with a recording, photo, file or text (accepts, maxDurationSec), for the provider to review |
On the first submission |
'custom' |
A block type your app defines (kind, version, data) |
Your app marks it complete |
Blocks with required: true count toward completing their lesson.
Every function returns a new object and leaves its input unchanged.
| Function | Does |
|---|---|
createCourse(input) |
An empty course (NewCourseInput: title, ownerId, optional description, settings, status, id, now) |
isCoursePublished(course) |
Whether clients can be enrolled. A course's status (CourseStatus) is 'draft', 'published' or 'archived'; none means published |
addSection / updateSection / moveSection / removeSection |
Sections. Removing one removes its lessons. |
addLesson / updateLesson / moveLesson / removeLesson |
Lessons. Removing a lesson removes it from other lessons' prerequisites. |
createLessonContent(courseId, lessonId) |
Empty content for a lesson |
addBlock / updateBlock / moveBlock / removeBlock |
Blocks in a lesson. A block's id and type never change. |
listLessons(course) / findLesson(course, id) |
Every lesson in order / a lesson and where it sits (LessonLocation) |
effectivePrerequisites(course, id) |
The lessons a lesson needs, including the one before it in a 'sequential' course |
collectMediaRefs(content) / lessonFileIds(content) |
The stored files a lesson uses (lessonFileIds adds customFileIds) |
summarizeCourse(course) |
A CourseSummary for lists |
validateCourse(course) |
Problems that would lock a lesson forever or make no sense (CourseIssue[]): unknown or self prerequisites, prerequisite cycles, drip rules that can never fire, repeated ids |
createId(prefix?) |
A new unique id |
isEmbedUrlAllowed(url, origins?) |
Whether an embed URL is HTTPS and from an allowed site (default DEFAULT_ALLOWED_EMBED_ORIGINS: YouTube, Vimeo, Loom) |
toEmbedUrl(url) |
A YouTube, Vimeo or Loom share link as its embed URL |
Each lesson's access can require other lessons (prerequisites), carry a due date (due, a Schedule like drip's — it marks an unfinished lesson overdue and never locks anything), release on a timer (drip: { afterEnrollmentDays }, or { onDate } with an ISO 8601 moment carrying its offset, like '2026-10-14T09:00:00Z' — a date on its own, 'YYYY-MM-DD', means midnight UTC, the same moment wherever it is checked, and must be a real calendar date), and require the provider's approval once completed (requiresApproval). A course with settings.progression: 'sequential' also requires each lesson's predecessor.
A lesson with a drip rule, one that needs a lesson requiring approval, or one that needs a lesson that is itself hard-locked (however far down the chain) is hard-locked: its content and files are not readable by the client until it opens. A lesson hard-locked only because of the lesson it needs opens together with that lesson, then stays locked on screen until its prerequisites are met. Every other lesson is opened at enrollment and locked on screen only, so it opens the moment its prerequisites are met.
| Function | Does |
|---|---|
computeLessonStates(course, enrollment, progress, now?) |
Every lesson's LessonState: status (LessonStatus), why it is locked (reasons: LockReason[]), whether it is hardLocked and granted, and the provider's latest approval |
planAccessGrants(course, enrollment, progress, now?) |
The lessons to open to the client now (PlannedGrant[]) |
nextDripAt(course, enrollment, now?) |
When the next drip lesson is due, so your app can check again then |
isHardLocked(course, lessonId) |
Whether a lesson's content is withheld until it opens |
lessonDueAt(lesson, enrolledAt) / dueStatus(lesson, enrolledAt, completed, now?) / overdueLessons(course, enrollment, progress, now?) |
When a lesson is due for a client, whether it is 'due' or 'overdue', and the unfinished lessons past their date |
scheduleAt(rule, enrolledAt) |
A Schedule (days after enrollment, or a date) as a moment |
dripAvailableAt(rule, enrolledAt) |
When a drip rule fires (epoch ms), or null if it never can |
isCourseComplete(course, enrollment, progress) |
Every lesson done, with approvals where needed |
applyGrants(enrollment, grants, now?) |
Record lessons as opened |
decideLesson(enrollment, lessonId, { approved, note?, completedAt? }, now?) |
Record the provider's decision on a lesson that needs approval. Pass the client's completedAt for the completion being decided, so a resubmission is recognised whatever the two devices' clocks say |
setEnrollmentStatus(enrollment, status, now?) |
Pause, resume or remove |
listEnrollmentOverviews(courseId) on the storage returns an EnrollmentOverview per client — status, what is open, approval decisions and completed lessons — without loading each client's progress, so provider screens stay fast with many clients.
| Function | Does |
|---|---|
computeOverviewStates(course, overview, now?) |
Every lesson's state for that client |
lessonsAwaitingApproval(course, overview, now?) |
Completed lessons waiting for the provider |
planOverviewGrants(course, overview, now?) |
Lessons due to open for that client |
planOverviewRevocations(course, overview, now?) |
Lessons open to that client whose gate has closed again |
buildEnrollmentOverview(enrollment, progress) |
An overview from full documents (for implementing storage) |
enrollmentFromOverview(overview) / progressFromOverview(overview) |
The documents an overview describes, for computing states (not for saving) |
LockReason is one of prerequisite (with lessonIds), approval (completed lessons awaiting approval), drip (with availableAt), paused, removed, or access-pending — the rules are met but the provider's app hasn't opened the lesson yet.
Drip lessons and lessons behind an approval open when the provider's app runs syncAccess (or syncCourseAccess) — on enrollment, after an approval, after a course edit, and from time to time while it is open. A server of your own can open them on time by implementing grantLessonAccess in your CourseStorage.
Time arguments (TimeInput) accept a Date, epoch milliseconds or an ISO 8601 string.
The client writes their own Progress; the provider reads it.
| Function | Does |
|---|---|
createProgress(enrollment) |
Empty progress |
updateBlockProgress(progress, blockId, update, now?) |
Record a position, completion, quiz attempt or custom block result (BlockProgressUpdate). Blocks stay complete once complete. |
completeLesson(progress, lessonId, now?) |
Record a lesson completed. Completing it again after the provider sends it back puts it back in front of them. |
setLastVisited(progress, lessonId, blockId?, now?) |
Where to resume |
isBlockComplete(progress, blockId) |
Whether a block is complete |
isMediaWatched(block, maxPosition) |
Whether enough of a video or audio block has played |
lessonCompletedAt(lessonId, enrollment, progress) |
When a lesson counts as completed: the provider's manual completion, or the client's own unless it predates a reset |
applyProgressReset(progress, enrollment, now?) |
The client's progress cleared for the provider's reset (replies kept), or the same object when there is nothing to apply |
isQuizAttemptComplete(quiz, attempt) |
Whether an attempt completes its quiz block |
quizAttemptsLeft(quiz, blockProgress) |
How many more tries an auto-scored quiz with maxAttempts allows (null when unlimited). The client's own device keeps the count, so the limit is a courtesy, not a guarantee: two devices used offline could each take the last try |
computeLessonCompletion(content, progress) |
Required blocks done (LessonCompletion) |
summarizeProgress(course, progress) |
Lessons done and a percentage (CourseProgressSummary) |
mergeProgress(a, b) |
Merge copies saved on two devices without losing anything |
mergeEnrollment(a, b) |
Merge copies of an enrollment saved by the provider on two devices |
Questions are single-choice, multi-choice, short-text (matched ignoring case and extra spaces), number (with an optional tolerance), long-text and scale; the last two are never scored.
A quiz with scoring: 'auto' is scored on submission, and its right answers travel with the lesson — a determined client can read them. With scoring: 'review' the provider scores it and the right answers never reach clients: save lessons with saveLesson, which keeps them in the course's private answers (CourseKeys).
| Function | Does |
|---|---|
scoreQuiz(quiz, answers, key?) |
A QuizScore: score (0–1, or null when nothing is scorable), correct, scorable, passed, per-question results |
unansweredQuestions(quiz, answers) |
Questions still without an answer |
extractQuizKey(quiz) / stripQuizKey(quiz) / applyQuizKey(quiz, key) |
Take right answers off a quiz, or put them back (e.g. to edit a 'review' quiz) |
questionKey(question) |
One question's right answer |
normalizeAnswerText(text) |
Text as short answers are compared |
A client answers a submission block as often as they like; every Submission is kept. The provider comments — pinned to a moment in a recording, a page or an area of a photo (CommentAnchor), with text and/or an attached recording or file — and gives a status from their own list (ReviewStatus). The client replies. The client's submissions and replies live in their progress; the provider's comments and statuses live in the enrollment, so each document keeps one writer. Files cross over: the client's recordings become readable by the provider when progress is saved, and the provider's attachments become readable by the client when the enrollment is saved.
| Function | Does |
|---|---|
addSubmission(progress, blockId, input, now?) |
Record a submission (SubmissionInput: lessonId, kind, media?, text?); completes the block |
addReply(progress, submissionId, input, authorId, now?) |
The client's reply (CommentInput: text?, media?, anchor?) |
addReviewComment(enrollment, submissionId, input, authorId, now?) |
The provider's comment |
setReviewStatus(enrollment, submissionId, status, now?) |
Give a status (AppliedReviewStatus records when) |
submissionThread(enrollment, progress, submissionId) |
Every comment on a submission, both sides, oldest first |
addLessonComment(progress, lessonId, input, authorId, now?) / addLessonReply(enrollment, lessonId, input, authorId, now?) / lessonThread(enrollment, progress, lessonId) |
A client's questions and notes on a lesson (in their progress), the provider's replies (in the enrollment), and the two threaded by time |
summarizeLessonComments(progress) / summarizeLessonReplies(enrollment) / unansweredLessonComments(overview) |
Per-lesson counts for overviews (CommentSummary), and the lessons where the client wrote after the provider last replied |
listSubmissions / latestSubmission(progress, blockId) / findSubmission(progress, submissionId) |
Look submissions up |
unreviewedSubmissions(overview) |
Latest submissions without a status yet, for a review queue |
submissionFileIds(progress) / reviewFileIds(enrollment) |
The files each side shares (for implementing storage) |
reviewSubmission(storage, course, enrollment, progress, input, now?) |
Comment and/or give a status in one step (ReviewInput → ReviewResult). A status whose effect is 'approve' or 'send-back' also decides a completed lesson that needs approval, with the comment as the note |
loadReviewStatuses(storage) / saveReviewStatuses(storage, statuses) |
The provider's statuses (default DEFAULT_REVIEW_STATUSES: Approved, Needs another try, Reviewed) |
replyToLesson(storage, enrollment, lessonId, input, now?) |
Reply to a client's question on a lesson; an attached file becomes readable by them |
Lessons for one client. A provider can add lessons for one client only and hide course lessons from them. Both live in that client's enrollment (clientLessons, hiddenLessons), so no one else is affected; the unlock rules, progress and overviews all work on the client's own version of the course.
| Function | Does |
|---|---|
courseForEnrollment(course, enrollment) |
The course as one client sees it (safe to apply more than once) |
addLessonForClient(storage, course, enrollment, input, now?) |
Add a lesson for one client (title, sectionId, afterLessonId?, access?, blocks?, and customFileIds? for files your custom blocks use), save its content and open it if due (ClientLessonResult) |
updateLessonForClient / removeLessonForClient |
Change one, or remove it and its content |
setLessonHiddenForClient(storage, course, enrollment, lessonId, hidden, now?) |
Hide a course lesson from one client, or show it again |
addClientLesson / updateClientLesson / removeClientLesson / setLessonHidden / isClientLesson |
The same changes on an enrollment, without saving (ClientLesson) |
Duplicates and templates. duplicateCourse(storage, courseId, { title?, isTemplate? }) copies a course — outline, lessons and private answers, not enrollments — sharing its stored files, as a draft; deleting either keeps files the other still uses. A course with isTemplate: true is a starting point rather than a course clients take. Your app can also supply ready-made outlines as plain data (CourseTemplate, with TemplateSections and TemplateLessons whose prerequisites name other lessons' keys): createCourseFromTemplate(storage, template, { title? }) saves one as a new course, and instantiateTemplate(template, { ownerId }) makes the documents without saving.
Falling behind. behindReason(course, overview, now?, { inactiveDays?, startGraceDays? }) says whether a client is behind — 'overdue' (a lesson's due date has passed; overdueLessonIds lists them), 'not-started' (enrolled a while ago, not begun) or 'inactive' (no activity for a while, lessons left) — or returns null (BehindReason, BehindOptions). Reminders are your app's: check overdueLessons on a schedule and send them however you like.
Everything is saved through a CourseStorage. Each document has one writer: the provider writes the course, its lessons, its private answers and every enrollment; the client writes only their progress. Saves return the document as saved, with revision increased. A save that would overwrite a newer copy rejects with a CourseStorageError whose code is 'conflict'; progress and enrollments are merged instead.
| Export | Is |
|---|---|
CourseStorage |
The contract to implement for your own backend |
createMemoryCourseStorage({ userId, backend?, now? }) |
In-memory storage for tests, demos and prototypes, with the same access rules as a real backend |
createMemoryCourseBackend() |
A backend several users' memory storages share (MemoryCourseBackend) |
CourseStorageError, isCourseStorageError(error, code?) |
Errors, with code: 'not-found', 'forbidden', 'conflict', 'not-ready', 'invalid' or 'network' (CourseStorageErrorCode) |
CourseStorageChange |
What subscribe reports |
deleteLessonContent(courseId, lessonId) removes one lesson's content. Besides the documents above, a provider has private review settings (ProviderSettings): loadProviderSettings() and saveProviderSettings(settings).
Each runs the engine against a storage and leaves the stored documents consistent with the unlock rules.
| Function | Does |
|---|---|
enrollClient(storage, course, client, now?) |
Enroll a client and open what they can have now. Refuses ('forbidden') for a draft or archived course |
syncAccess(storage, course, enrollment, progress, now?) |
Open every lesson the client should have now (SyncAccessResult) |
syncCourseAccess(storage, course, now?) |
Do that for every client in a course, loading a client's documents only when something is due to open or to be taken back (CourseAccessGrant[], each with granted and revoked) |
decideLessonApproval(storage, course, enrollment, progress, lessonId, decision, now?) |
Approve or send back a lesson, then open what it unlocks |
grantLessonsManually(storage, enrollment, lessonIds, now?) |
Open lessons whatever their rules say |
markLessonComplete(storage, course, enrollment, progress, lessonId, { note? }, now?) |
Mark a lesson complete for a client by hand — it counts as done and approved — and open what that unlocks |
resetClientProgress(storage, enrollment, now?) |
Start a client over: what they did before stops counting, approvals and manual completions are cleared, and their app clears its progress document next time (applyProgressReset). Lessons already open stay open |
setEnrollmentPaused(storage, course, enrollment, paused, now?) |
Pause, or resume and open what came due |
revokeLessonAccess (storage) |
Takes lessons, and the files only they use, back from a client. syncAccess calls it for lessons whose timer or approval gate has closed again (after a reset, or a tightened rule); pausing takes every open lesson back until the client is resumed; hiding a lesson takes it back |
removeClient(storage, enrollment, now?) |
Stop the client reading the course. Enrolling them again restores what they had. |
saveLesson(storage, content) |
Save a lesson with 'review' quiz answers kept private (SaveLessonResult) |
saveCourseAndSync(storage, course, { previous?, now?, onSaved? }) |
Save a course and open newly added lessons to clients who should have them. onSaved receives the saved course before lessons are opened, so a failure while opening them (which rejects) doesn't hide that the course was saved |
loadOrCreateProgress(storage, enrollment) |
The client's progress, or a fresh one |
@hiyve/courses/hiyve keeps courses in Hiyve user files, in a /Courses folder, each shared with exactly the people allowed to read it:
| Document | Belongs to | Readable by |
|---|---|---|
| Course outline | Provider | Every enrolled client |
| Lesson, and the files its blocks use | Provider | Clients the lesson has opened to |
| Private quiz answers | Provider | Nobody else |
| Enrollment, and files attached to review comments | Provider | That client |
| Progress, and the files in the client's submissions and replies | Client | The provider |
| Review settings | Provider | Nobody else |
import { createHiyveCourseStorage } from '@hiyve/courses/hiyve';
const storage = createHiyveCourseStorage({
store, // your HiyveStore
userId: 'coach@example.com',
getFileClient: () => store.getSlice('client').client,
});
| Option | Type | Description |
|---|---|---|
store |
HiyveCourseFileStore |
Your HiyveStore |
userId |
string |
The signed-in user |
getFileClient |
() => Pick<FileClient, 'modifyFile'> | null |
The connected client, or null while not connected |
isProviderTrusted |
(providerId: string) => boolean |
Which providers this user accepts courses from, by cleaned user id (cleanUserId from @hiyve/core). Everyone by default — which lets anyone who can share a file with the user put a course in front of them, and leaves two providers' courses of the same id indistinguishable (that read is refused). Set it in any app where that matters |
location |
string |
Folder for course documents. Default DEFAULT_HIYVE_COURSES_LOCATION ('/Courses') |
fetch |
typeof fetch |
Used to read documents. Default: the global fetch |
now |
() => number |
Clock in epoch ms |
The result is a HiyveCourseStorage: a CourseStorage plus canWrite() and refresh(). Creating documents, sharing and reading work without a room. Changing a saved document needs a connected client — a room or a no-video room; without one, such saves reject with code: 'not-ready' and canWrite() is false. subscribe reports { type: 'resync' } whenever the user's file list changes. HIYVE_COURSE_RESOURCE_TYPES lists the resource types course documents carry, for filtering them out of a file manager.
| Export | Value |
|---|---|
defaultCourseSettings / mergeCourseSettings(settings?) |
{ progression: 'free' } |
DEFAULT_MEDIA_COMPLETE_AT |
0.9 |
DEFAULT_ALLOWED_EMBED_ORIGINS |
https://www.youtube.com, https://www.youtube-nocookie.com, https://player.vimeo.com, https://www.loom.com |
DEFAULT_REVIEW_STATUSES |
Approved (approves), Needs another try (sends back), Reviewed (label only) |
COURSE_SCHEMA_VERSION |
1 |
Course, CourseStatus, CourseSettings, CourseProgression, CourseSummary, Section, LessonSummary, AccessRule, DripRule, Schedule, LessonContent, Block, BlockBase, BlockType, VideoBlock, AudioBlock, FileBlock, RichTextBlock, RichTextDoc, EmbedBlock, LiveSessionBlock, QuizBlock, CustomBlock, MediaRef, Question, QuestionKind, SingleChoiceQuestion, MultiChoiceQuestion, ShortTextQuestion, NumberQuestion, LongTextQuestion, ScaleQuestion, ChoiceOption, QuizScoring, QuizAnswer, QuizAnswers, QuizAttempt, QuestionKey, QuizKey, CourseKeys, ClientLesson, CourseTemplate, TemplateSection, TemplateLesson, ClientLessonResult, SubmissionBlock, SubmissionKind, Submission, SubmissionSummary, SubmissionComment, CommentSummary, CommentAnchor, CommentInput, SubmissionInput, ReviewStatus, ReviewTone, AppliedReviewStatus, SubmissionReview, ProviderSettings, ReviewInput, ReviewResult, ClientRef, Enrollment, EnrollmentOverview, EnrollmentStatus, EnrollmentSummary, GrantReason, LessonGrant, LessonApproval, ManualCompletion, Progress, BlockProgress, BlockResult, LessonProgress, LastVisited, CourseSchemaVersion.
fetch and File (modern browsers, Node 20+).@hiyve/core — only for the /hiyve entry.