Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions domain-skills/skool/course-authoring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# 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**

@cubic-dev-ai cubic-dev-ai Bot Jul 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Consider adding a minimal fetch template that includes method, headers: {'Content-Type': 'application/json'}, and body: JSON.stringify(...). Without it, a reader might pass the shown JSON objects directly as body, which silently coerces to [object Object] and produces confusing failures against the API.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At domain-skills/skool/course-authoring.md, line 4:

<comment>Consider adding a minimal `fetch` template that includes `method`, `headers: {'Content-Type': 'application/json'}`, and `body: JSON.stringify(...)`. Without it, a reader might pass the shown JSON objects directly as `body`, which silently coerces to `[object Object]` and produces confusing failures against the API.</comment>

<file context>
@@ -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.
+
</file context>
Fix with cubic

(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:

@cubic-dev-ai cubic-dev-ai Bot Jul 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The TipTap document unwrapping example "[v2]" + JSON.stringify(doc.content) assumes doc.content is always present. If a TipTap doc is empty or partially constructed and lacks a content array, JSON.stringify(undefined) returns undefined, which string-concatenates to [v2]undefined — an invalid body that will likely be rejected or fail to render. Consider adding a fallback so the expression safely yields [v2][] when content is absent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At domain-skills/skool/course-authoring.md, line 20:

<comment>The TipTap document unwrapping example `"[v2]" + JSON.stringify(doc.content)` assumes `doc.content` is always present. If a TipTap doc is empty or partially constructed and lacks a `content` array, `JSON.stringify(undefined)` returns `undefined`, which string-concatenates to `[v2]undefined` — an invalid body that will likely be rejected or fail to render. Consider adding a fallback so the expression safely yields `[v2][]` when `content` is absent.</comment>

<file context>
@@ -0,0 +1,75 @@
+
+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:
</file context>
Suggested change
unwrap it: `"[v2]" + JSON.stringify(doc.content)`. Supported nodes seen in the editor toolbar:
unwrap it: `"[v2]" + JSON.stringify(doc.content ?? [])`
Fix with cubic

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":<parent id>,"root_id":<course id>,
"unit_type":"module","state":2,"metadata":{"title":"...","resources":"[]"}}
```
- 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/<id>` → **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/<id>` → 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 — and remember a `"6.5 "` numeric prefix eats 4 chars, so cap the base title at ~45.

@cubic-dev-ai cubic-dev-ai Bot Jul 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The concrete ~45-character base-title cap is based on a fixed 4-character prefix example ("6.5 "), but the prefix length grows with double-digit module or lesson numbers. For example, "10.10 " is 6 characters, so a 45-character base title would exceed the documented 50-character API limit and trigger the HTTP 422 error this section warns about. Consider phrasing this as a dynamic calculation (base title length ≤ 49 - prefix.length) or noting that the 4-char assumption only holds for single-digit module and lesson numbers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At domain-skills/skool/course-authoring.md, line 65:

<comment>The concrete ~45-character base-title cap is based on a fixed 4-character prefix example (`"6.5 "`), but the prefix length grows with double-digit module or lesson numbers. For example, `"10.10 "` is 6 characters, so a 45-character base title would exceed the documented 50-character API limit and trigger the HTTP 422 error this section warns about. Consider phrasing this as a dynamic calculation (`base title length ≤ 49 - prefix.length`) or noting that the 4-char assumption only holds for single-digit module and lesson numbers.</comment>

<file context>
@@ -54,7 +61,8 @@ no-ops):
 - **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).
</file context>
Suggested change
under — and remember a `"6.5 "` numeric prefix eats 4 chars, so cap the base title at ~45.
+ under — and remember the prefix length varies (`"6.5 "` = 4 chars, `"10.10 "` = 6 chars, etc.), so cap the base title at `49 - prefix.length`.
Fix with cubic

- **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/<course id>?group_id=G` → `{course, children:[{course, children}]}`.
- **URL scheme:** course landing `/<group>/classroom/<course 8-char name slug>`; a specific lesson is
`?md=<lesson FULL id>` (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/<id>`.