Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.hawkings.education/llms.txt

Use this file to discover all available pages before exploring further.

The data model is intentionally minimal: Course → Unit → Lesson → Activity → Question, with Cohort as the run-time instance and free metadata + external_ids on every resource. That tiny set covers everything from a flat Google-Classroom-style course to a deeply nested regulated training program — without forcing a domain-specific schema on you. This page shows three concrete examples that look very different on paper but reuse the same primitives.

The mental model in one paragraph

A Course is the curriculum. A Cohort is one run of it. A Unit groups lessons and can nest into other units via parent_unit_id when you need extra depth. A Lesson is the atomic piece of content (markdown, video, reading). An Activity is something the student does (quiz, flashcard, assignment). Anything domain-specific (SEPE codes, CRNs, FUNDAE file numbers, Canvas IDs) lives in metadata and external_ids — never in dedicated columns.

Example 1 — Replicate a Moodle course

Moodle’s structure is shallow: course → section → activity/resource. Map it like this:
MoodleHawkings
courseCourse
section (week or topic)Unit (top-level)
mod_page / mod_resourceLesson with type: markdown
mod_quizActivity with type: quiz
mod_assignAssignment
const course = await hk.courses.create({
  title: "Introduction to Python",
  description: "An 8-week intro to programming",
  metadata: {
    moodle_format: "weeks",
    moodle_category: "Programming"
  },
  external_ids: { moodle: "course_1234" }
});

const week1 = await hk.units.create({
  course_id: course.id,
  title: "Week 1 — Syntax basics",
  position: 1,
  external_ids: { moodle: "section_1" }
});

await hk.lessons.create({
  unit_id: week1.id,
  type: "markdown",
  title: "Variables and types",
  content: "# Variables\n\nIn Python...",
  external_ids: { moodle: "course_module_45" }
});

const quiz = await hk.activities.create({
  unit_id: week1.id,
  type: "quiz",
  title: "End-of-week 1 quiz",
  external_ids: { moodle: "course_module_46" }
});

await hk.questions.create({
  activity_id: quiz.id,
  type: "multiple_choice",
  prompt: "What is the type of 3.14?",
  options: ["int", "float", "str"],
  correct_answer: 1
});
Because the original Moodle IDs are preserved in external_ids, you can re-export the course back into the same Moodle instance and the mapping survives.

Example 2 — Spanish vocational training (especialidad formativa)

A SEPE-registered especialidad formativa has three internal levels: módulos formativos → unidades formativas → epígrafes. This is where nestable units pay rent — you set parent_unit_id to compose depth without inventing new resources.
Especialidad formativaHawkings
Especialidad (e.g. ADGD0308)Course
Módulo formativo (MF)Unit (top-level)
Unidad formativa (UF)Unit with parent_unit_id = MF
EpígrafeLesson
ConvocatoriaCohort
const af = await hk.courses.create({
  title: "Actividades de gestión administrativa",
  metadata: {
    codigo_sepe: "ADGD0308",
    familia_profesional: "Administración y gestión",
    nivel_qualificacion: "2",
    horas_totales: "880"
  }
});

// Módulo formativo (top-level unit)
const mf1 = await hk.units.create({
  course_id: af.id,
  title: "MF0233_2 — Ofimática",
  position: 1,
  metadata: {
    codigo_mf: "MF0233_2",
    horas: "190",
    transversal: true
  }
});

// Unidad formativa nested inside MF1
const uf1 = await hk.units.create({
  course_id: af.id,
  parent_unit_id: mf1.id,
  title: "UF0319 — Sistema operativo y búsqueda de información",
  position: 1,
  metadata: { codigo_uf: "UF0319", horas: "30" }
});

// Epígrafes = lessons
await hk.lessons.create({
  unit_id: uf1.id,
  type: "markdown",
  title: "1.1 Introducción al ordenador",
  content: "...",
  metadata: { epigrafe: "1.1" }
});

