# Generation and jobs

> Have WorkRoad's AI write notes and study sets, apply or discard the results, and follow background jobs.

Source: https://workroad.app/docs/api/generation

## Let your AI do the writing

An agent with its own model, such as Claude or ChatGPT on the user's own subscription, doesn't need WorkRoad's AI to make study material. It reads the material, writes the content itself and saves it with a plain operation, which uses none of the user's [AI uses](https://workroad.app/docs/api/limits#ai-allowance):

| To save                      | Call                                                                                                                  | Instead of                                        |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| A note                       | `POST /notes` with `markdown`, then `POST /notes/{noteId}/content/append`                                             | `POST /notes/{noteId}/generate`, `generate-topic` |
| A study set with its cards   | `POST /sets` with up to 2000 `terms`; `POST`, `PATCH` and `POST …/delete` on `/sets/{setId}/terms` for up to 500 more | `POST /sets/generate`                             |
| A practice test              | `POST /tests/from-questions`                                                                                          | `POST /tests`                                     |
| Tasks, exams and deadlines   | `POST /tasks/batch`                                                                                                   | a copilot message                                 |
| A timetable with its lessons | `POST /schedules/timetable`                                                                                           | the app's photo import                            |
| A lecture's summary          | `PUT /recordings/{recordingId}/summary`                                                                               | the summary WorkRoad's AI writes                  |

To read the material, `GET /notes/{noteId}` returns a note's Markdown, `GET /recordings/{recordingId}` a lecture's transcript, `GET /sets/{setId}` and `GET /sets/{setId}/terms` a set's cards, and `GET /sets/{setId}/review-queue` or `GET /today` what is due. Call the operations that run WorkRoad's AI only when the user asks for it; each operation's reference says when it does.

## Generating notes and study sets

A generation writes a note for you from material on the note, or from a topic. Each start is one AI use (see [AI allowance](https://workroad.app/docs/api/limits#ai-allowance)), answers 202 with a `jobId`, and runs as a job.

1. Upload the material with [Uploading files](https://workroad.app/docs/api/files#uploading-files) and poll `GET /files/{fileId}` until each file is `ready`. A file that isn't parsed yet makes the start fail with 400 `sources.fileIds` `not-ready`.
2. `POST /notes/{noteId}/generate` with `sources`: the `fileIds`, any `recordingIds` made in the app, and `includeNote` to use the note's own text. `depth` is `standard` or `advanced`, and `instructions` says what to focus on.
3. Poll `GET /jobs/{jobId}` until it ends, waiting the seconds in `Retry-After` between polls (5 for a generation). A finished `note_generation` job's `result.generationId` names the result.
4. `GET /generations/{generationId}` shows the Markdown. `POST /generations/{generationId}/apply` writes it into the note, and `POST /generations/{generationId}/discard` drops it. A `copilot:use` key can do the same for a generation its copilot started; applying also needs `notes:update`.

```json
{
   "requestId": "0f8e3a52-6c1d-4b7e-9a24-5d3c1b0e7f96",
   "sources": {
      "fileIds": ["3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f"],
      "recordingIds": [],
      "includeNote": false
   },
   "depth": "standard"
}
```

The start's `intoNote` says what the result becomes. On an empty note it is `true`, and applying writes the result in as the app does, whatever `mode` you send, as long as the note is still empty and unchanged. Only the generation you name is written, even when several wait on the same note. On a note with text the result is a preview. Apply it with `mode` `replace-content` to swap the note's content, or `insert-end` to append. `insert-end` returns 400 `invalid_request` with `details.field` `markdown` and `details.reason` `note-too-large` when the note would pass 200,000 visible characters; nothing is written and the preview stays applicable. `replace-content` returns 409 `conflict` when the note changed after the generation read it, so the edits aren't lost; append instead, or generate again. The same holds for an empty-note result once the note has changed, so `replace-content` returns 409 and `insert-end` appends.

Nothing applies a generation for you. The app writes an empty note's result when the job ends, but an API client must call apply. Reading a generation or its job never changes the note, and applying twice returns `alreadyApplied: true`.

`POST /notes/{noteId}/generate-topic` writes about a `topic` instead and always makes a preview. `POST /sets/generate` with a `noteId` makes flashcards from a note; the finished job's `result.setId` is the new set. While a set is being made from a note, another start for that note returns the running job.

Mint a uuid `requestId` for each start and keep it for retries; it becomes the job id. Sending it again with the same body returns the same job and counts one AI use, and a different body under a used `requestId` returns 409 `idempotency_conflict`. A note generation from sources also records the note's version and its sources, so once those change, for example after a generation was applied to the note, its `requestId` returns 409 even with the same body. Topic and study set starts return the original job. The `Idempotency-Key` header works on these starts too, but it only covers 24 hours; the `requestId` is the key that lasts.

## Jobs

Work that takes longer than a request, such as a copilot reply, a note generation or a file parse, runs as a job. The request that starts it returns 202 with a `jobId`. Poll `GET /jobs/{jobId}` until `status` is `succeeded`, `failed` or `cancelled`, or register a [webhook](https://workroad.app/docs/api/webhooks#webhooks) to hear when it ends. While a job is `pending` or `running`, the response carries a `Retry-After` header with the seconds to wait before the next poll: 2 for a copilot reply or grading, 3 for a practice test or a file parse, 5 for a note, topic or study set generation. The header is absent once the job has ended. `kind` names the work and decides the shape of `result`, which stays `null` until the job succeeds. Practice test, grading and file parse jobs never carry a result; read the test, the attempt or the file instead.

A key reads a job when it holds a scope that starts that kind of work or views its results. Only the starting scopes cancel. A job of any other kind returns 403 `forbidden`, and another user's job returns 404.

| Job kind               | Read with any one of              | Cancel with any one of            |
| ---------------------- | --------------------------------- | --------------------------------- |
| `copilot_reply`        | `copilot:use`                     | `copilot:use`                     |
| `note_generation`      | `generation:start`, `copilot:use` | `generation:start`, `copilot:use` |
| `topic_generation`     | `generation:start`, `copilot:use` | not cancellable                   |
| `study_set_generation` | `generation:start`, `copilot:use` | not cancellable                   |
| `file_parse`           | `files:upload`, `files:view`      | not cancellable                   |
| `test_generation`      | `tests:generate`, `tests:view`    | not cancellable                   |
| `test_grading`         | `tests:attempt`, `tests:view`     | not cancellable                   |

A failed job has `error.code`, such as `generation_failed`, and `error.retryable`. When `retryable` is true, starting the same work again may succeed.

`POST /jobs/{jobId}/cancel` stops a copilot reply or a note generation, the two jobs the app has a Stop button for. A stopped note generation keeps the sections it already wrote and finishes as `succeeded` with `result.stopped: true`, or as `cancelled` when it had written nothing. Cancelling any other kind returns 409 `conflict` with `details.reason` `not-cancellable`. A key with neither `copilot:use` nor `generation:start` gets 403 `forbidden` instead, even on a kind it can read.

## Generate a note from sources

`POST https://workroad.app/api/v1/notes/{noteId}/generate`

Writes a note from the note's own files, recordings and text, as the app's Generate button does. The generation runs as a job: poll `GET /jobs/{jobId}` until it ends. An empty note gets the result written in when you apply it (`intoNote` is true); a note with text gets a preview to apply or discard. Counts as an AI use.

Runs WorkRoad's AI, which counts toward the user's WorkRoad AI uses (`GET /ai-allowance`). If you can write this content yourself, write it and save it with `POST /notes`, `PUT /notes/{noteId}/content` or `POST /notes/{noteId}/content/append`, which use no AI. Call this only when the user asks for WorkRoad's AI to do the work.

- MCP tool: `generateNoteFromSources` on `https://workroad.app/api/mcp`
- Required scopes: generation:start
- Test mode: `POST https://workroad.app/api/test/v1/notes/{noteId}/generate` checks the request and returns the example response without changing anything.

### Path parameters

- `noteId`: string (uuid), required. Note id, scoped to the key's user.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Request body

JSON.

- `requestId`: string (uuid), required. A uuid you mint for this start. It becomes the job id. Sending it again with the same body returns the same job and is not counted twice while the note and its sources are unchanged. Once they change, for example after a generation was applied to the note, the same `requestId` is 409 `idempotency_conflict`, as is a different body. Mint a new one for each new generation.
- `sources`: object, required. What to write from: up to 20 of the note's files (`fileIds`, each `ready`), up to 20 of its recordings (`recordingIds`, made in the app) and, with `includeNote`, the note's own text. Name each source once, and at least one.
  - `fileIds`: array of string (uuid), required, up to 20 items
  - `recordingIds`: array of string (uuid), required, up to 20 items
  - `includeNote`: boolean, required
- `depth`: one of `"standard"`, `"advanced"`, required. `standard` for a study note, `advanced` for a longer, more detailed one.
- `instructions`: string, up to 1000 characters. What to focus on, up to 1,000 characters after trimming.
- `modelTier`: one of `"swift"`, `"scholar"`, `"sage"`. `swift`, `scholar` or `sage`; `scholar` when omitted. `sage` needs a paid plan; a free user gets 400 with `details.reason` `premium-only`.

### Response

`202` Accepted

- `data`: object, required
  - `jobId`: string (uuid), required. The job, the `requestId` you sent. Poll `GET /jobs/{jobId}` until it ends.
  - `intoNote`: boolean, required. True when the note was empty: apply the finished generation to write it in. False means it becomes a preview to apply or discard.

#### Example

```json
{
  "data": {
    "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a",
    "intoNote": false
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: `invalid_request`. `details.field` and `details.reason` say why: `sources` `not-found` (a file or recording isn't on this note), `sources.fileIds` `not-ready` (a file isn't parsed yet; wait for `status` `ready`), `sources.recordingIds` `not-ready` (a recording has no transcript), `sources` `empty-note` (only the note was picked and it has no text), `sources` `empty-sources` (none of the picked sources has text), or `modelTier` `premium-only`. A refused start takes no AI use.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `ai_limit_reached`: the free plan's AI uses for this month are spent, or the paid plan reached its fair-use limit, and no credits are left. `details.code` tells them apart (`ai-limit-reached` or `fair-use-limit`); `details` also has `limit` and `resetsAt`. Nothing starts. `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No note with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `idempotency_conflict`: this `requestId` already started a job with other inputs. That happens when the body differs, or when the note or its sources changed since, for example once a generation was applied to the note. Send a new `requestId`. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: `rate_limited` with `details.reason` `ai-rate` when the user is starting AI work faster than their plan's AI rate limit refills, `ai-busy` when 4 or more of their AI jobs are still running (2 on the free plan), or `ai-key-burst` when this key called operations that run AI more than 10 times in a minute. Nothing starts and nothing counts; retry after `Retry-After` seconds. Without a `details.reason`, the user has too many jobs queued: retry after `Retry-After` seconds (30). Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error
- `503`: WorkRoad can't start background work right now. Send the same request again after `Retry-After` seconds; the `requestId` keeps it from starting twice.

## Generate a note from a topic

`POST https://workroad.app/api/v1/notes/{noteId}/generate-topic`

Writes a note about a topic into a preview for this note. Poll `GET /jobs/{jobId}` until it ends, then apply or discard the preview named by `result.generationId`. Counts as an AI use.

Runs WorkRoad's AI, which counts toward the user's WorkRoad AI uses (`GET /ai-allowance`). If you can write this content yourself, write it and save it with `POST /notes` or `POST /notes/{noteId}/content/append`, which use no AI. Call this only when the user asks for WorkRoad's AI to do the work.

- MCP tool: `generateNoteFromTopic` on `https://workroad.app/api/mcp`
- Required scopes: generation:start
- Test mode: `POST https://workroad.app/api/test/v1/notes/{noteId}/generate-topic` checks the request and returns the example response without changing anything.

### Path parameters

- `noteId`: string (uuid), required. Note id, scoped to the key's user.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Request body

JSON.

- `requestId`: string (uuid), required. A uuid you mint for this start. It becomes the job id. Sending it again with the same body returns the same job and is not counted twice; a different body under a used `requestId` is 409 `idempotency_conflict`.
- `topic`: string, required, 1-200 characters. What to write about, 1-200 characters.
- `context`: string, up to 500 characters. More detail on what the note should cover, up to 500 characters.
- `modelTier`: one of `"swift"`, `"scholar"`, `"sage"`. `swift`, `scholar` or `sage`; `scholar` when omitted. `sage` needs a paid plan; a free user gets 400 with `details.reason` `premium-only`.

### Response

`202` Accepted

- `data`: object, required
  - `jobId`: string (uuid), required. The job, the `requestId` you sent. Poll `GET /jobs/{jobId}` until it ends.

#### Example

```json
{
  "data": {
    "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a"
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: `invalid_request`: the body fails validation, or `details.reason` is `premium-only` for the `sage` tier.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `ai_limit_reached`: the free plan's AI uses for this month are spent, or the paid plan reached its fair-use limit, and no credits are left. `details.code` tells them apart (`ai-limit-reached` or `fair-use-limit`); `details` also has `limit` and `resetsAt`. Nothing starts. `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No note with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `idempotency_conflict`: this `requestId` already started a job with a different body. Send a new `requestId`. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: `rate_limited` with `details.reason` `ai-rate` when the user is starting AI work faster than their plan's AI rate limit refills, `ai-busy` when 4 or more of their AI jobs are still running (2 on the free plan), or `ai-key-burst` when this key called operations that run AI more than 10 times in a minute. Nothing starts and nothing counts; retry after `Retry-After` seconds. Without a `details.reason`, the user has too many jobs queued: retry after `Retry-After` seconds (30). Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error
- `503`: WorkRoad can't start background work right now. Send the same request again after `Retry-After` seconds; the `requestId` keeps it from starting twice.

## Generate a study set

`POST https://workroad.app/api/v1/sets/generate`

Makes a study set of flashcards from a note. Poll `GET /jobs/{jobId}` until it ends; a finished `study_set_generation` job's `result.setId` is the new set. While a set is being made from the note, starting another returns that job instead. Counts as an AI use.

Runs WorkRoad's AI, which counts toward the user's WorkRoad AI uses (`GET /ai-allowance`). If you can write this content yourself, write it and save it with `POST /sets` or `POST /sets/{setId}/terms`, which use no AI. Call this only when the user asks for WorkRoad's AI to do the work.

- MCP tool: `generateStudySet` on `https://workroad.app/api/mcp`
- Required scopes: generation:start
- Test mode: `POST https://workroad.app/api/test/v1/sets/generate` checks the request and returns the example response without changing anything.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Request body

JSON.

- `requestId`: string (uuid), required. A uuid you mint for this start. It becomes the job id. Sending it again with the same body returns the same job and is not counted twice; a different body under a used `requestId` is 409 `idempotency_conflict`.
- `noteId`: string (uuid), required. The note to make cards from.
- `modelTier`: one of `"swift"`, `"scholar"`, `"sage"`. `swift`, `scholar` or `sage`; the server's study-set tier when omitted. `sage` needs a paid plan; a free user gets 400 with `details.reason` `premium-only`.

### Response

`202` Accepted

- `data`: object, required
  - `jobId`: string (uuid), required. The job, the `requestId` you sent. Poll `GET /jobs/{jobId}` until it ends.

#### Example

```json
{
  "data": {
    "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a"
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: `invalid_request`: the body fails validation, `details.reason` is `premium-only` for the `sage` tier, `details.field` `noteId` with `details.reason` `unknown-note` says the note isn't one of this key's user's, and `empty-note` says it has no text to make cards from (no AI use is taken).
- `401`: Missing, invalid, disabled or expired API key
- `402`: `ai_limit_reached`: the free plan's AI uses for this month are spent, or the paid plan reached its fair-use limit, and no credits are left. `details.code` tells them apart (`ai-limit-reached` or `fair-use-limit`); `details` also has `limit` and `resetsAt`. Nothing starts. `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No endpoint exists at this path.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `idempotency_conflict`: this `requestId` already started a job with a different body. Send a new `requestId`. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: `rate_limited` with `details.reason` `ai-rate` when the user is starting AI work faster than their plan's AI rate limit refills, `ai-busy` when 4 or more of their AI jobs are still running (2 on the free plan), or `ai-key-burst` when this key called operations that run AI more than 10 times in a minute. Nothing starts and nothing counts; retry after `Retry-After` seconds. Without a `details.reason`, the user has too many jobs queued: retry after `Retry-After` seconds (30). Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error
- `503`: WorkRoad can't start background work right now. Send the same request again after `Retry-After` seconds; the `requestId` keeps it from starting twice.

## Get a generation

`GET https://workroad.app/api/v1/generations/{generationId}`

A note or topic generation's result. Its id is the id of the job that made it, and a finished `note_generation` or `topic_generation` job's `result.generationId`. Reading it changes nothing: apply it with `POST /generations/{generationId}/apply` or discard it with `POST /generations/{generationId}/discard`.

- MCP tool: `getGeneration` on `https://workroad.app/api/mcp`
- Required scopes: generation:start or notes:view or copilot:use
- Test mode: `GET https://workroad.app/api/test/v1/generations/{generationId}` checks the request and returns the example response without changing anything.

### Path parameters

- `generationId`: string (uuid), required. Generation id, scoped to the key's user.

### Response

`200` OK

- `data`: object, required
  - `id`: string (uuid), required. The generation, the id of the job that made it.
  - `noteId`: string (uuid), required
  - `status`: one of `"preview"`, `"applied"`, `"discarded"`, required. `preview` until it is applied or discarded.
  - `markdown`: string, required. The generated note, as Markdown.
  - `createdAt`: string (date-time), required. When the generation was saved.

#### Example

```json
{
  "data": {
    "id": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a",
    "noteId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
    "status": "preview",
    "markdown": "# Enzymes\n\nEnzymes lower the activation energy of a reaction.",
    "createdAt": "2026-01-15T09:30:00.000Z"
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No generation with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Apply a generation

`POST https://workroad.app/api/v1/generations/{generationId}/apply`

Writes a generation into its note. A generation made for an empty note is written in while the note is still empty and unchanged, as the app does; otherwise `mode` decides. Applying again changes nothing and returns `alreadyApplied` true. Needs `generation:start` or `copilot:use`, and `notes:update`.

- MCP tool: `applyGeneration` on `https://workroad.app/api/mcp`
- Required scopes: notes:update; generation:start or copilot:use
- Test mode: `POST https://workroad.app/api/test/v1/generations/{generationId}/apply` checks the request and returns the example response without changing anything.

### Path parameters

- `generationId`: string (uuid), required. Generation id, scoped to the key's user.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Request body

JSON.

- `mode`: one of `"insert-end"`, `"replace-content"`, required. `replace-content` swaps the note's content for the preview; `insert-end` appends it, unless the note would pass 200,000 visible characters, which is 400 `note-too-large`. A generation made for an empty note is written in as the app writes it while the note is still empty and unchanged; once the note has changed, `mode` applies, so `replace-content` returns 409 and `insert-end` appends.

### Response

`200` OK

- `data`: object, required
  - `applied`: boolean, required. True when this request wrote the note.
  - `alreadyApplied`: boolean, required. True when the generation was applied before; nothing changed.
  - `noteId`: string (uuid), required

#### Example

```json
{
  "data": {
    "applied": true,
    "alreadyApplied": false,
    "noteId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e"
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: `invalid_request`. With `details.field` `generationId`, `details.reason` is `discarded` (it was discarded), `inline-editor-artifact` (an editor suggestion, accepted in the app), or `legacy-note-content` (the note's stored content can't be appended to; use `replace-content`). With `details.field` `markdown`, `details.reason` `note-too-large` means `insert-end` would grow the note past 200,000 visible characters; nothing is written and the preview stays applicable.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No generation with that id exists for this key's user. Also returned as "No note with that id exists for this key's user." when the note was deleted while applying.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `conflict`: `replace-content` would drop edits made to the note after the generation read it. `details` has `baseVersion` and `currentVersion`. Apply with `insert-end`, or generate again. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Discard a generation

`POST https://workroad.app/api/v1/generations/{generationId}/discard`

Drops a preview without touching the note. Discarding again returns `discarded` false.

- MCP tool: `discardGeneration` on `https://workroad.app/api/mcp`
- Required scopes: generation:start or copilot:use
- Test mode: `POST https://workroad.app/api/test/v1/generations/{generationId}/discard` checks the request and returns the example response without changing anything.

### Path parameters

- `generationId`: string (uuid), required. Generation id, scoped to the key's user.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Response

`200` OK

- `data`: object, required
  - `discarded`: boolean, required. True when this request discarded it. False when it was discarded before.

#### Example

```json
{
  "data": {
    "discarded": true
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: `invalid_request` with `details.reason` `already-applied`: the generation is already in the note.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: The API key lacks the required scope
- `404`: No generation with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Get a job

`GET https://workroad.app/api/v1/jobs/{jobId}`

Returns a job started by a copilot message, a generation, a practice test or a file upload. Poll it until `status` is `succeeded`, `failed` or `cancelled`. While it is `pending` or `running` the response has a `Retry-After` header with the seconds to wait between polls. `kind` decides the shape of `result`. Practice test, grading and file jobs have no result: read the test, the attempt or the file.

- MCP tool: `getJob` on `https://workroad.app/api/mcp`
- Required scopes: copilot:use or generation:start or files:upload or files:view or tests:generate or tests:view or tests:attempt
- Test mode: `GET https://workroad.app/api/test/v1/jobs/{jobId}` checks the request and returns the example response without changing anything.
- Next: while `status` is `pending` or `running`, wait `retryAfter` seconds and call again.

### Path parameters

- `jobId`: string (uuid), required. Job id, scoped to the key's user.

### Response

`200` OK

- `data`: one of the objects below, required
  - One of `kind`: `"copilot_reply"`
    - `id`: string (uuid), required
    - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final.
    - `createdAt`: string (date-time), required. When the job was queued.
    - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then.
    - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error.
      - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`.
      - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed.
    - `kind`: `"copilot_reply"`, required
    - `result`: object or null, required. Null until the job succeeds, and when the stored result has an unexpected shape.
      - `conversationId`: string (uuid), required
      - `message`: string, required. The assistant's reply, as Markdown.
      - `actions`: array of one of the objects below or null, required. The actions the reply proposed, as the app shows them. `kind` decides the fields. An action's position is its index for `POST /copilot/messages/{messageId}/actions`; null marks a stored action this API can't describe.
        - One of `kind`: `"reschedule_task"`
          - `kind`: `"reschedule_task"`, required
          - `taskId`: string, required, at least 1 character
          - `name`: string, required, 1-120 characters
          - `dueDate`: string, required, at least 1 character
          - `previousDueDate`: string or null, required
          - `expectedUpdatedAt`: string, required, at least 1 character
        - Or `kind`: `"create_study_block"`
          - `kind`: `"create_study_block"`, required
          - `name`: string, required, 1-120 characters
          - `startsAt`: string, required, at least 1 character
          - `endsAt`: string, required, at least 1 character
          - `timeZone`: string, required, at least 1 character
          - `subjectId`: string, at least 1 character
          - `taskId`: string, at least 1 character
          - `setId`: string, at least 1 character
        - Or `kind`: `"create_task"`
          - `kind`: `"create_task"`, required
          - `name`: string, required, 1-120 characters
          - `dueDate`: string
          - `subject`: string
          - `subjectId`: string
          - `type`: string
          - `recurrence`: object
            - `unit`: one of `"day"`, `"week"`, `"month"`, required
            - `interval`: integer, required, 1 to 30
            - `endsAt`: string
          - `priority`: one of `"none"`, `"low"`, `"medium"`, `"high"`
        - Or `kind`: `"create_note"`
          - `kind`: `"create_note"`, required
          - `name`: string, required, 1-64 characters
          - `content`: string, up to 100000 characters
          - `pinned`: boolean
          - `folderId`: string, at least 1 character
        - Or `kind`: `"create_note_from_files"`
          - `kind`: `"create_note_from_files"`, required
          - `name`: string, required, 1-64 characters
          - `attachmentIds`: array of string, required, 1-4 items
          - `folderId`: string, at least 1 character
        - Or `kind`: `"create_folder"`
          - `kind`: `"create_folder"`, required
          - `name`: string, required, 1-64 characters
          - `subjectId`: string, at least 1 character
        - Or `kind`: `"update_note"`
          - `kind`: `"update_note"`, required
          - `noteId`: string, required, at least 1 character
          - `currentName`: string, required, 1-64 characters
          - `name`: string, 1-64 characters
          - `folderId`: string or null, at least 1 character
          - `pinned`: boolean
        - Or `kind`: `"delete_note"`
          - `kind`: `"delete_note"`, required
          - `noteId`: string, required, at least 1 character
          - `currentName`: string, required, 1-64 characters
        - Or `kind`: `"update_task"`
          - `kind`: `"update_task"`, required
          - `taskId`: string, required, at least 1 character
          - `currentName`: string, required, 1-200 characters
          - `expectedUpdatedAt`: string, required, at least 1 character
          - `name`: string, 1-200 characters
          - `description`: string or null, up to 512 characters
          - `dueDate`: string or null
          - `type`: string
          - `subjectId`: string or null, at least 1 character
          - `completed`: boolean
        - Or `kind`: `"delete_task"`
          - `kind`: `"delete_task"`, required
          - `taskId`: string, required, at least 1 character
          - `currentName`: string, required, 1-200 characters
          - `expectedUpdatedAt`: string, required, at least 1 character
        - Or `kind`: `"create_set_from_note"`
          - `kind`: `"create_set_from_note"`, required
          - `noteId`: string, required, at least 1 character
        - Or `kind`: `"start_generation"`
          - `kind`: `"start_generation"`, required
          - `noteRecordingId`: string, at least 1 character
          - `topic`: string, 1-200 characters
        - Or `kind`: `"create_schedule"`
          - `kind`: `"create_schedule"`, required
          - `name`: string, required, 2-64 characters
          - `scheduleType`: one of `"defined"`, `"dynamic"`, required
          - `firstDay`: integer, 0 to 6
          - `periods`: array of objects, up to 30 items
            - `periodName`: string, required, up to 200 characters
            - `time`: object or null, required
          - `days`: array of array of object or null, required, up to 7 items
            - `lessonName`: string, required, up to 200 characters
            - `room`: string, required, up to 200 characters
            - `subject`: string, required, up to 200 characters
            - `teacher`: string, required, up to 200 characters
            - `timeStart`: string, up to 5 characters
            - `timeEnd`: string, up to 5 characters
        - Or `kind`: `"update_subject"`
          - `kind`: `"update_subject"`, required
          - `subjectId`: string, required, at least 1 character
          - `currentName`: string, required, 1-64 characters
          - `name`: string, 1-64 characters
          - `teachers`: array of string, up to 20 items
          - `period`: string or null, up to 60 characters
          - `archived`: boolean
        - Or `kind`: `"update_set"`
          - `kind`: `"update_set"`, required
          - `setId`: string, required, at least 1 character
          - `currentName`: string, required, 1-120 characters
          - `name`: string, 1-120 characters
          - `description`: string or null, up to 2000 characters
          - `subjectId`: string or null, at least 1 character
        - Or `kind`: `"update_schedule"`
          - `kind`: `"update_schedule"`, required
          - `scheduleId`: string, required, at least 1 character
          - `currentName`: string, required, 1-200 characters
          - `name`: string, 2-64 characters
          - `cells`: array of objects, up to 40 items
            - `day`: integer, required, 0 to 6
            - `period`: integer, required, 1 to 30
            - `current`: string or null, required, up to 200 characters
            - `lesson`: object or null, required
        - Or `kind`: `"run_operation"`
          - `kind`: `"run_operation"`, required
          - `operation`: string, required, 1-80 characters, matches `^[A-Za-z][A-Za-z0-9]*$`
          - `arguments`: any value, required
        - Or `kind`: `"change_setting"`
          - `kind`: `"change_setting"`, required
          - `operation`: one of `"updateProfile"`, `"updatePreferences"`, `"updateNotificationSettings"`, `"updateNotificationKind"`, `"resumeNotificationEmail"`, `"updateReviewSettings"`, `"updateTaskSettings"`, `"replaceDashboardLayout"`, `"deleteCalendarFeed"`, required. The settings operation of this API that applying runs.
          - `arguments`: any value, required. That operation's arguments, laid out as its MCP tool takes them: path parameters and body fields in one object.
        - Or `kind`: `"navigate"`
          - `kind`: `"navigate"`, required
          - `path`: string, required, 1-200 characters
        - Or `kind`: `"insert_into_note"`
          - `kind`: `"insert_into_note"`, required
          - `noteId`: string, required, at least 1 character
          - `markdown`: string, required, 1-20000 characters
          - `anchor`: object or null, required
            - `text`: string, required, 1-128 characters
            - `where`: one of `"before"`, `"after"`, required
      - `researchUsed`: boolean, required. True when the reply searched the web.
      - `sources`: array of objects, required. The web pages the reply cites.
        - `url`: string, required
        - `title`: string, required
        - `host`: string, required
  - Or `kind`: `"note_generation"`
    - `id`: string (uuid), required
    - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final.
    - `createdAt`: string (date-time), required. When the job was queued.
    - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then.
    - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error.
      - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`.
      - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed.
    - `kind`: `"note_generation"`, required
    - `result`: object or null, required. Null until the job succeeds, and when the stored result has an unexpected shape.
      - `generationId`: string (uuid), required. The preview to apply or discard.
      - `title`: string, required
      - `sectionCount`: integer, required
      - `failedSections`: array of objects, required. Sections the generation gave up on. The preview leaves them empty.
        - `index`: integer, required
        - `title`: string, required
      - `stopped`: boolean, required. True when a cancel stopped the generation. The preview has only the sections written before it.
  - Or `kind`: `"topic_generation"`
    - `id`: string (uuid), required
    - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final.
    - `createdAt`: string (date-time), required. When the job was queued.
    - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then.
    - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error.
      - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`.
      - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed.
    - `kind`: `"topic_generation"`, required
    - `result`: object or null, required. Null until the job succeeds, and when the stored result has an unexpected shape.
      - `generationId`: string (uuid), required. The preview to apply or discard.
      - `title`: string, required
      - `sectionCount`: integer, required
  - Or `kind`: `"study_set_generation"`
    - `id`: string (uuid), required
    - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final.
    - `createdAt`: string (date-time), required. When the job was queued.
    - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then.
    - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error.
      - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`.
      - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed.
    - `kind`: `"study_set_generation"`, required
    - `result`: object or null, required. Null until the job succeeds, and when the stored result has an unexpected shape. A job started through the API (`POST /sets/generate`, or a `create_set_from_note` applied with `POST /copilot/messages/{messageId}/actions`) writes its set, and this names it. A job the user started in the WorkRoad app writes no set: it succeeds with cards the user reviews in the app, so its result stays null. The set they save there takes the job's id, readable with `GET /sets/{setId}` once it exists.
      - `setId`: string (uuid), required. The new study set.
      - `termCount`: integer, required. How many terms it has.
  - Or `kind`: `"test_generation"`
    - `id`: string (uuid), required
    - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final.
    - `createdAt`: string (date-time), required. When the job was queued.
    - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then.
    - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error.
      - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`.
      - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed.
    - `kind`: `"test_generation"`, required
    - `result`: any value, required. Always null. Read the practice test, the attempt or the file instead.
  - Or `kind`: `"test_grading"`
    - `id`: string (uuid), required
    - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final.
    - `createdAt`: string (date-time), required. When the job was queued.
    - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then.
    - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error.
      - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`.
      - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed.
    - `kind`: `"test_grading"`, required
    - `result`: any value, required. Always null. Read the practice test, the attempt or the file instead.
  - Or `kind`: `"file_parse"`
    - `id`: string (uuid), required
    - `status`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"`, required. `pending` and `running` are unfinished. `succeeded`, `failed` and `cancelled` are final.
    - `createdAt`: string (date-time), required. When the job was queued.
    - `finishedAt`: string (date-time) or null, required. When the job reached a final status. Null until then.
    - `error`: object or null, required. Why the job failed or was cancelled. Null when there is no error.
      - `code`: string, required. What failed, in snake case, such as `generation_failed`, `job_cancelled`, `lease_expired` or `unknown`.
      - `retryable`: boolean, required. True when the failure was temporary and starting the same work again may succeed.
    - `kind`: `"file_parse"`, required
    - `result`: any value, required. Always null. Read the practice test, the attempt or the file instead.

#### Example

```json
{
  "data": {
    "id": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a",
    "kind": "note_generation",
    "status": "succeeded",
    "createdAt": "2026-01-15T09:30:00.000Z",
    "finishedAt": "2026-01-15T09:31:10.000Z",
    "result": {
      "generationId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a",
      "title": "Photosynthesis",
      "sectionCount": 4,
      "failedSections": [],
      "stopped": false
    },
    "error": null
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: This API key lacks the scope for this kind of job. Reading: copilot replies need `copilot:use`; note generations need `generation:start` or `copilot:use`; topic generations need `generation:start` or `copilot:use`; study set generations need `generation:start` or `copilot:use`; file parses need `files:upload` or `files:view`; practice test generations need `tests:generate` or `tests:view`; grading jobs need `tests:attempt` or `tests:view`. Cancelling: copilot replies need `copilot:use`; note generations need `generation:start` or `copilot:use`. Also `details.reason` `settings-in-conversation`: a copilot reply from a conversation that read or proposed the user's settings, for a key without `settings:view`.
- `404`: No job with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error

## Cancel a job

`POST https://workroad.app/api/v1/jobs/{jobId}/cancel`

Stops a copilot reply or a note generation, the two jobs the app has a Stop button for. A stopped copilot reply ends as `cancelled`. A stopped note generation keeps the sections it already wrote: it finishes as `succeeded` with `result.stopped` true, or as `cancelled` when nothing was written. A stopped job still counts as an AI use. `cancelled` is false when the job had already finished.

- MCP tool: `cancelJob` on `https://workroad.app/api/mcp`
- Required scopes: copilot:use or generation:start
- Test mode: `POST https://workroad.app/api/test/v1/jobs/{jobId}/cancel` checks the request and returns the example response without changing anything.

### Path parameters

- `jobId`: string (uuid), required. Job id, scoped to the key's user.

### Headers

- `Idempotency-Key`: string, optional, 1-255 visible ASCII characters. Makes the POST safe to retry: a retry with the same key and body replays the first response, and the same key with a different body returns 409 `idempotency_conflict`. Kept for 24 hours. Over MCP it is the `idempotencyKey` argument.

### Response

`200` OK

- `data`: object, required
  - `id`: string (uuid), required
  - `cancelled`: boolean, required. True when the cancel was accepted. False when the job had already finished.

#### Example

```json
{
  "data": {
    "id": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a",
    "cancelled": true
  }
}
```

### Errors

Every error has the body `{ "error": { "code", "message", "details" } }`.

- `400`: Invalid request body or query
- `401`: Missing, invalid, disabled or expired API key
- `402`: `api_allowance_reached`: this month's API and AI app requests on the free plan are used up. `details` has `limit`, `used`, `resetsAt` and `upgradeUrl`. Nothing ran; WorkRoad Plus has no monthly limit.
- `403`: This API key lacks the scope for this kind of job. Reading: copilot replies need `copilot:use`; note generations need `generation:start` or `copilot:use`; topic generations need `generation:start` or `copilot:use`; study set generations need `generation:start` or `copilot:use`; file parses need `files:upload` or `files:view`; practice test generations need `tests:generate` or `tests:view`; grading jobs need `tests:attempt` or `tests:view`. Cancelling: copilot replies need `copilot:use`; note generations need `generation:start` or `copilot:use`.
- `404`: No job with that id exists for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `details.reason` is `not-cancellable`: only copilot replies and note generations can be cancelled. A key with neither `copilot:use` nor `generation:start` gets 403 instead. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: Rate limit exceeded. Wait for Retry-After before retrying. The rate-limit headers are absent when the key's own limiter rejected the request before the per-endpoint bucket check ran.
- `500`: Internal server error
- `503`: WorkRoad couldn't reach the running reply, so it was not cancelled. Retry after `Retry-After` seconds.
