← Exam Paper Desk
API
Tokens

Run Exam Paper Desk from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page reads the brief and works out the blueprint for free - each topic's target marks, the weights scaled to the total and rounded by largest remainder so they add up exactly, and the pace in marks a minute - and checks a pasted draft paper the way the source skill asks: marks that add up at every level and to the total, every question tagged to a topic in the coverage map, no topic left without a question or drifting from its target, one assessable outcome per call, no bare "Discuss", sound multiple choice. That is epscan.js, and every finding carries its rule id, line and fix. The run writes the paper and its mark scheme ("write") or reviews the draft ("review"); the page then holds the reply to its own reading (epreply.js): every rule answered, every mark accounted for, every topic on its target.

A drafting aid. A written paper is a draft for the teacher: check every question, key and marking point against what the class was taught before it is sat.

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"exam-paper-desk"}), so no slug header is needed afterwards. Send it as Authorization: Bearer .... The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a field the run needs (task, brief, facts) is missing or not a string. Send every field as a string; focus may be "".
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes - "write" (the paper written from the brief to the page's blueprint: an instructions block, sections of questions with their topic, outcome, style, marks, calls and answer space, and a separate scoring outline with model answers and marking points; a draft under Paper: is rewritten, keeping what works) and "review" (the draft under Paper: judged against the brief: a verdict, findings with fixes, a call on every page finding, and keep, revise with a rewrite, or cut for every question). The EP- rules belong to both. A missing or unknown task is answered as write, with a note saying so. Both lanes send the same fields.

The input

Every field is a string.

FieldRequiredWhat
taskyes"write" or "review". The model answers that task and only that task.
briefyesthe exam brief as pasted, in the brief format. The page sends up to 18,000 characters - the syllabus summary, not the readings - and needs a Total: and at least one topic under Topics:; "review" also needs a draft under Paper:.
focusnowhat the teacher wants weighed: the class, accessibility, plain language for EAL students - or a review to apply in a write (see review, then write). Up to 1,500 characters on the page. May be ""; when given, the summary says how it changed the answer.
factsyesthe page's own reading of the same brief: the total, the time and the pace, the target marks per topic, every draft question with its topic, marks, parts and options, and every finding as rule | severity | line | where | text. See the facts format.
retry_notenopresent only on a reformat retry: the previous reply could not be read as one JSON object, and this says how to answer again. Leave it out otherwise.

The brief format

brief is plain text in three parts, in this order:

Course: Year 9 Biology
Assessment: End-of-unit test - Cells and organisation
Time: 40 minutes
Total: 40 marks
Rules: closed book; no calculators; answer in the spaces provided
Topics:
- Cell structure: 30%
- Microscopy: 20%
- Diffusion and osmosis: 25%
- Levels of organisation: 25%
Paper:
1. [Cell structure] Name the part of a plant cell that controls what enters and leaves the cell. (1 mark)
3. [Microscopy] (8 marks)
a) A cell measures 4 mm in a drawing magnified 400 times. Calculate its real size in micrometres. (4)
4. [Diffusion and osmosis] Which statement defines diffusion? (1 mark)
A. Movement of particles from low to high concentration
B. Movement of particles from high to low concentration

A header the brief does not give is simply left out; the page reports what that costs as a finding.

The free blueprint

The page turns the coverage map into target marks in the browser, for free:

The targets go into facts as the coverage-map lines, and the model is told to follow the facts' targets: each topic's marks in a written paper follow its target, and a deliberate departure is stated in notes. So an API client must compute the targets itself and send its own target-marks lines in facts - with epscan.js under node (Build the input yourself), or with your own largest-remainder rounding written in the same form:

Read by the page: total <T> marks, <M> minutes, <pace> marks a minute.
Coverage map (target marks, weights <in %|in marks|in teaching time|not given - equal shares>, scaled to the total and rounded so they add up):
- <topic>: <target> marks (weight <w>)[; the draft gives it <n>]

Spell the topics exactly as the coverage map does: the model uses those names in topic and coverage, and the page matches them back.

The facts format

facts is the page's own reading, written as plain text. The page builds it from the brief with epscan.js, and the model answers against it. A client calling the API should send the same format (from epscan.js run under node) or at least its own summary - the total and the time, your target-marks lines, the draft questions as you read them, and what you already know is wrong. The check's format gets the most out of a run: the model answers every error and warn rule id it finds there with one prescan entry per id. The format, line by line:

