# Copilot and generations

> Save content your agent writes, send copilot messages and follow generation jobs.

Source: https://workroad.app/docs/api/copilot-and-generations

## 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 page says when it does.

## Copilot

`POST /copilot/messages` sends the copilot a message and answers 202 with the `conversationId` and the reply's `jobId`. Omit `conversationId` to start a conversation; `context` and `subjectId` then say what it is about. Poll `GET /jobs/{jobId}` for the reply: a finished `copilot_reply` job's `result` holds the message, its sources and the actions it proposed.

Mint a uuid `requestId` for each message and keep it for retries. It is the message's idempotency key: sending the same message with the same `requestId` again returns the same turn and counts one AI use, even if the first request timed out. A different message under a used `requestId` returns 409 `idempotency_conflict`.

Each turn is one AI use (see [AI allowance](https://workroad.app/docs/api/limits#ai-allowance)). The free plan also allows 10 turns per UTC day; the 11th returns 429 with `Retry-After: 3600`, or 402 if the month's AI uses are spent as well. The `sage` model tier needs a paid plan. A busy copilot returns 429 with `Retry-After: 15`.

Attachments can't be sent through the API yet. The body rejects any key it doesn't list, so a body with `attachmentIds` or any other unknown key returns 400 `invalid_request`. To give the copilot material, write it into a note or upload a note file, then ground the conversation on that note with `context: { "kind": "note", "noteId": "..." }`.

`GET /copilot/conversations` lists the user's conversations, pinned ones first and then the most recently updated, with the usual `cursor` and `limit`. `GET /copilot/conversations/{conversationId}/messages` returns the newest messages oldest first; pass the page's `nextCursor` as `cursor` to read further back, with the same `limit`. Both cursors are opaque. A conversation cursor carries the pin state, update time and id it was cut at, so paging continues from the same place. A conversation that gets a new turn or is pinned between pages moves to the front, though, so it can appear twice or be missed on that pass.

### Applying actions

When the reply's job succeeds, find the assistant message with that `jobId` in the conversation's messages, then call [`POST /copilot/messages/{messageId}/actions`](https://workroad.app/docs/api/apply-copilot-actions).

Send `{}` for the task, note and folder creations the app applies automatically, or send `indices` to select actions. The operation page lists which actions need explicit selection, their scopes and how to follow their jobs.

The response has a receipt for every action that has one, by index. An action with a receipt never runs again, so repeating a request is safe and returns the same receipts. A receipt with `error: "in-progress"` belongs to an apply that is still running; `action-retryable` means the action can be applied again. A set whose note doesn't exist gets `note-not-found`. Receipt `error` values are kebab-case codes, unlike the snake_case `error.code` of a response.

One reply can run at most 3 actions that change data; the next gets `turn-action-cap`. On the free plan, 20 such actions can run per UTC day, and the 21st request returns 429 with `Retry-After: 3600`.

## 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/notes-and-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.
