← Course Desk / API
Get a token

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.

tasklanewhat it produces
outcomesLearning outcomesMeasurable, Bloom-mapped objectives, rewritten where the original was not assessable.
syllabusSyllabusA session-by-session syllabus with a contact-time budget that actually adds up.
lessonLesson planOne module expanded into a minute-by-minute lesson plan.
assessAssessment planAn assessment plan plus an analytic rubric aligned to the objectives.
quizItem bankQuiz 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 thinover-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 440; 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

codeHTTPwhat to do
UNAUTHORIZED401Missing or expired app token. Mint a new guest token, or sign in and take a personal one from the tokens page.
FORBIDDEN403The token is valid but not for this app — or the call is metered and you sent a guest token.
VALIDATION_ERROR400The input object is malformed. The usual cause is an {"input": ...} wrapper around a body that should have been sent bare.
INSUFFICIENT_CREDITS402The balance is below min_credits. Call /estimate and compare with /me.
RATE_LIMITED429Back off and retry. Do not tight-loop.
NOT_FOUND404Unknown job id on GET /jobs/{id}. Job ids are not guessable and do not live forever.
INTERNAL500Retry 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"}; }

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

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.

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

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.

fieldtypemeaning
taskstringThe 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.
briefstringThe 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_clippednumberHow many characters the clip removed. 0 when nothing was cut.
objectivesstringExtra objectives from a separate box, one per line. Optional; they are merged with any found in the brief.
focusstringWhich 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.
levelstringintro, working, advanced or mixed.
deliverystringlive-online, in-person, async or hybrid. It changes the activities, not just the wording.
rigorstringlight, standard or strict: how hard the model pushes on measurability.
item_countnumberHow many items the quiz lane writes, clamped to 4–40. Present in every lane, ignored by four of them.
notesstringFree context: cohort size, tooling, holidays, constraints the brief does not carry.
upstreamstringThe previous lane's artifact when you are chaining lanes, else "". This is how outcomes feeds syllabus and syllabus feeds lesson.
prescanobjectThe 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.

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":"{...}"}

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"
}
fieldtypemeaning
lanestringThe lane the reply is for. Compare it with the task you sent.
titlestringUnder 80 characters.
verdictstringOne of the lane's own list — see the table below.
headlinestringOne sentence, under 160 characters.
summarystringTwo to four sentences. Names the lane it chose if your task was unrecognised.
checksarray{name, value, verdict, note}; verdict is good|weak|missing|risky|not-applicable.
findingsarray{id, severity, target, quote, why, so_what}; severity is critical|high|medium|low. quote is lifted from your brief, not paraphrased.
rowsarray{key, label, a, b, c, note}, all strings. One table whose six columns mean something different per lane — see below.
artifactstringThe lane's document, in Markdown. This is the deliverable.
artifact_jsonobjectThe same document as structured data, carrying a kind that names the lane's schema.
coverage_checkarray{flag_id, status, note}; status is confirmed|cleared|not-applicable. Exactly one entry per prescan flag.
questionsarrayStrings: what the model would need to ask a subject-matter expert before this document could be taught.
confidencestringhigh|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.

laneverdictsrows columns: key / label / a / b / c / noteartifact fileartifact_json.kind
outcomesmeasurable · mostly-measurable · needs-rewrite · not-assessableobjective id / rewritten objective / Bloom level / verb / evidence that would show it / noteOUTCOMES.mdlearning_outcomes
syllabusteachable · overloaded · underfilled · unsequencedmodule id / module title / minutes / objectives covered / activities / noteSYLLABUS.mdsyllabus
lessonready-to-teach · needs-materials · over-scheduled · thinsegment id / segment name / minutes / what the teacher does / what the learner does / noteLESSON-PLAN.mdlesson_plan
assessaligned · partly-aligned · misaligned · no-evidencecriterion id / criterion / weight / objective ids / evidence / noteASSESSMENT-PLAN.mdassessment_plan
quizready-to-use · needs-review · too-easy · ungradeableitem id / stem / item type / correct answer / distractors and why each is plausible / noteQUIZ.mditem_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:

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

Two last reminders