Games and simulations are generated in three steps, and each step 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. 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 is null for a brief, because briefs are not tracked as runs.
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. 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 build or revision (runId, phase, stage, startedAt), or null. |
last | How the most recent build or revision ended: runId, phase, status (done or failed), message and violations on failure, startedAt, and finishedAt. Kept for 1 hour. |
Poll every 10 to 30 seconds. A build or revision runs for minutes and is stopped after 30 minutes. Briefs do not appear here; read the brief from the webhook body or from GET /{kind}/{id}.
Idempotency in async mode
Send an Idempotency-Key with every async request. A retry with the same key and the same body returns the first 202, with the same runId, and never starts or charges a second run. The same key with a different body returns 409. Keys replay for 24 hours. Use a new key, for example game:{gameId}:build:v2, when you mean to build again.
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.
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. 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 the resource with 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. Read the plan from the*.brief.completedwebhook or 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.