The IBL course-creator app on Studio adds api/v1/ibl/… routes that wrap the
stock /xblock/ handler for bulk work. create_full_course walks a JSON
spec — sections → subsections → units → components — creates everything
inside one modulestore transaction and publishes the whole course at the
end. A five-section course with text and quizzes becomes one request instead
of a hundred.
Before you start
- Three situations, three tools. New, empty course →
create_full_course(below; it appends and then publishes the entire course, drafts included). One section, lesson, unit or component of an existing course → the individual skills (/iblai-api-studio-section,-subsection,-unit,-html,-problem,-pdf), which act on one locator. Many changes to an existing course (rewrite readings, add units across lessons, restructure) → the reconcile driver (section "Bulk edits to an existing course" below), which diffs the same spec against the live outline and only touches what differs. - Preflight:
node .claude/skills/iblai-api-studio-auth/scripts/studio-login.mjs --checkmust print twooks — otherwise run/iblai-api-studio-authfirst, do not attempt the calls below. - A course key from
/iblai-api-studio-course-create; the user must be instructor on it (the creator is). Forcreate_full_course, confirm withGET /xblock/outline/{ROOT}thatchild_info.childrenis empty; for the reconcile driver, that outline is the baseline it diffs against. - Settings and grading first (
/iblai-api-studio-settings,/iblai-api-studio-grading): this call publishes, and pacing cannot change after the course start. - The outline planned per
/iblai-api-studio"Planning a production-grade outline" and reviewed with the user. - If sub-agents wrote parts of the spec: they worked from the same content brief, and every fragment has been reviewed against it (
/iblai-api-studio"Content quality") — consistent voice and headings, no thin or placeholder units, valid OLX, titles on every block. The builder posts whatever it is given and publishes it; divergent or shallow content goes live verbatim.
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") COURSE="course-v1:<org>+<number>+<run>";ROOT="block-v1:<org>+<number>+<run>+type@course+block@course".- Responses use
xblock_locator(v1 routes) where the stock handler sayslocator. - Do not send
positionon the v1 create route: it returns500on this deployment. Order is creation order; reorder afterwards withchildren(/iblai-api-studio-section). - The v1
course/asset/uploadroute also returns500— upload assets withPOST /assets/{COURSE}/(/iblai-api-studio-settings). - Creating content is not reversible in bulk (there is no undo; delete blocks one by one) — show the user the spec and confirm before posting.
Reads
- GET
/api/v1/ibl/course/{COURSE}/units→{ "units": [ { "id": "<unit locator>", "display_name", "type": "vertical", "children": [ { "id", "display_name", "type": "html" | "problem" | "pdf" | … } ] } ] }— every unit in outline order with its components, straight from the modulestore (no cache lag). The cheapest way to get all locators after a bulk build. - GET
/api/v1/ibl/xblock/get-details?locator=<url-encoded usage key>→{ "id", "data", "display_name" }forhtml(andvideo) blocks;406for other types. - Full tree with publish state:
GET /xblock/outline/{ROOT}(/iblai-api-studio-section).
Writes
- POST
/api/v1/ibl/course/create_full_course— build and publish the tree. Confirm with the user first.
→{ "course_parent_locator": "<ROOT>", "section": [ { "name": "Week 1 · Foundations", "subsection": [ { "name": "1.1 What is data?", "unit": [ { "name": "Overview", "problems": [ { "problem_type": "html", "data": "<section><h3>Why data matters</h3><p>…</p></section>" }, { "problem_type": "video", "metadata": { "display_name": "Intro video", "youtube_id_1_0": "dQw4w9WgXcQ", "html5_sources": [] } } ] }, { "name": "Practice", "problems": [ { "problem_type": "blank", "data": "<problem>…OLX with one or more responses…</problem>", "metadata": { "display_name": "Check your understanding", "max_attempts": 3, "weight": 2, "showanswer": "finished", "markdown": null } } ] } ] } ] } ] }200 { "course_parent_locator": "<ROOT>", "created_units": [ { "locator": "<unit>", "display_name" } ] }. Keys are exactlysection[].name,subsection[].name,unit[].name,unit[].problems[]; every level is required (a unit may have"problems": []).problems[].problem_type:html(data= HTML fragment),blank(aproblemblock:data= OLX,metadataas in/iblai-api-studio-problem, optionalboilerplate),multiplechoice(same, seeded from the multiple-choice boilerplate),dropdown(same,boilerplate: "optionresponse.yaml"),video(metadata.youtube_id_1_0,html5_sources),drag-and-drop-v2,lti_consumer,ibl_openedx_scorm_xblock.pdfis not supported — add PDFs afterwards with/iblai-api-studio-pdf. Always put"markdown": nullin a problem'smetadataso the OLX is authoritative. Limits: html components are created with the default title "Text" (the builder ignoresdisplay_nameforhtml) — rename them afterwards with the update route below or the section rhythm will look uniform; the whole course is published on success, including previously unpublished drafts. Errors:400(serializer message) for a missingcourse_parent_locator;403 "You do not have permission to create content in this course"; a500HTML page usually means a malformed spec (e.g. a missingproblemskey) — nothing before the failing block is rolled back, so read the outline before retrying. - POST
/api/v1/ibl/xblock/create{ "category": "chapter" | "sequential" | "vertical" | "html" | "problem", "parent_locator": "<parent>", "display_name": "…", "boilerplate": "…" }→{ "xblock_locator": "…" }. Same asPOST /xblock/; noposition. - POST
/api/v1/ibl/xblock/update{ "locator": "<block>", "category": "<its category>", "courseKey": "<COURSE>", "data": "…", "metadata": { "display_name": "…", … } }→200(empty). Top-leveldisplay_nameis ignored — put it inmetadata. - POST
/api/v1/ibl/xblock/publish{ "locator": "<block>" }→200(empty). Same as{ "publish": "make_public" }on/xblock/{block}.
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"
# spec.json written from the reviewed plan; validate every OLX string first
python3 - <<'PY'
import json, xml.dom.minidom
spec = json.load(open("spec.json"))
for s in spec["section"]:
for ss in s["subsection"]:
for u in ss["unit"]:
for p in u["problems"]:
if p["problem_type"] in ("blank", "multiplechoice", "dropdown"):
xml.dom.minidom.parseString(p["data"])
print("spec ok:", sum(len(ss["unit"]) for s in spec["section"] for ss in s["subsection"]), "units")
PY
curl "${S[@]}" -X POST "$STUDIO_URL/api/v1/ibl/course/create_full_course" -d @spec.json > build.json
python3 -c 'import json; print(len(json.load(open("build.json"))["created_units"]), "units created")'
# rename the html blocks the builder titled "Text"
curl "${S[@]}" "$STUDIO_URL/api/v1/ibl/course/$COURSE/units" | python3 -c '
import json, sys, subprocess
for u in json.load(sys.stdin)["units"]:
for c in u["children"]:
if c["type"] == "html" and c["display_name"] == "Text":
print(c["id"])' | while read -r ID; do
curl "${S[@]}" -o /dev/null -X POST "$STUDIO_URL/api/v1/ibl/xblock/update" \
-d "{\"locator\":\"$ID\",\"category\":\"html\",\"courseKey\":\"$COURSE\",\"metadata\":{\"display_name\":\"Reading\"}}"
done
After a bulk build — the three fix-up passes
create_full_course does not finish the course. Two things it cannot express,
plus the republish they cause, are always needed before the outline is done.
Budget for them; they are pure derivation from the spec you already have.
- Rename the html components. The builder titles every
htmlblock "Text" (it ignoresdisplay_namefor html;problemblocks keep theirmetadata.display_nameand need nothing). Rename them withPOST /api/v1/ibl/xblock/update— the loop in the Example above does it for a whole course. Leave this out and every reading is called "Text" in the unit header, the breadcrumb and analytics. - Set the graded subsections. The spec has no
graderTypekey —subsection[]accepts onlynameandunit[]. Every graded subsection isnotgradeduntil youPOST /xblock/{subsection}{"graderType": "Homework"}per/iblai-api-studio-subsection. Read the tree back withGET /xblock/outline/{ROOT}to get the subsection locators. - Publish again. The builder publishes at the end of its request, so the
two passes above leave the course dirty (
has_changes: true). Finish withPOST /xblock/{ROOT}{"publish": "make_public"}and confirm the root readspublished: true, has_changes: false.
Verify the finished outline in one read: GET /xblock/outline/{ROOT} gives
course_graders plus every subsection's graded / format and publish state.
Bulk edits to an existing course — reconcile
scripts/reconcile.py (standard library only) takes the same spec shape
and makes the live course match it: creates what is missing, updates
components whose data/metadata differ, reorders, reports blocks that are
live but not in the spec (deletes them only with --delete), and publishes
changed units with --publish. Idempotent — a second run is a no-op. Dry run
by default.
python3 .claude/skills/iblai-api-studio-outline/scripts/reconcile.py spec.json --course "$COURSE" # plan only
python3 .claude/skills/iblai-api-studio-outline/scripts/reconcile.py spec.json --course "$COURSE" --apply --publish # do it
python3 .claude/skills/iblai-api-studio-outline/scripts/reconcile.py spec.json --course "$COURSE" --apply --delete # also remove blocks absent from the spec — confirm with the user first
- Matching: by
"id"(a locator) when a node carries one, else by name at the same level (section/subsection/unitname, componentdisplay_name- type). Rename = delete + create, so give nodes an
id(frombuild.json) before renaming.
- type). Rename = delete + create, so give nodes an
- Spec additions over
create_full_course:subsection.graderType, anddisplay_nameon every component (required — it is the match key).problem_typehandled:html,blank,multiplechoice,dropdown,video;pdfis skipped with a note (/iblai-api-studio-pdf). - Reconciling a course that was bulk-built. Components match on
(type, display_name), andcreate_full_courseleaves every html block named"Text". The driver treats a live block that still carries a default title ("Text","Multiple Choice","Video", …) as unnamed and adopts it for the next spec component of the same type in that unit, then sets the spec'sdisplay_nameon it — so the first reconcile after a bulk build renames the readings instead of duplicating them (fix-up pass 1 becomes part of the reconcile). Two cautions: the adoption is positional within a unit, so keep the spec's component order equal to the built order until the first reconcile has run; and a spec component with nodisplay_namefalls back to the literalproblem_typeas its name — always give components a title. Pin a component with an"id"frombuild.jsonwhen in doubt. - Plan lines read
create | update | reorder | grade | delete | extra | publish <path> [locator];--applywritesbuild.json(path → locator). - Cost: 3 calls for the plan (outline + units + nothing else) plus one
GETper matched component, then one call per change. Rewriting 40 readings ≈ 45 calls. - Flags:
--env studio.env,--map build.json. Exit with the failing call's status on any HTTP error (302/403/500 →/iblai-api-studio-auth).
Workflow for "rewrite the readings and add units": export the live outline
into a spec (GET /xblock/outline/{ROOT} + GET /api/v1/ibl/course/{COURSE}/units,
or reuse the previous spec.json), edit it, dry-run, review the plan with the
user, --apply --publish.
Same thing, three names
| Endpoint | locator | title | type |
|---|---|---|---|
stock /xblock/… create / outline |
locator (create) · id (read) |
display_name |
category |
v1 xblock/create · course/{COURSE}/units |
xblock_locator (create) · id (units) |
display_name |
type |
/api/contentstore/v1/container/vertical/{unit}/children |
block_id |
name |
block_type |
Notes
- Keep
spec.jsonandbuild.jsonnext to the project: together they are the resumable record of what exists (re-running the bulk call duplicates every section; the reconcile driver does not). - One
blankproblem per unit with several*responseelements is the intended shape; several problem blocks in one unit force separate submissions (/iblai-api-studio-problem). - The builder publishes; if the user wants to review before going live, build
with the block-by-block skills instead, or build into a course whose
start_dateis in the future andvisible_to_staff_onlyis set on sections. - Stock equivalents:
POST /xblock/(/iblai-api-studio-section),POST /xblock/{block}(/iblai-api-studio-html),{ "publish": "make_public" }(/iblai-api-studio-publish).