# Copilot

> Send the copilot messages, read conversations, and apply the actions its replies propose.

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

## Sending messages

`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`](#apply-copilot-actions).

Send `{}` for the task, note and folder creations the app applies automatically, or send `indices` to select actions. Its reference below 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`.

## Conversations that read settings

A Copilot conversation in which a turn read or proposed the user's settings is only readable with `settings:view`: without it, `GET /copilot/conversations` leaves it out, its messages, its replies' jobs and sending into it return 403 with `details.reason` `settings-in-conversation`, and search skips it.

## Send a copilot message

`POST https://workroad.app/api/v1/copilot/messages`

Adds a message to a copilot conversation, or starts one, and queues the reply. The reply runs as a job: poll `GET /jobs/{jobId}` until it ends. A turn counts as an AI use, and on the free plan also as one of 10 turns per UTC day. Attachments are uploaded in the app only; to give the copilot material, write a note or upload a note file and mention or ground on it.

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`, `POST /sets`, `POST /tests/from-questions` or `POST /tasks/batch`, which use no AI. Call this only when the user asks for WorkRoad's AI to do the work.

- MCP tool: `sendCopilotMessage` on `https://workroad.app/api/mcp`
- Required scopes: copilot:use
- Test mode: `POST https://workroad.app/api/test/v1/copilot/messages` checks the request and returns the example response without changing anything.
- Next: poll `getJob` with the `jobId` until it ends, then read the reply with `listCopilotMessages` and apply its proposed actions with `applyCopilotActions`.

### 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 message. It becomes the reply's job id. Sending the same message with the same `requestId` again returns the same turn and is not counted twice; a different message with a used `requestId` is 409 `idempotency_conflict`.
- `conversationId`: string, at least 1 character. Continue this conversation. Omit it to start a new one.
- `message`: string, required, 1-4000 characters. The message, 1-4000 characters after trimming.
- `modelTier`: one of `"swift"`, `"scholar"`, `"sage"`, default `"scholar"`. `swift`, `scholar` (the default) or `sage`. `sage` needs a paid plan; a free user gets 400 with `details.reason` `premium-only`.
- `research`: boolean, default `false`. Let the copilot search the web for this reply.
- `context`: object. What a new conversation is about: `general`, a `note` (`noteId`), a `subject` (`subjectId`) or a practice test explanation (`activityRef`). Ignored when continuing a conversation, which keeps its own.
  - `kind`: one of `"general"`, `"note"`, `"activity"`, `"subject"`, `"schedule"`, required
  - `noteId`: string, up to 200 characters
  - `activityRef`: string, up to 200 characters
  - `subjectId`: string, up to 200 characters
  - `scheduleId`: string, up to 200 characters
- `subjectId`: string, at least 1 character. File a new conversation under this subject. An id the user doesn't own is ignored.
- `retryJobId`: string, at least 1 character. Retry a failed or stopped reply: that turn's job id, sent with the same `message`, `modelTier` and `research` under a new `requestId`. The retry takes the failed turn's place in the conversation and does not count against the free plan's daily turns.

### Response

`202` Accepted

- `data`: object, required
  - `conversationId`: string (uuid), required. The conversation the message was added to.
  - `jobId`: string (uuid), required. The reply's job, the `requestId` you sent. Poll `GET /jobs/{jobId}` until it ends; its result holds the reply.

#### Example

```json
{
  "data": {
    "conversationId": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f",
    "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a"
  }
}
```

### Errors

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

- `400`: `invalid_request`. `details.field` and `details.reason` say why: `modelTier` `premium-only` (the `sage` tier needs a paid plan), `retryJobId` `not-retryable` (that turn can't be retried with this message, or it was sent with files, which the API can't attach), `requestId` `expired` (this request id's turn already ended; send with a new one), or `stopLimit` (20 replies were stopped today, UTC).
- `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`. The message is not saved and uses none of the free plan's 10 turns a day. Once a use is available, sending the same `requestId` again sends it. When this month's uses and today's turns are both spent, the response is this 402: the month's limit binds, and `resetsAt` says when it lifts. `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`: `details.reason` is `settings-in-conversation`: `conversationId` names a conversation that read or proposed the user's settings, and this key lacks `settings:view`.
- `404`: The `conversationId`, or the practice test in `context.activityRef`, doesn't exist for this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `idempotency_conflict`: this `requestId` was already used for a different message. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: The free plan's 10 turns for today are used (`Retry-After` is 3600), or the copilot is busy (`Retry-After` is 15). When this month's AI uses are spent too, the response is 402 instead. Also `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. 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.

## List copilot conversations

`GET https://workroad.app/api/v1/copilot/conversations`

Pinned conversations first, then the most recently updated. A conversation that gets a new turn moves to the front, so paging while the user chats can repeat or skip one. Without `settings:view`, a conversation that read or proposed the user's settings is left out.

- MCP tool: `listCopilotConversations` on `https://workroad.app/api/mcp`
- Required scopes: copilot:use
- Test mode: `GET https://workroad.app/api/test/v1/copilot/conversations` checks the request and returns the example response without changing anything.

### Query parameters

- `limit`: integer, 1 to 100, default `25`. Page size, 1-100.
- `cursor`: string or null, up to 512 characters. Opaque. Pass `nextCursor` from the previous page unchanged.

### Response

`200` OK

- `data`: array of objects, required
  - `id`: string (uuid), required
  - `title`: string or null, required. The user's name for it, or its first message. Null for an untitled one.
  - `updatedAt`: string (date-time), required. When it last changed, such as a new turn or a rename.
  - `pinned`: boolean, required
  - `subjectId`: string (uuid) or null, required. The subject it is filed under. Null when it isn't filed.
  - `subjectName`: string or null, required
- `nextCursor`: string or null, required. Opaque. Pass as `?cursor=` unchanged to fetch the next page. `null` on the last page.

#### Example

```json
{
  "data": [
    {
      "id": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f",
      "title": "Enzymes",
      "updatedAt": "2026-02-01T12:00:00.000Z",
      "pinned": false,
      "subjectId": "5f0c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
      "subjectName": "Biology"
    }
  ],
  "nextCursor": 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`: 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.
- `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

## List copilot messages

`GET https://workroad.app/api/v1/copilot/conversations/{conversationId}/messages`

The newest messages of a conversation, oldest first within a page. Pass `nextCursor` as `?cursor=` to read further back. An assistant reply's `actions` are what the copilot proposed; apply them with `POST /copilot/messages/{messageId}/actions`.

- MCP tool: `listCopilotMessages` on `https://workroad.app/api/mcp`
- Required scopes: copilot:use
- Test mode: `GET https://workroad.app/api/test/v1/copilot/conversations/{conversationId}/messages` checks the request and returns the example response without changing anything.

### Path parameters

- `conversationId`: string (uuid), required. Copilot conversation id, scoped to the key's user.

### Query parameters

- `limit`: integer, 1 to 100, default `25`. Page size, 1-100.
- `cursor`: string or null, up to 512 characters. Opaque. Pass `nextCursor` from the previous page unchanged.

### Response

`200` OK

- `data`: array of objects, required
  - `id`: string (uuid), required
  - `role`: one of `"user"`, `"assistant"`, required
  - `content`: string, required. The message, as Markdown.
  - `actions`: array of one of the objects below or null, required. The actions an assistant reply proposed. An action's position is its index for `POST /copilot/messages/{messageId}/actions`. Null marks a stored action this API can't describe. Empty for user messages.
    - 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
          - `start`: string, required, up to 5 characters
          - `end`: string, required, up to 5 characters
      - `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
          - `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`: `"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
  - `actionResults`: array of objects, required. What happened to each applied action, by index. Unapplied ones are absent.
    - `index`: integer, required. The action's position in `actions`.
    - `ok`: boolean, required. True when the action completed or its asynchronous job was accepted.
    - `id`: string or null, required. The created or updated entity's id, when the operation returns one. For run_operation, entityKind identifies its type.
    - `jobId`: string or null, required. An asynchronous job the action started. Poll `GET /jobs/{jobId}`. A create_set_from_note applied through the API writes its set when the job succeeds, and result.setId names it.
    - `entityKind`: one of `"note"`, `"task"`, `"set"`, `"subject"`, `"schedule"`, `"test"` or null
    - `status`: one of `"started"`, `"completed"` or null. Operation completion state. A started job must still be polled; acceptance is not completion.
    - `error`: string or null, required. Why it didn't run, such as `operation-uncertain` (the write may have completed; inspect the resource before taking another action), `in-progress` (another apply is running it), `action-retryable` (apply it again), `note-not-found`, `turn-action-cap` (a reply can run 3 actions that change data) or `task-already-exists`. Null when `ok` is true.
  - `modelTier`: string or null, required. The tier an assistant reply ran on. Null for user messages.
  - `jobId`: string (uuid) or null, required. The turn's job. A user message and its reply share it.
  - `jobStatus`: one of `"pending"`, `"running"`, `"succeeded"`, `"failed"`, `"cancelled"` or null, required. The status of that job. Null when the message has no job.
  - `createdAt`: string (date-time), required. When the message was written.
  - `researchUsed`: boolean, required. True when the turn searched the web.
  - `sources`: array of objects, required. The web pages the turn cites.
    - `url`: string, required
    - `title`: string, required
    - `host`: string, required
- `nextCursor`: string or null, required. Opaque. Pass as `?cursor=` unchanged to fetch the next page. `null` on the last page.

#### Example

```json
{
  "data": [
    {
      "id": "4d3c2b1a-0f9e-4d8c-8b7a-6f5e4d3c2b1a",
      "role": "assistant",
      "content": "Enzymes lower the activation energy of a reaction.",
      "actions": [
        {
          "kind": "create_task",
          "name": "Revise enzymes",
          "dueDate": "2026-02-03"
        },
        {
          "kind": "create_set_from_note",
          "noteId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e"
        }
      ],
      "actionResults": [
        {
          "index": 0,
          "ok": true,
          "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
          "jobId": null,
          "error": null
        }
      ],
      "modelTier": "scholar",
      "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a",
      "jobStatus": "succeeded",
      "createdAt": "2026-01-15T09:30:00.000Z",
      "researchUsed": false,
      "sources": []
    }
  ],
  "nextCursor": 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`: `details.reason` is `settings-in-conversation`: a turn of this conversation read or proposed the user's settings, and this key lacks `settings:view`.
- `404`: No conversation 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 copilot actions

`POST https://workroad.app/api/v1/copilot/messages/{messageId}/actions`

Runs the actions an assistant reply proposed and returns a receipt for each.

### Selecting actions

The app applies a reply's task, note and folder creations by itself; an API client must call this. Send `{}` for those, or name `indices`.

`create_schedule` runs only when named, and naming it also needs the `schedule:create` scope, as `POST /schedules` does. With `subjects:create` it also creates the subjects its lessons name that the user doesn't have yet, as `createSubjectsIfMissing` does on `POST /schedules/timetable`; without it those names stay on the timetable as written.

### Edits, deletes and AI actions

Edits and deletes run only when named too, and each needs the scope of the matching endpoint: `update_note` needs `notes:update`, `delete_note` `notes:delete` (the note goes to the trash), `update_task` `tasks:update`, `delete_task` `tasks:delete`, `update_subject` `subjects:update`, `update_set` `sets:update` and `update_schedule` `schedule:update`. An edit or delete whose item changed since the reply fails with a `-stale` error such as `task-stale`. So do `create_set_from_note`, `start_generation` and `create_note_from_files`, and each of those counts as an AI use.

### Generation jobs

A `create_set_from_note` applied here writes the set when its job succeeds, and `GET /jobs/{jobId}` then returns it as `result.setId`; in the app the user reviews the cards before any set exists.

`create_note_from_files` also needs `notes:create`, `files:upload` and `generation:start`: it copies the files the user attached to the message onto a new note and starts a generation that writes into it: poll the receipt's `jobId`. Poll it with `GET /jobs/{jobId}`. A `copilot:use` key can follow the job and read, apply or discard the resulting generation, and applying also needs `notes:update`.

### Inserting text

`insert_into_note` is text for the user to accept in the app's note editor: applying it writes nothing and returns a receipt with the error `applies-in-note`.

### Settings

`change_setting` runs only when named: it calls the settings operation it names, such as `updateNotificationKind`, with its `arguments` and only the scopes both this key and the turn that proposed it hold, so it needs that operation's scope (`settings:update`). A refused change fails with an error such as `setting-forbidden` or `setting-invalid`, and a reply that used web research changes no setting (`setting-after-research`).

### Running any operation

`run_operation` runs only when named: it calls the v1 operation its `operation` names (an operationId from this API, such as `archiveSets`) with its `arguments`, path parameters included, and only the scopes both this key and the turn that proposed it hold. A refused one fails with `operation-invalid`, `operation-forbidden`, `operation-not-found` or `operation-conflict`. One whose outcome couldn't be confirmed fails with `operation-uncertain` and never runs again, so check its result before asking for it again. A receipt with `status: "started"` started background work. A settings operation proposed this way keeps `change_setting`'s rules.

### Receipts and limits

An action with a receipt never runs again, so sending the same request twice is safe.

On the free plan, 20 actions that change data can run per UTC day.

Runs WorkRoad's AI for the actions that start a generation, which counts toward the user's WorkRoad AI uses (`GET /ai-allowance`).

- MCP tool: `applyCopilotActions` on `https://workroad.app/api/mcp`
- Required scopes: copilot:use
- Test mode: `POST https://workroad.app/api/test/v1/copilot/messages/{messageId}/actions` checks the request and returns the example response without changing anything.

### Path parameters

- `messageId`: string (uuid), required. Copilot message 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.

- `indices`: array of integer, up to 8 items. The positions in the message's `actions` to apply. Omit it to apply what the app applies by itself: the `create_task` and `create_note` actions, unless the reply searched the web. `create_set_from_note`, `start_generation` and `create_note_from_files` run only when named here.

### Response

`200` OK

- `data`: object, required
  - `results`: array of objects, required. Every action of the message that has a receipt, including ones applied earlier, by index.
    - `index`: integer, required. The action's position in `actions`.
    - `ok`: boolean, required. True when the action completed or its asynchronous job was accepted.
    - `id`: string or null, required. The created or updated entity's id, when the operation returns one. For run_operation, entityKind identifies its type.
    - `jobId`: string or null, required. An asynchronous job the action started. Poll `GET /jobs/{jobId}`. A create_set_from_note applied through the API writes its set when the job succeeds, and result.setId names it.
    - `entityKind`: one of `"note"`, `"task"`, `"set"`, `"subject"`, `"schedule"`, `"test"` or null
    - `status`: one of `"started"`, `"completed"` or null. Operation completion state. A started job must still be polled; acceptance is not completion.
    - `error`: string or null, required. Why it didn't run, such as `operation-uncertain` (the write may have completed; inspect the resource before taking another action), `in-progress` (another apply is running it), `action-retryable` (apply it again), `note-not-found`, `turn-action-cap` (a reply can run 3 actions that change data) or `task-already-exists`. Null when `ok` is true.

#### Example

```json
{
  "data": {
    "results": [
      {
        "index": 0,
        "ok": true,
        "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
        "jobId": null,
        "error": null
      },
      {
        "index": 1,
        "ok": true,
        "id": null,
        "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a",
        "error": null
      }
    ]
  }
}
```

### Errors

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

- `400`: `invalid_request`. `details.field` `messageId` with `details.reason` `answer-incomplete` means the reply's job hasn't succeeded. A `start_generation` action can also fail with `noteRecordingId` `empty-transcript` or `unknown-recording` (the recording isn't one of this key's user's), and a start can fail with `modelTier` `premium-only`.
- `401`: Missing, invalid, disabled or expired API key
- `402`: `ai_limit_reached`: a set or generation action needs an AI use and none is left this month, or the paid plan reached its fair-use limit. Actions that ran before it keep their receipts; the rest can be applied again later. `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 key lacks a scope this request needs: `copilot:use`, or the scope a named index's kind needs (`schedule:create`, `schedule:update`, `notes:create`, `files:upload`, `generation:start`, `notes:update`, `notes:delete`, `tasks:update`, `tasks:delete`, `subjects:update` or `sets:update`).
- `404`: The message isn't an assistant reply of this key's user.
- `405`: Method not allowed. Use a method from the Allow header.
- `409`: `idempotency_conflict`: a different job already runs under this action's id. The Idempotency-Key was reused with a different request body, or a request with the same key is still in progress.
- `429`: The free plan's 20 actions for today are used (`Retry-After` is 3600), or WorkRoad is busy (`Retry-After` is 30). Also `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. 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. Apply again after `Retry-After` seconds; the action is retried, not run twice.
