Generation and jobs
Have WorkRoad's AI write notes and study sets, apply or discard the results, and follow background 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 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), answers 202 with a jobId, and runs as a job.
- Upload the material with Uploading files and poll
GET /files/{fileId}until each file isready. A file that isn't parsed yet makes the start fail with 400sources.fileIdsnot-ready. POST /notes/{noteId}/generatewithsources: thefileIds, anyrecordingIdsmade in the app, andincludeNoteto use the note's own text.depthisstandardoradvanced, andinstructionssays what to focus on.- Poll
GET /jobs/{jobId}until it ends, waiting the seconds inRetry-Afterbetween polls (5 for a generation). A finishednote_generationjob'sresult.generationIdnames the result. GET /generations/{generationId}shows the Markdown.POST /generations/{generationId}/applywrites it into the note, andPOST /generations/{generationId}/discarddrops it. Acopilot:usekey can do the same for a generation its copilot started; applying also needsnotes: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 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
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.
Required scopes: generation:start
AuthorizationBearer <token>noteId*stringNote 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
requestId*stringA 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.
^([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)$uuidsources*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.
depth*stringstandard for a study note, advanced for a longer, more detailed one.
"standard""advanced"instructions?stringWhat to focus on, up to 1,000 characters after trimming.
length <= 1000modelTier?stringswift, scholar or sage; scholar when omitted. sage needs a paid plan; a free user gets 400 with details.reason premium-only.
"swift""scholar""sage"Accepted
application/json- response
data*curl -X POST 'https://workroad.app/api/v1/notes/497f6eca-6276-4993-bfeb-53cbbbba6f08/generate' \ -H 'Authorization: Bearer $WORKROAD_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6", "sources": { "fileIds": [ "ede129fb-d491-4897-912f-401a8573064f" ], "recordingIds": [ "ea761e80-df65-459d-985a-43b6340026cd" ], "includeNote": true }, "depth": "standard"}'{ "data": { "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "intoNote": false }}Generate a note from a 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.
Required scopes: generation:start
AuthorizationBearer <token>noteId*stringNote 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
requestId*stringA 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.
^([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)$uuidtopic*stringWhat to write about, 1-200 characters.
1 <= length <= 200context?stringMore detail on what the note should cover, up to 500 characters.
length <= 500modelTier?stringswift, scholar or sage; scholar when omitted. sage needs a paid plan; a free user gets 400 with details.reason premium-only.
"swift""scholar""sage"Accepted
application/json- response
data*curl -X POST 'https://workroad.app/api/v1/notes/497f6eca-6276-4993-bfeb-53cbbbba6f08/generate-topic' \ -H 'Authorization: Bearer $WORKROAD_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6", "topic": "string"}'{ "data": { "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a" }}Generate a study set
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.
Required scopes: generation:start
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 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.
^([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)$uuidnoteId*stringThe note to make cards from.
^([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)$uuidmodelTier?stringswift, 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.
"swift""scholar""sage"Accepted
application/json- response
data*curl -X POST 'https://workroad.app/api/v1/sets/generate' \ -H 'Authorization: Bearer $WORKROAD_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6", "noteId": "11bee8fe-2793-4911-9b27-a6f8473a2ccf"}'{ "data": { "jobId": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a" }}Get a generation
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.
Required scopes: generation:start or notes:view or copilot:use
AuthorizationBearer <token>generationId*stringGeneration 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)$uuidOK
application/json- response
data*curl -X GET 'https://workroad.app/api/v1/generations/497f6eca-6276-4993-bfeb-53cbbbba6f08' \ -H 'Authorization: Bearer $WORKROAD_API_KEY'{ "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" }}Apply a generation
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.
Required scopes: notes:update; generation:start or copilot:use
AuthorizationBearer <token>generationId*stringGeneration 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
mode*stringreplace-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.
"insert-end""replace-content"OK
application/json- response
data*curl -X POST 'https://workroad.app/api/v1/generations/497f6eca-6276-4993-bfeb-53cbbbba6f08/apply' \ -H 'Authorization: Bearer $WORKROAD_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "mode": "insert-end"}'{ "data": { "applied": true, "alreadyApplied": false, "noteId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e" }}Discard a generation
Drops a preview without touching the note. Discarding again returns discarded false.
Required scopes: generation:start or copilot:use
AuthorizationBearer <token>generationId*stringGeneration 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 <= 255OK
application/json- response
data*curl -X POST 'https://workroad.app/api/v1/generations/497f6eca-6276-4993-bfeb-53cbbbba6f08/discard' \ -H 'Authorization: Bearer $WORKROAD_API_KEY'{ "data": { "discarded": true }}Get a job
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.
Required scopes: copilot:use or generation:start or files:upload or files:view or tests:generate or tests:view or tests:attempt
AuthorizationBearer <token>jobId*stringJob 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)$uuidOK
application/json- response
data*||||||curl -X GET 'https://workroad.app/api/v1/jobs/497f6eca-6276-4993-bfeb-53cbbbba6f08' \ -H 'Authorization: Bearer $WORKROAD_API_KEY'{ "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 }}Cancel a job
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.
Required scopes: copilot:use or generation:start
AuthorizationBearer <token>jobId*stringJob 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 <= 255OK
application/json- response
data*curl -X POST 'https://workroad.app/api/v1/jobs/497f6eca-6276-4993-bfeb-53cbbbba6f08/cancel' \ -H 'Authorization: Bearer $WORKROAD_API_KEY'{ "data": { "id": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "cancelled": true }}