Studio API
Français · Type: reference · Audience: author, contributor · Context: local Studio · Status: ready · Applies from: 4.4.0
The local Studio · contract: stable · verified by CI (support matrix of 4.4.0, 2026-10-23): Chromium end to end (the Studio's checks); Windows: the unit suite only · automated checks: jobs
e2e,windows; CI plane2e,windows; 2 test files · people's passes:studio-hero-matrixnot run (#238) · known limits:hosted-only-routesThe Studio Server (hosted team Studio) · contract: experimental · verified by CI (support matrix of 4.4.0, 2026-10-23): Postgres, S3 and Keycloak in CI; Chromium and Firefox (job studio-hosted); WebKit on macOS, by hand only · automated checks: jobs
studio-hosted,studio-install-smoke; CI planstudioHosted; 3 test files · people's passes:studio-hostednot run (#149),studio-collabnot run (#150),studio-previewnot run (#166),studio-assets-hostednot run (#167),studio-mcp-remotenot run (#196),studio-oidc-genericnot run (#197) · known limits:hosted-only-routes
The JSON API the Studio's page talks to: read the game, edit a room, run the checks. Each route below has its own
section; link to it as studio.api.md#put-room-id-layout (the method and the path in lowercase, - between words).
Overview
- Where it lives. The dev server (
npm run devornpm run studio, in the engine repository or in a created project) serves it under/__studio/api/. The Studio Server (4.2.1) serves the same table of routes under/api/, and names that base in a meta tag of the page it serves (src/studio/api-base.ts,studio-server/src/server/http.ts). One table,studio-server/src/ops/routes.ts, so both answer alike; the routes marked "dev server only" below exist on the dev server alone. - What a path is relative to. Every path is relative to the current game (
GAME, see environment variables). - Bodies. A
PUT,POSTorDELETEbody is a JSON object (the body must be a JSON objectotherwise, 400); aGETtakes none. The answer is JSON (cache-control: no-store), except the change feed, the screenshots, the Assets files and the Assistant's stream. - On the Studio Server, a write must carry the header
x-studio-request: 1(the Studio's page sends it on every request): withOriginand a JSON body, it is what no other site's page can send without a preflight. The session, the roles and the spans held by others apply there too (403, 423). - Errors. A refusal answers
{ "error": "<message>" }, sometimes with more fields, and a 4xx or 5xx status: 400 bad input, 404 unknown room, path or endpoint (no such endpoint: <path>), 405 wrong method (method <M> not allowed on <path>), 409 already exists or changed since, 422 the write would add validation errors (or nodefineRoom({...})in the file), 423 a span another member holds (shared Studio), 500 the game does not load or an edit would break the file, 501 unavailable here (a screenshot without Playwright, a capture on a hosted Studio). Every status and message is listed in the error register.
The same operations as functions
The routes call the plain functions of
studio-server/src/ops/core.ts, which the Vite plugin, the tests and the MCP
server use too: gameInfo(), getRoom(id), setLayout(id, layout), setText(id, path, value), addEntity(id, e),
getStoryboard(), setStoryboard(sb), exportStoryboardMarkdown(), getNotes(), addNote(n),
editNote(id, { text, about? }), deleteNote(id), validate(), solve(from?),
screenshot(room, checkpoint, devServerUrl) for the current game, and createStudio({ gameDir, root, importFresh })
for any other game folder.
- They never exit the process: an error is thrown as a
StudioError, with itsstatus. - The game is imported again on every call, so an edit on disk is seen: by default with tsx's scoped loader (fast, in
the same process);
importInChild(a childnode+ tsx process) is the fallback where in-process imports are cached, inside Vitest for instance. - Writes are serialized. Since 4.2.1 each write is one operation of the game's journal
(
studio-server/src/ops/journal.ts, kept in.cache/studio-journal/): every file it touches is written at once or not at all, durably (fsync), and an operation a crash interrupted is finished when the Studio opens again. - Every write whose files the validator reads is guarded on the game as it would be after it: 422 when it adds validation errors (text edits included since 4.2.1).
- Adding a prop, a hotspot or an actor writes the room and its layout as one operation, undone in one step.
- A change made outside the Studio (an editor, an agent, git) is recorded before the next write touches that file; a file another process rewrote while an edit was being prepared is a 409.
- The shared types are in
tools/studio/types.ts; the storyboard's schema, its normalisation and the Markdown export are intools/pages/storyboard-data.ts(pure, shared by the page generator, the core and the page).
Limit ·
hosted-only-routesThis page covers the table both servers share and the dev server's own routes. The Studio Server's own routes (sessions and login, members and invitations, comments, builds and previews, its Assets routes, the journal's cursor/api/ops, the engine upgrade, the probes,/mcp) are not described here: see the hosted Studio's pages.
Texts
A string literal under defineRoom({...}) is a text, addressed by its JSON path, when the path ends in name,
topic, toast, lines[n], say[1], shout[1], guide.say, choice[n].text, look.<id> or look.<id>[n], or
when it is a bare string in a command list (do, once, then, else, cutscene, after, onEnter, or a branch
of nth, cycle, random, parallel): those are hero lines. kind says which (name, look, say, hero,
topic, hint, toast, choice, guide); who is the speaker of a say.
- Ids, image refs, flags and conditions are never texts, and the API refuses to edit them.
- A replacement touches only the literal (the same quote style, escaped as needed).
- An append follows the list's layout (inline, or one item per line with the same indentation).
- A deletion removes the item and its separator.
- The paths of the following items shift after an append or a deletion: read the room again.
Serving
The Studio page is studio.html at the root of the engine (entry src/studio/main.ts). Any dev server serves it at
/__studio/ (npm run dev too); npm run studio also opens it. A production build leaves it out unless STUDIO=1
(the demo mode). The Rooms tab shows /?edit=<room>&at=<checkpoint> in an iframe and talks to the editor through
postMessage (placement editor). The Studio page ignores Vite's full reloads
caused by the game's files: the engine view reloads, the Studio keeps what you are typing and follows the change
through GET /events.
The game and its rooms
Read the game, read a room, and write a room's layout, texts, entities and structured values. Every write is one operation of the game's journal, guarded on the game as it would be after it.
GET /game
- Answer:
{ id, title, hero, rooms: [{ id, name, decor }], characters: { id: { name, color, portrait } }, items: { id: { name, icon } }, verbs, checkpoints, images, sfx }, read from the game module (with itsobjectivessince 4.1.12).imagesis the asset manifest, id →[w, h], thumbnails at/assets/img/<id>.webp;sfxthe ids ofaudio.sfx. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
GET /room/:id
- Answer:
{ def: RoomDef, layout: Layout, texts: TextRef[], file }, withTextRef = { path, value, file, line, kind, who? }for every text literal of the room file (see Texts). 404 for an unknown room. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
PUT /room/:id/layout
- Body: the whole
Layoutobject. - Answer:
{ ok }:layout/<id>.jsonwritten. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
PUT /room/:id/text
- Body:
{ path, value },valuea string ornull. - Answer:
{ ok, line, changed }: the string literal atpathreplaced inrooms/<id>.ts;changed: falsewhen the value was already there (nothing is written).value: nulldeletes a list line (or a wholelook.<id>); a path ending in[+]appends a line (look.piano[+],hints[2].lines[+],on[3].do[+]). A path that is not a text (an id, an image, a flag, a condition) is refused;valuethat is neither a string nor null is a 400. Guarded since 4.2.1: 422 when the edit would add validation errors. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
POST /room/:id/add
- Body:
{ kind: 'prop' | 'hotspot' | 'actor', id, name?, img?, char?, at: [x, y], look? }. - Answer:
{ ok, line, changed }: the entry inserted in the room file (its section created if absent), the optional first look line, and a place in the layout (prop: foot atat, height 60; hotspot: a 60 × 60 box centred onat; actor: feet atat), as one operation undone in one step.nameis required for props and hotspots; 409 when the id exists;kind,idandatare checked first (`kind` must be prop, hotspot or actor,`at` must be [x, y]). - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
PUT /room/:id/value
- Body:
{ path, value?, dry? }. - Answer:
{ ok, line, changed, diff, dry? }: a structured value (a reactionon[3]oron[<length>]to add one, a conditionon[3].if, a command liston[3].dooronEnter, the room'sstageorrenderer) written as code atpathin the file's style;valueabsent removes the path;dry: truereturns the diff only. 422{ error, errors, diff }when the write would add validation errors: since 4.2.1 nothing is written, the guard judges the game as it would be. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 3.4.
PUT /room/@game/value
- Body:
{ path, value?, dry? }. - Answer: The same for the game file's
defineGame({...}), objectives only:objectives,objectives.<id>({ title, done, optional?, parent? }) or one of its fields; any other path is a 400. Validated the same way, so an objective whosedonecan never hold is refused. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 4.1.12.
Revisions and undo
What changed between two revisions of the journal, the rooms it names drawn at both, and the undo and redo of the Studio's own edits.
GET /diff/:from..:to
- Answer: What changed between two revisions of the journal, by what the game names:
{ files: [{ path, change: added|removed|changed, ids?: { added, removed, changed: [{ id, fields }] }, values?: { added, removed, changed }, opaque?, cut? }] }; 2,000 revisions at most. The Check tab's "Changes between revisions". Each revision is 1 to 12 digits. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 4.2.3.
GET /diff/:from..:to/captures
- Answer: Local Studio only: each room the diff names, drawn at both revisions:
{ from, to, size: [960, 600], rooms: [{ room, why, before, after, box?, same? }], cut?, ms }. A side is{ file, url }(a PNG data URL and its file under.cache/studio/) or{ missing }(why it is not drawn: the revision does not build, the room is not there);box[x, y, w, h]where they differ,samewhen nowhere. 501{ unavailable, reason }on a hosted Studio or without Playwright. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 4.4.0.
POST /undo
- Answer:
{ ok, file, what }, or{ ok: false, reason }when there is nothing to undo; 409 when one of the operation's files changed since (an AI, an editor, git, another author). Itself one operation of the journal. On a shared Studio (4.2.2) each member undoes their own latest operation, and a span another member holds is a 423. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 3.4.
POST /redo
- Answer:
{ ok, file, what }, or{ ok: false, reason }when there is nothing to redo; 409 when one of the operation's files changed since (an AI, an editor, git, another author). Itself one operation of the journal. On a shared Studio (4.2.2) each member redoes their own latest operation, and a span another member holds is a 423. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 3.4.
GET /history
- Answer:
{ undo: [what], redo: [what] }: what an undo and a redo would take back, latest last. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 3.4.
Storyboard, scenario map and notes
The storyboard (storyboard.json), the groupings of the scenario map (groupings.json) and the shared notes (notes.json) of the game folder.
GET /storyboard
- Answer:
storyboard.json,{ boards: [] }when it is absent. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
PUT /storyboard
- Body:
{ boards: [...] }. - Answer:
{ ok, changed }: an unchanged storyboard is not rewritten, a changed one is written compactly (what fits on 120 columns stays on one line). - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
POST /storyboard/markdown
- Answer:
{ ok, file, bytes, boards, panels }:games/<id>/storyboard.mdwritten from the savedstoryboard.json(the same text asnpm run page:storyboard -- --md); 404 without astoryboard.json. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
GET /groupings
- Answer: The groupings file,
{ groupings: [{ id, kind: chapter|puzzle|character, title, refs }] }. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 4.2.3.
PUT /groupings
- Body:
{ groupings: [...] }. - Answer: Writes the groupings file, checked: ids unique and lowercase, kinds known, references without spaces, 500 at most of each.
- Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 4.2.3.
GET /map
- Answer: The scenario map derived from the groupings and the IR:
{ groupings: [{ …, refs: [{ ref, kind, at? }] }], ungrouped: { <kind>: [refs] } }. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 4.2.3.
GET /notes
- Answer:
{ entries: Note[] }, withNote = { id, about, author, text, at, edited? }. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
POST /notes
- Body:
{ about?, author?, text, task? }. - Answer: The new
Note, appended tonotes.json(created on first write);authordefaults toyou,atis an ISO date.task: truemarks a request for an MCP-connected agent. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
PUT /notes/:id
- Body:
{ text, about? }. - Answer: The updated
Note: text trimmed,editedset to now;at,authorand the order unchanged. 400 for an empty text (or anaboutthat is not a string), 404 for an unknown id. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
DELETE /notes/:id
- Answer:
{ ok }: the note removed fromnotes.json; 404 for an unknown id. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
Checks and views
The validator, the solver, the lint, the playtests, the coverage of the storyboard, the profiler, the world map, the puzzle graph and the game's IR: read-only, the game reloaded on each call. On the Studio Server, validate, solve, lint and playtests run on the processing worker.
POST /validate
- Answer:
{ ok, errors: string[], warnings: string[], ms }. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
POST /report
- Answer: The content profiler:
{ report, markdown, ms }, what each room, item and character amounts to and what is thin (src/engine/tools/report.ts, the MCP toolcontent_report). - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
GET /graph
- Answer: The world map:
{ graph, svg, dot }, the rooms and the ways between them (src/engine/tools/graph.ts, the MCP toolworld_graph). - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
POST /puzzle
- Body:
{ id? }. - Answer: The puzzle graph:
{ graph, svg, dot, markdown, classes, id? }, what every rule needs and changes;markdownis the overview, or withidthe card of that item, flag or prop;classessays why the solver keeps each node (critical, world, visible or dead). The MCP toolpuzzle_graph. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
POST /coverage
- Answer:
{ coverage, markdown, ms }: the saved storyboard checked against the content (src/engine/tools/coverage.ts: per board and panel, each room, speaker, line, topic, sound and action ok / partial / missing / unknown). - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
POST /playtests
- Answer:
{ report, markdown, files, ms }: the playtests ofgames/<id>/playtests/replayed and summed up (src/engine/tools/playtests.ts). - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
POST /lint
- Body:
{ prove? }. - Answer:
{ lint, markdown, mode, ms }: the content lint after a solver run (src/engine/tools/lint.ts;prove: the exhaustive search first). - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
POST /solve
- Body:
{ from?: checkpoint, prove? }. - Answer:
{ finished, states, truncated, path, roomsReached, unlockedReached, flagsReached, itemsNeverUsed, unusedItems, deadEnds: [{ room, inventory, path }], errors, from, ms, profile };profileis theSolveProfileofsrc/engine/tools/solve.ts. 400 for an unknown checkpoint. 20,000 states at most;proveruns the exhaustive search instead of the witness. On the Studio Server it runs on the worker, under a time limit. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
GET /ir
- Answer: The game's IR (DSL reference): its logic as data with the
file:lineof each id (provenance) and the trusted extensions' hash; the same JSON asnpm run ir -- --json. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 4.1.12.
Voices and heroes
The voice table of each language (voices.json) and the hero × language matrix.
GET /voices[/:lang]
- Answer: The voice table of a language (the game's own by default; an unknown language falls back to it):
{ lang, langs, statuses, rows }. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 3.4.
PUT /voices/:lang/:id
- Body:
{ status?, note?, actor? }(nullcounts as absent). - Answer:
{ ok }, the row written intovoices.json(statusdraft, empty note and actor by default). The id is the line's, URL-encoded by the client: a clip of a line with switches is keyed<id>~<flag>=<value>(4.3.0,=sent as%3D); 404 when the game has no such line; a field that is not a string is a 400. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 3.4.
GET /voices/:lang/csv
- Answer:
{ lang, csv }: the voice table as CSV. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 3.4.
GET /hero-matrix
- Answer: The hero × language matrix (
src/engine/tools/hero-matrix.ts), read-only:{ langs, flags, columns, rows, truncated? }, each row'scells[column][language]{ text, want, clip?, file?, fallback?, missing?, borrowed?, unlisted? }and itsuntranslated,agreement,problem. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts). - Since: 4.4.0.
Change feed and screenshots
The dev server's server-sent events and the room screenshots it renders.
POST /screenshot
- Body:
{ room, checkpoint? }. - Answer:
{ file, url }: a PNG of the room (the editor's overlays hidden) under.cache/studio/<game>-<room>[-<checkpoint>].png, served aturl(GET screenshots/<name>.png). 400 withoutroom; 501{ unavailable: true, reason, error }when Playwright or its Chromium is missing. - Served by: both the dev server and the Studio Server (the shared table,
studio-server/src/ops/routes.ts).
GET /screenshots/:name
- Answer: The PNG a screenshot wrote (
image/png, not cached); 404no such screenshot.nameis letters, digits,.,_and-, ending in.png. - Served by: the dev server only (
tools/studio/plugin.ts).
GET /events
- Answer: Server-sent events:
{ type: 'hello', game }on connection, then{ type: 'changed', file }when a file of the game folder changes on disk (filerelative to it, e.g.rooms/house.ts; dotfiles, editor backups andprivate/are ignored; 150 ms debounce per file); a: pingcomment every 25 s. - Served by: the dev server only (
tools/studio/plugin.ts).
Assets tab
Every image and sound of the game with where it is used, the art prompts per sheet, and the uploads. Nothing is ever deleted: a replaced file is first kept as <name>_v<N>.<ext> beside it. Since 4.2.1 every upload is one operation of the journal: the backups, the new files and the cells a tool cut are written together or not at all. A JSON body is 96 MiB at most (413 above). The Studio Server serves its own Assets routes (studio-server/src/server/hosted-assets.ts), not described here.
GET /assets
- Answer:
{ sheets: [{ id, kind: 'sprites' | 'furniture' | 'talk', character?, grid, promptKind?, cells: [{ id, file, w, h, used, ids, prepared, asset?, backups, mtime, missing? }] }], decors: [{ name, rooms, …cell }], sounds: { music, sfx: [{ id, kind, file, used, prepared, asset?, backups }] }, missing, unprepared, prompts: { sheets: [{ id, kind, markdown, missingMarkdown? }], style } }. - Served by: the dev server only (
tools/studio/assets.ts).
GET /assets/file/:path
- Answer: A source file of
art/oraudio/, its path relative to the game folder; anything else is a 404no such file. - Served by: the dev server only (
tools/studio/assets.ts).
GET /assets/prompts
- Answer:
{ markdown }: the wholenpm run promptsdocument;?missing=1givesnpm run prompts -- --missing's. - Served by: the dev server only (
tools/studio/assets.ts).
POST /assets/sheet
- Body:
{ sheetId, grid?: '6x4', cells?: 'r1c1,r2c3', data },datain base64 or a data URL. - Answer:
{ ok, file, output, written, backups, cells }: the sheet saved asprivate/sheets/<sheetId>-<time>.<ext>and cut withtools/cut-sheet.pyintoart/<sheetId>/. 409{ conflicts }when cells of that sheet exist andcellsdoes not name them; a named cell that exists is first kept as<cell>_v<N>.png. - Served by: the dev server only (
tools/studio/assets.ts).
POST /assets/cell
- Body:
{ sheetId, cell, data, key?: 'auto' | 'always' | 'never' }. - Answer:
{ ok, file, backup?, keyed: 'keyed' | 'kept' | 'opaque', cell }:art/<sheetId>/<cell>.pngreplaced (or added), the old file kept as<cell>_v<N>.png; a flat background is keyed with the cutter's rule. - Served by: the dev server only (
tools/studio/assets.ts).
POST /assets/sound
- Body:
{ kind: 'music' | 'sfx', file, data }. - Answer:
{ ok, file, backup? }: written toaudio/<kind>/<file>(any format ffmpeg reads), the old file kept as a backup. - Served by: the dev server only (
tools/studio/assets.ts).
POST /assets/decor
- Body:
{ name, data }, a PNG or a JPEG. - Answer:
{ ok, file, backup? }: written toart/decor/<name>.<ext>, the old picture kept as<name>_v<N>.<ext>. - Served by: the dev server only (
tools/studio/assets.ts).
POST /assets/prepare
- Answer: Runs
npm run assets:{ ok, code, output }. With?stream=1, the output as plain text while it runs, ending with[exit <code>]. - Served by: the dev server only (
tools/studio/assets.ts).
Assistant
The Studio's Assistant on the dev server: a conversation relayed to a chat model, and a task for an MCP-connected agent.
POST /assistant/chat
- Body:
{ provider: { kind, baseUrl, model, apiKey? }, messages: [{ role: 'user' | 'assistant', content }], context? }. - Answer: Server-sent events of the conversation:
{ type: 'text', delta },{ type: 'tool_call', id, name, args },{ type: 'tool_result', id, name, result, isError? },{ type: 'done', usage?, wrote?, stopped? },{ type: 'error', message }. The model's tool calls run on the game through the MCP tools' registry (MCP tools). The API key comes with each request and lives in memory for that request only, never on disk nor in a log. 400 for a bad provider or conversation:kindmust beopenai,anthropicorollama;baseUrlan http(s) URL without credentials, one of the known providers' origins or an Ollama on the loopback, otherwise refused unlessWEB_SCUMM_ALLOW_CUSTOM_PROVIDER=1, and then HTTPS to a public host only;modelrequired; the last message the user's. - Served by: the dev server only (
tools/studio/assistant.ts).
POST /assistant/task
- Body:
{ text, about? }. - Answer: The new note, tagged
task: true, authoryou: a request for an MCP-connected agent, no key needed. 400 withouttext. - Served by: the dev server only (
tools/studio/assistant.ts).