# Study sets and practice

> Manage cards, record reviews, run practice sessions and take practice tests.

Source: https://workroad.app/docs/api/study-and-practice

## Study sets and terms

A study set holds a fixed maximum of terms, and each term carries its own flashcard review history. `POST /sets` takes the set's terms in the same call. `POST /sets/{setId}/terms` appends a batch of terms and leaves the existing ones untouched. `PATCH /sets/{setId}/terms/{termId}` changes only the fields you send, and `PATCH /sets/{setId}/terms` does the same for up to 500 terms at once, all or none: a `termId` that isn't the set's returns 400 with `details.reason` `unknown-term`. `POST /sets/{setId}/terms/delete` deletes up to 500 terms and their review history, and refuses with 409 `last-term` to empty the set. `DELETE /sets/{setId}/terms/{termId}` erases the term's review history for good, so it needs `sets:delete`; a set's last term can't be deleted this way, delete the set instead.

A term is at most 2000 characters and a definition at most 4000. A term has up to 10 hints of at most 500 characters each, and a hint may be empty. A `subjectId` the key's user doesn't own returns the same 400 `invalid_request` on set endpoints.

## Reviewing flashcards

The server decides which cards are due, using its own clock. Study a set like this:

1. Fetch `GET /sets/{setId}/review-queue`. `cards` holds what is due now: learning and relearning cards, then reviews, then new cards.
2. Show a card, then send `POST /reviews` with a fresh `reviewId` (a UUID you generate before the first attempt), the `cardId`, the `rating` (`again`, `hard`, `good` or `easy`) and `durationMs`.
3. Remove the graded card from your local copy of the queue. Do not put it back yourself, even when the response says it is due again soon.
4. When your copy runs out, fetch the queue again. The server refills it.
5. When `finished` is true, nothing is due. If `nextDueAt` is set, fetch again at that time; a learning card becomes due then.

Never filter the queue with your own clock. A device clock that runs a few minutes off would hide due cards or keep showing cards the server refuses. `serverTime` in each queue response is the time the server used.

Each set serves at most 50 new cards per study day. A study day starts at 4am in the user's time zone, or at 4am UTC when none is set. When nothing else is due, the queue also serves the next learning card due within 20 minutes.

`POST /reviews` returns 201 with the card's next `due`. It accepts only a card the queue would serve now. Otherwise it returns 409 `conflict` with `details.reason`:

| Reason           | Meaning                                         |
| ---------------- | ----------------------------------------------- |
| `card-not-due`   | The card is not due yet. Fetch the queue again. |
| `new-card-limit` | The set has reached today's 50 new cards.       |
| `card-suspended` | The user suspended the card in the app.         |
| `card-deleted`   | The card was deleted.                           |

Grade one card at a time per set. Grades in the same set that run at the same time can return 409 `conflict` with `details.reason` `concurrent-grade` and `Retry-After: 1`; retry those with the same `reviewId`.

Retrying a review with the same `reviewId` is always safe. If the first attempt was recorded, the retry returns 200 with `replayed: true` and the card's current schedule, even though the card is no longer due. It grades nothing. The same `reviewId` with a different card, rating or duration returns 409 `idempotency_conflict`; the duration is compared in whole seconds. Reviews count toward the user's streak the same way reviews in the app do.

## Practice sessions

A practice session is the app's Review page over the API: one queue across every study set the user owns, or only some, served a batch at a time and answered card by card. Each answer is recorded as an FSRS review exactly as in the app, so it reschedules the card, counts toward the daily goal and the streak, and respects the daily new-card and review limits from the user's review settings.

1. `POST /practice-sessions` starts one. Send `subjectId` or `setIds` to narrow it, or nothing for every set. The response already holds the first `cards`, up to 30: learning and relearning first, then reviews, then new cards, taking turns between sets.
2. Show a card, then send `POST /practice-sessions/{sessionId}/answers` with its `itemId`, a `rating` (`again`, `hard`, `good` or `easy`) and `durationMs`. `remaining` in the response counts the cards from the last read still waiting.
3. When `remaining` is 0, `GET /practice-sessions/{sessionId}` refills the session. Reading it again before then returns the same cards, so it is safe to repeat.
4. `finished: true` means nothing is due and today's new cards are spent. The session stays open for 7 days; read it again at `nextDueAt`.

`POST /practice-sessions/{sessionId}/undo` with the `undoableItemId` from the last read takes the newest answer back: its review is removed, the card's schedule is restored, and the card comes back into the session under a new `itemId`. It needs `activity:delete`.

