# 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.

Source: https://www.opentrain.ai/docs/developers/api-reference/project-todos/quizzes/quiz-as-code/

Endpoint: GET /api/public/v1/project-todos/quizzes/code

A 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.

> Note
>
> Requires CLI and MCP 0.39.0 or later and SDK 0.36.0 or later. Reads need
> `project_todos:read`; imports and template writes need `project_todos:write`,
> a claimed account, and an `idempotencyKey`.

## 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.

```yaml
opentrainQuizVersion: 1
title: Warehouse safety check
description: Five questions on the PPE page.
settings:
  attemptPolicy: { mode: LIMITED, maxAttempts: 2 }
  passRule: { mode: PERCENTAGE, thresholdPercent: 80 }
  resultRelease: MANUAL
definition:
  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](https://www.opentrain.ai/docs/developers/api-reference/project-todos/quizzes/grounding/) (`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](https://www.opentrain.ai/docs/developers/api-reference/project-todos/quizzes/grading-and-results/#authoring-capabilities) read returns these limits and types as data.

## Export

`GET /api/public/v1/project-todos/quizzes/code?formId=…` exports the current draft. Add `formVersionId` to export a published version.

### REST

```bash
curl -s "https://www.opentrain.ai/api/public/v1/project-todos/quizzes/code?formId=form_456" \
  -H "Authorization: Bearer $OPENTRAIN_API_TOKEN"
```

### SDK

```ts
const { document, contentHash } = await opentrain.exportProjectTodoQuizCode({
  formId: 'form_456',
});
```

### CLI

```bash
opentrain todos quiz export --form-id form_456 --out ./quizzes/safety.yaml
```

The 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

`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 optional `confirmedPlan` as `form_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. Add `acknowledgeSourceChanges` when 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

```bash
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

```ts
await opentrain.importProjectTodoQuizCode({
  idempotencyKey: 'safety-quiz-import-2026-11',
  target: { kind: 'UPDATE_DRAFT', formId: 'form_456', expectedRevision: 3 },
  document,
});
```

### CLI

```bash
# Replace a draft
opentrain 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-live
```

The 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

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: true` and instantiate it (below), or
- delete `definition.grounding` and every question's `sourceRefs` from the document, create it in the new job, and ground it again there.

## Diff

`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

```bash
curl -s "https://www.opentrain.ai/api/public/v1/project-todos/quizzes/diff?formId=form_456" \
  -H "Authorization: Bearer $OPENTRAIN_API_TOKEN"
```

### SDK

```ts
const { diff } = await opentrain.diffProjectTodoQuiz({ formId: 'form_456' });
```

### CLI

```bash
opentrain todos quiz diff --form-id form_456
opentrain todos quiz diff --form-id form_456 --from-version-id ver_1 --to-version-id DRAFT
```

### MCP

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

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

```bash
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

```ts
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

```bash
opentrain todos quiz templates list
opentrain todos quiz templates create --form-id form_456 \
  --title "Safety quiz (template)" --detach-grounding --confirm-live
opentrain todos quiz templates instantiate --template-id form_tpl_1 \
  --job-id job_b --confirm-live
```

### MCP

Call `opentrain_list_quiz_templates`, `opentrain_create_quiz_template`, and `opentrain_instantiate_quiz_template`.
