# Roll Out a Quiz (Plan, Then Confirm)

Publish a quiz draft and assign it as a Project To-do in one reviewed decision: a no-write plan with a signed token, then one idempotent confirm.

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

Endpoint: POST /api/public/v1/project-todos/quizzes/manage

A rollout publishes a quiz and assigns it to AI trainers as one decision that a person reviews once. It has two steps on the shared manage endpoint:

1. `quiz_rollout_plan` writes nothing. It returns every fact the person needs to approve: the exact version that will be published, the To-do settings with the projected due and blocking times, the masked list of current recipients, active duplicates, the notification impact, and a signed `plan.token`.
2. `quiz_rollout_confirm` takes that token. It publishes the draft (or binds the version you planned), then creates and assigns the Project To-do, all under one idempotency key.

The person's "yes" in the agent conversation or terminal is the confirmation. No OpenTrain page is required.

> Note
>
> Requires CLI and MCP 0.42.0 or later and SDK 0.39.0 or later. Check the
> contract any time with [authoring capabilities](https://www.opentrain.ai/docs/developers/api-reference/project-todos/quizzes/grading-and-results/#authoring-capabilities).

## Plan

`quiz_rollout_plan` needs only `project_todos:read` and no idempotency key. For a draft grounded in the job's Instructions it also needs `instructions:read`, because it reads the cited pages. It changes nothing users see. For a grounded draft, resolving the released Instructions may record the same idempotent view row that an AI trainer's read records.

#### action (string, required)`quiz_rollout_plan`

#### jobId (string, required)

#### formId (string, required)

#### source (object, required)

`{ "kind": "DRAFT", "expectedDraftRevision": n }` plans to publish the draft at the revision you reviewed. `{ "kind": "VERSION", "formVersionId": "…" }` plans to bind a version that is already published, without publishing again.

#### todo (object, required)

The Project To-do settings: `completionPolicy` (`ON_SUBMIT`, `ON_PASS`, or `ON_REVIEW`), `audience` (`ACTIVE`, `FUTURE`, `ACTIVE_AND_FUTURE`, or `SELECTED` with up to 500 `selectedContractIds`), and optional `priority` (`NORMAL`, `HIGH`, `URGENT`), `blockingMode` (`NONE`, `IMMEDIATE`, `AFTER_DUE`), `dueOffsetMinutes` (1 to 525,600), `required`, `title`, `description`, and `categoryId`.

### 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": "quiz_rollout_plan",
  "jobId": "job_123", "formId": "form_456",
  "source": { "kind": "DRAFT", "expectedDraftRevision": 3 },
  "todo": {
    "completionPolicy": "ON_PASS", "audience": "ACTIVE_AND_FUTURE",
    "priority": "HIGH", "blockingMode": "AFTER_DUE", "dueOffsetMinutes": 1440
  }
}'
```

### SDK

```ts
import { OpenTrainClient } from '@opentrain-ai/sdk';

const opentrain = new OpenTrainClient({
  apiToken: process.env.OPENTRAIN_API_TOKEN!,
});

const todo = {
  completionPolicy: 'ON_PASS',
  audience: 'ACTIVE_AND_FUTURE',
  priority: 'HIGH',
  blockingMode: 'AFTER_DUE',
  dueOffsetMinutes: 1440,
} as const;

const plan = await opentrain.planProjectTodoQuizRollout({
  jobId: 'job_123',
  formId: 'form_456',
  source: { kind: 'DRAFT', expectedDraftRevision: 3 },
  todo,
});
```

### CLI

```bash
opentrain todos quiz rollout plan --job-id job_123 --form-id form_456 --draft-revision 3 \
  --completion-policy ON_PASS --audience ACTIVE_AND_FUTURE --priority HIGH \
  --blocking-mode AFTER_DUE --due-offset-minutes 1440
