Course Builder settings
The admin link Courses → Build with AI (/courses/builder) opens the studio in the web app; the studio itself is described in the author guide.
Turning it on
Section titled “Turning it on”The builder calls Claude through the official Anthropic SDK. Set in the API environment (api/.env,
or the tenant’s .env.<host>):
| Variable | Default | Meaning |
|---|---|---|
AI_DRIVER |
anthropic |
anthropic, fake (no network: recorded or synthetic answers, for demos and tests) or disabled |
ANTHROPIC_API_KEY |
– | API key. The legacy spelling ANTROPHIC_API_KEY is accepted second. Without a key the driver is disabled |
AI_MODEL_DEFAULT / AI_MODEL_LIGHT / AI_MODEL_PREMIUM |
claude-sonnet-5-5 / claude-haiku-5-5 / claude-opus-5-5 |
Models of the three profiles. The premium profile is only used when a task is switched to it |
AI_TASK_<TASK>_PROFILE |
see below | Switch one task to another profile, e.g. AI_TASK_OUTLINE_PROFILE=premium |
AI_CACHE_TTL |
1h |
Prompt cache TTL for the source (5m or 1h) |
AI_TIMEOUT / AI_MAX_RETRIES |
600 / 2 |
Seconds per model request and retries of transient HTTP errors |
ANTHROPIC_BASE_URL |
– | Point the SDK at a proxy or gateway |
AI_FAKE_MODE |
synthetic |
With AI_DRIVER=fake: cassette (replay only) or synthetic (replay, else a built-in deterministic answer) |
When AI is disabled, every builder endpoint answers 503 with a message and the studio says so; the rest of the LMS works as before.
Tasks and their default profiles: interview (light), outline (default), lesson (default), quiz (default), grounding (light), metadata (light), price (light), patch (default) and, for Living Course, update (default).
The web app needs nothing new. The admin link derives the studio address from the admin host
(coffee.admin.example → coffee.app.example); set REACT_APP_STUDIO_URL in the admin’s runtime
environment when your hosts differ.
Who can use it
Section titled “Who can use it”The permission course_builder_use is given to the admin and tutor roles. On an existing
tenant, run the permissions seeder once after deploying:
php artisan db:seed --class=PermissionsSeeder --force --domain=<tenant host>Sessions are visible to their author and to admins; only the author can act on a session.
Limits and budgets
Section titled “Limits and budgets”| Variable | Default | |
|---|---|---|
COURSE_BUILDER_SOURCE_MB |
20 | Upload size |
COURSE_BUILDER_PDF_PAGES |
300 | PDF pages |
COURSE_BUILDER_SOURCE_TOKENS |
400000 | Source tokens per session |
COURSE_BUILDER_SESSION_TOKENS |
3000000 | Tokens per session (cache reads count 10 %) |
COURSE_BUILDER_SESSION_COST_USD |
5 | AI cost per session |
COURSE_BUILDER_CONCURRENT_RUNS |
2 | AI runs at once per author |
COURSE_BUILDER_SESSIONS_PER_DAY |
10 | New sessions per author per day |
COURSE_BUILDER_LESSON_CONCURRENCY |
4 | Lessons generated at once per run |
AI_LIMIT_TENANT_MONTHLY_USD |
50 | AI spend per tenant per month (all tasks); 0 turns the cap off |
COURSE_BUILDER_EVAL_MONTHLY_USD |
20 | Spend cap of the eval command |
COURSE_BUILDER_DOCX_UNCOMPRESSED_MB |
100 | Largest unpacked size of a DOCX (zip-bomb guard) |
Lesson formats
Section titled “Lesson formats”LiaScript, H5P and interactive lessons are offered in the outline only when the installation can create them. Each can be turned off for the whole installation:
| Variable | Default | |
|---|---|---|
COURSE_BUILDER_LIASCRIPT |
true | LiaScript lessons with self-checks |
COURSE_BUILDER_H5P |
true | Lessons with an H5P activity (needs the H5P service and the libraries Blanks, Drag the Words and Dialog Cards) |
COURSE_BUILDER_INTERACTIVE |
true | Lessons with an interactive from the library (needs at least one package, and the interactive topic type switched on) |
COURSE_BUILDER_AUTO_FORMATS |
false | The outline gives every lesson with two or more objectives the LiaScript format; authors can still change it |
The libraries the builder may use are listed in config/course_builder.php (h5p_libraries). Install them in the H5P service
(H5P libraries). The extra generation tasks (selfcheck, interaction_h5p, interaction_interactive) use the same models
as the other tasks and can be given another profile with AI_TASK_SELFCHECK_PROFILE, AI_TASK_INTERACTION_H5P_PROFILE and
AI_TASK_INTERACTION_INTERACTIVE_PROFILE.
Before every model call the budget is checked against the session tokens, the session cost and the tenant’s month. The first
that would be exceeded stops the call: the run fails with RUN_ERROR (code: "budget") and a message such as “Budget
reached: this course has used $5.00 of its $5.00 AI budget. Ask an admin to raise the limit to continue.”, the studio shows the
message, and the session is marked budgetReached. Nothing already generated is lost; the author retries after you raise the
limit. AI_LIMIT_SUBJECT_TOKENS and AI_LIMIT_SUBJECT_COST_USD (3,000,000 and 5) are the defaults for AI calls that carry no budget of their own; builder sessions always carry one. A run that would start another AI run beyond COURSE_BUILDER_CONCURRENT_RUNS is refused with 429, a new
session beyond COURSE_BUILDER_SESSIONS_PER_DAY too. The API page has the exact messages.
Cost and usage
Section titled “Cost and usage”Every model call is logged in the ai_calls table of the tenant: task, prompt version, profile,
model requested and served, input, output and cache tokens, cost in micro-USD, latency, status and
the session. Totals per day and task:
php artisan ai:usage --days=30 --domain=<tenant host>The studio shows the running cost per course; GET /api/admin/course-builder/sessions/{id}/usage
returns it per task and model.
Checking a run from a script
Section titled “Checking a run from a script”Every generation, patch or apply is a run. GET /api/admin/course-builder/runs/{run} returns its
state without opening the event stream, which is what the ulams CLI polls for --wait:
{ "id": "01j9z3k8m2x4q7r5t6v8w0y1ab", "sessionId": "01j9z3…", "kind": "generate", "status": "running", "needsAttention": false, "steps": [{ "id": "01j9z4…", "name": "lesson:el_8f2c", "status": "succeeded", "error": null }], "startedAt": "2026-10-09T10:02:11+00:00", "finishedAt": null, "error": null }status is one of queued, running, succeeded, failed, cancelled; step status is one of
queued, running, succeeded, failed. needsAttention is true while a step has failed and the run waits for a retry (ulams builder runs retry-step), so a poller stops and asks. Only the session’s author and tenant admins can read a run (403 otherwise,
404 for an unknown id).
Queues and streaming
Section titled “Queues and streaming”Generation runs in queued jobs on a dedicated connection (COURSE_BUILDER_QUEUE_CONNECTION,
COURSE_BUILDER_QUEUE). With QUEUE_CONNECTION=database or redis the default is database-builder or
redis-builder, queue builder, defined in api/config/queue.php with retry_after 2400 s
(BUILDER_QUEUE_RETRY_AFTER), above the 1800 s job timeout. Run a worker for it on each tenant: Horizon
(supervisor-builder) or api/workers.sh queue, which starts queue:work <driver>-builder --queue=builder --timeout=1800 per tenant. Do not point these jobs at a connection with a shorter retry_after: a second
worker would pick up a step that is still running and the model would be called, and billed, twice. You can run several workers
(queue:work processes) against the same queue, including the database connection: stage changes of a
run are serialised on the run row, so a run finishes the same way with one worker or four.
The long-job queue (LONG_JOB_QUEUE_CONNECTION, LONG_JOB_QUEUE; <driver>-long-job, queue queue-long-job, retry_after 19000 s) is separate and serves course clone and import. Living Course checks and analysis steps use the builder queue by default (LIVING_COURSE_QUEUE_CONNECTION, LIVING_COURSE_QUEUE). A run job has one try (tries = 1): the SDK already retries transient errors, and a failed step is retried by the author, so a model call is never repeated silently. A step can take minutes; a test (QueueRetryAfterConfigTest) keeps retry_after above every job timeout. The event stream
(/api/admin/course-builder/sessions/{id}/events) holds a PHP worker for up to 25 s per open
browser tab (COURSE_BUILDER_SSE_SECONDS); in production route it to a small separate PHP-FPM pool.
Old events are deleted by php artisan course-builder:prune-events (30 days,
COURSE_BUILDER_EVENTS_RETENTION_DAYS); schedule it daily.
Eval command
Section titled “Eval command”php artisan course-builder:eval --fixtures=all --author=<user id> # fake driver, freephp artisan course-builder:eval --fixtures=coffee,injection --live --author=<id> # real model, costs moneyphp artisan course-builder:eval --fixtures=coffee --live --record --author=<id> # also write cassettesOptions: --fixtures (all or a list of coffee, injection, git, pdf, docx), --live, --record, --author
(default: the first admin), --patch=1 (one element-chat patch) and --total-minutes=30. --live needs ANTHROPIC_API_KEY
and stops at COURSE_BUILDER_EVAL_MONTHLY_USD for the month. Details: LLM layer.
For the endpoints and the event stream see the Course Builder API.
Uploaded sources are stored on a private disk (COURSE_BUILDER_DISK, default
storage/app/private), never in the public bucket. Removing a builder session does not remove a
course it created.