Course settings on ibl.ai Studio live behind one JSON endpoint that merges the
Open edX "Schedule & Details" fields with the ibl.ai catalog fields the LMS app
shows. Read it, send back only the keys you change. Files (course image,
banner, any asset) go through the assets endpoint first; advanced settings
(advanced_modules and friends) have their own endpoint.
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. - A course key from
/iblai-api-studio-course-create. - Do this before building content and before the first publish. Three rules that Studio enforces silently:
- Pacing is locked once the course has started.
self_pacedcan only change whilestart_dateis in the future; after that the endpoint returns200and echoes the old value. Decide pacing now. (Workaround for a started course: setstart_dateto a future date, flipself_paced, restorestart_date.) The lock is evaluated against thestart_datein the request, not the stored one — so sendingself_pacedand a past/todaystart_datein the same body silently keeps the old pacing, even on a fresh course still sitting at2030-01-01. Always sendself_pacedin its own call first, verify the echo, then set the real dates in a second call. (Verified live:self_paced+ a futurestart_datein one body is accepted even on a started course, which is why the workaround above works in two calls;self_paced+ a paststart_datein one body applies the date and drops the pacing change.) - Enrollment opens before the course starts:
enrollment_startmust be earlier thanstart_date(andenrollment_end, if set, later thanenrollment_start). - The
/iblai-api-studio-outlinebuilder publishes the whole course; set dates, description and images first so the catalog never shows a half-configured course.
- Pacing is locked once the course has started.
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") U=("${S[@]:0:11}") # same headers without Content-Type, for multipart uploads COURSE="course-v1:<org>+<number>+<run>"; URL-encode it in query strings (CE=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$COURSE")).- Dates are ISO 8601 UTC (
2026-10-01T00:00:00Z) ornull. - Settings changes take effect on the LMS immediately (no publish step); content still needs
/iblai-api-studio-publish. - Do not use the stock
/settings/details/{course}for writes: partial bodies return500. - Silent no-ops: the settings endpoint answers
200with the stored object even when a field was refused (pacing after start, unknown keys). Always compare the response with what you sent.
Reads
-
GET
/api/ibl/v1/course_settings?course_key={CE}→{ "formData": { …59 keys… } }:Group Keys identity org,course_id(number),run,course_key,platform_key(org key),slugschedule start_date,end_date,enrollment_start,enrollment_end,self_paced(bool),certificate_available_date,certificates_display_behavior(end·end_with_date·early_no_info)about page title,subtitle,description,short_description,overview(HTML),about_sidebar_html,duration,effort(e.g."3:00"h:mm per week),language(en),license,syllabus,learning_info(list of strings),instructor_info({instructors: [{name, title, organization, image, bio}]}),pre_requisite_courses,entrance_exam_*media intro_video(YouTube video id, e.g.dQw4w9WgXcQ),course_image_name+course_image_asset_path,banner_image_name+banner_image_asset_path,video_thumbnail_image_name+video_thumbnail_image_asset_pathibl.ai catalog subject,tags(list),level,topics(list),promotion,audit_allowed,certificate_type,usage_limit,job_role,industry,custom,credential,social_team,social_channels,mentor_uuid(agent attached to the course),mentor_hidden,agent_content_mode,course_content_mode,agent_autoplay,agent_content_mode_audience,course_content_mode_audience,enable_agent_based_completion -
GET
/settings/advanced/{COURSE}→{ "<key>": { "value", "display_name", "help", "deprecated" }, … }(about 68 keys:advanced_modules,display_name,max_attempts,showanswer,rerandomize,days_early_for_beta,cert_html_view_enabled,mobile_available,invitation_only,catalog_visibility, …). -
GET
/assets/{COURSE}/?page=0&page_size=50&sort=date_added&direction=desc&asset_type=Images|Documents|Audio|Code|Other&text_search=…→{ "assets": [ { "display_name", "content_type", "url", "portable_url", "external_url", "thumbnail", "id", "locked", "file_size", "date_added", "usage_locations" } ], "totalCount", "page", "pageSize", "start", "end" }. -
GET
/api/ibl/manage/course/?course_key={CE}→{ course_key, org, number, run, display_name, url }(display name only).
Writes
- POST
/api/ibl/v1/course_settings— merge-update; body =course_key+ the keys to change (everything else is preserved). Response = the full updated settings object (top level, noformDatawrapper).
The LMS reads the same object back ({ "course_key": "course-v1:acme+DATA101+2026-T1", "enrollment_start": "2026-09-15T00:00:00Z", "start_date": "2026-10-01T00:00:00Z", "end_date": "2026-12-15T00:00:00Z", "self_paced": false, "language": "en", "title": "Intro to Data", "subtitle": "Reading, measuring, deciding", "short_description": "A six-week introduction to working with data.", "overview": "<section class=\"about\"><h2>About This Course</h2><p>…</p></section>", "effort": "3:00", "intro_video": "dQw4w9WgXcQ", "subject": "Data", "tags": ["data", "statistics"], "level": "beginner" }GET $LMS_URL/api/ibl/v1/course_metadata?course_key=…) and the ibl.ai catalog picks up the catalog keys — nothing else to sync. To clear a value sendnull(or""for the string fields Studio stores as text). - Course image / banner: upload, then point the settings at the asset:
- POST
/assets/{COURSE}/multipartfile=@cover.png(use"${U[@]}") →{ "asset": { "display_name": "cover.png", "url": "/asset-v1:…+type@asset+block@cover.png", "portable_url": "/static/cover.png", "external_url": "https://<lms>/asset-v1:…", "id": "asset-v1:…", … }, "msg": "Upload completed" }. - POST
/api/ibl/v1/course_settings{ "course_key": "…", "course_image_name": "cover.png", "course_image_asset_path": "/asset-v1:…+type@asset+block@cover.png" }(same pair forbanner_image_*andvideo_thumbnail_image_*; useasset.urlfor the_asset_path).
- Same filename re-uploaded replaces the file (URL unchanged). DELETE
/assets/{COURSE}/{asset_id}→204removes one (confirm with the user first); POST/assets/{COURSE}/{asset_id}{ "locked": true }→201 { "locked": true }restricts it to enrolled users. (The v1course/asset/uploadroute returns500— use this one.)
- POST
- POST
/settings/advanced/{COURSE}— merge-update of advanced settings,{ "<key>": { "value": … } }; response = the full map. Example:{ "advanced_modules": { "value": ["pdf"] } }(read first and merge lists),{ "invitation_only": { "value": true } },{ "catalog_visibility": { "value": "about" } },{ "days_early_for_beta": { "value": 7 } }. A bad value returns400with[ { "key", "message", "model": { "value" } } ]and nothing is saved.
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")
U=("${S[@]:0:11}")
COURSE="course-v1:acme+DATA101+2026-T1"; CE=$(python3 -c "import urllib.parse;print(urllib.parse.quote('$COURSE',safe=''))")
IMG=$(curl "${U[@]}" -X POST "$STUDIO_URL/assets/$COURSE/" -F "file=@cover.png" \
| python3 -c 'import sys,json; a=json.load(sys.stdin)["asset"]; print(a["display_name"], a["url"])')
set -- $IMG
cat > settings.json <<EOF
{"course_key": "$COURSE", "enrollment_start": "2026-09-15T00:00:00Z", "start_date": "2026-10-01T00:00:00Z",
"self_paced": false, "title": "Intro to Data", "short_description": "A six-week introduction to working with data.",
"course_image_name": "$1", "course_image_asset_path": "$2", "subject": "Data", "tags": ["data", "statistics"]}
EOF
curl "${S[@]}" -X POST "$STUDIO_URL/api/ibl/v1/course_settings" -d @settings.json \
| python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["start_date"], d["enrollment_start"], d["self_paced"], d["title"], d["subject"])'
# compare: every printed value must equal what settings.json sent
Notes
- A new course has
start_date: 2030-01-01— users see "coming soon" until you set a real start;enrollment_startafter "now" blocks self-enrollment. self_paced: truemakes due dates relative and hides the schedule; set it before building subsections with due dates, and before the start date passes (see above).overviewis the about page HTML (<section class="about">…</section>blocks); keep the authoring rules of/iblai-api-studio-html.intro_videotakes the YouTube id only; the LMS turns it intohttp://www.youtube.com/watch?v=<id>inmedia.course_video.- Catalog keys (
subject,tags,level, …) are what the LMS app filters on;mentor_uuidattaches an ibl.ai agent to the course (ids from/iblai-api-agent-setting). - Verify what users see with
/iblai-api-studio-lms(course_metadata,courses/v1/courses/{key}).