<Course|Level|Assessment|Time|Total|Rules|Reading time|Past papers>: <as written - only the lines the brief gives>
Read by the page: total <T|not stated> marks, <M minutes|time not stated>[, <pace> marks a minute].
Coverage map (target marks, ...):        (or: Coverage map: none.)
- <topic>: <target> marks (weight <w>)[; the draft gives it <n>]
Learning outcomes: <a | b | c>           (when given)
Draft paper: <n> questions, <m> marks:   (or: Draft paper: none pasted - write it from the brief.)
- <Q1> [<topic|untagged>] <n marks|no marks>[, parts a (3) b (2)][, 4 options] | <stem>
Prescan verdict: <ready|revise|rework> (<n> errors, <n> warnings, <n> notes).
Findings (rule | severity | line | where | text):
- <rule id> | <error|warn|info> | line <n> | <Q3b|-> | <title>[ (note)][ | the brief text]

A brief with no findings gets the single line - none. The prescan verdict is rework at two or more errors, or with errors or warnings in three or more areas (exam lock, coverage, marks, calls); revise on any error or warning; ready otherwise. A real example - the facts the page sends for the biology paper used in the worked example below:

Course: Year 9 Biology
Assessment: End-of-unit test - Cells and organisation
Time: 40 minutes
Total: 40 marks
Rules: closed book; no calculators; answer in the spaces provided
Read by the page: total 40 marks, 40 minutes, 1 marks a minute.
Coverage map (target marks, weights in %, scaled to the total and rounded so they add up):
- Cell structure: 12 marks (weight 30%); the draft gives it 12
- Microscopy: 8 marks (weight 20%); the draft gives it 8
- Diffusion and osmosis: 10 marks (weight 25%); the draft gives it 10
- Levels of organisation: 10 marks (weight 25%); the draft gives it 10
Draft paper: 6 questions, 40 marks:
- Q1 [Cell structure] 1 mark | Name the part of a plant cell that controls what enters and leaves the cell.
- Q2 [Cell structure] 11 marks, parts a (3) b (2) c (6) | 
- Q3 [Microscopy] 8 marks, parts a (4) b (2) c (2) | 
- Q4 [Diffusion and osmosis] 1 mark, 4 options | Which statement defines diffusion?
- Q5 [Diffusion and osmosis] 9 marks | In about 100 words, explain what happens to a red blood cell placed in pure water. Use the word osmosis.
- Q6 [Levels of organisation] 10 marks, parts a (2) b (4) c (4) | 
Prescan verdict: ready (0 errors, 0 warnings, 0 notes).
Findings (rule | severity | line | where | text):
- none

Rule ids the check uses, with their severity and area:

RuleSeverityAreaTitle
EP-NOTOTALerrorlockNo total marks
EP-NOTIMEwarnlockNo sitting length
EP-NORULESinfolockMaterials and rules not stated
EP-NOTOPICSerrorcoverageNo coverage map
EP-WEIGHTSUMwarncoverageTopic weights do not add up
EP-PACEwarnlockPace out of range
EP-SUMerrormarksPaper marks do not add up to the total
EP-PARTSUMerrormarksSub-part marks do not add up to the question
EP-NOMARKSerrormarksQuestion without marks
EP-UNTAGGEDwarncoverageQuestion not tagged to a topic
EP-OFFSYLLABUSerrorcoverageQuestion on a topic outside the coverage map
EP-DRIFTwarncoverageTopic weighting drifts from the map
EP-ORPHANwarncoverageTopic in the map with no question
EP-COMPOUNDwarncallsCompound call
EP-VAGUEwarncallsOpen call without expectations
EP-MCQwarncallsWeak multiple-choice options
EP-DUPwarncallsTwo questions with the same stem
EP-REPEATinfocallsInstruction repeated in questions

A finding the check cannot see (an item that tests the wrong outcome, a give-away cue, an answer that depends on an unstated fact or a missing diagram, a wrong or ambiguous key, marks a call cannot earn) comes back with the rule NEW.

The standard the model applies

The source skill - @suzheng/k2f-exam-paper (suzheng/k2f, Apache-2.0) - is the only standard:

House rules

The replies

Both lanes carry task, summary, findings (rule - an EP- id from the facts or NEW - severity high / medium / low, title, where, why, fix; most severe first) and prescan (rule, call agree or disagree, note). The rest differs by lane.

write

