Every edit in Studio is a draft. Users see a block only once it — or an
ancestor — has been published. Publishing is a value of the publish key on
the generic block update, applied at any level of the tree. This skill is the
last step of a build and the first thing to check when "it's not showing".
Before you start
- Preflight:
node .claude/skills/iblai-api-studio-auth/scripts/studio-login.mjs --checkprints twooks; otherwise run/iblai-api-studio-authfirst — do not attempt the calls below. - First publish of a course — check, in this order: settings done (
/iblai-api-studio-settings: realstart_date,enrollment_startbefore it, pacing decided — pacing locks once the course starts), grading policy set, the outline complete and reviewed with the user (GET /xblock/outline/{ROOT}), every unit has content. A published empty or half-built course is what users find in the catalog. - The locator of what to publish (a unit, subsection, section, or
ROOT).
Auth & conventions
- Base URL:
$STUDIO_URL; Studio session cookies fromstudio.env(its values are single-quoted — bash strips the quotes; any other parser must strip them too, or Studio answers 500:/iblai-api-studio-auth). - Snippet:
set -a; . ./studio.env; set +a S=(-s -b "studio_session_id=$STUDIO_SESSION; csrftoken=$STUDIO_CSRF" -H "X-CSRFToken: $STUDIO_CSRF" \ -H "Origin: $STUDIO_URL" -H "Referer: $STUDIO_URL/" -H "Accept: application/json" -H "Content-Type: application/json") ROOT="block-v1:<org>+<number>+<run>+type@course+block@course"; any block locator works as the target.- Publishing is outward-facing (users see it at once, on schedule) — confirm the target with the user before publishing a whole course.
discard_changesdestroys draft work — confirm with the user first. /iblai-api-studio-outline'screate_full_coursepublishes the whole course by itself — nothing to do here after it, except re-publishing later edits.
Concepts — what the state fields mean
| Field | Values | Meaning |
|---|---|---|
published |
bool | a published version exists |
has_changes |
bool | the draft differs from the published version |
visibility_state |
live |
published, released, visible now |
ready |
published, release date (start) in the future |
|
unscheduled |
published but no release date | |
needs_attention |
unpublished, or published with unpublished changes | |
staff_only |
hidden from users (visible_to_staff_only) |
|
gated |
behind a prerequisite | |
released_to_students |
bool | start has passed (course start also applies) |
"Visible to a user" = published ∧ released_to_students ∧ not staff_only ∧ enrolled.
Reads
- GET
/xblock/outline/{ROOT}— the whole tree with the fields above on every section/subsection/unit (child_info.children, recursive). Use it to find everything withhas_changes: true. - GET
/xblock/outline/{section}/ GET/xblock/{block}— same fields for a subtree or one block. - GET
/api/v1/ibl/course/{COURSE}/units— every unit with its components (/iblai-api-studio-outline); components have no publish state of their own, the unit's covers them.
Writes
- POST
/xblock/{block}{ "publish": "make_public" }— publish the block and everything below it.blockmay be a unit, subsection, section, orROOTfor the whole course. →{ "id", "data", "metadata" }(200). Idempotent. (v1 equivalent:POST /api/v1/ibl/xblock/publish{ "locator" }→200.) - POST
/xblock/{block}{ "publish": "discard_changes" }— throw away the draft and revert to the published version (subtree). →{ "id" }. Confirm with the user first. - POST
/xblock/{block}{ "publish": "republish" }— re-run publish for a block already published with no changes (used after settings-only edits; harmless). - Combining: an update and a publish can be one call —
{ "metadata": { "display_name": "Week 1" }, "publish": "make_public" }.
Example
set -a; . ./studio.env; set +a
S=(-s -b "studio_session_id=$STUDIO_SESSION; csrftoken=$STUDIO_CSRF" -H "X-CSRFToken: $STUDIO_CSRF" \
-H "Origin: $STUDIO_URL" -H "Referer: $STUDIO_URL/" -H "Accept: application/json" -H "Content-Type: application/json")
COURSE="course-v1:acme+DATA101+2026-T1"; ROOT="block-v1:${COURSE#course-v1:}+type@course+block@course"
# publish everything
curl "${S[@]}" -o /dev/null -w '%{http_code}\n' -X POST "$STUDIO_URL/xblock/$ROOT" -d '{"publish":"make_public"}'
# what is still not visible? (script in a heredoc so quoting cannot break)
curl "${S[@]}" "$STUDIO_URL/xblock/outline/$ROOT" > outline.json
python3 - <<'PY'
import json
def walk(n, d=0):
state = n.get("visibility_state")
flag = "" if state == "live" else " <- " + str(state) + (" (unpublished changes)" if n.get("has_changes") else "")
print(" " * d + n["category"].ljust(11), n["display_name"] + flag)
for c in (n.get("child_info") or {}).get("children", []):
walk(c, d + 1)
walk(json.load(open("outline.json")))
PY
Notes
- Publishing does not change dates: a block published with
startin the future isready, notlive. Coursestart_date(/iblai-api-studio-settings) gates all of it. staff_onlystays hidden after publishing — clearvisible_to_staff_onlyon the block (and checkancestor_has_staff_lock).- Publish at the smallest sensible level while iterating (a unit); publish
ROOTonce at the end of a build. - Course settings, grading policy and team changes need no publish.
- LMS-side confirmation (published outline, block list, enrollment) is
/iblai-api-studio-lms; the LMS app URL is$LMS_APP_URL/platform/<org>/course-content/<course_key>/course.