Games are reachable from the Content API with the same operations the admin UI has, and at the same credit cost. The API routes through the very services the UI calls, so nothing about access, validation, or pricing differs between the two.
All routes live under /v1/content/classrooms/{classroomId}/games and take a tf_content_ key. Discover the classroom id with GET /v1/content/classrooms first.
Resource operations
| Method | Path | Success response |
|---|---|---|
POST | /games | 201 with the created game |
GET | /games | 200 with a page of games |
GET | /games/{gameId} | 200 with the game, including its built HTML |
PATCH | /games/{gameId} | 200 with the updated game |
DELETE | /games/{gameId} | 200 with the deleted game |
POST | /games/{gameId}/versions/{version}/restore | 200 with the restored game |
None of these consume AI Credits. POST /games and the restore action honour the Idempotency-Key header like the other content resources: a retry with the same key and body returns the response made the first time, and the same key with a different body returns 409.
The game object
Every read, create, update, and restore returns the same shape. content appears only on GET /games/{gameId}.
| Field | Notes |
|---|---|
id | Use as {gameId} on later calls. |
classroomId | The classroom the game belongs to. |
title, description | Display text. description may be null. |
visibility | PRIVATE or PUBLIC. A public game opens at its play link without an account. |
slug | A short label TutorFlow assigns. It is not part of any link. |
metadata | An object you own. The keys listed under Metadata that shapes the plan feed the brief; anything else is stored untouched. |
brief | The plan the build is generated from. null until a brief has been generated or supplied. |
contentKey | TutorFlow's internal storage reference for the served version. Opaque; null until the first build. |
contentVersion | The version currently served. 0 until the first build, then increments on every build, revision, and restore. |
buildHistory | One entry per version: version, contentKey, createdAt, source (build, revision, or restore), an optional note holding the feedback a revision was asked for, and restoredFromVersion on a restore. |
thumbnail | A storage reference for a capture of the opening screen, or null. Treat it as opaque, like contentKey. |
content | The built game as one complete HTML document, or null before the first build. Only on GET /games/{gameId}. |
createdAt, updatedAt | Timestamps for incremental sync. |
A game is a self-contained page, so content is the whole thing and can be large. GET /games never includes it. List responses carry metadata only, so paging through a classroom stays cheap. Fetch the content for the one game you actually need.
The brief
brief is what the build reads, and the most useful thing to inspect before spending credits. A generated brief has these fields:
| Field | Notes |
|---|---|
title | Short, concrete title for the game. |
pitch | One sentence on what playing it is like. |
coreLoop | The action the player repeats. |
mechanics | Two to five rules or devices the game is built from. |
levels | Two to six entries of name, goal, and twist. |
winCondition, failCondition | What ends a run either way. |
controls | How it is played, by keyboard and by touch. |
You can replace the brief with PATCH /games/{gameId} and a brief object, so an integration that already knows the plan it wants can skip the priced brief call.
Creating a game
title and visibility are required. Keep new games PRIVATE until a person has played the build.
curl -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$TUTORFLOW_CLASSROOM_ID/games" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: game:fractions-run:v1" \
-d '{
"title": "Fractions run",
"description": "Practise comparing fractions under time pressure.",
"visibility": "PRIVATE",
"metadata": {
"topic": "Comparing fractions with unlike denominators",
"audience": "Grade 5",
"objectiveFocus": "Decide which of two fractions is larger",
"playLength": "short",
"language": "en",
"externalId": "game:fractions-run"
}
}'Metadata that shapes the plan
The admin UI's create wizard stores its answers on metadata, and the brief reads the same keys. Set them on create or with a later PATCH. All are optional.
| Key | Values | Notes |
|---|---|---|
topic | Free text | What the game teaches. Falls back to title. |
audience | Free text | Who plays it. |
objectiveFocus | Free text | The skill a round should exercise. |
mechanic | Free text | A mechanic to build around, if you have one in mind. |
dimension | 2d or 3d | Defaults to 2d. |
persistence | session or local | Whether progress survives a reload. Defaults to session. |
playLength | short, standard, or deep | Defaults to standard. |
visualStyle | Free text | Art direction for the page. |
language | Language name or code | Language of on-screen text. Set it; the model does not infer it from your key. |
referenceMarkdown | Markdown | Your own material to ground the plan. POST /brief can override it per call. |
PATCH merges metadata: keys you send replace their old values, and keys you leave out stay as they were.
Listing games
GET /v1/content/classrooms/{classroomId}/games?page=1&limit=20&order=DESC&field=updatedAt&search=fraction| Parameter | Default | Notes |
|---|---|---|
page | 1 | 1-based. |
limit | 10 | Up to 5000. |
order | DESC | ASC or DESC. |
field | title | Sort column: title, createdAt, or updatedAt. Anything else returns 422. |
search | Case-insensitive match against title and description. |
Response:
{
"items": [
{
"id": "00000000-0000-4000-8000-000000000501",
"title": "Fractions run",
"visibility": "PRIVATE",
"contentVersion": 2,
"updatedAt": "2026-09-09T00:00:00.000Z"
}
],
"totalCount": 1
}Updating a game
PATCH /games/{gameId} is partial. Fields omitted keep their values. It accepts title, description, visibility, brief, thumbnail, and metadata. It cannot set contentKey or contentVersion; those belong to the build pipeline.
Generation
Building a game is three steps, and each is its own call. They are separate because the plan is cheap and the build is not: reading a plan and rejecting it should not cost what building it costs.
| Method | Path | Credits | Requires |
|---|---|---|---|
POST | /games/{gameId}/brief | 1 | A game. |
POST | /games/{gameId}/build | 20 | A brief. |
POST | /games/{gameId}/revise | 20 | A built version. |
These are the same prices the admin UI pays, drawn from the organization's AI Credit balance. Check the balance with GET /v1/content/credits before a batch. A credit is charged only after the new version is stored; a build that fails costs nothing.
POST /games/{gameId}/brief accepts an optional referenceMarkdown body field to ground the plan in your own material. POST /games/{gameId}/build takes no body. POST /games/{gameId}/revise takes a feedback field of 1 to 2000 characters describing the change in plain language, the same way the chat panel in the editor does. The feedback is kept, truncated to 200 characters, as the note on the resulting history entry.
These three stream
The generation routes respond with text/event-stream, not a JSON body. Read them as Server-Sent Events. Each event has an event name and a JSON data line, and the response ends after the terminal event.
event: progress
data: {"stage":"writing"}
event: progress
data: {"stage":"checking"}
event: build-done
data: {"contentKey":"classrooms/.../content/2.html","contentVersion":2,"thumbnail":"classrooms/.../thumbnail/2.png"}| Event | Sent by | data |
|---|---|---|
progress | build, revise | { "stage": "writing" | "checking" | "fixing" | "publishing" }. fixing means a version failed the checks and is being rebuilt. |
brief-done | brief | { "brief": { ... } }, the plan that was stored on the game. |
build-done | build, revise | { "contentKey", "contentVersion", "thumbnail" } for the version that is now served. Call GET /games/{gameId} for the HTML. |
error | any | { "message", "violations"? }. Nothing was stored and nothing was charged. violations lists the checks the last attempt failed. |
Exactly one of brief-done, build-done, or error ends a stream.
Errors before and during the stream
The classroom, the game, its readiness, and the credit balance are all checked before the stream opens. Those failures come back as the normal JSON error envelope, not as events:
| Status | error.code | When |
|---|---|---|
400 | content_invalid_request | Building without a brief, or revising a game that has not been built. |
402 | content_payment_required | The organization does not have enough available AI Credits for this step. |
402 | content_payment_failed | The organization has a failed payment. |
404 | content_not_found | The classroom or game was not found. |
Once the stream has opened, the HTTP status is 200 whatever happens next, and a failure arrives as an error event.
Disconnecting aborts the work. If your client hangs up mid-build, the model call stops rather than running to completion unread, and nothing is charged. That also means a client timeout shorter than a build will cancel the build. Builds take minutes, not seconds, so set the timeout on these three calls with the build duration in mind rather than reusing the default you use for CRUD.
These three routes do not take Idempotency-Key. Retrying a build that already streamed build-done builds and charges again.
Restoring a version
POST /v1/content/classrooms/{classroomId}/games/{gameId}/versions/{version}/restore
Idempotency-Key: game:{gameId}:restore:3:v1{version} is a version number from buildHistory. Restoring runs no model and charges nothing. It copies the chosen version forward as the newest one rather than rewinding, so a restore is itself reversible and nothing an educator made is lost. The response is the game with contentVersion incremented and a new history entry whose source is restore and whose restoredFromVersion names the version you chose.
An unknown version returns 404 with content_not_found. Send an Idempotency-Key: without one, a retried restore appends a second identical version.
Sharing and deleting
Set visibility to PUBLIC with a PATCH when the game is ready to share. It then opens at:
https://tutorflow.io/games/play/{gameId}That link needs no account. While the game is PRIVATE the link returns 404 to everyone, including its own classroom.
DELETE /games/{gameId} soft-deletes the game and returns it. Later reads return 404 with content_not_found, and the play link stops working.
A typical run
POST /gameswith a title,visibility: "PRIVATE", and themetadatakeys above. Keep the returnedid.POST /games/{gameId}/brief. Read the plan from thebrief-doneevent.- Inspect the plan. If it is wrong,
PATCHthe game'smetadataorbrief, or ask for another brief. This is the cheap point to change your mind. POST /games/{gameId}/build. Wait forbuild-done.GET /games/{gameId}to read the built game and its version history.POST /games/{gameId}/revisewith feedback if something needs changing, or restore an earlier version if the revision made it worse.PATCH /games/{gameId}withvisibility: "PUBLIC"once a person has played it, and hand out its link.
What the build guarantees
Every generated version is checked twice before it is stored: an automated validation of the page, then a playthrough that opens the game and works the controls. A version that will not start, or that freezes partway, is rebuilt rather than returned. If the rebuild fails too, the stream ends with an error event that lists the violations, and nothing is charged.
Those checks confirm the game runs. They judge nothing about whether it suits a particular class, so an integration that publishes automatically is publishing something no person has played. See Games Overview for what the checks do and do not cover.