The paper, written from the brief to the page's blueprint; a draft under Paper: is rewritten, keeping what works and fixing what the facts flag.

  1. summary - two sentences: what the paper assesses and how it is built.
  2. instructions - the single instructions block for the cover: time, materials, how to answer.
  3. sections - each with title and items. Each item: id, topic (spelled as in the map), outcome, style (recall, application, analysis, quantitative, mcq or extended), marks, stem, and either parts (each label, marks, call, response - lines, calculation, diagram or table - and lines, the answer lines it needs) or, for a single call, response and lines; a mcq item has options (the texts, in order) and key (the letter).
  4. scoring - one entry per item: id, answer (the model answer or the key) and points (each part, marks, criteria - the marking point and any partial credit), adding up to the item's marks.
  5. coverage - one entry per topic: topic and marks, what the paper gives it.
  6. out_of_scope (what the brief asks for that the syllabus does not support) and notes (departures from the map, choices made).
{
  "task": "write",
  "summary": "",
  "instructions": "",
  "sections": [{"title": "Section A", "items": [{"id": "Q1", "topic": "", "outcome": "", "style": "recall", "marks": 2, "stem": "", "parts": [{"label": "a", "marks": 2, "call": "", "response": "lines", "lines": 2}]}]}],
  "scoring": [{"id": "Q1", "answer": "", "points": [{"part": "a", "marks": 2, "criteria": ""}]}],
  "coverage": [{"topic": "", "marks": 0}],
  "findings": [],
  "prescan": [],
  "out_of_scope": [""],
  "notes": [""]
}

review

verdict on the draft: ready (fit to sit as is), revise (fixable problems) or rework (marks that do not add up, off-syllabus or missing topics, or calls students cannot answer as written). A page error the reply agrees with rules out ready.

  1. summary - two or three sentences: what the paper assesses, the verdict, the first thing to fix.
  2. findings and prescan.
  3. items - one entry per draft question: id, call (keep, revise or cut), note, and for revise a rewrite of the question with its marks.
  4. coverage - one or two sentences (a string) on how the paper's marks sit against the map, using the page's numbers.
  5. questions (what the teacher must decide) and caveats.
{
  "task": "review",
  "verdict": "revise",
  "summary": "",
  "findings": [{"rule": "EP-COMPOUND", "severity": "medium", "title": "", "where": "Q1", "why": "", "fix": ""}],
  "prescan": [{"rule": "EP-COMPOUND", "call": "agree", "note": ""}],
  "items": [{"id": "Q1", "call": "revise", "note": "", "rewrite": ""}],
  "coverage": "",
  "questions": [""],
  "caveats": [""]
}

The page holds either reply to its own reading: every error and warning answered by rule id, and no rule id the page did not report other than NEW. For write it also wants an instructions block, unique question ids, marks on every question, sub-parts that add up to their question and questions that add up to the total, every item on a coverage-map topic, each topic within max(2, 5% of the total) marks of its target (or the departure stated in notes or out_of_scope), the coverage table equal to what the paper really gives, a scoring entry for every item whose points add up to its marks, the same number of options in every multiple-choice item and no "all/none of the above", and no compound or bare open calls. For review it wants every draft question judged (and no question the draft does not have), a rewrite for every revise, a word on the coverage, and no ready over an undisputed error. Each miss is shown next to the reply as a note.

Reading the reply

The job's output.output is text that should be exactly one JSON object. Parse it; if a model ever wraps it in prose or a fence, take the text from the first { to the last } and parse that (the page does the same in epreply.js). If it still does not parse, send the same input again with a retry_note such as "Your previous reply was not usable. Reply again with ONLY the JSON object for task 'review' - no prose, no code fences." and a new attempt number in the key (see step 5). A "write" reply is two documents: its instructions and sections with their items print as the student paper (the page adds a name line and answer lines from each call's lines), and its scoring prints as a separate mark scheme, not for students. Step 5 prints both. Check every question, key and marking point before the paper is sat.

Review, then write

A review can be handed to a "write" run as its focus, so the paper is rewritten with it. The page builds that focus from the review reply, one line each, clipped to 1,500 characters: Apply this review (Exam Paper Desk, verdict revise): <summary>, then - <where> <title>: <fix> per finding and - <id> <call>: <note> per question that is not kept. The same brief and facts go along.

Worked example - reviewing the biology paper

A clean-looking draft: a Year 9 end-of-unit test on cells and organisation, 40 marks in 40 minutes, four topics weighted 30/20/25/25%. The page's blueprint is 12, 8, 10 and 10 marks, the draft gives exactly that, the pace is one mark a minute, and the check finds nothing wrong. The input below is exactly what the page sends for the "review" lane; for the "write" lane only task changes to "write", and the same facts go along. Input:

{
 "task": "review",
 "brief": "Course: Year 9 Biology\nAssessment: End-of-unit test - Cells and organisation\nTime: 40 minutes\nTotal: 40 marks\nRules: closed book; no calculators; answer in the spaces provided\nTopics:\n- Cell structure: 30%\n- Microscopy: 20%\n- Diffusion and osmosis: 25%\n- Levels of organisation: 25%\nPaper:\n1. [Cell structure] Name the part of a plant cell that controls what enters and leaves the cell. (1 mark)\n2. [Cell structure] (11 marks)\na) Label the nucleus, cell membrane and cytoplasm on the animal cell diagram. (3)\nb) Give two structures found in plant cells but not in animal cells. (2)\nc) Explain how the structure of a root hair cell helps it absorb water, using two features. (6)\n3. [Microscopy] (8 marks)\na) A cell measures 4 mm in a drawing magnified 400 times. Calculate its real size in micrometres. (4)\nb) State one advantage of an electron microscope over a light microscope. (2)\nc) Describe how to focus a light microscope on a slide, in three steps. (2)\n4. [Diffusion and osmosis] Which statement defines diffusion? (1 mark)\nA. Movement of particles from low to high concentration\nB. Movement of particles from high to low concentration\nC. Movement of water through a partially permeable membrane only\nD. Movement that always needs energy from respiration\n5. [Diffusion and osmosis] In about 100 words, explain what happens to a red blood cell placed in pure water. Use the word osmosis. (9 marks)\n6. [Levels of organisation] (10 marks)\na) Put these in order from smallest to largest: organ, cell, organ system, tissue. (2)\nb) Explain why the stomach is an organ, using two tissues in your answer. (4)\nc) Describe the job of the digestive system as an organ system, in two points. (4)",
 "focus": "",
 "facts": "Course: Year 9 Biology\nAssessment: End-of-unit test - Cells and organisation\nTime: 40 minutes\nTotal: 40 marks\nRules: closed book; no calculators; answer in the spaces provided\nRead by the page: total 40 marks, 40 minutes, 1 marks a minute.\nCoverage map (target marks, weights in %, scaled to the total and rounded so they add up):\n- Cell structure: 12 marks (weight 30%); the draft gives it 12\n- Microscopy: 8 marks (weight 20%); the draft gives it 8\n- Diffusion and osmosis: 10 marks (weight 25%); the draft gives it 10\n- Levels of organisation: 10 marks (weight 25%); the draft gives it 10\nDraft paper: 6 questions, 40 marks:\n- Q1 [Cell structure] 1 mark | Name the part of a plant cell that controls what enters and leaves the cell.\n- Q2 [Cell structure] 11 marks, parts a (3) b (2) c (6) | \n- Q3 [Microscopy] 8 marks, parts a (4) b (2) c (2) | \n- Q4 [Diffusion and osmosis] 1 mark, 4 options | Which statement defines diffusion?\n- Q5 [Diffusion and osmosis] 9 marks | In about 100 words, explain what happens to a red blood cell placed in pure water. Use the word osmosis.\n- Q6 [Levels of organisation] 10 marks, parts a (2) b (4) c (4) | \nPrescan verdict: ready (0 errors, 0 warnings, 0 notes).\nFindings (rule | severity | line | where | text):\n- none"
}

Reply - the real saved reply the page shows for this sample, abbreviated: long strings are cut and end in ..., findings keeps only its first two entries, items its first two, questions its first and caveats its last (the full reply has 8 findings, 6 items, 3 questions and 3 caveats). prescan is empty because the page reported no findings to answer:

{
 "task": "review",
 "verdict": "revise",
 "summary": "A 40-mark, 40-minute Year 9 test on cells and organisation whose topic totals match the coverage map exactly (12/8/10/10), but several parts carry marks their calls cannot earn and Q2a depends on a diagram the paper does not contain. Verdict: revise, not ...",
 "findings": [
  {
   "rule": "NEW",
   "severity": "high",
   "title": "Q2a needs a diagram that is not in the paper",
   "where": "Q2a",
   "why": "The call says \"on the animal cell diagram\", but the draft supplies no diagram and no figure is named in the brief. As written, 3 marks cannot be ...",
   "fix": "Insert a clear unlabelled animal cell diagram with three label lines, or use the text version in the rewrite (name the structure described by each of ..."
  },
  {
   "rule": "NEW",
   "severity": "medium",
   "title": "Q5 puts 9 marks on one short explanation",
   "where": "Q5",
   "why": "A red blood cell in pure water has about four creditable points (water concentration higher outside, water enters by osmosis, through the partially ...",
   "fix": "Keep the 9 marks for the topic but split them: three factors that increase the rate of diffusion (3), two observed changes in the cell (2), and the ..."
  }
 ],
 "prescan": [],
 "items": [
  {
   "id": "Q1",
   "call": "keep",
   "note": "A fair 1-mark warm-up that tests the membrane/wall misconception. Keep the wording; the scheme must reject \"cell wall\"."
  },
  {
   "id": "Q2",
   "call": "revise",
   "note": "Q2a cannot be answered without the missing diagram, and Q2c offers 6 marks for two features. Q2b is fine as written. The question stays at 11 marks.",
   "rewrite": "2. [Cell structure] (11 marks)\na) Label the nucleus, cell membrane and cytoplasm on the animal cell diagram. (3) [Insert the diagram: an unlabelled ..."
  }
 ],
 "coverage": "The page's targets are Cell structure 12, Microscopy 8, Diffusion and osmosis 10 and Levels of organisation 10 (40 marks), and the draft gives exactly 12/8/10/10. Every rewrite moves marks within its own question, so the totals stay at 12/8/10/10 and the ...",
 "questions": [
  "Q2a: can you supply the animal cell diagram? If not, use the text version, which swaps the cell membrane for mitochondria (Q1 already tests the ..."
 ],
 "caveats": [
  "The diagram for Q2a was not seen. If it exists and is clear, Q2a can stay as written."
 ]
}