await hk.lessons.create({
  unit_id: uf1.id,
  type: "markdown",
  title: "1.2 Hardware básico",
  content: "...",
  metadata: { epigrafe: "1.2" }
});

// The May 2026 cohort being delivered to a specific group
const convocatoria = await hk.cohorts.create({
  course_id: af.id,
  title: "Convocatoria mayo 2026 — Madrid",
  starts_at: "2026-05-20",
  ends_at: "2026-11-20",
  metadata: {
    expediente_fundae: "F262345",
    modalidad: "teleformacion",
    centro: "Centro Madrid"
  }
});

await hk.enrollments.create({
  cohort_id: convocatoria.id,
  student_id: "usr_alice",
  role: "student"
});
Note what you did not have to do: no Section resource, no Epigrafe resource, no EspecialidadFormativa subclass. Depth comes from parent_unit_id; domain semantics come from metadata. The same SDK call shape works.
If you ship many courses in Spain and want typed fields (codigoSepe instead of metadata.codigo_sepe), the recommended path is a companion package (e.g. @hawkings/sdk-spain) that wraps these calls, not core SDK changes. See the Companion packages guide for the pattern.

Example 3 — A US university subject

A typical US college course (say CS 101 — Introduction to Computer Science) lives once in the catalog but is delivered multiple times per academic year, each section with its own instructor, schedule, and enrollment. This is the textbook use case for the Course / Cohort split.
UniversityHawkings
Subject / catalog entry (CS 101)Course
Course offering (Fall 2026 sec. 001)Cohort
Week / unitUnit (top-level)
Topic / lectureLesson
Problem set / quiz / midtermActivity / Assignment
const cs101 = await hk.courses.create({
  title: "Introduction to Computer Science",
  description: "Foundational CS using Python",
  metadata: {
    course_code: "CS 101",
    department: "Computer Science",
    credits: "4",
    catalog_level: "undergraduate-100"
  }
});

const week1 = await hk.units.create({
  course_id: cs101.id,
  title: "Week 1 — Computation and variables",
  position: 1,
  metadata: { week_number: "1" }
});

await hk.lessons.create({
  unit_id: week1.id,
  type: "video",
  title: "Lecture 1.1 — What is computation?",
  content: { video_url: "...", duration_seconds: 2700 }
});

await hk.lessons.create({
  unit_id: week1.id,
  type: "markdown",
  title: "Reading — Sipser Ch. 1.1",
  content: "..."
});

const pset1 = await hk.assignments.create({
  unit_id: week1.id,
  title: "Problem Set 1",
  points_possible: 100,
  due_offset_days: 7
});

// Two different runs of the same course, no content duplication
const fall2026 = await hk.cohorts.create({
  course_id: cs101.id,
  title: "CS 101 — Fall 2026, Section 001",
  starts_at: "2026-09-08",
  ends_at: "2026-12-18",
  metadata: {
    term: "Fall 2026",
    section_number: "001",
    crn: "12345",
    meeting_pattern: "MWF 10:00-10:50",
    room: "Stata 32-123"
  }
});

const spring2027 = await hk.cohorts.create({
  course_id: cs101.id,
  title: "CS 101 — Spring 2027, Section 001",
  starts_at: "2027-01-15",
  ends_at: "2027-05-20",
  metadata: { term: "Spring 2027", crn: "67890" }
});

// Per-cohort schedule override on the shared content
await hk.cohorts.scheduleAssignment({
  cohort_id: fall2026.id,
  assignment_id: pset1.id,
  due_date: "2026-09-22T23:59:00Z"
});

// LTI launch — if the university runs Canvas as the official LMS
await hk.cohorts.connectLTI({
  cohort_id: fall2026.id,
  platform_issuer: "https://canvas.school.edu",
  context_id: "canvas_course_99887"
});
One canonical CS 101. Two cohorts. Zero content duplication. When the syllabus changes mid-year, you update the Course and decide explicitly whether existing cohorts inherit or stay frozen.

