Driving Course Desk from your own code
Everything the web app does over HTTP, you can do. The base URL is
https://api.skillsafe.ai/v1/app-api. Every call takes
Authorization: Bearer <token> except the one that mints a token.
One course brief goes in, one teachable document comes out, and which document you get is
decided by a single field: task.
Two mistakes account for most of the 400s on this API, so they are worth saying before anything
else. First: the request body for /estimate, /run and
/run-stream is the input object itself. There is no
{"input": { ... }} wrapper — if you send one, task is not where the
server looks for it and you get VALIDATION_ERROR. Second: there is no
X-App-Slug header on any endpoint. The only headers are
Content-Type: application/json, Authorization: Bearer <token> and,
on the two run endpoints, Idempotency-Key. The slug appears exactly once in this
whole API: in the body of POST /guest.
The task field comes first
Course Desk is one app with five lanes over one work object — the course brief you paste
— and task selects the lane. The lanes run in the order you meet them in the
app: inspect, then plan, then teach, then assess, then test. Every lane takes the same input
object and returns the same envelope; only the meaning of the inner sections changes.
task | lane | what it produces |
|---|---|---|
outcomes | Learning outcomes | Measurable, Bloom-mapped objectives, rewritten where the original was not assessable. |
syllabus | Syllabus | A session-by-session syllabus with a contact-time budget that actually adds up. |
lesson | Lesson plan | One module expanded into a minute-by-minute lesson plan. |
assess | Assessment plan | An assessment plan plus an analytic rubric aligned to the objectives. |
quiz | Item bank | Quiz items (MCQ, true/false, fill-in-blank, matching) with distractors and an answer key. |
If task is missing or unrecognised the model does not fail: it picks the closest lane
and names the lane it chose in the first sentence of summary. That is a courtesy, not
a feature to rely on — send the lane you want.
One worked example per lane
Each block below is a complete, valid request body for /estimate, /run
and /run-stream — copy one, replace the brief, and send it as-is.
Note again that nothing wraps these objects. Every key is present in every lane, including the
ones a given lane ignores: item_count travels with an outcomes request
and focus travels with an assess request.
task: "outcomes"
The inspection lane. It reads the objectives you have, classifies each against Bloom's revised
taxonomy, and rewrites the ones that no assessment could evidence. rows[] comes back
one row per objective; verdict is measurable,
mostly-measurable, needs-rewrite or not-assessable. Push
harder by sending rigor: "strict".
{
"task": "outcomes",
"brief": "Course: Practical Data Analysis with Python\nAudience: analysts who already use spreadsheets daily\nLevel: working\nShape: 6 weekly sessions, 90 minutes live each\nPrerequisites: comfortable with formulas; no programming assumed\n\nModules\nm1. Why notebooks (90m)\nm2. Tables in pandas (90m)\nm3. Cleaning messy data (90m)\nm4. Grouping and joining (90m)\nm5. Charts that answer a question (90m)\nm6. Capstone clinic (90m)\n\nObjectives\n- Understand pandas\n- Be familiar with data cleaning\n- Produce a chart from a real dataset\n\nAssessment\nWeekly exercises 40%, capstone notebook 60%",
"brief_clipped": 0,
"objectives": "Choose an appropriate join for a two-table question\nExplain one cleaning decision to a non-analyst",
"focus": "",
"level": "working",
"delivery": "live-online",
"rigor": "strict",
"item_count": 12,
"notes": "The cohort is internal; attendance is mandatory but graded pass/fail.",
"upstream": "",
"prescan": {
"readable": true,
"meta": { "title": "Practical Data Analysis with Python", "level": "working", "sessions": 6 },
"schedule": { "sessions": 6, "minutes_each": 90, "total_minutes": 540 },
"budget": { "contact_minutes": 540, "claimed_minutes": 540, "slack_minutes": 0 },
"bloom": { "remember": 0, "understand": 2, "apply": 1, "analyze": 0, "evaluate": 0, "create": 0 },
"stats": { "chars": 812, "lines": 24, "modules": 6, "objectives": 3 },
"modules": [
{ "id": "m1", "title": "Why notebooks", "minutes": 90 },
{ "id": "m3", "title": "Cleaning messy data", "minutes": 90 }
],
"objectives": [
{ "id": "o1", "text": "Understand pandas", "verb": "understand", "bloom": "understand", "measurable": false },
{ "id": "o3", "text": "Produce a chart from a real dataset", "verb": "produce", "bloom": "create", "measurable": true }
],
"assessments": [
{ "id": "a1", "name": "Weekly exercises", "weight": 40 },
{ "id": "a2", "name": "Capstone notebook", "weight": 60 }
],
"alignment": { "objectives_with_assessment": 1, "objectives_without_assessment": 2 },
"signals": { "has_prerequisites": true, "has_assessment_weights": true, "weights_sum": 100 },
"sampling": { "method": "full", "coverage": 1.0 },
"negative_notes": ["No accessibility or accommodation statement found in the brief."],
"flags": [
{
"flag_id": "f1",
"rule": "unmeasurable_verb",
"severity": "high",
"confidence": "high",
"scope": "objective",
"target": "o1",
"message": "\"Understand\" cannot be observed or scored.",
"evidence": "- Understand pandas"
},
{
"flag_id": "f2",
"rule": "no_slack_in_budget",
"severity": "medium",
"confidence": "unknown",
"scope": "schedule",
"target": "all",
"message": "Claimed activity minutes equal contact minutes exactly; nothing is left for transitions.",
"evidence": "6 x 90m = 540m contact, 540m claimed"
}
]
}
}
task: "syllabus"
The planning lane. It sequences the modules and makes the time budget add up, which usually means
telling you which module is overloaded rather than quietly compressing it. rows[] is
one row per module with its minutes and the objectives it covers; verdict is
teachable, overloaded, underfilled or
unsequenced. Send the outcomes artifact as upstream and the
syllabus is built against the rewritten objectives instead of the original ones.
{
"task": "syllabus",
"brief": "Course: Practical Data Analysis with Python\nAudience: analysts who already use spreadsheets daily\nShape: 6 weekly sessions, 90 minutes live each\n... (the same brief as above) ...",
"brief_clipped": 0,
"objectives": "",
"focus": "",
"level": "working",
"delivery": "live-online",
"rigor": "standard",
"item_count": 12,
"notes": "Week 4 falls on a public holiday and has to be async.",
"upstream": "# Learning outcomes\n\n1. Load a CSV into a pandas DataFrame and report its shape...\n",
"prescan": {
"readable": true,
"meta": { "title": "Practical Data Analysis with Python", "sessions": 6 },
"schedule": { "sessions": 6, "minutes_each": 90, "total_minutes": 540 },
"budget": { "contact_minutes": 540, "claimed_minutes": 540, "slack_minutes": 0 },
"bloom": {},
"stats": { "modules": 6, "objectives": 5 },
"modules": [{ "id": "m1", "title": "Why notebooks", "minutes": 90 }],
"objectives": [],
"assessments": [],
"alignment": {},
"signals": {},
"sampling": {},
"negative_notes": [],
"flags": [
{
"flag_id": "f2",
"rule": "no_slack_in_budget",
"severity": "medium",
"confidence": "unknown",
"scope": "schedule",
"target": "all",
"message": "Claimed activity minutes equal contact minutes exactly.",
"evidence": "6 x 90m = 540m contact, 540m claimed"
}
]
}
}
task: "lesson"
The teaching lane, and the one lane that is about a single module. focus names it
— either a module id such as "m3" or a title such as
"Cleaning messy data". Leave focus empty and the model picks a module
itself and says which one it picked in summary; that is a fine way to get a sample but
a poor way to build six lesson plans. rows[] is one row per segment with its minutes,
what the teacher does and what the learner does. verdict is
ready-to-teach, needs-materials, over-scheduled or
thin — over-scheduled means the segments do not fit the session,
which is a real answer and not an error.
{
"task": "lesson",
"brief": "Course: Practical Data Analysis with Python\n...\nm3. Cleaning messy data (90m)\n...",
"brief_clipped": 0,
"objectives": "Repair a column of mixed date formats and justify the choice",
"focus": "m3",
"level": "working",
"delivery": "live-online",
"rigor": "standard",
"item_count": 12,
"notes": "Breakout rooms are available. Cohort of 18, so plan for pairs.",
"upstream": "",
"prescan": {
"readable": true,
"meta": { "title": "Practical Data Analysis with Python" },
"schedule": { "sessions": 6, "minutes_each": 90, "total_minutes": 540 },
"budget": { "contact_minutes": 90, "claimed_minutes": 90, "slack_minutes": 0 },
"bloom": {},
"stats": { "modules": 6 },
"modules": [{ "id": "m3", "title": "Cleaning messy data", "minutes": 90 }],
"objectives": [],
"assessments": [],
"alignment": {},
"signals": { "focus_resolved": true },
"sampling": { "method": "module", "coverage": 1.0 },
"negative_notes": [],
"flags": []
}
}
An empty prescan.flags array is legal and common — it means the browser found
nothing to flag in the material it was given, and coverage_check comes back
[] to match.
task: "assess"
The assessment lane. It produces an assessment plan and an analytic rubric, and it checks the
alignment in both directions: an objective with nothing that would evidence it, and an assessment
criterion that evidences no stated objective. rows[] is one row per rubric criterion
with its weight and the objective ids it covers. verdict is aligned,
partly-aligned, misaligned or no-evidence. Weights that do
not sum to 100 come back as a finding rather than being silently normalised.
{
"task": "assess",
"brief": "Course: Practical Data Analysis with Python\n...\nAssessment\nWeekly exercises 40%, capstone notebook 60%",
"brief_clipped": 0,
"objectives": "Choose an appropriate join for a two-table question\nExplain one cleaning decision to a non-analyst",
"focus": "",
"level": "working",
"delivery": "live-online",
"rigor": "strict",
"item_count": 12,
"notes": "Grading is pass/fail overall but we still want per-criterion feedback.",
"upstream": "# Syllabus\n\n## Session 3 - Cleaning messy data (90m)\n...",
"prescan": {
"readable": true,
"meta": { "title": "Practical Data Analysis with Python" },
"schedule": {},
"budget": {},
"bloom": { "understand": 2, "apply": 1, "create": 1 },
"stats": { "objectives": 5, "assessments": 2 },
"modules": [],
"objectives": [
{ "id": "o5", "text": "Explain one cleaning decision to a non-analyst", "bloom": "understand" }
],
"assessments": [
{ "id": "a1", "name": "Weekly exercises", "weight": 40 },
{ "id": "a2", "name": "Capstone notebook", "weight": 60 }
],
"alignment": { "objectives_with_assessment": 3, "objectives_without_assessment": 2 },
"signals": { "has_rubric": false, "weights_sum": 100 },
"sampling": {},
"negative_notes": ["No rubric of any kind was found in the brief."],
"flags": [
{
"flag_id": "f7",
"rule": "objective_without_evidence",
"severity": "high",
"confidence": "high",
"scope": "alignment",
"target": "o5",
"message": "Nothing in the assessment plan would evidence this objective.",
"evidence": "Weekly exercises 40%, capstone notebook 60%"
}
]
}
}
task: "quiz"
The testing lane. item_count decides how many items you get and is clamped to
4–40; focus aims the bank at one module, exactly as in
the lesson lane. rows[] is one row per item: stem, item type, correct
answer, and each distractor with the reason it is plausible — a distractor nobody would pick
is not a distractor. verdict is ready-to-use,
needs-review, too-easy or ungradeable.
item_count must be present in the other four lanes too, where it is simply ignored.
{
"task": "quiz",
"brief": "Course: Practical Data Analysis with Python\n...\nm4. Grouping and joining (90m)\n...",
"brief_clipped": 0,
"objectives": "Choose an appropriate join for a two-table question",
"focus": "Grouping and joining",
"level": "working",
"delivery": "async",
"rigor": "standard",
"item_count": 16,
"notes": "Delivered in an LMS that supports MCQ, true/false and matching only.",
"upstream": "",
"prescan": {
"readable": true,
"meta": { "title": "Practical Data Analysis with Python" },
"schedule": {},
"budget": {},
"bloom": { "apply": 2, "analyze": 1 },
"stats": { "modules": 6, "objectives": 5 },
"modules": [{ "id": "m4", "title": "Grouping and joining", "minutes": 90 }],
"objectives": [],
"assessments": [],
"alignment": {},
"signals": { "focus_resolved": true, "item_count_clamped": false },
"sampling": { "method": "module", "coverage": 1.0 },
"negative_notes": [],
"flags": [
{
"flag_id": "f11",
"rule": "module_has_no_objective",
"severity": "medium",
"confidence": "high",
"scope": "module",
"target": "m4",
"message": "No stated objective belongs to the focused module.",
"evidence": "m4. Grouping and joining (90m)"
}
]
}
}
The envelope
Success is {"data": { ... }}. Failure is
{"error": {"code": "...", "message": "..."}} with a matching HTTP status. Read
error.code, never the message, when you branch — the messages are written for
people and will change.
// success
{ "data": { "job_id": "job_9f2c...", "status": "queued" } }
// failure
{ "error": { "code": "VALIDATION_ERROR", "message": "task must be one of outcomes, syllabus, lesson, assess, quiz" } }
Error codes
| code | HTTP | what to do |
|---|---|---|
UNAUTHORIZED | 401 | Missing or expired app token. Mint a new guest token, or sign in and take a personal one from the tokens page. |
FORBIDDEN | 403 | The token is valid but not for this app — or the call is metered and you sent a guest token. |
VALIDATION_ERROR | 400 | The input object is malformed. The usual cause is an {"input": ...} wrapper around a body that should have been sent bare. |
INSUFFICIENT_CREDITS | 402 | The balance is below min_credits. Call /estimate and compare with /me. |
RATE_LIMITED | 429 | Back off and retry. Do not tight-loop. |
NOT_FOUND | 404 | Unknown job id on GET /jobs/{id}. Job ids are not guessable and do not live forever. |
INTERNAL | 500 | Retry with the same Idempotency-Key. A new key on a retry can bill twice. |
Step 1 — a tiny client
Six endpoints, one base URL, one header pair. Paste one of these helpers and every later step is a
one-liner. Replace "YOUR_TOKEN" with a token from
the tokens page if you already have one, or leave it empty and let
step 2 mint a guest token for you. Keep real tokens out of source control — read them from
your own secret store or, at worst, from an environment variable at startup.
# Every call in this guide uses these two values.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # Replace "YOUR_TOKEN" with a token from /tokens.html
# The envelope is the same on every endpoint:
# success -> {"data": { ... }}
# failure -> {"error": {"code": "...", "message": "..."}}
#
# There is no X-App-Slug header on any endpoint. Do not add one.
call() { curl -s -X "$1" "$BASE$2" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" ${3:+-d "$3"}; }
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # Replace "YOUR_TOKEN" with a token from /tokens.html
def call(method, path, body=None, extra=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
for k, v in (extra or {}).items():
req.add_header(k, v) # never add X-App-Slug; it is not a header here
with urllib.request.urlopen(req) as r:
payload = json.load(r)
# success is {"data": ...}; failure is {"error": {"code", "message"}}
if "error" in payload:
raise RuntimeError(payload["error"]["code"])
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // Replace "YOUR_TOKEN" with a token from /tokens.html
async function call(method, path, body, extraHeaders) {
const headers = Object.assign({ "Content-Type": "application/json" }, extraHeaders || {});
if (TOKEN) headers.Authorization = "Bearer " + TOKEN;
const res = await fetch(BASE + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (json.error) throw new Error(json.error.code + ": " + json.error.message);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
// Falls back to the literal placeholder so the sample runs unmodified.
var token = firstNonEmpty(os.Getenv("SKILLSAFE_TOKEN"), "YOUR_TOKEN")
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
type envelope struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(method, path string, body any, extra map[string]string) (json.RawMessage, error) {
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, r)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
for k, v := range extra {
req.Header.Set(k, v) // Idempotency-Key only; there is no X-App-Slug
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var e envelope
json.NewDecoder(res.Body).Decode(&e)
if e.Error != nil {
return nil, errors.New(e.Error.Code)
}
return e.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
public class CourseDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // Replace with a token from /tokens.html
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String method, String path, String body, Map<String, String> extra)
throws Exception {
HttpRequest.BodyPublisher pub = body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body);
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.method(method, pub);
if (extra != null) extra.forEach(b::header); // Idempotency-Key only
// success {"data":...}, failure {"error":{"code","message"}}
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # Replace "YOUR_TOKEN" with a token from /tokens.html
def call(method, path, body = nil, extra = {})
uri = URI(BASE + path)
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.fetch(method)
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
extra.each { |k, v| req[k] = v } # Idempotency-Key only; no X-App-Slug
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise payload["error"]["code"] if payload["error"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // Replace "YOUR_TOKEN" with a token from /tokens.html
function call(string $method, string $path, $body = null, array $extra = []) {
global $TOKEN;
$ch = curl_init(BASE . $path);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge([
"Authorization: Bearer " . $TOKEN,
"Content-Type: application/json",
], $extra)); // $extra carries Idempotency-Key only
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($payload["error"])) {
throw new RuntimeException($payload["error"]["code"]);
}
return $payload["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static class CourseDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
static string Token = "YOUR_TOKEN"; // Replace with a token from /tokens.html
static readonly HttpClient Http = new HttpClient();
static async Task<JsonElement> Call(HttpMethod method, string path, object body = null,
(string, string)? extra = null)
{
var req = new HttpRequestMessage(method, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (extra is (string name, string value))
req.Headers.Add(name, value); // Idempotency-Key only
if (body != null)
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("error", out var err))
throw new Exception(err.GetProperty("code").GetString());
return doc.RootElement.GetProperty("data");
}
}
Step 2 — get a token
POST /guest needs no Authorization header, and its body key is
slug. This is the one and only place the string course-desk belongs in a
request — it goes in the body, never in a header. A guest token is enough for
/me and /estimate; running a lane is metered and wants a personal token
from the tokens page.
# A guest token needs NO Authorization header. The body key is "slug".
curl -s -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug": "course-desk"}'
# -> {"data":{"token":"aut_...","subject_type":"guest", ...}}
#
# For a metered run, take a personal token instead:
# https://course-desk.skillsafe.ai/tokens.html
# The body key is "slug" - not "app_slug", and not a header.
req = urllib.request.Request(BASE + "/guest",
data=json.dumps({"slug": "course-desk"}).encode(),
method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
print(TOKEN[:12] + "...")
// The body key is "slug" - not "app_slug", and not a header.
const res = await fetch(BASE + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "course-desk" }),
});
TOKEN = (await res.json()).data.token;
// The body key is "slug" - not "app_slug", and not a header.
raw, err := call("POST", "/guest", map[string]string{"slug": "course-desk"}, nil)
if err != nil {
panic(err)
}
var guest struct {
Token string `json:"token"`
}
json.Unmarshal(raw, &guest)
token = guest.Token
// The body key is "slug" - not "app_slug", and not a header.
String guest = call("POST", "/guest", "{\"slug\":\"course-desk\"}", null);
// parse guest with your JSON library and read data.token into `token`
# The body key is "slug" - not "app_slug", and not a header.
TOKEN = call("POST", "/guest", { "slug" => "course-desk" })["token"]
<?php
// The body key is "slug" - not "app_slug", and not a header.
$guest = call("POST", "/guest", ["slug" => "course-desk"]);
$TOKEN = $guest["token"];
// The body key is "slug" - not "app_slug", and not a header.
var guest = await Call(HttpMethod.Post, "/guest", new { slug = "course-desk" });
Token = guest.GetProperty("token").GetString();
Step 3 — who am I
GET /me is free and tells you two things worth branching on:
subject_type (guest or user) and credits.
Compare the balance with the min_credits that step 4 reports before you spend a run.
curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"
# -> {"data":{"subject_type":"user","username":"...","credits":123456}}
# subject_type is "guest" or "user". Only a "user" can run a lane.
me = call("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
raw, _ := call("GET", "/me", nil, nil)
fmt.Println(string(raw))
System.out.println(call("GET", "/me", null, null));
me = call("GET", "/me")
puts me["subject_type"], me["credits"]
<?php
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await Call(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Step 4 — price the run (free)
/estimate creates no job and charges nothing. The request body is the input
object — the same object you saw five times above, sent bare, with no input key
wrapped around it and no X-App-Slug header. Price each lane separately: the five lanes
have different prompts and different output caps, so the quiz hold at
item_count: 40 is nothing like the outcomes hold.
# /estimate is FREE. It creates no job and charges nothing.
# The body IS the input object - it is not wrapped in anything.
curl -s -X POST "$BASE/estimate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"task":"outcomes","brief":"Course: Practical Data Analysis with Python\n...","brief_clipped":0,"objectives":"","focus":"","level":"working","delivery":"live-online","rigor":"strict","item_count":12,"notes":"","upstream":"","prescan":{"readable":true,"flags":[]}}'
# -> {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":2900,"min_credits":400,
# "sponsor_enabled":false}}
#
# hold_credits is RESERVED, not charged. It prices the full output cap; the
# actual charge is usually far lower. Estimate each lane separately.
#
# WRONG: -d '{"input": {"task": "outcomes", ...}}' <- VALIDATION_ERROR
# WRONG: -H "X-App-Slug: course-desk" <- no such header
# INPUT is one of the five objects above, sent as the whole body.
est = call("POST", "/estimate", INPUT)
print(est["model_alias"], est["hold_credits"], "reserved,", est["min_credits"], "minimum")
# Do NOT do this - there is no wrapper key:
# call("POST", "/estimate", {"input": INPUT})
// INPUT is one of the five objects above, sent as the whole body.
const est = await call("POST", "/estimate", INPUT);
console.log(est.model_alias, est.hold_credits, "reserved");
// Do NOT do this - there is no wrapper key:
// call("POST", "/estimate", { input: INPUT })
// input is a map or struct that marshals to the input object itself.
raw, _ := call("POST", "/estimate", input, nil)
var est struct {
ModelAlias string `json:"model_alias"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
json.Unmarshal(raw, &est)
fmt.Println(est.ModelAlias, est.HoldCredits, est.MinCredits)
// inputJson is the serialised input object - no wrapper, no X-App-Slug.
System.out.println(call("POST", "/estimate", inputJson, null));
# input is the Hash for the input object itself.
est = call("POST", "/estimate", input)
puts est["model_alias"], est["hold_credits"], est["min_credits"]
<?php
// $input is the array for the input object itself - not ["input" => ...].
$est = call("POST", "/estimate", $input);
echo $est["model_alias"], " ", $est["hold_credits"], "\n";
// input is the input object itself - not new { input = ... }.
var est = await Call(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
The input object, field by field
Every key is always present, in every lane. Send them all; a lane that does not use a field is
happy to receive it, and a missing field is a VALIDATION_ERROR waiting to happen.
| field | type | meaning |
|---|---|---|
task | string | The lane. One of outcomes, syllabus, lesson, assess, quiz. Missing or unrecognised, the model picks the closest lane and names its choice in the first sentence of summary. |
brief | string | The pasted course brief or outline: title, audience, level, session shape, prerequisites, modules, objectives, assessment plan — whatever subset exists. Required. Clipped to 24,000 characters from the MIDDLE, head and tail kept on line boundaries, with the cut announced in-band. |
brief_clipped | number | How many characters the clip removed. 0 when nothing was cut. |
objectives | string | Extra objectives from a separate box, one per line. Optional; they are merged with any found in the brief. |
focus | string | Which module the lesson and quiz lanes target — a module id such as "m3" or a title. Empty means the model picks one and says which. |
level | string | intro, working, advanced or mixed. |
delivery | string | live-online, in-person, async or hybrid. It changes the activities, not just the wording. |
rigor | string | light, standard or strict: how hard the model pushes on measurability. |
item_count | number | How many items the quiz lane writes, clamped to 4–40. Present in every lane, ignored by four of them. |
notes | string | Free context: cohort size, tooling, holidays, constraints the brief does not carry. |
upstream | string | The previous lane's artifact when you are chaining lanes, else "". This is how outcomes feeds syllabus and syllabus feeds lesson. |
prescan | object | The browser's own measurements: readable, meta, schedule, budget, bloom, stats, modules[], objectives[], assessments[], alignment, signals, sampling, negative_notes[] and flags[]. The model must answer every flags[].flag_id by id. |
Step 5 — run it and poll
POST /run takes the same bare input object and returns a job_id; poll
GET /jobs/{id} until status is succeeded or
failed. Always send an Idempotency-Key: hash the lane, the input and an
attempt counter. A retry after a network blip must reuse the exact same key or it bills
twice. On INTERNAL (500) that is not a nicety — it is the documented retry.
# Metered. The body is still the bare input object.
# Idempotency-Key = lane + input + attempt, hashed. Reuse it on a retry.
KEY="course-desk:outcomes:$(printf %s "$INPUT" | shasum | cut -c1-16):1"
JOB=$(curl -s -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until terminal. Two seconds is polite; do not tight-loop.
until curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
| grep -q '"status":"succeeded"'; do sleep 2; done
curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN"
# -> data.output is a STRING holding the JSON envelope described below.
import hashlib, time
key = "course-desk:%s:%s:1" % (
INPUT["task"],
hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16],
)
job_id = call("POST", "/run", INPUT, {"Idempotency-Key": key})["job_id"]
while True:
job = call("GET", "/jobs/" + job_id)
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
result = json.loads(job["output"]) # the envelope described below
print(result["lane"], result["verdict"], len(result["rows"]), "rows")
const key = `course-desk:${INPUT.task}:${await sha256Hex(JSON.stringify(INPUT))}:1`;
const { job_id } = await call("POST", "/run", INPUT, { "Idempotency-Key": key });
let job;
do {
await new Promise((r) => setTimeout(r, 2000));
job = await call("GET", "/jobs/" + job_id);
} while (job.status !== "succeeded" && job.status !== "failed");
const result = JSON.parse(job.output);
console.log(result.lane, result.verdict, result.rows.length, "rows");
async function sha256Hex(text) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
}
// POST /run with an Idempotency-Key header, then poll GET /jobs/{id}.
b, _ := json.Marshal(input)
sum := sha256.Sum256(b)
key := fmt.Sprintf("course-desk:%s:%x:1", input["task"], sum[:8])
raw, _ := call("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var started struct {
JobID string `json:"job_id"`
}
json.Unmarshal(raw, &started)
for {
time.Sleep(2 * time.Second)
raw, _ = call("GET", "/jobs/"+started.JobID, nil, nil)
var job struct {
Status string `json:"status"`
Output string `json:"output"`
}
json.Unmarshal(raw, &job)
if job.Status == "succeeded" || job.Status == "failed" {
fmt.Println(job.Output) // a string holding the JSON envelope
break
}
}
// POST /run with the Idempotency-Key header, then poll GET /jobs/{id}.
String key = "course-desk:outcomes:" + Integer.toHexString(inputJson.hashCode()) + ":1";
String started = call("POST", "/run", inputJson, Map.of("Idempotency-Key", key));
// read data.job_id from `started` with your JSON library, then:
String jobId = readJobId(started);
String job;
do {
Thread.sleep(2000);
job = call("GET", "/jobs/" + jobId, null, null);
} while (!isTerminal(job)); // status "succeeded" or "failed"
// data.output is a String holding the JSON envelope described below.
require "digest"
key = "course-desk:#{input['task']}:#{Digest::SHA256.hexdigest(JSON.dump(input))[0, 16]}:1"
started = call("POST", "/run", input, { "Idempotency-Key" => key })
job = nil
loop do
sleep 2
job = call("GET", "/jobs/#{started['job_id']}")
break if %w[succeeded failed].include?(job["status"])
end
result = JSON.parse(job["output"])
puts result["lane"], result["verdict"]
<?php
$key = "course-desk:" . $input["task"] . ":"
. substr(hash("sha256", json_encode($input)), 0, 16) . ":1";
$started = call("POST", "/run", $input, ["Idempotency-Key: " . $key]);
do {
sleep(2);
$job = call("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));
$result = json_decode($job["output"], true);
echo $result["lane"], " ", $result["verdict"], "\n";
var payload = JsonSerializer.Serialize(input);
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
Encoding.UTF8.GetBytes(payload))).Substring(0, 16).ToLowerInvariant();
var key = $"course-desk:{input.task}:{hash}:1";
var started = await Call(HttpMethod.Post, "/run", input, ("Idempotency-Key", key));
var id = started.GetProperty("job_id").GetString();
JsonElement job;
string status;
do
{
await Task.Delay(2000);
job = await Call(HttpMethod.Get, "/jobs/" + id);
status = job.GetProperty("status").GetString();
} while (status != "succeeded" && status != "failed");
var result = JsonDocument.Parse(job.GetProperty("output").GetString());
Step 6 — stream it instead
POST /run-stream takes the same bare body and the same
Idempotency-Key, and returns text/event-stream with four event names:
job, delta, done and error. A
delta frame's data is {"text": "..."} and the text is
cumulative — the whole output so far, not the increment. Render the latest
delta, and take the authoritative result from done.
# Server-sent events. Four event names: job, delta, done, error.
# Same bare input object, same Idempotency-Key header.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT"
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"lane\":\"outcomes\",\"title\":\"Learning outcomes..."}
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":142,"output":"{...}"}
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
event, buf, result = "message", "", None
with urllib.request.urlopen(req) as r:
for raw in r:
line = raw.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
buf += line[5:].strip()
elif line == "":
if buf and event == "delta":
print(len(json.loads(buf)["text"]), "chars so far") # cumulative
elif buf and event == "done":
result = json.loads(json.loads(buf)["output"])
buf = "" # reset for the next frame
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + TOKEN,
"Idempotency-Key": key,
},
body: JSON.stringify(INPUT), // the bare input object
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += dec.decode(value, { stream: true });
let i;
while ((i = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, i);
buffer = buffer.slice(i + 2);
let name = "message", data = "";
frame.split("\n").forEach((l) => {
if (l.startsWith("event:")) name = l.slice(6).trim();
else if (l.startsWith("data:")) data += l.slice(5).trim();
});
if (name === "delta") render(JSON.parse(data).text); // cumulative
if (name === "done") finish(JSON.parse(JSON.parse(data).output));
if (name === "error") throw new Error(JSON.parse(data).code);
}
}
// POST /run-stream and read the body line by line with a bufio.Scanner.
// Frames end at a blank line; "event:" names the frame, "data:" carries JSON.
// job -> {"job_id": "..."}
// delta -> {"text": "<the whole output so far>"} (cumulative)
// done -> {"status": "succeeded", "output": "<the envelope as a string>"}
// error -> {"code": "...", "message": "..."}
// Send the same Idempotency-Key you would send to /run.
// POST /run-stream with HttpResponse.BodyHandlers.ofLines() and fold the
// "event:"/"data:" pairs into frames separated by blank lines.
// delta -> {"text": "..."} and the text is cumulative, not incremental
// done -> {"output": "..."} holds the envelope as a JSON string
// Send the same Idempotency-Key header you would send to /run.
# Net::HTTP#request with a block and res.read_body streams the SSE frames.
# Split on a blank line, read "event:" and "data:", JSON.parse the data.
# delta -> {"text" => "..."} (cumulative)
# done -> {"output" => "..."} the envelope as a string
# Send the same Idempotency-Key header you would send to /run.
<?php
// Set CURLOPT_WRITEFUNCTION and parse "event:"/"data:" frames as they arrive.
// delta frames carry {"text": "..."} and the text is cumulative; the done
// frame carries {"output": "..."} holding the envelope as a JSON string.
// Send the same "Idempotency-Key: ..." header you would send to /run.
// Use HttpCompletionOption.ResponseHeadersRead, then read the stream line by
// line and group lines into frames at each blank line.
// delta -> {"text": "..."} cumulative
// done -> {"output": "..."} the envelope as a JSON string
// Send the same Idempotency-Key header you would send to /run.
The output envelope
Whichever lane you ran, data.output is a string holding one JSON object with the same
keys. Every key is always present: an empty section is [], "" or
{}, never null and never omitted. Parse it with a tolerant reader —
the web app recovers a truncated stream by walking the bracket stack and rendering whatever
sections completed.
{
"lane": "outcomes",
"title": "string under 80 chars",
"verdict": "one of the lane's verdicts",
"headline": "one sentence under 160 chars",
"summary": "two to four sentences",
"checks": [{"name": "", "value": "", "verdict": "good|weak|missing|risky|not-applicable", "note": ""}],
"findings": [{"id": "", "severity": "critical|high|medium|low", "target": "", "quote": "", "why": "", "so_what": ""}],
"rows": [{"key": "", "label": "", "a": "", "b": "", "c": "", "note": ""}],
"artifact": "the full document, Markdown",
"artifact_json": {},
"coverage_check": [{"flag_id": "", "status": "confirmed|cleared|not-applicable", "note": ""}],
"questions": ["..."],
"confidence": "high|medium|low"
}
| field | type | meaning |
|---|---|---|
lane | string | The lane the reply is for. Compare it with the task you sent. |
title | string | Under 80 characters. |
verdict | string | One of the lane's own list — see the table below. |
headline | string | One sentence, under 160 characters. |
summary | string | Two to four sentences. Names the lane it chose if your task was unrecognised. |
checks | array | {name, value, verdict, note}; verdict is good|weak|missing|risky|not-applicable. |
findings | array | {id, severity, target, quote, why, so_what}; severity is critical|high|medium|low. quote is lifted from your brief, not paraphrased. |
rows | array | {key, label, a, b, c, note}, all strings. One table whose six columns mean something different per lane — see below. |
artifact | string | The lane's document, in Markdown. This is the deliverable. |
artifact_json | object | The same document as structured data, carrying a kind that names the lane's schema. |
coverage_check | array | {flag_id, status, note}; status is confirmed|cleared|not-applicable. Exactly one entry per prescan flag. |
questions | array | Strings: what the model would need to ask a subject-matter expert before this document could be taught. |
confidence | string | high|medium|low. |
The output contract, lane by lane
rows is one table in the envelope but six differently-meaning columns per lane, and
verdict is drawn from a closed list that differs per lane. If you are rendering the
output yourself, this is the table to code against — do not assume rows[].a
means the same thing in syllabus as it does in quiz.
| lane | verdicts | rows columns: key / label / a / b / c / note | artifact file | artifact_json.kind |
|---|---|---|---|---|
outcomes | measurable · mostly-measurable · needs-rewrite · not-assessable | objective id / rewritten objective / Bloom level / verb / evidence that would show it / note | OUTCOMES.md | learning_outcomes |
syllabus | teachable · overloaded · underfilled · unsequenced | module id / module title / minutes / objectives covered / activities / note | SYLLABUS.md | syllabus |
lesson | ready-to-teach · needs-materials · over-scheduled · thin | segment id / segment name / minutes / what the teacher does / what the learner does / note | LESSON-PLAN.md | lesson_plan |
assess | aligned · partly-aligned · misaligned · no-evidence | criterion id / criterion / weight / objective ids / evidence / note | ASSESSMENT-PLAN.md | assessment_plan |
quiz | ready-to-use · needs-review · too-easy · ungradeable | item id / stem / item type / correct answer / distractors and why each is plausible / note | QUIZ.md | item_bank |
The artifact filenames are what the app's download button writes and what a handoff carries
forward. If you are chaining lanes, put the previous lane's artifact string into
upstream on the next request — the Markdown, not the JSON.
Answering the prescan: the coverage_check rule
The browser measures the brief before any model sees it — modules and their durations, every
objective classified against Bloom's revised taxonomy, the assessment weights, the alignment, the
contact-time budget, and about twenty lints — and sends the result as prescan.
The contract on the reply is exact: every prescan.flags[].flag_id comes back in
coverage_check exactly once. Not zero times, not twice. Ten flags in means ten
entries out, one per id, and nothing invented that was not sent.
// sent
"prescan": { "flags": [ {"flag_id": "f1", ...}, {"flag_id": "f2", ...} ] }
// returned - exactly these two ids, exactly once each
"coverage_check": [
{ "flag_id": "f1", "status": "confirmed",
"note": "\"Understand pandas\" is rewritten as objective o1 with an observable verb." },
{ "flag_id": "f2", "status": "not-applicable",
"note": "The outcomes lane does not schedule time, so the budget flag is left to the syllabus lane." }
]
That rule is what makes the output auditable: a flag can be confirmed,
cleared or not-applicable, but it cannot be ignored. Validate it on your
side — compare the two id sets and treat any difference as a failed run rather than a partial
one. If you build your own prescan, give each flag a stable flag_id, a
rule, a severity, a scope, a target, a
message, some evidence, and a confidence of either
high or unknown:
high— you measured it against material that is present. Definite.unknown— you are asserting an absence across a brief you may only have part of. The model is told not to upgrade this into a claim about what the course does not contain.
Flags with no confidence are treated as high, which is safe but throws
away the distinction — and that distinction is what stops a report saying a course has no
assessment plan when the user simply pasted the module list.
Rate limits and cost
/guest,/meand/estimateare free.- A run reserves
hold_creditsand chargescharged_credits, usually far less, because the hold prices the full output cap. - The five lanes price differently. Estimate the lane you are about to run, not the one you ran
last time; a
quizatitem_count: 40is the most expensive request this app makes. - If the balance sits between
min_creditsandhold_creditsthe run still executes with a reduced cap and returns"truncated": true. Surface that rather than presenting a clipped syllabus as complete. - On
429, back off. On500, retry with the sameIdempotency-Key. Never tight-loop either one.
Two last reminders
- The body of
/estimate,/runand/run-streamis the input object itself. Noinputwrapper, ever. - There is no
X-App-Slugheader. The slug goes in thePOST /guestbody and nowhere else.