Every finding is NEW: the totals add up, so the page could not see that Q2a needs a diagram the paper does not contain, or that several parts carry marks their calls cannot earn. Sent to "write", the same brief comes back as a six-question paper with a mark scheme entry for every question and coverage of exactly 12, 8, 10 and 10 marks.

Build the input yourself

The checker and the reply checker are plain scripts that run under node (global.window = global; require("./epscan.js"); require("./epreply.js")), then:

const S = EpScan, R = EpReply;
const st = { brief: fs.readFileSync("brief.txt", "utf8"), focus: "" };
const scan = S.scan(st);                        // findings, counts, total, time, pace, targets, items, verdict
console.log(scan.targets.rows.map(r => r.name + ": " + r.target)); // the largest-remainder targets
const b = S.buildInput(st, "review");           // or "write"; b.ok is false (with b.note) when the brief cannot run
const wire = S.toWire(b.input);                 // task, brief, focus, facts - all strings; POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b)                            // {notes, warn}: anything the reply missed or got wrong
// R.paperText(reply, b), R.schemeText(reply, b) // a write reply as the student paper and the mark scheme
// R.handoff(reply)                             // a review reply as the focus of a write run

Get a token for the steps below from /tokens.html.

Step 1 - a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html in place of YOUR_TOKEN, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://exam-paper-desk.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 - a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"exam-paper-desk"}' | jq -r '.data.token')

Step 3 - GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}'

Step 4 - POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra. Save the worked-example input above as input.json (with task set to "write" or "review") to follow along.

# input.json: the run input object - {"task": "review", "brief": "Course: ...\nTotal: 40 marks\nTopics:\n- ...\nPaper:\n1. ...", "focus": "", "facts": "Course: ...\nCoverage map ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'

Step 5 - POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses exam-paper-desk:<task>:<inputHash>:a<attempt> - the lane is part of the key, so a review and a write of the same brief are two runs - and its one automatic reformat retry (sent with a retry_note, which the hash leaves out) is a new attempt number, a2, named in the page as one extra run. Any stable hash of the input works for your own key. The code below parses the JSON object out of the output; for "write" it prints the summary, the student paper (instructions, sections, questions with their options and parts) and then the mark scheme, and for "review" the verdict, the summary, the call on each question, the coverage and the questions for the teacher.

KEY="exam-paper-desk:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "write" then
    .summary, "", "INSTRUCTIONS", .instructions,
    (.sections[]? | "", (.title | ascii_upcase), (.items[]? | "\(.id) [\(.topic)] (\(.marks) marks) \(.stem)",
      (.options // [] | to_entries[] | "   \([65 + .key] | implode). \(.value)"),
      (.parts[]? | "   (\(.label)) \(.call) [\(.marks)]"))),
    "", "MARK SCHEME",
    (.scoring[]? | "\(.id): \(.answer)", (.points[]? | "   (\(.part)) [\(.marks)] \(.criteria)"))
  else
    "verdict: \(.verdict)", .summary,
    (.items[]? | "\(.id): \(.call) - \(.note)"),
    "Coverage: \(.coverage)",
    (.questions[]? | "? \(.)")
  end' reply.json   # write: the student paper, then the mark scheme

Step 6 - POST /run-stream

The same run as an SSE stream, with the same input.json and the same kind of key. Browsers receive progress ticks rather than text deltas; the final done message carries the job, whose output.output is the reply.

# SSE: tick/delta messages while the job runs, then a final message with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: exam-paper-desk:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json