Copilot
Send the copilot messages, read conversations, and apply the actions its replies propose.
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). 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.
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
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.
Required scopes: copilot:use
AuthorizationBearer <token>Idempotency-Key?stringA client-generated key (1 to 255 visible ASCII characters) that makes this POST safe to retry. A retry with the same key and body replays the original response; the same key with a different body returns 409 idempotency_conflict. Stored for 24 hours.
^[\x21-\x7E]{1,255}$1 <= length <= 255application/json- body
requestId*stringA 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.
^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$uuidconversationId?stringContinue this conversation. Omit it to start a new one.
1 <= lengthmessage*stringThe message, 1-4000 characters after trimming.
1 <= length <= 4000modelTier?stringswift, scholar (the default) or sage. sage needs a paid plan; a free user gets 400 with details.reason premium-only.
"scholar""swift""scholar""sage"research?booleanLet the copilot search the web for this reply.
falsecontext?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.
subjectId?stringFile a new conversation under this subject. An id the user doesn't own is ignored.
1 <= lengthretryJobId?stringRetry 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.
1 <= lengthAccepted
application/json- response
data*curl -X POST 'https://workroad.app/api/v1/copilot/messages' \ -H 'Authorization: Bearer $WORKROAD_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6", "message": "string"}'{ "data": { "conversationId": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a9f", "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a" }}List 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.
Required scopes: copilot:use
AuthorizationBearer <token>limit?integerPage size, 1-100.
1 <= value <= 10025cursor?|Opaque. Pass nextCursor from the previous page unchanged.
OK
application/json- response
data*array<>nextCursor*|Opaque. Pass as ?cursor= unchanged to fetch the next page. null on the last page.
curl -X GET 'https://workroad.app/api/v1/copilot/conversations' \ -H 'Authorization: Bearer $WORKROAD_API_KEY'{ "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}List copilot 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.
Required scopes: copilot:use
AuthorizationBearer <token>conversationId*stringCopilot conversation id, scoped to the key's user.
^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$uuidlimit?integerPage size, 1-100.
1 <= value <= 10025cursor?|Opaque. Pass nextCursor from the previous page unchanged.
OK
application/json- response
data*array<>nextCursor*|Opaque. Pass as ?cursor= unchanged to fetch the next page. null on the last page.
curl -X GET 'https://workroad.app/api/v1/copilot/conversations/497f6eca-6276-4993-bfeb-53cbbbba6f08/messages' \ -H 'Authorization: Bearer $WORKROAD_API_KEY'{ "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}Apply copilot 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).
Required scopes: copilot:use
AuthorizationBearer <token>messageId*stringCopilot message id, scoped to the key's user.
^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$uuidIdempotency-Key?stringA client-generated key (1 to 255 visible ASCII characters) that makes this POST safe to retry. A retry with the same key and body replays the original response; the same key with a different body returns 409 idempotency_conflict. Stored for 24 hours.
^[\x21-\x7E]{1,255}$1 <= length <= 255application/json- body
indices?array<>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.
items <= 8OK
application/json- response
data*curl -X POST 'https://workroad.app/api/v1/copilot/messages/497f6eca-6276-4993-bfeb-53cbbbba6f08/actions' \ -H 'Authorization: Bearer $WORKROAD_API_KEY' \ -H 'Content-Type: application/json' \ -d '{}'{ "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 } ] }}