Example 4 — A program bundling multiple courses

Catalog model: you have a library of ~100 standalone courses (Python, Anthropic API, RAG, prompt engineering…). You don’t only sell them individually — you also sell named bundles like “AI Specialization” which packages three of them together, with a defined start and end date per intake. This is the same pattern as Coursera Specializations, edX MicroMasters, and Udacity Nanodegrees. It’s modeled with a third top-level resource: Program.

The three resources in play

ResourceWhat it isHas its own content tree?
CourseAtomic catalog item (Python, RAG, etc.). Has its own units/lessons.Yes
ProgramA named bundle that references course_ids. Has title, description.No (delegates to its courses)
CohortOne time-boxed run. Belongs to either a course or a program.
The key design choice: Program is lightweight. It does not duplicate content; it references courses. If you update Python in the catalog, every program that includes it gets the update for free.
// 1. The catalog of standalone courses
const python = await hk.courses.create({
  title: "Python from zero",
  description: "Fundamentals of Python programming"
});
const anthropic = await hk.courses.create({
  title: "Building with the Anthropic API",
  description: "Claude, tool use, agents"
});
const rag = await hk.courses.create({
  title: "Production-grade RAG",
  description: "Retrieval, embeddings, evals"
});

// 2. The bundle: a lightweight program referencing courses
const aiSpec = await hk.programs.create({
  title: "AI Specialization",
  description: "Master Python, LLM APIs and RAG in six months",
  course_ids: [python.id, anthropic.id, rag.id],
  metadata: {
    estimated_hours: "180",
    level: "intermediate",
    certificate: "true"
  }
});

// 3a. Cohort attached to the PROGRAM — the 2026 intake of the bundle
const intake2026 = await hk.cohorts.create({
  program_id: aiSpec.id,           // ← program_id, not course_id
  title: "AI Specialization — 2026 cohort",
  starts_at: "2026-01-15",
  ends_at: "2026-07-15",
  metadata: { capacity: "100" }
});

// 3b. Or cohort attached to a single course — for à-la-carte buyers
const pythonQ1 = await hk.cohorts.create({
  course_id: python.id,
  title: "Python — Q1 2026"
});

// 4. Pricing — Prices point at either a course or a program
await hk.prices.create({
  program_id: aiSpec.id,
  amount: 99900,
  currency: "EUR",
  type: "one_time"
});
await hk.prices.create({
  course_id: python.id,
  amount: 19900,
  currency: "EUR",
  type: "one_time"
});

// 5. Enrollments — same primitive, different cohort
await hk.enrollments.create({
  cohort_id: intake2026.id,        // bought the bundle
  student_id: "usr_alice"
});
await hk.enrollments.create({
  cohort_id: pythonQ1.id,          // bought Python only
  student_id: "usr_bob"
});

The non-obvious question: what happens when a student enrolls into a program cohort?

You have to make a product decision. Both options are valid and the SDK supports both — pick the simpler one unless you have a reason not to. When Alice is enrolled in intake2026:
  • She automatically has access to the full content tree of all three referenced courses.
  • Her progress, submissions, grades and certificates live under the program cohort, not in separate per-course cohorts.
  • No sub-cohorts are created. The program cohort is the only container.
Pros: one source of truth for progress (“Alice has completed 73% of the program”), simple dashboards, simple billing. Cons: if you want the three courses to follow staggered schedules inside the program (Python in months 1-2, Anthropic in months 3-4, RAG in months 5-6), you’ll need to express that with cohort.metadata.schedule or a small cohortSchedule resource.

Option B — Auto-generated sub-cohorts

Creating the program cohort transparently spins up one cohort per referenced course, all linked back:
intake2026 (program cohort)
  ├─ intake2026-python      (course cohort)
  ├─ intake2026-anthropic   (course cohort)
  └─ intake2026-rag         (course cohort)
