Serve your courses on your own website
Chalkenso is where a course is written, taught, annotated and improved. Your website stays your website: it keeps its design, its URLs and its search rankings, and reads the published lessons from Chalkenso's API. Learner feedback from your site goes back into the lesson margin in Chalkenso, next to your instructors' notes, and learners keep their private notes wherever they read.
This guide is written so an AI coding agent (Claude Code, Cursor, Copilot, …) can do the switch for you. Give it this prompt:
Switch this website's course pages to Chalkenso.
Read https://chalkenso.com/llms.txt and follow the integration guide it links to.
The site key is in the environment variable CHALKENSO_SITE_KEY (server-side only) and the
webhook secret in CHALKENSO_WEBHOOK_SECRET. Keep every existing URL and the site's design,
load each lesson's text only when a reader opens it, and don't remove the old content source
until I confirm.
What you need
- A site key from Chalkenso: Website in the sidebar → Create site key. It can read published courses, send learner feedback and keep learners' notes, nothing else. Keep it on the server: never ship it to the browser.
- The API base URL:
https://chalkenso.com/api/v1. - Optional: a webhook secret, if your site should refresh as soon as you publish (Website → Publish notifications).
Instructions for AI coding agents
Work in small, reviewable steps and keep the site working after each one.
1. Inventory the current content
- Find every place the site loads course or lesson content: a CMS, Markdown files, a database, an API. List the routes and URL patterns that show courses, lesson pages and learner-only pages.
- Note how lesson text is rendered (Markdown renderer, HTML from a CMS) and any components that depend on its structure (table of contents, code highlighting, image components).
- Note how lessons load today: all at once, one page per lesson, or opened one by one on a course page. After the switch every lesson loads on demand (step 4).
- Write the plan down for the human before changing code: which data source each page uses now, and what replaces it.
2. Add a small server-side client
- One module (for example
lib/chalkenso.ts) withGETandPOSThelpers that callhttps://chalkenso.com/api/v1withAuthorization: Bearer ${process.env.CHALKENSO_SITE_KEY}. - Call it only from server code: server components, API routes, serverless functions. A fully static site can prebuild its course pages from the outlines, but lesson text needs one small function (step 4).
- Responses are
{ "data": … }; errors are{ "error": { "code", "message" } }with a matching HTTP status. SendIf-None-Matchwith the lastETagto get a cheap304.
3. Map the data to the existing pages
| Site page | Chalkenso endpoint | Use |
|---|---|---|
| Course list | GET /courses |
title, summary, outcome, tags, cover, partners, join, lessonCount; show courses where listed is true, pinned first |
| Course page | GET /courses/{courseSlug} |
parts (with description) and lessons (each with number, title, shortDescription, part, free); githubUrl, partners, join |
| A lesson, when opened | GET /courses/{courseSlug}/lessons/{lesson} |
markdown, previous, next |
| Learner page | GET /access/{token} |
The learner's own group copy of the course, titles only, with parts, the group (slackHandle, description, partners) and the course (githubUrl, partners) |
| A learner's lesson, when opened | GET /access/{token}/lessons/{lesson} |
markdown, previous, next |
- Keep the site's URL scheme. If old URLs used different slugs, map them in one place and redirect.
{lesson}is the lesson's slug or its id, both from the outline.- Lessons past the free ones answer
403 learner_link_requiredon the public endpoint: show a "for learners" message and the course's Join button. - The Join button follows
join:partnerandlinklead tojoin.url(namejoin.partneron a partner button);defaultmeans the site decides, for example its contact page. - Partners ("In partnership with", with their logos): on course pages show
partners; on learner pages showgroup.partnerswhen the group has any, otherwisecourse.partners. Keep their order.
4. Load lessons on demand
A course page shows the outline; a lesson's text loads when a reader opens it, one lesson per request. This is how Chalkenso's own learner pages work, and it keeps course pages fast however long the course is.
- Render the course page (and the learner page) from the outline only: number, title, short description, part.
- When a reader opens a lesson (expands it in the list, or goes to its page), load that one lesson.
The browser calls a small route on the site (for example
GET /api/lessons/{courseSlug}/{lesson}, orGET /api/learn/{token}/lessons/{lesson}for learners), and that route calls Chalkenso with the site key. Keep opened lessons in memory, so opening one again doesn't fetch it again. - With one page per lesson, the page's server code fetches that lesson when the page is requested.
- Don't fetch every lesson to render a course page, don't prebuild every lesson at deploy time, and don't call Chalkenso from the browser (the site key would leak).
The lesson text is Markdown, always:
- Render it with the site's Markdown renderer (remark/rehype, markdown-it, marked, …), with GFM for tables. Images are absolute URLs that work as they are.
- Callouts are
> [!note] Titleblockquotes, GitHub's alert syntax: style them with an alerts plugin, or leave them as blockquotes. - Code blocks are fenced with their language: highlight them like the site's other code.
- Links to other lessons are
[Title](#lesson-<slug>). On a one-page course, give each lesson's elementid="lesson-<slug>", and open and scroll to it when such a link is clicked. With a page per lesson, rewrite them to the site's lesson URLs. - A site without a Markdown pipeline can ask for HTML as well:
?include=htmladdshtml, already rendered and highlighted (code blocks arediv.lesson-code-block, calloutsdiv.callout).
5. Learner links
- Chalkenso gives each learner a personal link token. Serve them on the site, for example
/courses/learn/{token}, and read the course withGET /access/{token}. - Tell the human to put that address in Chalkenso: Website → Learner links, with
{token}where the token goes (for examplehttps://example.com/courses/learn/{token}). The learner and preview links they copy in Chalkenso then point at the site. - Handle every state:
404 link_not_found,410 link_expired,410 link_revoked,410 link_used. Show a friendly page with a way to ask for a new link. - Don't put the token in analytics, logs or referrers: set
Referrer-Policy: no-referreron these pages.
6. Feedback and notes on a passage
- On learner pages, let readers select text and leave feedback. Send it from the site's server (a
small API route), never with the key in the browser:
POST /access/{token}/feedbackwith{ "lesson": "<lesson slug or id>", "selectedText": "…", "context": "<the paragraph>", "body": "…" }. - Show the learner's earlier feedback and any replies with
GET /access/{token}/feedback?lesson=<lesson>; let them delete their own withDELETE /access/{token}/feedback/{id}. - Only personal links can send feedback (
canSendFeedbackinGET /access/{token}); hide the box otherwise. - Feedback appears in Chalkenso in the lesson's margin. That's the point of the integration, so test it.
- Learners can also keep private notes on a passage, the same way:
POST /access/{token}/noteswith the same fields,GET /access/{token}/notes?lesson=<lesson>to show them,PATCH /access/{token}/notes/{id}with{ "body": "…" }to edit one,DELETE /access/{token}/notes/{id}to remove it. Only the learner sees them (they are the same notes as on Chalkenso's learner pages). Offer them only whencanKeepNotesis true, and mark notes apart from feedback, so learners know which one the team reads.
7. Learner script: the lesson question, surveys and reading statistics
Chalkenso asks learners as little as possible and learns the rest from how lessons are read. One script does all of it on the site's learner pages, with the same rules as Chalkenso's own pages:
<script src="https://chalkenso.com/embed/learner.v1.js" defer></script>
<article data-chalkenso-token="{token}" data-chalkenso-lesson="{lesson id}">
<chalkenso-lesson-start></chalkenso-lesson-start>
…the lesson's html…
<chalkenso-lesson-end></chalkenso-lesson-end>
</article>
<chalkenso-class-note></chalkenso-class-note>
<chalkenso-survey kind="MID"></chalkenso-survey>
<chalkenso-survey kind="END"></chalkenso-survey>
<chalkenso-reading-notice></chalkenso-reading-notice>
data-chalkenso-tokenis the learner link token;data-chalkenso-lessonis the lesson'sidfrom the API (not the slug). Render the lesson'shtml(?include=html) inside that element: itsh2/h3headings are the lesson'ssections.<chalkenso-lesson-end>asks one question when the end of the lesson scrolls into view ("Could you explain this lesson to someone else?", or the lesson's check question), then an optional "Which part?".<chalkenso-lesson-start>asks once about the previous lesson if it got no answer.<chalkenso-survey>shows the halfway check and the end-of-course survey when they are open, and<chalkenso-class-note>the instructor's note to the group.<chalkenso-reading-notice>says what is counted and lets the learner opt out: keep it on the page.- Anonymous reading statistics run for every
[data-chalkenso-lesson]element: time per section, how far readers get and where they go back. Nothing identifies the reader, nothing is stored in the browser, and the script stops when the browser sends Global Privacy Control. - Style it with CSS custom properties on the page:
--chalkenso-accent,--chalkenso-on-accent,--chalkenso-text,--chalkenso-muted,--chalkenso-border,--chalkenso-surface,--chalkenso-radius,--chalkenso-font,--chalkenso-marker,--chalkenso-note-line. - Single-page sites call
window.ChalkensoLearner.init({ token })when the learner link changes. - The script talks to Chalkenso directly, with the learner link token: no site key in the browser.
Chalkenso accepts it from the site of the Learner link address and from the addresses listed on
Website → Learner script; add the site there if learner pages live elsewhere. To pin one build,
copy the versioned script with its
integrityhash from the same card. - Sites that draw their own questions send answers from their server with the site key:
POST /access/{token}/lessons/{lesson}/pulseandPOST /access/{token}/survey, guided bysignalsinGET /access/{token}. They should still load the script for the reading statistics. - Course pages can show
reviewsfromGET /courses/{courseSlug}: the average end-of-course score once five learners gave one, and quotes learners allowed (never with names).
8. Caching and refresh
- Cache the outline and each lesson separately, keyed by course and lesson slug (for example
fetchwithnext: { tags: [...] }in Next.js, or a small key-value cache), and revalidate withIf-None-Match. Lesson text can be cached for as long as you like: image URLs are stable. - Add a webhook route (for example
POST /api/chalkenso-webhook). Chalkenso sendscontent.publishedwith the course slug, version and lesson slugs whenever lessons are published,content.unpublishedwhen published lessons are deleted or their version goes back to draft, andcourse.updatedwhen a course's settings, cover, partners or Join button change. - Verify every request before acting on it (see Webhooks in the API reference), then drop the
cached course list and outline and the cached copy of each lesson listed (for example
revalidateTagin Next.js); forcourse.updated, the course list and that course's page. Don't rebuild the whole site: the next reader to open a lesson loads the new text. - Treat any other event as "refresh everything", and keep a time-based refresh too (for example
revalidatein Next.js): a notification can get lost. - Register the route's public URL in Chalkenso: Website → Publish notifications, then press Send a test.
9. Verify, then hand over
Check, and report to the human:
- Every old URL still answers with the same content (spot-check against the old source).
- Opening a course page makes one Chalkenso request (the outline), and opening a lesson makes one more; nothing fetches every lesson (check the site's server logs).
- A free lesson, a learner-only lesson (403 publicly, readable through a learner link) and an expired link all behave as expected.
- Feedback sent from the site shows up in the lesson's margin in Chalkenso.
- A note made on the site shows on Chalkenso's learner page for the same link, and the other way round.
- Publishing a lesson in Chalkenso refreshes the page on the site; so does changing the course's partners or Join button.
- A learner link copied in Chalkenso opens the site's learner page.
- On a learner page, reading to the end of a lesson shows the lesson question, and the answer shows up in Chalkenso's insights; the reading notice is on the page.
- The site key never appears in client bundles (search the build output for
site_).
Only after the human confirms, remove the old content source and its admin.
Moving existing courses into Chalkenso
If the courses live somewhere else today (a CMS, Markdown, an older course system), bring them into Chalkenso first: write them in the browser, sync an Obsidian vault, or ask us to import them. Chalkenso then becomes the single source; the site only reads.