DEVELOPER DOCUMENTATION
Quiz-as-Code, Diff, and Templates
Export a quiz as a portable JSON or YAML document, edit it in a repository, import it back, compare versions, and reuse quizzes across jobs with organization templates.
/api/public/v1/project-todos/quizzes/codeA quiz can live in your repository as a document. Export it, edit it, review the change with a diff, and import it back. Organization templates let you start a quiz in a new job from one you already wrote.
The document
Section titled “The document”A quiz document holds exactly the authoring content: title, description, settings, and definition, with your own stable question and option ids. It never holds database ids, timestamps, job scope, revisions, assets, attempts, or the confirmed learning-plan decision that OpenTrain stores per form.
opentrainQuizVersion: 1title: Warehouse safety checkdescription: Five questions on the PPE page.settings: attemptPolicy: { mode: LIMITED, maxAttempts: 2 } passRule: { mode: PERCENTAGE, thresholdPercent: 80 } resultRelease: MANUALdefinition: schemaVersion: 2 questions: - id: q_goggles type: MULTIPLE_CHOICE prompt: When are safety goggles required? required: true options: - { id: o_always, label: Always on the floor } - { id: o_cutting, label: Only when cutting } grading: { points: 1, answerKey: { optionId: o_always } } - id: q_why type: PARAGRAPH prompt: Explain why the forklift lane stays clear. required: true manualGrading: points: 3 rubric: Full credit names the hazard and the consequence.| Field | Rules |
|---|---|
opentrainQuizVersion | Always 1. |
title, description | description may be null. An optional rich descriptionDoc may be included. |
settings.attemptPolicy | { "mode": "UNLIMITED" } or { "mode": "LIMITED", "maxAttempts": n }. |
settings.passRule | { "mode": "PERCENTAGE", "thresholdPercent": n }, { "mode": "ABSOLUTE", "thresholdPoints": n }, or null. |
settings.resultRelease | IMMEDIATE or MANUAL. |
definition.schemaVersion | 1, or 2 when the quiz is grounded (definition.grounding). |
definition.questions | Up to 500 entries. |
Question types:
type | Extra fields | Auto-grading (grading.answerKey) |
|---|---|---|
SHORT_TEXT, PARAGRAPH | none | { "value", "caseSensitive"? }. Leave out grading to grade by hand. |
MULTIPLE_CHOICE, DROPDOWN | options (2–200) | { "optionId" } |
CHECKBOXES | options (1–200) | { "optionIds": [...] } |
LINEAR_SCALE | min, max (−100 to 100), optional labels | { "value" } with optional tolerance and partialCredit (NONE or LINEAR, with partialCreditRange) |
NUMBER | none | Same as LINEAR_SCALE |
DATE | none | { "value": "YYYY-MM-DD" } |
SECTION | title, optional description | Layout only |
TITLE | text | Layout only |
Every answerable question has id, prompt (up to 10,000 characters), and required, plus these optional fields: grading.points (1–10,000, default 1), manualGrading ({ "points", "rubric"? }, rubric up to 4,000 characters, never shown to AI trainers), practiceExplanation (shown after submission), sourceRefs and rationale (grounding only). A question uses grading or manualGrading, never both. Rich-text fields (promptDoc, descriptionDoc, textDoc) are optional.
The authoring capabilities read returns these limits and types as data.
Export
Section titled “Export”GET /api/public/v1/project-todos/quizzes/code?formId=… exports the current draft. Add formVersionId to export a published version.
REST
curl -s "https://www.opentrain.ai/api/public/v1/project-todos/quizzes/code?formId=form_456" \ -H "Authorization: Bearer $OPENTRAIN_API_TOKEN"SDK
const { document, contentHash } = await opentrain.exportProjectTodoQuizCode({ formId: 'form_456',});CLI
opentrain todos quiz export --form-id form_456 --out ./quizzes/safety.yamlThe CLI writes JSON, or YAML when the file ends in .yaml or .yml (or with --format yaml), with a stable key order.
MCP
Call opentrain_export_quiz_code with formId and optional formVersionId.
The response has formId, scopeJobId, source (DRAFT or VERSION), draftRevision, contentHash, recordedContentHash, exportedAt, and document. contentHash identifies the portable content. recordedContentHash is the hash the publication stored, and it is null for a draft. The two differ for a version, because publishing prunes uncited grounding pins and includes the stored learning-plan decision.
Export, then import, then export again gives the same content hash.
Import
Section titled “Import”form_import reads a document back. The target is one of:
{ "kind": "CREATE", "jobId": "…" }creates a new form in the job. It takes the same optionalconfirmedPlanasform_create. Without it, the response carries a learning-plan hint.{ "kind": "UPDATE_DRAFT", "formId": "…", "expectedRevision": n }replaces an existing draft at that revision. It keeps the stored learning-plan decision. AddacknowledgeSourceChangeswhen the document pins a cited page at a new checksum.
Publishing is a separate step. A retry with the same key returns the original result, including after a lost reply. The same key with a different document or job is 409 IDEMPOTENCY_KEY_REUSED.
REST
curl -s -X POST "https://www.opentrain.ai/api/public/v1/project-todos/quizzes/manage" \ -H "Authorization: Bearer $OPENTRAIN_API_TOKEN" -H 'content-type: application/json' -d '{ "action": "form_import", "idempotencyKey": "safety-quiz-import-2026-11", "target": { "kind": "UPDATE_DRAFT", "formId": "form_456", "expectedRevision": 3 }, "document": { "opentrainQuizVersion": 1, "title": "Warehouse safety check", "description": null, "settings": { "attemptPolicy": { "mode": "UNLIMITED" }, "passRule": null, "resultRelease": "MANUAL" }, "definition": { "schemaVersion": 1, "questions": [ { "id": "q_goggles", "type": "SHORT_TEXT", "prompt": "Name the required eye protection.", "required": true, "grading": { "points": 1, "answerKey": { "value": "goggles" } } } ] } }}'SDK
await opentrain.importProjectTodoQuizCode({ idempotencyKey: 'safety-quiz-import-2026-11', target: { kind: 'UPDATE_DRAFT', formId: 'form_456', expectedRevision: 3 }, document,});CLI
# Replace a draftopentrain todos quiz import --file ./quizzes/safety.yaml \ --form-id form_456 --expected-revision 3 --confirm-live# Create a new form in another job (the document must be ungrounded)opentrain todos quiz import --file ./quizzes/safety-ungrounded.yaml \ --job-id job_b --confirmed-plan-file ./plan.json --confirm-liveThe CLI reads JSON or YAML up to 4 MiB. YAML must stay inside its JSON-compatible subset: one document, unique keys, no custom tags, and bounded aliases and depth. The CLI refuses a file outside it before sending any request.
MCP
Call opentrain_import_quiz_code with idempotencyKey, target, and document.
Grounding stays with its job
Section titled “Grounding stays with its job”A document from a form grounded in its job’s Instructions can be created again only in that job. Aimed at another job, it is refused with 400 (“Grounding must reference the form’s own job”). To move it to another job, either:
- make a template with
detachGrounding: trueand instantiate it (below), or - delete
definition.groundingand every question’ssourceRefsfrom the document, create it in the new job, and ground it again there.
GET /api/public/v1/project-todos/quizzes/diff?formId=… compares the latest published version with the draft. Pass fromVersionId and toVersionId to compare any pair; DRAFT names the draft. Questions are matched by their stable id. Grounding is compared by its cited pins only.
REST
curl -s "https://www.opentrain.ai/api/public/v1/project-todos/quizzes/diff?formId=form_456" \ -H "Authorization: Bearer $OPENTRAIN_API_TOKEN"SDK
const { diff } = await opentrain.diffProjectTodoQuiz({ formId: 'form_456' });CLI
opentrain todos quiz diff --form-id form_456opentrain todos quiz diff --form-id form_456 --from-version-id ver_1 --to-version-id DRAFTMCP
Call opentrain_diff_quiz with formId and optional fromVersionId and toVersionId.
The diff lists questions.added, questions.removed, questions.changed (each with the changed fields), questions.unchanged, and questions.reordered, plus settings, title, and description changes, affectedQuestionIds, and identical.
Organization templates
Section titled “Organization templates”A template is a form owned by the organization, not a job. Instantiating one creates a new, independent form in a job. The new form is not linked to the template, and later changes to either do not affect the other.
| Operation | Request |
|---|---|
| List | GET /quizzes/templates (add includeArchived=true to include archived ones) |
| Create from a form | Manage action template_create_from_form with formId, optional formVersionId, title, and detachGrounding |
| Instantiate | Manage action template_instantiate with templateId, jobId, and optional title |
A grounded source cannot become a template as it is: the request is refused with 400 and details.reason GROUNDED_SOURCE. Set detachGrounding: true to template the portable content (grounding and sourceRefs removed, rationale kept). The response then reports groundingDetached: true. Ground the new form in its own job after you instantiate it.
Both creates are idempotent per organization and key. A retry returns the original form with replayed: true, even if the source changed since. The same key with different choices (another source, template, job, title, detachGrounding, or confirmed plan) is 409 IDEMPOTENCY_KEY_REUSED. A template id from another organization, or a form that is not a template, is 404.
REST
curl -s -X POST "https://www.opentrain.ai/api/public/v1/project-todos/quizzes/manage" \ -H "Authorization: Bearer $OPENTRAIN_API_TOKEN" -H 'content-type: application/json' -d '{ "action": "template_create_from_form", "idempotencyKey": "safety-template-2026-11", "formId": "form_456", "title": "Safety quiz (template)", "detachGrounding": true}'
curl -s -X POST "https://www.opentrain.ai/api/public/v1/project-todos/quizzes/manage" \ -H "Authorization: Bearer $OPENTRAIN_API_TOKEN" -H 'content-type: application/json' -d '{ "action": "template_instantiate", "idempotencyKey": "safety-template-job-b-2026-11", "templateId": "form_tpl_1", "jobId": "job_b"}'SDK
const templates = await opentrain.listProjectTodoQuizTemplates();const template = await opentrain.createProjectTodoQuizTemplate({ idempotencyKey: 'safety-template-2026-11', formId: 'form_456', title: 'Safety quiz (template)', detachGrounding: true,});await opentrain.instantiateProjectTodoQuizTemplate({ idempotencyKey: 'safety-template-job-b-2026-11', templateId: 'form_tpl_1', jobId: 'job_b',});CLI
opentrain todos quiz templates listopentrain todos quiz templates create --form-id form_456 \ --title "Safety quiz (template)" --detach-grounding --confirm-liveopentrain todos quiz templates instantiate --template-id form_tpl_1 \ --job-id job_b --confirm-liveMCP
Call opentrain_list_quiz_templates, opentrain_create_quiz_template, and opentrain_instantiate_quiz_template.