Build courses from the command line
Every step of the AI Course Builder and of Living Course is a
command (ulams builder …, ulams living …) and an MCP tool of the same name (builder_start, living_proposals_apply,
toolsets builder and living). The decisions behind them are in
ADR 0084. The principle holds on the command line: AI proposes,
the author approves. Nothing past the interview happens unless you pass the flag for it, and nothing reaches the academy
before apply.
You need AI enabled on the instance (commands exit 12 FEATURE_DISABLED otherwise) and a token with the builder:write
scope (builder:read for the read commands; Living Course uses living-course:read and living-course:write). The @author
preset has them (scoped tokens). Every command is a call to the
Course Builder API (ulams describe builder.start lists the endpoints it uses), and
--dry-run prints the request without sending it:
ulams builder chat <session> "Shorter" --element blk_x1 --dry-run --json# {"ok":true,…,"data":{"dryRun":true,"request":{"method":"POST","path":"/api/admin/course-builder/sessions/<session>/runs","body":{"message":"Shorter","selection":{"elementId":"blk_x1"}}}}}One command, as far as you allow
Section titled “One command, as far as you allow”# Until the first question: prints the questions in data.pending, exit 0ulams builder start --from ./guide.md --json
# A whole course as an unpublished draft: defaults for the interview, approve the outline, applyulams builder start --from ./guide.md --defaults --approve-outline --apply --json
# Answers from a file, stop at the outline for reviewulams builder start --from ./guide.md --answers @answers.yaml --json
# A document by URL (the file itself: Markdown, PDF or DOCX, e.g. a raw GitHub link)ulams builder start --from-url https://raw.githubusercontent.com/org/repo/main/docs/guide.md --defaultsanswers.yaml maps the interview’s question keys to answers:
audience: new baristaslevel: beginner # beginner | intermediate | advancedduration: "60|10" # total|lesson minutestone: friendly # friendly | professional | playful | academicassessments: [quiz, final]language: enThe flags of start are --from and --from-url (repeatable), --session (continue one), --title, --answers,
--defaults, --approve-outline, --apply, --publish and --overwrite. Each stage past the interview runs only if its
flag is there: --approve-outline approves the proposed outline and generates the lessons, --apply creates the unpublished
course, --publish publishes it. Long steps wait by default (--wait, --timeout <s>, 600 s); --no-wait returns a handle.
The result names where it stopped (status: interviewing, outline_review, apply_review, applied) and what is
waiting in data.pending, so an agent knows the next command. --no-wait returns right after the upload with a run handle
(builder-run:<id>) for ulams operations wait. Generation takes a few minutes and costs model tokens; the budget limits of
the instance apply and ulams builder usage <session> shows tokens and cost per task.
Stage by stage
Section titled “Stage by stage”ulams builder sessions listulams builder sources add <session> --file ./handbook.pdf # more sources; waits for ingestionulams builder interview show <session> # questions, options, defaults, which are openulams builder interview answer <session> --key level --value beginnerulams builder interview answer <session> --answers @answers.json --defaultsulams builder interview decide <session> # defaults for everything openulams builder outline show <session> # objectives, modules, lessonsulams builder outline request-changes <session> --comment "Fewer modules, more practice"ulams builder outline approve <session> --edit obj_a1="Brew a balanced espresso" # generates the lessonsulams builder apply <session> # creates the course, unpublishedulams builder publish <session>Waiting commands print progress to stderr and return the run and the session (status, currentVersionId, courseId).
A failed run exits 9 with the run’s error; a wait that runs out exits 10 and names the handle.
ulams builder sessions wait <session> --for outline_review waits for a status on its own.
Sessions and runs
Section titled “Sessions and runs”ulams builder sessions create --title "Onboarding" # empty session; at most 10 per author per dayulams builder sessions get <session> # status, brief, sources, cost, linksulams builder sessions delete <session> # an applied course is keptulams builder sources get <session> <source> # section tree of a sourceulams builder runs get <run> # status and steps; needsAttention when a step failedulams builder runs retry-step <run> <step> # one failed generation stepulams builder runs cancel <run>ulams builder retry <session> # repeat the interview or outline step that failedChange one element
Section titled “Change one element”ulams builder elements list <session> --type lesson # ids to scope a message toulams builder chat <session> "Make this shorter and add an example" --element blk_x1 # proposes a patch with its diffulams builder patches approve <version> # an applied course is updatedulams builder patches reject <version>Structure and options:
ulams builder outline-edit <session> --action rename --id <module-or-lesson> --title "Better title"ulams builder outline-edit <session> --action move --id <lesson> --module <module> --index 0ulams builder outline-edit <session> --action add --kind lesson --module <module> --title "…" --objective "…" --citation frg_…ulams builder outline-edit <session> --action remove --id <lesson>ulams builder variants <session> <element> --instruction "Harder distractors" --count 2 # 2–3 options, one model call eachEach outline edit is saved as an approved author version and re-applied; builder variants makes proposed versions that share a group,
which you approve one of (builder patches approve) or reject.
ulams builder citations <session> is the sources panel as data: per source its sections with the elements that cite them
(data.sources[].sections[].citedBy), how many sections nothing cites (data.sources[].uncovered) and, per element, the
fragments it cites (data.elements). Use it to find what the course leaves out.
The proposal cites source fragments like everything else the builder writes; ulams builder fragments get frg_… shows the
passage a citation points to.
Versions, undo and the brief
Section titled “Versions, undo and the brief”ulams builder versions list <session>ulams builder versions get <version> [--document]ulams builder versions diff <version> [--against <version>] # element-aware diff, default: against its parentulams builder versions restore <version>ulams builder undo <session> # and redoulams builder brief get <session>ulams builder brief set <session> --lesson-minutes 5 --level beginner # marks the outline stale, regenerates nothingEvents as NDJSON
Section titled “Events as NDJSON”ulams builder events <session> # stored events so far, then exitulams builder events <session> --follow --until-run <run> # stream until that run finishesulams builder events <session> --types RUN_STARTED,RUN_FINISHED,RUN_ERROR,TEXT_MESSAGE_CONTENTEach line is {"type":"event","id":"412","data":{…AG-UI event…}} (the server’s AG-UI over SSE, resumed across its 25 s
connection cap with Last-Event-ID, never repeated). The last line is the normal envelope with events and
lastEventId (pass it to --after to continue). --until-run exits 0 on that run’s RUN_FINISHED and 9 on RUN_ERROR
with the event in error.details. For agents, builder events list returns a bounded batch as ordinary JSON.
Living Course
Section titled “Living Course”ulams living sources list <session>ulams living sources add <session> --file ./handbook-v2.md # or: living revisions upload <source> ./handbook-v2.mdulams living connectors list # connectors, their settings schema and whether they have webhooksulams living sources connect <session> --connector git --secrets @token.json --schedule daily \ --config '{"host":"github","repository":"owner/name","branch":"main","paths":["docs/**/*.md"]}'ulams living connections check <connection> # check for a new revision nowulams living connections update <connection> --schedule weeklyulams living connections webhook-secret <connection> # new secret, shown onceulams living connections delete <connection> # stop checking; revisions and audit stayulams living revisions get <revision>ulams living revisions list <source>ulams living revisions changes <revision> # fragments added, removed, moved, changedulams living staleness <session> # works with AI offulams living proposals list <session>ulams living proposals get <proposal> # items grouped by lessonulams living proposals analyse <proposal> --confirm-estimate # model calls; the estimate is in the error if it is highulams living proposals accept <proposal> --item <item> # reject, reset, regenerate --comment "…"ulams living proposals accept-all <proposal> # also: reject-all, reanalyse, learner-note --note "…"ulams living proposals apply <proposal> # a new course version; --overwrite replaces admin editsulams living audit list <session>ulams living audit export <session> --format csv --out audit.csvulams living audit verify <session> # recompute the hash chain (whole academy)ulams living audit export-all --format json --out all.json # admins; also verify-allThe Git connector’s --config keys are host (github, gitlab or gitea; Gitea and Forgejo also need base_url),
repository (owner/name), branch (default main), paths (globs, default **/*.md) and extensions; unknown keys are
refused. The url connector takes urls (1 to 20 https addresses of one site) and an optional selector. --secrets @token.json ({"token": "…"}) is write-only. The webhook URL and secret of a Git connection are in the connect result
(connection.webhookUrl, webhookSecret, shown once); see Living Course internals.
proposals analyse exits with CONFLICT and the estimate when it is above LIVING_COURSE_AUTO_ANALYSE_USD; repeat with
--confirm-estimate. A proposal over its cost caps is budget_blocked and the elements are updated by hand.
ulams mcp --toolsets core,builder,living adds these as tools. Tools that start runs accept wait and
timeout_seconds; the wait is capped at 55 seconds so the call returns inside a client’s tool timeout. When it runs out the
result is a success with status: "running", the run handle and a STILL_RUNNING warning: call operations_wait with the
handle to continue. See ulams for AI agents.