From 5809abddf76c86d3b37c372a33182fb02c20a9ce Mon Sep 17 00:00:00 2001 From: Rod Kendrick Date: Mon, 6 Jul 2026 22:52:16 -0700 Subject: [PATCH 1/2] domain-skills/skool: classroom course authoring API (api2.skool.com) Full create/fill/delete contract for Skool classroom courses discovered while publishing a 28-lesson course: POST /courses for course+sections+lessons (unit_type=module, nested by parent_id, 3 levels render), PUT /courses/{id} with a FLAT {title,desc,transcript,video_id} body to set [v2] content, and the field-tested traps (50-char title cap = 422, don't navigate mid-loop, chunked payload + fire-and-poll to beat the socket timeout, stale __NEXT_DATA__). Co-Authored-By: Claude Fable 5 --- domain-skills/skool/course-authoring.md | 75 +++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 domain-skills/skool/course-authoring.md diff --git a/domain-skills/skool/course-authoring.md b/domain-skills/skool/course-authoring.md new file mode 100644 index 00000000..049dae25 --- /dev/null +++ b/domain-skills/skool/course-authoring.md @@ -0,0 +1,75 @@ +# Skool — classroom course authoring (api2.skool.com) + +Create and fill classroom courses via Skool's private API. Auth is httpOnly cookies, so make +credentialed `fetch(..., {credentials:"include"})` calls **from a logged-in Skool page context** +(run them via `js(...)` / `Runtime.evaluate`). Must be a group admin. + +## IDs you need first + +Load any classroom page, then read the SSR blob: +```js +const pp = window.__NEXT_DATA__.props.pageProps; +pp.currentGroup.id // group_id +pp.self.id // user_id (must be group-admin: pp.self.member.role) +``` + +## Content format: `[v2]` bare array (NOT doc-wrapped) + +A lesson body (`desc`) is the literal string `[v2]` followed by compact JSON of a **top-level ARRAY** +of TipTap block nodes — NOT `{"type":"doc","content":[...]}`. If you have a doc-wrapped TipTap doc, +unwrap it: `"[v2]" + JSON.stringify(doc.content)`. Supported nodes seen in the editor toolbar: +paragraph, text (marks: bold, italic, strike, code, link), heading (H1–H4), bulletList/orderedList/ +listItem, blockquote, codeBlock, horizontalRule, hardBreak, image, video. Simplest reliable subset: +paragraphs of text+marks, with `• `-prefixed lines for bullets. + +## The three calls + +**Create course (root):** `POST https://api2.skool.com/courses` → 200, returns the object incl. `id`. +```json +{"group_id":G,"user_id":U,"unit_type":"course","state":2,"is_afl_comp_eligible":false, + "metadata":{"title":"...","desc":"...","cover_image":"","privacy":0,"min_tier":0}} +``` +`state:2` = published (state:1 = draft/unpublished). `privacy:0` = Open (all members). + +**Create module/lesson:** same `POST /courses` → 200, returns `id`. `unit_type` is `"module"` for +BOTH section headers and lessons — the hierarchy is by `parent_id`: +```json +{"group_id":G,"user_id":U,"parent_id":,"root_id":, + "unit_type":"module","state":2,"metadata":{"title":"...","resources":"[]"}} +``` +- Section under the course: `parent_id = root_id = course id`. +- Lesson under a section: `parent_id = section id`, `root_id = course id`. +- **3-level nesting (course → section → lesson) works and renders** as sidebar sections with lessons. +- **Display order = creation order.** There is no order field — create sequentially (await each). + +**Set lesson content:** `PUT https://api2.skool.com/courses/` → **204**. Body is a FLAT 4-field +object — NOT the metadata-wrapped create shape (sending the create shape returns 200 but silently +no-ops): +```json +{"title":"...","desc":"[v2][...]","transcript":null,"video_id":""} +``` + +**Delete:** `DELETE /courses/` → 200. Deleting a section cascades to its lessons. + +## Traps (field-tested) + +- **Title max = 50 chars.** Titles ≥ 51 fail the create with **HTTP 422** (silent in the UI). Keep + sidebar titles ≤ 49; put the full title as an H1 in the body if needed. 50 exactly passed, but stay under. +- **Update needs the flat body.** The create body's `{...,metadata:{desc}}` shape does nothing on PUT. + Discover/confirm by editing one lesson in the UI and capturing the PUT (see below). +- **Do NOT navigate the page while a fetch loop is running** — a reload kills the JS context and the + loop dies mid-way, leaving a partial course. Run the whole loop in one page context. +- **Large payloads break the harness socket.** Injecting >~100KB in a single `Runtime.evaluate`, or + awaiting a 60+ call loop synchronously, times out the unix socket. Instead: push the payload in + chunks (per module), then FIRE the loop without `awaitPromise` while it writes progress to a global + (`window.__PROGRESS`), and POLL that global with short separate calls until `done`. +- **`__NEXT_DATA__` is a stale SSR snapshot** — it does not reflect creates/deletes made this session. + For live tree state, `GET /courses/?group_id=G` → `{course, children:[{course, children}]}`. +- **URL scheme:** course landing `//classroom/`; a specific lesson is + `?md=` (the full 32-char id, NOT the short name). Wrong md → 404/Oops page. + +## Reference: capture any create/update call + +Enable `Network`, do the action once in the UI, drain events, filter `api2.skool.com` requests for +`postData` + status. The "Add course" dialog's Published toggle + Open radio map to `state:2`, +`privacy:0`. Editing a lesson and clicking SAVE emits the flat `PUT /courses/`. From 9c53fbb44eb4c0247d0ea05caf323cf45a726ae9 Mon Sep 17 00:00:00 2001 From: Rod Kendrick Date: Mon, 6 Jul 2026 23:05:13 -0700 Subject: [PATCH 2/2] =?UTF-8?q?skool:=20correct=20=E2=80=94=203-level=20ne?= =?UTF-8?q?sting=20does=20NOT=20render,=20use=20flat=20+=20numbered=20titl?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field-corrected while publishing: the classroom UI renders only 2 levels (course -> lessons). A section-with-children stores fine via the API but shows as an empty page with unreachable lessons. Use flat lessons under the course root with '{m}.{l} ' title prefixes for module grouping. --- domain-skills/skool/course-authoring.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/domain-skills/skool/course-authoring.md b/domain-skills/skool/course-authoring.md index 049dae25..97db4279 100644 --- a/domain-skills/skool/course-authoring.md +++ b/domain-skills/skool/course-authoring.md @@ -37,11 +37,18 @@ BOTH section headers and lessons — the hierarchy is by `parent_id`: {"group_id":G,"user_id":U,"parent_id":,"root_id":, "unit_type":"module","state":2,"metadata":{"title":"...","resources":"[]"}} ``` -- Section under the course: `parent_id = root_id = course id`. -- Lesson under a section: `parent_id = section id`, `root_id = course id`. -- **3-level nesting (course → section → lesson) works and renders** as sidebar sections with lessons. +- Lesson under the course: `parent_id = root_id = course id`. **This is the structure to use.** - **Display order = creation order.** There is no order field — create sequentially (await each). +## Structure: use FLAT (course → lessons). 3-level does NOT render. + +You *can* POST a "module" whose `parent_id` is another module (course → section → lesson), and the +API stores it 3 levels deep — but the classroom UI does **not** render it: the section shows as a +flat row that opens its own (empty) content page, and its child lessons are invisible and unreachable +to members. Every working Skool classroom course is 2-level. So: create all lessons directly under the +course root, and encode module grouping in the title with a numeric prefix (`"1.1 …" … "6.5 …"`) plus +list the module names in the course `desc`. Order the lessons by creating them in curriculum order. + **Set lesson content:** `PUT https://api2.skool.com/courses/` → **204**. Body is a FLAT 4-field object — NOT the metadata-wrapped create shape (sending the create shape returns 200 but silently no-ops): @@ -54,7 +61,8 @@ no-ops): ## Traps (field-tested) - **Title max = 50 chars.** Titles ≥ 51 fail the create with **HTTP 422** (silent in the UI). Keep - sidebar titles ≤ 49; put the full title as an H1 in the body if needed. 50 exactly passed, but stay under. + sidebar titles ≤ 49; put the full title as an H1 in the body if needed. 50 exactly passed, but stay + under — and remember a `"6.5 "` numeric prefix eats 4 chars, so cap the base title at ~45. - **Update needs the flat body.** The create body's `{...,metadata:{desc}}` shape does nothing on PUT. Discover/confirm by editing one lesson in the UI and capturing the PUT (see below). - **Do NOT navigate the page while a fetch loop is running** — a reload kills the JS context and the