Alice is enrolled in all four. Pros: each course keeps its own teacher, schedule, and rules independently. Closer to how universities model multi-course programs. Cons: ambiguity about which cohort is the source of truth for program-level progress. More moving parts. To opt in:
await hk.programs.create({
  // ...
  cohort_strategy: "subcohorts"   // default is "unified"
});

Content that belongs to the program, not to any single course

What if AI Specialization has its own onboarding video, capstone project, or graduation exam — content that doesn’t naturally belong to Python, Anthropic, or RAG individually? The clean answer: create it as a course, then add it to the program’s course_ids:
const capstone = await hk.courses.create({
  title: "AI Specialization — Capstone Project",
  description: "Final cross-disciplinary project",
  metadata: {
    capstone: "true",
    access_via: "program_only"     // your business layer enforces this
  }
});

await hk.programs.update(aiSpec.id, {
  course_ids: [python.id, anthropic.id, rag.id, capstone.id]
});
The access_via: "program_only" metadata lets your application hide this course from the public catalog — the model itself doesn’t need a special “program-exclusive” type.

Quick reference

QuestionAnswer
Is Program a required hierarchy level?No. Optional, lightweight, lateral.
Can a course exist with no program?Yes. Most courses will.
Can a course be in multiple programs?Yes. Many-to-many.
Does a cohort always have a course_id?No. It has course_id XOR program_id.
Where does pricing go?prices — points to either course_id or program_id.
Where does the program’s content live?In the referenced courses. The program is just the bundle.
Can the program have exclusive content?Yes — model it as a course, mark it access_via: "program_only".
This is structurally the same shape Stripe uses: Subscription covers many SubscriptionItems, each pointing at a Price for a Product. Here: a Program Cohort covers many referenced Courses. Same pattern, different domain.

The pattern, in one screen

Across all three examples — wildly different domains, depth, and vocabulary — you used the same five primitives:
1

Course

The curriculum. Same shape whether it’s a 4-week Moodle course or an 880-hour SEPE program.
2

Unit (nestable)

Top-level when you need flat structure, parent_unit_id when you need depth. No Section, no Epigraph, no domain subclasses.
3

Lesson + Activity + Question

The leaves. Content, exercises, questions — typed by a type discriminator, not by separate resources.
4

Cohort

The runtime instance. Carries dates, students, teachers, and any per-run overrides.
5

metadata + external_ids

Where domain-specific data lives. SEPE codes, CRNs, Canvas IDs, FUNDAE expedients — everything that’s specific to your market or LMS without polluting the core schema.

When you actually need more

The model is deliberately small. Reach for one of these only when the core primitives genuinely can’t express what you need:
You want to…Use
Group multiple courses (master, itinerary, learning path)programs (lightweight many-to-many)
Sell courses or cohortsprices + checkoutSessions
Map content into a third-party LMSexternal_ids + the relevant exporter (SCORM, Common Cartridge, LTI)
Type strongly the domain-specific fieldsA companion package, not core SDK
Track competencies / outcomesoutcomes (CASE-aligned, v2)

Where Program fits — and where it does not

Program exists in the SDK but it is not a mandatory hierarchy level above Course. It is a lateral resource you reach for only when you need it. Two patterns to keep straight:
  • Grouping multiple courses into a sellable bundle (a master’s, a specialization, a learning path) → use programs as shown in Example 4. Lightweight, many-to-many with courses, with its own cohorts and prices.
  • Domain hierarchy inside a single course (e.g. a SEPE especialidad containing módulos formativosunidadesepígrafes) → that is not a program. It is a single Course with nested Units, as shown in Example 2.
If your “program” only contains a single course, you don’t need a Program — just use the Course directly. Like Stripe doesn’t force you to wrap every Product in a ProductCatalog, Hawkings doesn’t force you to wrap every Course in a Program. Reach for it when you’re actually bundling.