Every resource lives in a classroom, under /v1/content/classrooms/{classroomId}. Use these routes for content that people create, review, or manage in TutorFlow, whether your system created it or an educator did. Reads need content:read, changes need content:write, and creating or changing content you supply costs no credits.
This page explains the conventions and the request bodies. Every route with its scope and response is listed once in the API Reference. Examples use the variables from the Quickstart: TUTORFLOW_API_BASE_URL, TUTORFLOW_CONTENT_API_KEY, and CLASSROOM_ID.
Resource model
| Resource | Collection | Use it for |
|---|---|---|
| Module | /modules | One interactive lesson that educators build and assign. |
| Course | /courses | A curriculum of chapters and lessons. |
| Video | /videos | An editable plan of scenes, narration scripts, and subtitles, rendered to mp4 on request. |
| Slides | /slides | A presentation deck. |
| Test | /tests | An assessment with items, answers, and scoring. |
| Game | /games | A playable web page. See Games. |
| Simulation | /simulations | An interactive model with controls. See Simulations. |
Conventions
Authorization: Bearer tf_content_...
Content-Type: application/json
Idempotency-Key: module:intro-to-safety:v1Idempotency
Send an Idempotency-Key on every POST that creates something or starts an action: resource creates, module copy, video scene creation, course chapter and lesson creation, version restore, video narration and render, async game and simulation generation, test, module, course, and slide deck generation, asset upload URLs, learner invitations, and course enrollments.
- A retry with the same key and the same body returns the first response, with the same status, and creates nothing new. A replayed response carries the header
Idempotent-Replayed: true; a first response does not. - The same key with a different body returns
409content_conflict. Path parameters count as part of the body, so one key cannot copy a different module or restore a different version. - While the first request with a key is still running, a retry returns
409. If it never finished, for example because the connection dropped, a retry can take the key over after 5 minutes. - A finished request replays for 24 hours. After that the key can be used again.
- Keys are scoped to the API key that sends them, the classroom, and the kind of request, and can be up to 255 characters. Two integrations in one organization can use the same key string without replaying each other's answers. Keys first used before 2026-09-30 still match a request from any key of the same organization until they expire.
PATCHandDELETEdo not take the header. Sending the same update twice leaves the same result. To stop an update from overwriting a change made since you read the resource, sendIf-Match; see Conditional requests.- Streamed game and simulation generation ignores the header; see Generation.
- Expansion jobs follow the same rules, with the key in the header or in the body. See Idempotency for expansion jobs.
Build keys from your own ids and a version you control, never from the current time:
module:{externalId}:v{version}
course:{externalId}:v{version}
video:{externalId}:v{version}
slides:{externalId}:v{version}
test:{externalId}:v{version}
game:{externalId}:v{version}
simulation:{externalId}:v{version}
chapter:{courseId}:{externalChapterId}:v{version}
lesson:{chapterId}:{externalLessonId}:v{version}
scene:{videoId}:{externalSceneId}:v{version}
module:{moduleId}:copy-to-course:{courseId}:v{n}
video:{videoId}:render:v{n}
game:{gameId}:build:v{n}
game:{gameId}:restore:{version}:v{n}
expansion:{externalLevelId}:v{sourceVersion}Raise v{n} only when you mean to repeat an action, such as rendering a video again after editing it.
Conditional requests
Single-resource reads and writes carry an ETag header. Send it back in If-None-Match on a GET to get 304 Not Modified when nothing changed, or in If-Match on a PATCH or DELETE to write only if nobody changed the resource since you read it (412 content_precondition_failed otherwise). Both are optional. See Concurrency and ETags.
Create responses
| Route | Success |
|---|---|
POST /modules, /courses, /videos, /slides, /tests, /games, /simulations | 201 with the created resource |
POST /courses/{courseId}/chapters | 201 with the chapter |
POST /courses/{courseId}/chapters/{chapterId}/lessons | 201 with the lesson |
POST /videos/{videoId}/scenes | 201 with the whole video, including the new scene |
POST /modules/{moduleId}/copy-to-course | 200 with { "lessonId", "courseLessonId", "message" } |
POST /games/{gameId}/versions/{version}/restore, and the simulation equivalent | 200 with the restored resource |
POST /videos/{videoId}/render | 202; see Video Rendering |
Update responses
Every update returns the updated state:
| Route | Success |
|---|---|
PATCH /modules/{moduleId} | 200 with the module as GET /modules/{moduleId} returns it (with pdfUrl, lecture, and quizzes), plus the deprecated success: true. |
PATCH /slides/{slideId} | 200 with the deck as GET /slides/{slideId} returns it (with content), plus the deprecated success: true. |
PATCH /tests/{testId} | 200 with the test as GET /tests/{testId} returns it (with items), plus the deprecated success: true. |
PATCH /courses/{courseId} | 200 with the updated course |
PATCH /videos/{videoId} | 200 with the updated video |
PATCH /videos/{videoId}/scenes/{sceneId}, PATCH /videos/{videoId}/scenes/reorder | 200 with the whole video |
PATCH /games/{gameId}, PATCH /simulations/{simulationId} | 200 with the updated resource |
PATCH /courses/{courseId}/chapters/{chapterId} | 200 with the chapter |
PATCH /courses/{courseId}/chapters/reorder | 200 with the whole curriculum, as GET /chapters |
PATCH /courses/{courseId}/chapters/{chapterId}/lessons/reorder | 200 with the chapter |
PATCH /courses/{courseId}/lessons/{lessonId} | 200 with the lesson |
Module, slide, and test updates used to answer only { "success": true }. That key is still sent, and is deprecated with a sunset of 2027-04-30; read the resource fields instead. See Versioning and Deprecation.
Delete responses
Every resource, chapter, and lesson delete returns 200 with:
{
"id": "00000000-0000-4000-8000-000000000101",
"deleted": true,
"success": true
}Read deleted. success is deprecated, and delete responses carry Deprecation, Sunset, and Link headers; see Versioning and Deprecation. A course delete can also carry chatSessionId. Deleting a video scene returns 200 with the whole video instead. A later read of a deleted resource returns 404 content_not_found.
List responses
Resource lists (modules, courses, videos, slides, tests, games, and simulations) return:
{
"data": [{ "id": "00000000-0000-4000-8000-000000000101", "title": "Introduction to safety" }],
"meta": {
"page": 1,
"take": 20,
"itemCount": 1,
"pageCount": 1,
"hasPreviousPage": false,
"hasNextPage": false
}
}Every resource list takes the same parameters: page, limit (default 20, values above 100 are clamped to 100), field and order (default createdAt DESC, ties broken on id), search (case-insensitive, on the title), and updatedSince. meta.take reports the page size used. The sort columns each list allows are in List pages.
GET /v1/content/classrooms/{classroomId}/courses?limit=50&field=title&order=ASC&search=onboardingThese lists also still return deprecated keys, items and totalCount (video list: total), and say so in Deprecation, Sunset, and Link headers.
These routes do not use the envelope:
| Route | Returns |
|---|---|
GET /v1/content/classrooms | A plain array of classrooms. |
GET /v1/content/organizations, GET .../api-keys | Plain arrays (admin session routes). |
GET .../webhooks | A plain array of endpoints. |
GET .../webhooks/{webhookId}/deliveries | A plain array, newest first. Page with before; see Delivery log. |
GET /courses/{courseId}/chapters | { "data": [chapter], "unassignedLessons": [lesson] }, the whole curriculum with no paging. |
Syncing changes
Every resource list accepts updatedSince, an ISO 8601 date-time. Only rows whose updatedAt is at or after it are listed. To sync incrementally:
-
Remember the time you start a sync run, for example
2026-09-30T00:00:00Z. -
List with
updatedSinceset to the start of your previous run, sorted oldest change first, and page untilmeta.hasNextPageisfalse:HTTPGET /v1/content/classrooms/{classroomId}/modules?updatedSince=2026-09-29T00:00:00Z&field=updatedAt&order=ASC&limit=100 -
Store the time from step 1 for the next run. Overlapping by a few minutes is safe: skip rows whose
updatedAtyou already hold.
Deleted rows are not listed. Subscribe to resource.deleted to learn about deletions. resource.created, resource.updated, and resource.deleted webhooks report changes made through the Content API, by other keys, and in the TutorFlow app's editors, a few seconds after the change. See Resource events from any source. A periodic updatedSince list is still a good safety net for a receiver that was down.
Store TutorFlow ids next to your own ids, and keep the updatedAt you last saw per resource to skip unchanged ones. A complete sync loop in Node.js and Python is in Examples.
Response fields
Each resource returns exactly these fields. A field TutorFlow adds internally never appears until it is added here and to the Changelog. "Single read" means GET /{collection}/{id} only.
| Resource | Fields |
|---|---|
| Module | id, classroomId, title, description, type, isPublic, videoUrl, pdfKey, isPdfDownloadEnabled, content, lectureKey, lectureVersion, status, lectureImagePrompt, metadata, slug, createdAt, updatedAt. Single read adds pdfUrl, lecture, and quizzes (id, sequence, title, question, type, options, correctAnswers, hint, explanation, showFeedback, createdAt, updatedAt). |
| Course | id, classroomId, title, description, thumbnail, slug, contentType, level, isPublic, visibility, createdAt, updatedAt. The single read returns the same fields. Lists add totalLearnerCount. With isIncludeStats=true, lessons, chapters, and stats are added; stats.topLearners and stats.mostBehindLearner need learners:read. |
| Video | id, title, description, visibility, renderStatus, renderProgress, renderPhase, thumbnailKey, bgmAudioKey, bgmVolume, bgmOffset, bgmFadeInDuration, bgmFadeOutDuration, userNarrationScript, aspectRatio, avatarId, metadata, slug, scenes, createdAt, updatedAt. |
| Video scene | id, order, script, displayText, narrationSource, hasNarration, videoClipSource, visualType, visualKey, visualSource, remotionTemplate, keywords, duration, audioOffset, audioDuration, videoOffset, videoDuration, visualOffset, visualDuration, visualFit, subtitleOffset, subtitleDuration, subtitles, transitionOut, transitionDuration, videoEffect, visualEffect, visualMetadata, visualAdjustments, visualOverlays, createdAt, updatedAt. |
| Slides | id, classroomId, title, description, outline, visibility, contentVersion, metadata, thumbnail, slug, createdAt, updatedAt. Create and single read add content, the deck body. |
| Test | id, classroomId, name, description, thumbnail, slug, level, visibility, timeLimit, passingValue, passingValueType, periodType, periodStartDate, periodEndDate, layoutType, topicPrompt, targetPrompt, metadata, createdAt, updatedAt, and items on create and single read. Each item has id, classroomTestId, title, question, questionAudioUrl, type, options, correctAnswers, explanation, sequence, score, createdAt, updatedAt, and stats when requested. |
| Game, simulation | See the game object and the simulation object. |
Storage references and URLs
Some fields hold a storage reference: TutorFlow's internal name for a stored file. They are opaque identifiers, not URLs. Do not build URLs from them, and do not depend on their format. They are returned because you can set some of them, and so you can tell whether a file is attached. To put your own file in one, upload it and send the returned assetKey; a key that points outside the classroom answers 400. Where a usable URL exists, it comes from another field:
| Field | What it names | Usable URL |
|---|---|---|
Module pdfKey | The attached PDF. | pdfUrl on the module's single read. |
Module lectureKey | The stored lecture body. | None needed: the single read returns the body itself in lecture. |
Video thumbnailKey | The video thumbnail. | thumbnailUrl on the render status, when TutorFlow stores it for the classroom. |
Video bgmAudioKey | The background music file. | None through the API. |
Scene visualKey, visualOverlays[].assetKey | A scene's visual and overlay images. | None through the API. |
Game and simulation contentKey, buildHistory[].contentKey | A built version. | None needed: the single read returns the served version's HTML in content. Earlier versions are restored, not downloaded. |
Game and simulation thumbnail, and contentKey and thumbnail in a build-done event | A capture of the opening screen, and the built version. | None through the API. |
| Rendered mp4 | The finished video. Its storage key is not returned. | videoUrl on the render status and in the video.render.completed webhook, signed for 6 hours. |
Course, slide, and test thumbnail are strings set by the TutorFlow editor or by your integration and returned as stored. Module videoUrl and test item questionAudioUrl are URLs.
Not returned at all: authoring tokens, video videoKey and metadata.remotionLambda, scene ttsAudioKey and videoClipKey, slide contentKey, course authorId, chatSession, and settings (which PATCH can still set), module authorId, and learner contact details in course stats.
Modules
curl -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/modules" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: module:intro-to-safety:v1" \
-d '{
"title": "Introduction to safety",
"description": "A short interactive lesson for new employees.",
"type": "markdown",
"isPublic": false,
"content": "<h2>Safety first</h2><p>Report every hazard you see.</p>",
"metadata": { "externalId": "module:intro-to-safety" }
}'title is required. type defaults to markdown, isPublic to false, and status to ready. type takes the module editor's types: markdown, ai-tutor, chat-ai, coding-lesson, coding-test, notebook, web, flashcard, dictation, reading-comprehension, translation, shadowing, or roleplay. Any other value, exam included, answers 422 content_invalid_request, on create and on PATCH. metadata is an object you own and is returned unchanged.
Update a module with PATCH /modules/{moduleId}:
{
"title": "Workplace safety basics",
"content": "<h2>Safety first</h2><p>Report every hazard you see.</p>",
"quizzes": [
{
"sequence": 1,
"title": "Safety check",
"question": "What should you do when you see a hazard?",
"type": "select",
"options": ["Ignore it", "Report it", "Hide it"],
"correctAnswers": ["Report it"],
"showFeedback": true
}
]
}Module quiz type values are select, blank, and true-false.
Copy a module into a course as a new lesson:
curl -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/modules/$MODULE_ID/copy-to-course" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: module:$MODULE_ID:copy-to-course:$COURSE_ID:v1" \
-d "{ \"courseId\": \"$COURSE_ID\", \"sequence\": 1 }"Courses
curl -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/courses" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: course:customer-onboarding:v1" \
-d '{
"title": "Customer onboarding",
"description": "Five onboarding lessons for customer success teams.",
"contentType": "default",
"level": "Beginner",
"visibility": "PRIVATE"
}'Response 201:
{
"id": "00000000-0000-4000-8000-000000000201",
"classroomId": "00000000-0000-4000-8000-000000000010",
"title": "Customer onboarding",
"description": "Five onboarding lessons for customer success teams.",
"thumbnail": null,
"slug": "customer-onboarding",
"contentType": "default",
"level": "Beginner",
"isPublic": true,
"visibility": "PRIVATE",
"createdAt": "2026-09-30T09:00:00.000Z",
"updatedAt": "2026-09-30T09:00:00.000Z"
}contentTypeis lowercase:default, orexamfor exam-oriented courses.visibilityis uppercase:PUBLIC,PRIVATE,ORGANIZATION,CLASSROOM, orCOURSE. It controls who can open the course.isPublicis a separate, older flag stored independently ofvisibility, with a default oftrue. Read and setvisibilityfor access.- A new course has no chapters or lessons. Add them with the chapter and lesson routes below.
GET /courses/{courseId}?isIncludeStats=trueaddslessons,chapters, andstats.dayssets the window ofstats.dailyLearningTime:7(default) or30; any other value returns422. There is one bucket per UTC calendar day, oldest first, ending today in UTC, withdateasYYYY-MM-DD.- Learners appear in stats by id and name only, and only for a key with
learners:read. Without it,topLearnersandmostBehindLearnerare left out and the aggregate counts stay. See Learner data. - A course that does not exist, or was deleted, returns
404content_not_found.
Update a course
PATCH /courses/{courseId} changes only the fields you send. An omitted field keeps its stored value, so a title-only update leaves visibility as it was:
curl -X PATCH "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/courses/$COURSE_ID" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "title": "Customer onboarding, v2" }'| Field | Notes |
|---|---|
title, description | Strings. |
level, thumbnail | Strings; null clears them. |
slug | A new slug. TutorFlow normalizes it, and adds a short suffix when another course in the classroom already uses it. |
visibility | PUBLIC, PRIVATE, ORGANIZATION, CLASSROOM, or COURSE. Any other value returns 422 with error.details[0].field set to visibility. |
settings.aiTutorDefaults | Object. The course's AI tutor defaults, as the TutorFlow course editor saves them. |
settings.celebrationTrigger | LESSON, COURSE, or NONE: when learners see a completion celebration. |
Only the settings keys you send change, and they are saved where the TutorFlow editor saves them. settings is not returned in responses. The classroom always comes from the URL. The response is 200 with the updated course.
Before 2026-09-30, a course PATCH without visibility set the course to PUBLIC, and settings were not saved. If your integration updated courses before then, check that their visibility is what you intend.
Course chapters and lessons
Paths are under /v1/content/classrooms/{classroomId}/courses/{courseId}:
| Method | Path | Body |
|---|---|---|
GET | /chapters | |
POST | /chapters | { "title", "position"? } |
PATCH | /chapters/reorder | { "chapterIds": [...] } |
PATCH | /chapters/{chapterId} | { "title"?, "position"? } |
DELETE | /chapters/{chapterId} | Deletes the chapter's lessons too. |
POST | /chapters/{chapterId}/lessons | { "title", "description"?, "lecture"?, "isPublic"?, "position"? } |
PATCH | /chapters/{chapterId}/lessons/reorder | { "lessonIds": [...] } |
GET | /lessons/{lessonId} | |
PATCH | /lessons/{lessonId} | { "title"?, "description"?, "lecture"?, "content"?, "isPublic"?, "chapterId"?, "position"? } |
DELETE | /lessons/{lessonId} |
Add a chapter, put a lesson in it, and read the curriculum back:
COURSE_PATH="$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/courses/$COURSE_ID"
AUTH="Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"
CHAPTER_ID="$(curl -sS -X POST "$COURSE_PATH/chapters" -H "$AUTH" -H "Content-Type: application/json" \
-H "Idempotency-Key: chapter:$COURSE_ID:getting-started:v1" \
-d '{ "title": "Getting started" }' | jq -r '.id')"
curl -sS -X POST "$COURSE_PATH/chapters/$CHAPTER_ID/lessons" -H "$AUTH" -H "Content-Type: application/json" \
-H "Idempotency-Key: lesson:$CHAPTER_ID:first-week:v1" \
-d '{ "title": "Your first week", "lecture": "<p>Meet your team and set up your tools.</p>" }' > /dev/null
curl -sS "$COURSE_PATH/chapters" -H "$AUTH" | jq '.data[0]'A chapter:
{
"id": "00000000-0000-4000-8000-000000000202",
"courseId": "00000000-0000-4000-8000-000000000201",
"title": "Getting started",
"slug": "1",
"position": 1,
"lessons": [
{
"id": "00000000-0000-4000-8000-000000000203",
"chapterId": "00000000-0000-4000-8000-000000000202",
"title": "Your first week",
"description": "",
"type": "markdown",
"slug": "1",
"position": 1,
"isPublic": false,
"status": null,
"updatedAt": "2026-09-30T09:00:00.000Z"
}
]
}A lesson from GET /lessons/{lessonId}, create, and update has the same fields plus lecture, the lesson body as HTML, and content, the lesson's structured content field (for example practice code or a linked asset id). unassignedLessons in GET /chapters lists lessons that belong to no chapter, with chapterId: null.
positionis 1-based. Omit it on create to append.- A lesson created through the API is hidden from learners (
isPublic: false) until you send"isPublic": trueon create or with the lessonPATCH. - To move a lesson to another chapter of the same course, send
chapterId, withpositionfor a specific place. - Reorder bodies must list every chapter id of the course, or every lesson id of the chapter, exactly once, otherwise
400. - Lessons created through the API are text lessons; a lesson's type cannot be changed through the API. Quizzes, coding problems, and translations of a lesson are not exposed.
lectureis HTML. Saving it marks existing translations of the lesson stale. It cannot be emptied through the API.- Every successful change sends a
resource.updatedwebhook for the course.
Videos
A new video has no scenes: create it, then add scenes one at a time with POST /videos/{videoId}/scenes and fill each in with the scene PATCH. The TutorFlow workspace lists a video only once it has at least one scene, so a video you created but have not added a scene to does not appear there yet. A scene added with "insertAfterSceneId": null goes last, so its id is the last one in scenes.
VIDEOS="$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/videos"
AUTH="Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"
VIDEO_ID="$(curl -sS -X POST "$VIDEOS" -H "$AUTH" -H "Content-Type: application/json" \
-H "Idempotency-Key: video:safety-summary:v1" \
-d '{ "title": "Safety summary", "description": "Editable video for workplace safety.", "visibility": "PRIVATE" }' \
| jq -r '.id')"
add_scene() {
curl -sS -X POST "$VIDEOS/$VIDEO_ID/scenes" -H "$AUTH" -H "Content-Type: application/json" \
-H "Idempotency-Key: scene:$VIDEO_ID:$1:v1" \
-d '{ "insertAfterSceneId": null }' | jq -r '.scenes[-1].id'
}
SCENE_ID="$(add_scene welcome)"
curl -sS -X PATCH "$VIDEOS/$VIDEO_ID/scenes/$SCENE_ID" -H "$AUTH" -H "Content-Type: application/json" \
-d '{
"script": "Welcome to the safety summary.",
"displayText": "Safety summary",
"remotionTemplate": "title",
"keywords": ["safety", "onboarding"],
"duration": 4,
"subtitles": [{ "text": "Welcome to the safety summary.", "offset": 0, "duration": 4 }],
"transitionOut": "fade",
"transitionDuration": 0.45
}' > /dev/null
SCENE_ID="$(add_scene report-hazards)"
curl -sS -X PATCH "$VIDEOS/$VIDEO_ID/scenes/$SCENE_ID" -H "$AUTH" -H "Content-Type: application/json" \
-d '{
"script": "Report hazards immediately and follow the posted emergency process.",
"displayText": "Report hazards immediately",
"remotionTemplate": "keyword",
"keywords": ["report", "hazards"],
"duration": 5
}' | jq '{id, scenes: [.scenes[] | {id, script, duration}]}'The video is landscape 16:9. POST /videos takes only title, description, and visibility; send scenes with the scene routes below. A new scene starts with an empty script, the keyword template, and a 5-second duration.
| Scene field | Notes |
|---|---|
script | Narration text. Rendering also needs narration audio for every scene; generate it from the script with the narration routes. The scene's hasNarration says whether it has audio. |
displayText | On-screen title or quote text. |
remotionTemplate | title, keyword, quote, sketch, or none. |
keywords | Highlight terms for keyword and sketch templates. |
duration | Scene length in seconds, at least 0.5. |
subtitles | Optional timed subtitles with text, offset, and duration. |
transitionOut | Optional transition into the next scene, for example fade. Omit it or send null for a hard cut. |
Timing fields such as audioDuration, videoDuration, visualDuration, and subtitleDuration must be numbers when present; omit them rather than sending null.
Scene operations, under /videos/{videoId}:
| Method | Path | Body | Returns |
|---|---|---|---|
POST | /scenes | { "insertAfterSceneId": null } | 201 with the whole video. Read the new scene id from scenes. |
PATCH | /scenes/{sceneId} | Any writable scene field, for example { "script", "displayText", "duration" } | 200 with the whole video |
PATCH | /scenes/reorder | { "sceneIds": [...] } | 200 with the whole video |
DELETE | /scenes/{sceneId} | 200 with the whole video |
To produce an mp4, see Video Rendering.
Slides
curl -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/slides" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: slides:safety-training:v1" \
-d '{
"title": "Safety training",
"description": "Deck for the October onboarding cohort.",
"outline": "1. Why safety matters\n2. Reporting hazards",
"visibility": "PRIVATE",
"content": "<slide><h1>Safety training</h1></slide>"
}'title and visibility are required. Without content, the deck has no body (content: null) until you send one. content is stored as given and returned by the single read, GET /slides/{slideId}; the create response leaves it out. Update with PATCH /slides/{slideId} and any of title, description, outline, visibility, content, thumbnail, and metadata.
Tests
curl -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/tests" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: test:safety-check:v1" \
-d '{
"topicPrompt": "Safety check",
"targetPrompt": "Assess basic workplace safety knowledge.",
"level": "MEDIUM",
"passingValueType": "PERCENTAGE",
"passingValue": 70
}'topicPrompt, level, passingValueType, and passingValue are required. A new test has no items: add them with the PATCH below, or let TutorFlow write them with POST /tests/generate.
Update a test and its items with PATCH /tests/{testId}:
{
"name": "Safety check",
"timeLimit": 20,
"items": [
{
"question": "What should you do when you see a hazard?",
"type": "select",
"options": ["Report it", "Ignore it", "Hide it"],
"correctAnswers": ["Report it"],
"explanation": "Reporting hazards helps the team respond quickly.",
"score": 10
}
]
}Test item type values are select, blank, open-ended, true-false, and submission.
An item id in items must belong to this test, or be one no item uses yet (you may choose ids for new items). An id that belongs to an item of another test answers 404 content_not_found, and nothing is saved. To attach audio to a question, upload it with purpose test-audio and send the key as the item's questionAudioKey.
Games and simulations
Create them with POST /games or POST /simulations (title and visibility are required), then plan and build them with the priced generation routes. See Games, Simulations, and Generation.
Checklist
- Store TutorFlow ids next to your own ids, with the
Idempotency-Keyyou used to create each resource. - Read a resource after creating it before showing it to reviewers.
- Send
If-Matchon updates when educators may edit the same content in TutorFlow. - Review a video before rendering it.
- Keep games and simulations
PRIVATEuntil a person has opened the build. - Use a separate key for test and production.
- Use
/v1/content/**only. Never use Agent Platform keys, workspace ids, edit tokens, or platform URLs for content managed in TutorFlow.