Games and simulations are generated in three steps, and each step is its own call. (Tests, modules, courses, and slide decks are generated in one call each; see Generate Tests, Modules, Courses, and Slides.) 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. This page covers what games and simulations share. Their object fields, brief schemas, and metadata keys are on Games and Simulations.
In the paths below, {kind} is games or simulations, and {id} is the game or simulation id. Every path is under /v1/content/classrooms/{classroomId}.
The three steps
| Method | Path | Body | Requires |
|---|---|---|---|
POST | /{kind}/{id}/brief | Optional referenceMarkdown to ground the plan in your own material. It overrides metadata.referenceMarkdown for this call. | A game or simulation. |
POST | /{kind}/{id}/build | None. | A brief. |
POST | /{kind}/{id}/revise | feedback, 1 to 2,000 characters, describing the change in plain language. | A built version. |
All three need a key with the content:generate scope and spend AI Credits; see Pricing. A credit charge happens only after the new version is stored, so a run that fails costs nothing.
The brief is stored on the resource as brief. You can also write brief yourself with PATCH /{kind}/{id} and skip the priced brief call. The revision feedback is kept, cut to 200 characters, as the note of the new buildHistory entry.
Choose a mode
| Streaming (default) | Async | |
|---|---|---|
| How to ask | No extra header. | Prefer: respond-async header, or ?async=true. |
| Response | 200 with text/event-stream until the run ends. | 202 with a run id once the preconditions pass. |
| Hanging up | Aborts the run. Nothing is stored or charged. | Has no effect. The run continues. |
| Result | The terminal event on the stream. Webhooks are sent too. | game.* or simulation.* webhook, or poll GET /{kind}/{id}/run. |
Idempotency-Key | Ignored. | Replays the first 202. |
| Use it for | An interface where a person watches progress. | Server-to-server integrations. |
Async mode
curl -i -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/games/$GAME_ID/build" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Prefer: respond-async" \
-H "Idempotency-Key: game:$GAME_ID:build:v1"HTTP/1.1 202 Accepted
Preference-Applied: respond-async
Location: /v1/content/classrooms/00000000-0000-4000-8000-000000000010/games/00000000-0000-4000-8000-000000000601/run{
"runId": "00000000-0000-4000-8000-000000000a01",
"status": "running",
"kind": "game",
"phase": "build",
"resourceId": "00000000-0000-4000-8000-000000000601",
"classroomId": "00000000-0000-4000-8000-000000000010",
"statusUrl": "/v1/content/classrooms/00000000-0000-4000-8000-000000000010/games/00000000-0000-4000-8000-000000000601/run"
}kind is game or simulation. phase is brief, build, or revise. runId identifies the run for every phase, briefs included; the same id appears on GET /{kind}/{id}/run and in the webhook.
A 202 means every precondition passed and the run has started. Failures that can be checked first answer synchronously instead; see Errors.
When the run ends, TutorFlow sends a webhook: game.brief.completed or game.brief.failed for a brief, and game.build.completed or game.build.failed for a build or revision, with simulation.* equivalents. Each carries the runId. See Webhooks for the body.
Run status
To poll instead of waiting for the webhook, call the statusUrl:
GET /v1/content/classrooms/{classroomId}/{kind}/{id}/run
Authorization: Bearer tf_content_...{
"active": {
"runId": "00000000-0000-4000-8000-000000000a01",
"phase": "build",
"stage": "checking",
"startedAt": "2026-09-30T09:15:02.000Z"
},
"last": null
}| Field | Meaning |
|---|---|
active | The running brief, build, or revision (runId, phase, stage, startedAt), or null. A running build or revision is shown ahead of a running brief. |
last | How the most recent brief, build, or revision ended: runId, phase, status (done or failed), message and violations on failure, startedAt, and finishedAt. Whichever finished most recently is shown. Kept for 1 hour. |
phase is brief, build, or revise. Poll every 10 to 30 seconds. A build or revision runs for minutes and is stopped after 30 minutes. A brief refused before it starts (402, 403, 404) leaves no run behind. Briefs and builds never block each other: a brief can run while a build runs.
Run status is stored, not held in one server's memory, so every request sees the same answer whichever server handles it, and a 409 for a build that is already running holds across servers.
Interrupted runs
A run whose server stops, for a deploy or a crash, is not lost. It ends as failed, with a *.failed webhook and last.status: "failed", and a message that says what happened:
| What happened | When the run fails | message |
|---|---|---|
| The server restarted normally | At once | "The generation was interrupted by a server restart. Start it again." |
| The server stopped without shutting down | Within about 3 minutes | "The server running this generation stopped responding, so it was stopped. Start it again." |
| The run was still going 31 minutes after it started | Then | "The build ran past its time limit and was stopped." |
A build that already saved its new version when its server stopped, after a deploy or a crash, is reported done, once, with *.build.completed carrying that contentVersion.
A build that a restart or the sweeper reported failed, but that then finished, saved, and was charged in its last seconds, is recorded as done. If the *.build.failed webhook had not gone out yet, only *.build.completed is sent. If it had, a *.build.completed with the same runId follows as a correction: the later event wins, and GET .../run agrees with it. Because the failure released the Idempotency-Key, an integration that retries on failure may have started a second build by then; check contentVersion and buildHistory on the resource before building again.
Idempotency in async mode
Send an Idempotency-Key with every async request. While the run is going, or after it succeeded, a retry with the same key and the same body returns the first 202, with the same runId and the header Idempotent-Replayed: true, and never starts or charges a second run. The same key with a different body returns 409. Keys replay for 24 hours and are scoped to your API key. Use a new key, for example game:{gameId}:build:v2, when you mean to build again after a success.
When a run fails, its key is released before the *.failed webhook is sent. A retry with the same key then starts a new run with a new runId, so on *.failed you can retry with the key you already have.
Streaming mode
Without async mode, the three routes answer text/event-stream. Read the response as Server-Sent Events: each event has an event name and one 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/00000000-0000-4000-8000-000000000010/games/00000000-0000-4000-8000-000000000601/content/2.html","contentVersion":2,"thumbnail":"classrooms/00000000-0000-4000-8000-000000000010/games/00000000-0000-4000-8000-000000000601/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 now stored on the resource. |
build-done | build, revise | { "contentKey", "contentVersion", "thumbnail" } for the version now served. contentKey and thumbnail are storage references, not URLs. Call GET /{kind}/{id} for the built 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. Games and simulations both charge before build-done is sent, so if the charge fails, the stream ends with error alone (402), never build-done followed by error.
Hanging up aborts the run. If your client disconnects mid-build, the model work stops and nothing is charged. A client timeout shorter than the build therefore cancels it. Set the timeout on these calls in minutes, up to the 30-minute run limit, or use async mode.
Idempotency-Key is ignored when streaming, because a stream cannot be replayed. Retrying a build that already sent build-done builds and charges again. A second build while one is running returns 409, which is what stops a retried stream from starting a parallel run.
Errors
The classroom, the resource, its readiness, and the credit balance are checked before the stream opens or the 202 is sent. Those failures use the JSON error envelope:
| Status | error.code | When | Action |
|---|---|---|---|
400 | content_invalid_request | Building without a brief, or revising something never built. | Create the brief, or build first. |
402 | content_payment_required | Not enough available AI Credits for this step. | Add credits, then retry with the same key. |
402 | content_payment_failed | The organization has a failed payment. | Resolve it in TutorFlow Billing. |
403 | content_insufficient_scope | The key lacks content:generate. | Use a key with that scope. |
404 | content_not_found | The classroom or resource was not found. | Check the ids. |
409 | content_conflict | A build or revision is already running, or an async Idempotency-Key was reused with a different body. | Read GET /{kind}/{id}/run, then decide. |
422 | content_invalid_request | The body failed validation, for example feedback longer than 2,000 characters, or an id in the path is not a UUID. error.details names the field. | Fix the request. |
After that point, failures arrive as an error event on a stream, or as a *.failed webhook and in last of the run status in async mode:
event: error
data: {"message":"The safety rules were not satisfied","violations":["Game did not start within 10s"]}When violations is present, the model could not produce a page that passed the checks for that plan. Change the brief or metadata before building again; retrying the same plan usually fails the same way.
What the build checks
Every generated version is checked before it is stored: an automated validation of the page, then a run in a browser that works its controls. Games are played; simulations have each declared control moved across its range while TutorFlow watches whether the drawing changes. A version that fails is rebuilt, and if the rebuild also fails the run ends with violations and charges nothing.
The checks confirm the page runs and responds. They do not judge whether it suits a particular class, and for simulations they do not check that the arithmetic is right. Keep new games and simulations PRIVATE until a person has opened the build.
Restore a version
POST /v1/content/classrooms/{classroomId}/{kind}/{id}/versions/{version}/restore
Authorization: Bearer tf_content_...
Idempotency-Key: game:00000000-0000-4000-8000-000000000601:restore:3:v1{version} is a version number from buildHistory. Restoring runs no model and charges nothing, and needs content:write. It copies the chosen version forward as the newest one rather than rewinding, so a restore is itself reversible. The response is 200 with the resource, contentVersion incremented, and a new buildHistory entry whose source is restore and whose restoredFromVersion names the version you chose.
An unknown version returns 404 content_not_found. Send an Idempotency-Key: without one, a retried restore appends a second identical version.
Share and delete
Set visibility to PUBLIC with PATCH /{kind}/{id} once a person has opened the build. It then opens without an account at:
https://tutorflow.io/games/play/{gameId}
https://tutorflow.io/simulations/play/{simulationId}While it is PRIVATE, the link returns 404 to everyone, including its own classroom.
DELETE /{kind}/{id} soft-deletes the resource and returns 200 with { "id", "deleted": true, "success": true }. Later reads return 404 content_not_found, and the play link stops working.
A typical run
POST /{kind}with a title,"visibility": "PRIVATE", and themetadatakeys that shape the plan. Keep the returnedid.POST /{kind}/{id}/briefin async mode. Wait for the*.brief.completedwebhook or poll/runfor itsrunId, then read the plan fromGET /{kind}/{id}.- Check the plan. If it is wrong,
PATCHthemetadataorbrief, or ask for another brief. This is the cheap point to change your mind. POST /{kind}/{id}/buildin async mode with anIdempotency-Key. Wait for*.build.completed, or poll/run.GET /{kind}/{id}to read the built HTML incontentand the version history.POST /{kind}/{id}/revisewith feedback if something needs changing, or restore an earlier version.PATCHvisibilitytoPUBLIConce a person has opened it, and share the play link.
A copy-paste version of this run in curl, Node.js, and Python is in Examples.