Answering the same `itemId` again with the same rating records nothing and returns 200 with `replayed: true`. A different rating for an answered item returns 409 `idempotency_conflict`. If the card was reviewed in the app since the session served it, the answer returns 409 `conflict` with `details.reason` `card-changed-refresh-session`: read the session again. A `cram: true` session walks every term once without changing any schedule; an `again` there queues the term again at the end.

Each answer is a write, so one key answers at most 30 cards a minute (see [Rate limits](https://workroad.app/docs/api/limits#rate-limits)). `GET /sets/{setId}/review-queue` and `POST /reviews` still review one set at a time, as described in [Reviewing flashcards](#reviewing-flashcards).

## Practice tests

A practice test asks questions written from the user's study sets and notes. Having WorkRoad's AI write one with `POST /tests` is one AI use, and AI grading of its short answers is included. Taking a test is free, except that AI grading of short answers in a test saved with `POST /tests/from-questions` is one AI use per submit.

To save questions you wrote yourself, `POST /tests/from-questions` takes a `name` and 1-50 `questions`, each with a `kind` (`multiple-choice`, `true-false`, `fill-blank` or `short-answer`), a `prompt`, the `answer`, an `explanation` and a `difficulty` (`recall`, `apply` or `analyse`), and multiple choice questions their `choices`. That is the shape WorkRoad's own generator writes, and the test is ready at once, with no job and no AI use. A question that can't be graded as written returns 400 with `details.index` and `details.reason`, such as `answer-not-a-choice`. Choices are shuffled once when the test is saved.

1. `POST /tests` with a uuid `requestId`, a `name`, `sourceSetIds` or `sourceNoteIds`, the question `kinds` (`MCQ`, `TRUE_FALSE`, `SHORT` or `FILL_BLANK`), a `length` of 1-50 and an optional `timerSecs`. It answers 202 with the `testId` and the generation's `jobId`. Mint the `requestId` once and keep it for retries: it becomes the job id, and sending it again with the same body returns the same test and counts one AI use. A used `requestId` with different sources, `kinds`, `length`, `timerSecs` or `modelTier` returns 409 `idempotency_conflict`. `name` is not compared, so a replay under another name returns the original test with its original name. A refused start (402 without AI uses left, or 429) creates no test, so there is nothing to clean up and the 402 body has no `testId`.
2. Poll `GET /jobs/{jobId}` or `GET /tests/{testId}` until the test's `status` is `ready`, waiting the seconds in `Retry-After` between job polls (3 for a test). A test that failed has the reason in `error`.
3. `POST /tests/{testId}/attempts` starts an attempt and returns its `attemptId`. A test has one open attempt at a time. If the user already has one open, you get it back with status 200 and `resumed: true`. A test that isn't ready returns 409 with `details.reason` `test-not-ready`.
4. `GET /attempts/{attemptId}` returns the questions. Answer them by index as a string, `"0"` for the first question. For multiple choice, send the text of the choice. For true/false, send `"true"` or `"false"`.
5. `PUT /attempts/{attemptId}/answers` saves the answers so far, replacing what was saved before. Past the time limit it stores nothing and returns `expired: true`. A key must be a question index without leading zeros, and a key past the last question returns 400 with `details.reason` `unknown-question`.
6. `POST /attempts/{attemptId}/submit` ends the attempt, optionally with final `answers`. Submitting twice changes nothing and returns `alreadySubmitted: true`.

```json
{
   "answers": {
      "0": "Mitochondrion",
      "1": "It keeps the cell's contents in and controls what enters."
   }
}
```

The answer key stays hidden until the attempt is submitted. While a test is generating, `GET /tests/{testId}` shows the prompts written so far in `generating`, without answers; once it is ready, the questions come only through an attempt. While an attempt is open every question has `answer` and `explanation` set to `null` and `perQuestion` is empty. Once it is submitted, `GET /attempts/{attemptId}` shows the correct answers and a verdict per question.

The time limit counts from the attempt's `startedAt` on the server's clock. `deadlineAt` is when it runs out, and `expired` turns true 15 seconds later, a grace period for a slow last request. After that, submitting grades the saved answers and ignores the ones in the body.

Multiple choice, true/false and fill-in answers are graded on submit. Short answers go to AI grading: `gradingQueued` is true, `gradingJobId` names the job, and `phase` stays `grading` until it ends. Grading never makes a submit fail. If AI grading can't start, because the engine is busy or, for a test saved with `POST /tests/from-questions`, the month's AI uses are spent, the attempt is still submitted with `gradingQueued: false`, and its `gradingStatus` and `phase` read `failed` and `grading_failed`. The short answers then keep the verdict `manual`, and the user marks them in the app.

A wrong answer's `explainRef` asks the copilot to explain it: send it as `context.activityRef` with `context.kind` `activity` to `POST /copilot/messages`.