```

Use `--form-version-id <id>` instead of `--draft-revision` to plan a version that is already published.

### MCP

Call `opentrain_plan_quiz_rollout` with the same `jobId`, `formId`, `source`, and `todo` fields.

### What the plan returns

| Field | Meaning |
| --- | --- |
| `publishable` | `true` when nothing blocks. Only then is `plan` set. |
| `blockers[]` | Each blocker has a `code` and a `message`. Codes: `PROJECT_TODOS_DISABLED`, `FORM_ARCHIVED`, `DRAFT_REVISION_STALE`, `DRAFT_INVALID`, `NOT_PUBLISHABLE`, `ASSET_UNUSABLE`, `SOURCE_CHANGED`, `GROUNDING_DRAFT_PREVIEW`, `LEARNING_PLAN_MISMATCH`, `ON_PASS_REQUIRES_PASS_RULE`, `ON_PASS_REQUIRES_AUTO_GRADING`. |
| `warnings[]` | Facts to show the person that do not block. |
| `form` | The source (`DRAFT` or `VERSION`), `draftRevision`, `nextVersionNumber`, and `contentHash` of what will be published. |
| `publication` | Total points, answerable and manually graded question counts, the pass rule, the attempt policy, and the result release mode. |
| `todo` | The settings as they will be stored, with `projectedDueAt` and `projectedBlockAt`. |
| `audience` | Whether the audience is broad, whether it includes future hires, `currentRecipientCount`, and `recipients` (contract id plus masked display name). |
| `duplicates` | Active to-dos on the same form (`sameForm`) or the same version (`sameFormVersion`). |
| `notifications` | How many assignment notifications go out, whether future hires get the to-do automatically, and whether it blocks project work. |
| `plan` | `{ token, expiresAt }`. The token expires after 15 minutes and is bound to the actor, job, form, draft revision and content hash (or version), the To-do settings, and the recipient set. |
| `nextAction` | One sentence that says what to do next. |

Show the plan to the person before you confirm.

## Confirm

`quiz_rollout_confirm` needs `project_todos:write`, a claimed account, and a stable `idempotencyKey`. For a grounded draft it also needs `instructions:read`.

#### action (string, required)`quiz_rollout_confirm`
#### idempotencyKey (string, required)8–128 characters of letters, digits, `.`, `_`, `:`, or `-`. Keep it stable across retries.
#### planToken (string, required)The `plan.token` from the plan.

#### todo (object, required)

The exact same `todo` you planned. The token signs a digest of it, not its contents, so you send it again.

#### allowDuplicate (boolean)Set to `true` to assign the exact published version again when the plan listed it under `duplicates.sameFormVersion`. Without it that request is refused. An earlier version of the same form (`sameForm`) only adds a warning: confirming adds a new to-do next to the old one.

### 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": "quiz_rollout_confirm",
  "idempotencyKey": "safety-quiz-rollout-2026-11",
  "planToken": "PLAN_TOKEN_FROM_THE_PLAN",
  "todo": {
    "completionPolicy": "ON_PASS", "audience": "ACTIVE_AND_FUTURE",
    "priority": "HIGH", "blockingMode": "AFTER_DUE", "dueOffsetMinutes": 1440
  }
}'
```

### SDK

```ts
if (plan.publishable && plan.plan) {
  const rollout = await opentrain.confirmProjectTodoQuizRollout({
    idempotencyKey: 'safety-quiz-rollout-2026-11',
    planToken: plan.plan.token,
    todo, // the same object you planned
  });
}
```

### CLI

```bash
opentrain todos quiz rollout confirm --plan-token PLAN_TOKEN_FROM_THE_PLAN \
  --idempotency-key safety-quiz-rollout-2026-11 \
  --completion-policy ON_PASS --audience ACTIVE_AND_FUTURE --priority HIGH \
  --blocking-mode AFTER_DUE --due-offset-minutes 1440 --confirm-live
```

The CLI requires `--confirm-live`, an explicit `--idempotency-key`, and the same To-do flags as the plan.

### MCP

Call `opentrain_confirm_quiz_rollout` with `planToken`, the same `todo`, and a stable `idempotencyKey`.

The response has `ok: true`, `published` (`formVersionId`, `versionNumber`, `createdNow`, `resumed`), `todo` (`itemId`, `versionId`, `replayed`), the fan-out counts (`matchedContracts`, `createdAssignments`, `skippedExistingAssignments`, `failedAssignments`), `warnings`, and `replayed`.

## Retries and drift

Before writing anything, confirm checks the roster and the Project To-dos setting again.

| Status and `details.reason` | What happened | What to do |
| --- | --- | --- |
| `409 PLAN_STALE` | Something changed since the plan: the roster, the Project To-dos setting, the draft, the archive state, or the version content. `details.changed` says what. Nothing was written. | Plan again. |
| `409 PLAN_TODO_MISMATCH` | The `todo` differs from the planned one. | Send the planned `todo`. |
| `409 PLAN_TOKEN_EXPIRED` / `PLAN_TOKEN_INVALID` | The token is older than 15 minutes, or it belongs to another actor or plan. | Plan again. |
| `409 TARGET_ALREADY_MUTATED` | This plan was already confirmed under a different key. Nothing is assigned twice. | Retry with the first key to get the result, or plan again for a new rollout. |
| `409 IDEMPOTENCY_KEY_REUSED` | Another operation already used this key to create a to-do on the job. Refused before anything is published. | Use a new key. |

A retry with the same key returns the same version and to-do with `replayed: true`. The publication is recorded under `rollout:<idempotencyKey>`, so a confirm that lost its reply after publishing resumes that version on retry, even after the token would have expired. If the roster changes between the publication and the assignment, the `409 PLAN_STALE` names the new version in `details.published`. Retry with the same key once the roster matches, or plan again with `{ "kind": "VERSION", "formVersionId": … }` to bind it without publishing a duplicate.

## Related

- [Author forms and quizzes](https://www.opentrain.ai/docs/developers/api-reference/project-todos/quizzes/authoring/) for single-step `form_publish`.
- [Assign a quiz](https://www.opentrain.ai/docs/developers/api-reference/project-todos/quizzes/assign/) for `quiz_todo_preflight` and `quiz_todo_create` on a version that is already published.
- [Ground a quiz in Instructions](https://www.opentrain.ai/docs/developers/api-reference/project-todos/quizzes/grounding/) for the `SOURCE_CHANGED` blocker.
