The content routes of the Agent Platform are deprecated. Courses, modules, tests, slides, videos, games, evaluations, and the learner model now live on the Content API, under /v1/content/classrooms/{classroomId}/.... The Agent Platform stays as the account layer: registration, the account and its balance, top-ups, pricing, discovery, LTI, and the MCP server.
This guide lists every deprecated route with its replacement and what changes in the answer. The full Content API reference is at Content API Reference.
Timeline
| Date | What happens |
|---|---|
| 2026-10-15 | The routes below are deprecated. They keep working and answer with deprecation headers. |
| 2027-01-31 | Sunset. The deprecated routes are removed, and tf_platform_ keys stop working on them. |
After the sunset, content created on the deprecated routes is planned to be copied into your account's default classroom, and share links already handed out are planned to redirect to the classroom pages. Do not depend on that: move your integration before 2027-01-31.
These stay where they are and are not deprecated:
/v1/platform/agent/*: register, account, billing, x402, email verification./v1/platform/pricing-catalogand/v1/platform/.well-known/agent.json./v1/platform/health./v1/platform/lti/*, including LTI registrations.- The MCP server at
https://mcp.tutorflow.io/api/mcp.
Deprecation headers
Every answer from a deprecated route, errors included, carries three headers:
Deprecation: @1792022400
Sunset: Sun, 31 Jan 2027 00:00:00 GMT
Link: <https://tutorflow.io/agent-platform/docs/guides/content-api-migration>; rel="deprecation"| Header | Meaning |
|---|---|
Deprecation | When the route was deprecated, as @ and Unix seconds (RFC 9745). @1792022400 is 2026-10-15T00:00:00Z. |
Sunset | When the route is removed (RFC 8594). |
Link | This guide, with rel="deprecation". |
Log a warning whenever your client sees Deprecation on an answer. That finds every call you still have to move.
Keys
The Content API takes only tf_content_ keys. Your agent account already has one.
Get a tf_content_ key
- From registration.
POST /v1/platform/agent/registerreturnscontentApiKey(atf_content_key) next to the legacyapiKey(atf_platform_key). UsecontentApiKeyfor everything. - From the dashboard. Sign in at tutorflow.io, open the Agent Platform dashboard, and create a key. The dashboard creates only
tf_content_keys now. Choose Live or Test as the mode.
The raw key is shown once. Store it in your secrets manager.
Live and test keys
| Live key | Test key | |
|---|---|---|
| Starts with | tf_content_ | tf_content_test_ |
| Classroom | Your default classroom (defaultClassroomId) | Only the sandbox classroom (sandboxClassroomId) |
| Credits | Spent from the account balance | Never spent; priced routes answer with canned content |
Account routes (/v1/platform/agent/*) | Yes | No, 403 platform_content_key_too_narrow |
Read both classroom ids from GET /v1/platform/agent/account with a live key. With only a test key, GET /v1/content/classrooms lists the sandbox. See Test Mode.
Which key works where
| Route | tf_content_ key | tf_platform_ key |
|---|---|---|
/v1/content/** | Yes | No, 401 content_platform_key_not_accepted |
Deprecated /v1/platform content routes | No, 401 platform_content_key_not_accepted | Yes, until 2027-01-31 |
/v1/platform/agent/*, /v1/platform/health, /v1/platform/lti/registrations | Yes, a live key with every classroom and the content:read, content:write, content:generate, and webhooks:manage scopes, like the key register and the dashboard issue | Yes |
The two refusals look like this:
{
"error": {
"code": "content_platform_key_not_accepted",
"message": "Agent Platform keys (tf_platform_) do not work on the Content API. Use a tf_content_ key: agent register returns one as contentApiKey, and the agent dashboard issues new ones.",
"status": 401,
"requestId": "..."
}
}{
"error": {
"code": "platform_content_key_not_accepted",
"message": "This deprecated Platform content route takes tf_platform_ keys only. The same work is on the Content API: call /v1/content with your tf_content_ key. Migration guide: https://tutorflow.io/agent-platform/docs/guides/content-api-migration",
"status": 401
}
}Rotate a key
Rotate or revoke keys in the Agent Platform dashboard. Rotation returns a new raw key once; update your secret store and restart the clients that hold the old one. Rotating a tf_platform_ key gives another tf_platform_ key, which still stops working at the sunset, so create a tf_content_ key instead and revoke the old one once nothing uses it.
The classroom in every URL
Every Content API route sits under a classroom: /v1/content/classrooms/{classroomId}/.... An agent account has one default classroom, created at registration. Read its id once and keep it:
curl https://api.tutorflow.io/v1/platform/agent/account \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"{
"defaultClassroomId": "00000000-0000-4000-8000-000000000010",
"sandboxClassroomId": null,
"contentApiBaseUrl": "https://api.tutorflow.io/v1/content",
"contentApiUrls": {
"listClassrooms": "GET /v1/content/classrooms",
"generateCourse": "POST /v1/content/classrooms/00000000-0000-4000-8000-000000000010/courses/generate",
"listCourses": "GET /v1/content/classrooms/00000000-0000-4000-8000-000000000010/courses",
"generateSlides": "POST /v1/content/classrooms/00000000-0000-4000-8000-000000000010/slides/generate",
"generateTest": "POST /v1/content/classrooms/00000000-0000-4000-8000-000000000010/tests/generate",
"createVideo": "POST /v1/content/classrooms/00000000-0000-4000-8000-000000000010/videos"
}
}sandboxClassroomId stays null until the account has a test key.
What changes in the answers
| Platform route behavior | Content API behavior |
|---|---|
Create routes generate synchronously, or asynchronously with mode: "async". | Generate routes always answer 202 with a run. Follow it with GET .../runs/{runId} or a *.generation.completed webhook. See Generation runs. Evaluations keep mode. |
The prompt field is prompt. | The field is topic. Course generation still reads prompt when topic is absent; tests, modules, and slides need topic. |
Edit tokens (editToken, previewUrl, POST /:id/edit-token) open a token editor. | Resources carry editorUrl, the TutorFlow classroom editor. A person opens it with their own TutorFlow sign-in, as an admin of your account's organization: for an agent account, the person who verified its email. There are no edit tokens. |
Share tokens (shareToken, publicUrl on a Platform share page). | Resources carry publicUrl only while their public page is served: a course that is public and published, a public or unlisted test, a module, a slide deck, or a public video that has been rendered. Otherwise it is null. The sandbox classroom has no public pages, so test-key resources have neither link. |
Lists use limit and offset and answer { "items", "total" }. | Resource lists use page and limit and answer { "data", "meta" }. Runs and evaluations use a cursor: send meta.nextCursor back as cursor. See List responses. |
Create answers carry priceSnapshot in USD. | A run carries estimatedCredits and creditsCharged. Every charge is in GET /v1/content/credits/history. |
tier is accepted and ignored. | Not accepted on generate routes. Unknown fields are dropped. |
Errors use platform_* codes. | Errors use content_* codes and carry requestId. See Errors. |
Idempotency-Key header where documented. | Idempotency-Key header on every create, generate, and render. An evaluation's idempotencyKey body field is ignored; send the header. |
Route map
{classroomId} is your defaultClassroomId. Paths on the right are under /v1/content/classrooms/{classroomId}.
Courses: /v1/platform/courses
| Deprecated route | Content API replacement |
|---|---|
POST /courses | POST /courses/generate. 202 with a run. Send topic, and optionally title, description, level, lessonCount (1 to 30), and language. subject and tier are gone. To create a course from content you already have, POST /courses. |
GET /courses | GET /courses |
GET /courses/:id | GET /courses/{courseId}. Add isIncludeStats=true for lessons, chapters, and stats. |
PATCH /courses/:id | PATCH /courses/{courseId} |
PATCH /courses/:id/canonical | No single bulk route. Use the chapter and lesson routes: GET /courses/{courseId}/chapters, POST /courses/{courseId}/chapters, PATCH /courses/{courseId}/chapters/reorder, POST /courses/{courseId}/chapters/{chapterId}/lessons. |
PATCH /courses/:id/chapters/:chapterId | PATCH /courses/{courseId}/chapters/{chapterId} |
PATCH /courses/:id/lessons/:sequence | PATCH /courses/{courseId}/lessons/{lessonId}. Lessons are addressed by id, not sequence; read the ids from GET /courses/{courseId}/chapters. |
PATCH /courses/:id/lessons/by-id/:lessonId/full | PATCH /courses/{courseId}/lessons/{lessonId} |
POST /courses/:id/edit-token | editorUrl on the course. |
GET, PATCH /courses/edit/:editToken, and every /courses/edit/:editToken/... route (full, canonical, lessons, lesson generation, infer-type) | No token routes. A person edits in the classroom editor at editorUrl. Your integration uses the course, chapter, and lesson routes above. |
GET /courses/public/:shareToken, .../full, .../lessons/:sequence | publicUrl on the course, for people. For your integration, GET /courses/{courseId}/chapters and GET /courses/{courseId}/lessons/{lessonId}. |
See Courses and Course chapters and lessons.
Modules: /v1/platform/modules
| Deprecated route | Content API replacement |
|---|---|
POST /modules | POST /modules/generate. 202 with a run. Send topic, and optionally type, referenceText, and language. To create a module from content you already have, POST /modules. |
GET /modules | GET /modules |
GET /modules/:id | GET /modules/{moduleId} |
PATCH /modules/:id | PATCH /modules/{moduleId} |
DELETE /modules/:id | DELETE /modules/{moduleId} |
GET /modules/public/:shareToken | publicUrl on the module. |
Tests: /v1/platform/tests
| Deprecated route | Content API replacement |
|---|---|
POST /tests | POST /tests/generate. 202 with a run. Send topic, and optionally instructions, level (EASY, MEDIUM, HARD), itemCount (1 to 30), itemTypes, totalScore, passingValueType, passingValue, and language. A test is charged flat, not per item. |
GET /tests | GET /tests |
GET /tests/:id | GET /tests/{testId} |
PATCH /tests/:id | PATCH /tests/{testId} |
PATCH /tests/:id/full | PATCH /tests/{testId} with items. |
PATCH /tests/:id/items/by-id/:itemId | PATCH /tests/{testId} with the item, by its id, in items. |
POST /tests/:id/edit-token | editorUrl on the test. |
Every /tests/edit/:editToken/... route, including items/generate | No token routes. A person edits at editorUrl; your integration uses PATCH /tests/{testId}. There is no route that adds AI-written items to an existing test; generate a new test instead. |
GET /tests/public/:shareToken, .../full, .../items/:sequence, .../take | publicUrl on the test, set while the test is PUBLIC or UNLISTED. |
POST /tests/public/:shareToken/submissions, PATCH /tests/submissions/:submissionToken, GET /tests/submissions/:submissionToken/result | No API for taking a test. Learners take it on the classroom page; anyone can take a PUBLIC test there without an account. |
GET /tests/:id/submissions | GET /tests/{testId}/results and GET /tests/{testId}/results/{resultId}. Attempts by people who are not learners of the classroom have isAnonymous: true and a taker. Needs learners:read; not available to test keys. |
Slides: /v1/platform/slides
| Deprecated route | Content API replacement |
|---|---|
POST /slides | POST /slides/generate. 202 with a run. Send topic, and optionally instructions, slideCount, textLength, themeId, and language. To create a deck from content you already have, POST /slides. |
GET /slides | GET /slides |
GET /slides/:slideRequestId | GET /slides/{slideId} |
PATCH /slides/:slideRequestId | PATCH /slides/{slideId} |
GET, PATCH /slides/edit/:editToken | editorUrl on the deck. Your integration uses GET and PATCH /slides/{slideId}. |
POST /slides/edit/:editToken/generate/image | No Content API route yet. The deprecated route keeps working until the sunset. |
POST /slides/edit/:editToken/pptx | No Content API route. |
GET /slides/public/:shareToken | publicUrl on the deck. |
Videos: /v1/platform/videos
The Content API has no route that writes a whole narrated video from a prompt. You create the video, add and fill its scenes, generate narration from each scene's script, then render. See Videos and Video Rendering.
| Deprecated route | Content API replacement |
|---|---|
POST /videos | POST /videos (takes title, description, visibility), then POST /videos/{videoId}/scenes and PATCH /videos/{videoId}/scenes/{sceneId} for each scene, then POST /videos/{videoId}/narration. You write the scene scripts. |
GET /videos | GET /videos |
GET /videos/:videoRequestId | GET /videos/{videoId}. Render progress is at GET /videos/{videoId}/render. |
PATCH /videos/:videoRequestId | PATCH /videos/{videoId} |
POST /videos/:videoRequestId/scenes | POST /videos/{videoId}/scenes |
PATCH /videos/:videoRequestId/scenes/:sceneId | PATCH /videos/{videoId}/scenes/{sceneId} |
DELETE /videos/:videoRequestId/scenes/:sceneId | DELETE /videos/{videoId}/scenes/{sceneId} |
PUT /videos/:videoRequestId/scenes/reorder | PATCH /videos/{videoId}/scenes/reorder with { "sceneIds": [...] } |
GET, PATCH /videos/edit/:editToken | editorUrl on the video. Your integration uses GET and PATCH /videos/{videoId}. |
POST /videos/edit/:editToken/scenes, PATCH and DELETE .../scenes/:sceneId, PUT .../scenes/reorder | The scene routes above. |
POST /videos/edit/:editToken/scenes/:sceneId/tts | POST /videos/{videoId}/scenes/{sceneId}/narration |
POST /videos/edit/:editToken/render | POST /videos/{videoId}/render |
POST /videos/edit/:editToken/render/cancel | POST /videos/{videoId}/render/cancel |
POST /videos/edit/:editToken/scenes/:sceneId/clip/upload, .../visual/image/upload | Upload with purpose scene-visual (Asset Uploads), then set the scene's visualKey. |
POST /videos/edit/:editToken/scenes/:sceneId/overlays/sticker/upload | Upload with purpose overlay, then set the scene's visualOverlays. |
POST /videos/edit/:editToken/thumbnail | Upload with purpose thumbnail, then set the video's thumbnailKey. |
.../clip/search, .../clip/select, .../image/search, .../image/select, .../overlays/character/generate, /visual/slides, .../visual/slide, .../visual/slide/create, /bgm/search, /bgm/select, DELETE /bgm, /sync-durations | No Content API route. A person does this in the editor at editorUrl. For your own music, upload with purpose bgm and set bgmAudioKey. |
GET /videos/public/:shareToken | publicUrl on the video, set once it is PUBLIC and rendered. |
POST /videos/render-callback/:platformVideoId | Internal to TutorFlow. Not for clients. |
Games: /v1/platform/games
| Deprecated route | Content API replacement |
|---|---|
POST /games | POST /games (takes title and visibility), then POST /games/{gameId}/brief and POST /games/{gameId}/build. See Games. |
GET /games | GET /games |
GET /games/:id | GET /games/{gameId} |
PATCH /games/:id | PATCH /games/{gameId} |
DELETE /games/:id | DELETE /games/{gameId} |
GET /games/public/:shareToken | The game's play link. See Share and delete. |
Evaluations: /v1/platform/evaluations
The request body is the same: evaluationType, questionText, learnerAnswer, referenceAnswer, rubric, maxScore, learnerExternalId or submitter, conceptTags, language, mode, clientMetadata, and the rest. JSON and multipart/form-data with rubricFiles and answerFiles both work.
| Deprecated route | Content API replacement |
|---|---|
POST /evaluations | POST /evaluations. 201. Charges 1 credit. The learner comes back as learner: { "externalId", "name" }. Send Idempotency-Key as a header. |
GET /evaluations/:id | GET /evaluations/{evaluationId} |
GET /evaluations/:id/files/:field/:index | GET /evaluations/{evaluationId}/files/{field}/{index} |
GET /evaluations | GET /evaluations, newest first, with a cursor. Filter with status, evaluationType, and learner (the external learner id). List rows leave out the feedback and files; read one evaluation for them. |
Learners: /v1/platform/learners
| Deprecated route | Content API replacement |
|---|---|
GET /learners/:learnerId | GET /mastery/{learner}, the learner's mastery of each concept, by the external id their evaluations were sent with. Needs learners:read. |
GET /learners | No list route. Keep your own list of learner ids, or list evaluations with the learner filter. |
| (new) | GET /concepts: the account's concepts from evaluation concept tags, with their prerequisites. A test key gets an empty list. |
The learner model is kept per organization, so every classroom of the account answers the same.
Webhook endpoints: /v1/platform/webhooks
| Deprecated route | Content API replacement |
|---|---|
POST /v1/platform/webhooks | POST /v1/content/webhooks with your key (webhooks:manage). |
GET /v1/platform/webhooks | GET /v1/content/webhooks |
DELETE /v1/platform/webhooks/:id | DELETE /v1/content/webhooks/{webhookId} |
See Webhooks for testing, rotation, delivery logs, and redelivery.
Webhooks
Your Platform webhook endpoints were copied to Content API endpoints for the same account, with the same URL and the same signing secret. Each copy starts active if the Platform endpoint was active. Events were mapped:
| Platform event | Content API events |
|---|---|
video.completed | video.render.completed, video.render.failed |
test.completed | test.generation.completed, test.generation.failed |
course.completed | course.generation.completed, course.generation.failed |
slide.completed | slide.generation.completed, slide.generation.failed |
evaluation.completed | evaluation.completed |
evaluation.failed | evaluation.failed |
lti.* | Not copied. LTI events stay on the Platform endpoints, managed in the dashboard; their replacement is not decided yet. |
assessment.*, *.deployed | Dropped. They were never sent. |
A Platform *.completed event also carried failures, so it maps to both a completed and a failed event. Note that video.render.completed means the mp4 is ready; the Platform's video.completed meant the scenes were written.
What your receiver has to change:
| Platform | Content API | |
|---|---|---|
| Event header | X-Platform-Event | X-Content-Integration-Event |
| Signature header | X-Platform-Signature: hex HMAC-SHA256 of the raw body | X-Content-Integration-Signature: the same HMAC of the raw body, with the same secret |
| Recommended signature | X-Content-Integration-Signature-V2: t=<timestamp>,v1=<hmac> over <t>. plus the raw body. It resists replays; move to it. See Verify the signature. | |
| Body | Event-specific fields | Event fields at the top level, plus event (id, type, createdAt) and livemode. Deduplicate on event.id or X-Content-Integration-Event-Id. |
The quickest switch: read X-Content-Integration-Signature where you read X-Platform-Signature, since the value is computed the same way. Then branch on the new event names and payloads. A run event carries runId, resourceId, status, creditsCharged, and error; an evaluation event carries resourceId (the evaluation id), evaluationType, status, learnerExternalId, and the scores or error.
Billing
There is one balance: the classroom credits of the organization behind your account. Content API calls, deprecated Platform calls, and anything a person does in the TutorFlow editor all spend it.
- Your old wallet, kept in cents, was converted once into credits at 2 US cents per credit, rounded down. A wallet that still held only the untouched $1.00 trial grant, with no usage, was not converted. New accounts start with 50 trial credits.
GET /v1/platform/agent/accountanswerscreditsandcreditBalance.creditBalanceiscreditsat 2 cents each, in cents, the unit the field always used.- Top-ups (Stripe checkout and x402 USDC deposits) buy credits at 2 cents per credit.
- With a verified card, the account may overdraw up to its
overdraftLimit, and the overdraft is billed by Stripe at 2 cents per credit. - The deprecated routes now charge the classroom credit amounts, so they cost the same as the Content API.
See Pricing and Billing for the price of each call.
MCP
The MCP server at https://mcp.tutorflow.io/api/mcp accepts both key kinds until the sunset. With a tf_content_ key, create_course, get_course_generation, list_courses, run_evaluation, and list_evaluations call the Content API in your default classroom (the sandbox for a test key). With a tf_platform_ key, create_course, list_courses, run_evaluation, and list_evaluations keep calling the deprecated routes and add a sunset notice to every answer, and get_course_generation is not available. To migrate, change the Authorization header in your MCP client configuration to Bearer tf_content_.... See MCP Integration.
Checklist
- Get a
tf_content_key from registration or the dashboard, and store it. - Read
defaultClassroomIdfromGET /v1/platform/agent/account. - Replace each deprecated route with its replacement above. Send
topicinstead ofprompt. - Replace synchronous waits with run polling or
*.generation.*webhooks. - Replace edit and share token handling with
editorUrlandpublicUrl. - Update your webhook receiver to the Content API headers and event names.
- Switch the MCP client configuration to the
tf_content_key. - Watch your logs for answers with a
Deprecationheader until none are left.