Skip to content
WorkRoad home
REST API

Copilot and generations

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

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:

To saveCallInstead of
A notePOST /notes with markdown, then POST /notes/{noteId}/content/appendPOST /notes/{noteId}/generate, generate-topic
A study set with its cardsPOST /sets with up to 2000 terms; POST, PATCH and POST …/delete on /sets/{setId}/terms for up to 500 morePOST /sets/generate
A practice testPOST /tests/from-questionsPOST /tests
Tasks, exams and deadlinesPOST /tasks/batcha copilot message
A timetable with its lessonsPOST /schedules/timetablethe app's photo import
A lecture's summaryPUT /recordings/{recordingId}/summarythe 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). 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. 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), answers 202 with a jobId, and runs as a job.

  1. Upload the material with 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.
{
   "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 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 kindRead with any one ofCancel with any one of
copilot_replycopilot:usecopilot:use
note_generationgeneration:start, copilot:usegeneration:start, copilot:use
topic_generationgeneration:start, copilot:usenot cancellable
study_set_generationgeneration:start, copilot:usenot cancellable
file_parsefiles:upload, files:viewnot cancellable
test_generationtests:generate, tests:viewnot cancellable
test_gradingtests:attempt, tests:viewnot 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.