The html component is the workhorse of course content: any HTML the user
should read. Create it, then write data (the HTML) and metadata.display_name
(the title). Two calls per component; no editor round-trips.
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 unit locator (
…+type@vertical+block@…) from/iblai-api-studio-unitor/iblai-api-studio-outline. - If images or files are referenced, upload them first (
POST /assets/{course}/,/iblai-api-studio-settings) and use their/static/…paths.
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") - Keys: parent = a unit locator; the block is
…+type@html+block@<hex>. - Content is a draft until the unit is published (
/iblai-api-studio-publish). DELETE is destructive — confirm with the user first. - A
500HTML page on these calls = malformed session cookie (/iblai-api-studio-auth, "Reading failures"), not bad HTML — Studio stores any string.
Reads
- GET
/xblock/{html}—{ "id", "display_name", "category": "html", "data": "<html string>", "metadata": { "display_name" }, "published", "has_changes", … }. - GET
/api/v1/ibl/xblock/get-details?locator=<url-encoded html locator>—{ "id", "data", "display_name" }(lighter;406for non-html blocks). - GET
/api/contentstore/v1/container/vertical/{unit}/children— the unit's components in order (block_type: "html",name,block_id).
Writes
- POST
/xblock/— create an empty text block (appends to the unit):
→{ "parent_locator": "<unit>", "category": "html" }{ "locator": "…+type@html+block@<hex>", "courseKey": "…" }. Optional"boilerplate": "announcement.yaml"pre-fills an announcement template;"display_name"is ignored on create — set it in the update. - POST
/xblock/{html}— write the content (this is the whole edit):
→{ "data": "<section>…</section>", "metadata": { "display_name": "Why data matters" } }{ "id", "data": "<the stored HTML>", "metadata": { "display_name": "…" } }.datareplaces the previous body entirely;metadatais merged. (Equivalent v1 route:POST /api/v1/ibl/xblock/update{ "locator", "category": "html", "courseKey", "data", "metadata": { "display_name" } }→200;/iblai-api-studio-outline.) - POST
/xblock/{ "duplicate_source_locator": "<html>", "parent_locator": "<unit>" }— copy into the same or another unit. - DELETE
/xblock/{html}→204. Confirm with the user first.
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")
UNIT="block-v1:acme+DATA101+2026-T1+type@vertical+block@645f5ded5f544adab9fb603c4afc3bac"
HTML=$(curl "${S[@]}" -X POST "$STUDIO_URL/xblock/" -d "{\"parent_locator\":\"$UNIT\",\"category\":\"html\"}" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["locator"])')
# Build the body in a file so quotes and newlines survive; send it as JSON.
cat > body.html <<'HTML'
<section>
<h3>Why data matters</h3>
<p>Every decision in this course starts with a question and ends with evidence.</p>
<div style="border-left:4px solid #0058cc;background:#f3f7ff;padding:12px 16px;margin:16px 0;border-radius:6px;">
<strong>Key idea.</strong> A number without a question is noise; a question without a number is a guess.
</div>
<img src="/static/decision-loop.png" alt="The question → data → decision loop" width="640">
<ol><li>Ask</li><li>Measure</li><li>Decide</li></ol>
</section>
HTML
python3 -c 'import json,sys; print(json.dumps({"data": open("body.html").read(), "metadata": {"display_name": "Why data matters"}}))' \
| curl "${S[@]}" -X POST "$STUDIO_URL/xblock/$HTML" -d @-
Authoring rules
The bar: every block is well written, presentable and beautiful,
informative without being distracting, structured and reviewed. Written by a
sub-agent? It must follow the same content brief as every other block
(/iblai-api-studio "Content quality"); review it for voice, depth (no
placeholder or padding text, no unit under ~150 words of real content),
heading levels, the same callout/card patterns as its neighbours, and a real
title — before posting, not after.
What the LMS owns — never override: font family, base font size, line
height, text colour of body copy, and the component's width. No font-family,
font-size on body text, max-width/width on the outer wrapper, no
margin: 0 auto centring, no <style> blocks, <script>, <html>/<head>/<body>.
The LMS app themes the page and adapts it to phones; a component that pins its
own width or type breaks that.
What you own — use inline CSS to make content look designed: structure and
emphasis inside the component. Wrap every component in one <section> (or
<div>), then build with these patterns, keeping colours to a small palette
(brand blue #0058cc, neutral greys, one accent for warnings):
| Pattern | Markup |
|---|---|
| Callout / key idea | <div style="border-left:4px solid #0058cc;background:#f3f7ff;padding:12px 16px;border-radius:6px;margin:16px 0;">…</div> |
| Warning / common mistake | same, border-left-color:#b45309;background:#fff7ed; |
| Card grid (2–3 concepts) | <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;"><div style="border:1px solid #e5e7eb;border-radius:8px;padding:14px;"><h4 style="margin-top:0;">Title</h4><p>…</p></div>…</div> |
| Definition / step list | <ol> with <li><strong>Step name.</strong> detail</li> |
| Comparison table | <table style="border-collapse:collapse;width:100%;"><thead><tr><th scope="col" style="text-align:left;border-bottom:2px solid #e5e7eb;padding:8px;">…</th></tr></thead><tbody><tr><td style="border-bottom:1px solid #e5e7eb;padding:8px;">…</td></tr></tbody></table> |
| Tooltip / glossary term | <abbr title="Full explanation">term</abbr> or <span title="…" style="border-bottom:1px dotted;">term</span> |
| Hierarchy inside a component | <h3> for the component's topic, <h4>/<h5> for parts; never <h1>/<h2> |
| Emphasis | <strong> for terms, <em> for nuance; colour only as a secondary cue |
Use width:100% only inside the component (tables, images, iframes) — it
means "fill whatever the LMS gives me", not "set the page width".
Spacing — the difference between a wall of text and a page:
- One idea per paragraph, 2–4 sentences; a
<p>per paragraph, never<br>to fake spacing. - A heading (
<h3>/<h4>) every 150–300 words; leave the LMS's own heading margins alone. - Vertical rhythm with
margin:16px 0on callouts, tables, images and card grids (24px 0before a new<h3>block if it follows a dense element). - Padding inside boxes (
12px 16pxcallouts,14pxcards,8pxtable cells); nothing inside a box touches its border. - Lists for anything enumerable; a blank line's worth of space (
margin-bottom:12px) after a list before prose resumes. - Media on its own line with space around it (
display:block;margin:16px auto;on images). - No empty
<p></p>or spacers — they render inconsistently across devices.
Images and files: upload with POST /assets/{course_key}/ and reference the
portable_url (/static/<filename>) — Studio rewrites it on both hosts. Always
set alt (empty alt="" for decorative) and width/height when known.
Video: <div style="aspect-ratio:16/9;"><iframe style="width:100%;height:100%;border:0;" src="https://www.youtube.com/embed/<id>" title="…" allowfullscreen loading="lazy"></iframe></div>.
Links: external target="_blank" rel="noopener"; other course pages via the
block's lms_url or /jump_to_id/<block_id> (relative, survives reruns).
Accessibility: one idea per paragraph, real lists for lists, tables only
for tabular data with <th scope="col">, ≥ 4.5:1 contrast on anything
coloured, no meaning by colour alone, headings in order.
Size and validity: under ~30 KB per component including inline SVG and
any data: image URIs — an inline chart or diagram easily runs to hundreds
of KB and is the usual way this limit is blown. Upload images and SVGs as
course assets (POST /assets/{course_key}/) and reference /static/<file>;
split long readings across components or units. Studio stores exactly what you
send, so validate the fragment yourself — one unclosed tag breaks the unit's
layout. \( … \) /
\[ … \] LaTeX is rendered by MathJax.
Notes
display_namedefaults to"Text"when never set (the bulk builder in/iblai-api-studio-outlineleaves it so) — give every block a title; it shows in the page header and analytics.- Updating
dataon a published block makes the unithas_changes: true; republish the unit. - The stored HTML is returned verbatim by
GET /xblock/{html}— read before a partial edit and resend the full body. - One html block per idea; several html blocks in a unit are fine (unlike problem blocks — see
/iblai-api-studio-problem). - Boilerplates other than
announcement.yamlexist upstream (raw.yaml,latex_html.yaml,zooming_image.yaml) but onlyannouncement.yamlis verified here; an unknown name creates a blank block without error.