Join the waitlist

Chalkenso opens soon. Leave your email and we'll tell you when; pilot spots go to the waitlist first.

One email when we open. We use your address for nothing else; to remove it, write to info@chalkenso.com. Privacy

Opening soon. Pilot spots go to the waitlist first. Join the waitlist →

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) with GET and POST helpers that call https://chalkenso.com/api/v1 with Authorization: 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. Send If-None-Match with the last ETag to get a cheap 304.

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_required on the public endpoint: show a "for learners" message and the course's Join button.
  • The Join button follows join: partner and link lead to join.url (name join.partner on a partner button); default means the site decides, for example its contact page.
  • Partners ("In partnership with", with their logos): on course pages show partners; on learner pages show group.partners when the group has any, otherwise course.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}, or GET /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] Title blockquotes, 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 element id="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=html adds html, already rendered and highlighted (code blocks are div.lesson-code-block, callouts div.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 with GET /access/{token}.
  • Tell the human to put that address in Chalkenso: Website → Learner links, with {token} where the token goes (for example https://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-referrer on 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}/feedback with { "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 with DELETE /access/{token}/feedback/{id}.
  • Only personal links can send feedback (canSendFeedback in GET /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}/notes with 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 when canKeepNotes is 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-token is the learner link token; data-chalkenso-lesson is the lesson's id from the API (not the slug). Render the lesson's html (?include=html) inside that element: its h2/h3 headings are the lesson's sections.
  • <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 integrity hash from the same card.
  • Sites that draw their own questions send answers from their server with the site key: POST /access/{token}/lessons/{lesson}/pulse and POST /access/{token}/survey, guided by signals in GET /access/{token}. They should still load the script for the reading statistics.
  • Course pages can show reviews from GET /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 fetch with next: { tags: [...] } in Next.js, or a small key-value cache), and revalidate with If-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 sends content.published with the course slug, version and lesson slugs whenever lessons are published, content.unpublished when published lessons are deleted or their version goes back to draft, and course.updated when 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 revalidateTag in Next.js); for course.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 revalidate in 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.