Skip to main content
A world is a programmable graph: nodes are states (what a scene is) and edges are events (what the player can do to move between them). Generating a world gives you a complete, valid graph. The Graph-Editing API lets you keep programming it (add a room, wire a new exit, retarget the entrance, or push a whole batch of changes at once) all over the same /v1 API-key model you already use. There are two tiers for changing a graph:
  • The deterministic tier (available now). Precise, scripted control: state and event CRUD, a batch op vocabulary, and the kernel validate/lint gate. This is what third-party editors and your own tooling build on. Most of this page is in this tier.
  • The agent tier. A natural-language endpoint, POST /v1/worlds/{id}/edit, where you describe the change in prose and the kernel agent authors it for you. It returns the validated result only, never the authoring brain. See Editing with natural language. One canned agent move, Arcadeify, also lives on this page. Both tiers funnel through the exact same validation gate described below, so a world edited either way behaves identically in the editor and at runtime.
Reading the graph needs a publishable (pk_) or secret (sk_) key with worlds:read. Every write needs a secret key with worlds:write. Never ship a secret key to a browser.

The validation gate

This is the load-bearing rule of the whole surface: every write persists only after passing the kernel grammar and the full doctrine lint suite, fail-closed. If a mutation would produce an invalid world, the API rejects it with 422 and a list of diagnostics, and nothing is persisted. There is no weaker path. The kernel is the sole authority that can approve a change, and the API is only a way to propose one. A diagnostic looks like this:
  • Structural lints (dangling-ref, transition-needs-to, kernel-cycle, the slot-* family, …) are always fatal errors and block the write.
  • Doctrine and budget lints (negation, whiteout, lethal-override, budget, …) are advisory: they come back as warning/info and don’t block a save, so you can fix them iteratively. A common one: prompt prose must describe pixels, not authorial intent (“a dim stone corridor,” not “the player feels trapped”).
When a write succeeds, the response carries the new world, any advisory diagnostics, and a fresh rev (see concurrency).

Read the graph

GET /v1/worlds/{id}/scene returns the whole graph. /states and /events return the node map and edge list on their own. States are addressed by their id. Events are always addressed by their unique name, never by index.
Every read returns the current rev both in the body and as the ETag response header. Hold onto it for your next write.

Add a state and wire an edge

Adding a node, then an edge into it, is the bread-and-butter of programming a graph. Omit id on a state to have one auto-assigned. The response tells you which id (or event name) was created.
A transition must carry a to. An override (a self-loop that re-renders the current state without moving) must not. Break either rule, or reference a state that doesn’t exist, and the op can’t be applied: the write comes back 400 with a GraphOpError, caught before the kernel ever runs. A 422 (GraphValidationError) is the other failure mode: the ops applied cleanly, but the resulting graph failed the kernel grammar or doctrine lint, so its blocking diagnostics (like dangling-ref or kernel-cycle) come back in the body and nothing is persisted. Patch a node or edge with PATCH (send the partial fields, or wrap them in { "patch": { … } }). Remove one with DELETE. Deleting a state that is the entrance, or that any event still references, is rejected with 400. Detach those edges first. Retarget the entry point with PATCH /v1/worlds/{id}/entrance:

Batch ops

POST /v1/worlds/{id}/ops applies an ordered batch of operations atomically. It backs nearly every mutation the visual editor makes. The op vocabulary is a stable, curated surface (add_state, update_state, delete_state, add_event, update_event, delete_event, set_entrance, set_subject_ref, add_variant, remove_variant). The resulting world is validated once, as a whole: if the batch leaves the world invalid, the entire batch is rejected 422 and nothing persists.
Individual ops that can’t be applied (say, deleting a state that doesn’t exist) are collected non-fatally in applyErrors, mirroring the editor’s behavior, so one bad op in a batch doesn’t sink the rest. A 422, by contrast, means the final world failed the kernel gate as a whole.

Attach a character (set_subject_ref)

set_subject_ref stamps a world’s canonical character reference: the pixel subject lock that POST /v1/worlds/{id}/scenes paints new scenes from, so the same character carries across scenes, levels and chapters. It is metadata-only: it sets world.subjectRef and changes no states or edges, but it funnels through the same kernel gate as every other write. Use it to attach a character to a world that was created without one, or to rotate the character on an existing world (no re-generation):
imageUrl must be a hosted, fetchable URL: a data: URI is rejected 400 (host it first) so later scene generations always have a durable reference and no multi-MB blob is stored on the world. descriptor is optional; when a scene is painted it defaults to the world’s subject. The same reference can also be set at create time with the subject_ref field on POST /v1/worlds: the two write surfaces agree, and both reject data: URIs. See Character consistency for the full flow.

Validate and lint without saving

Before you commit a change, or to check a world you’ve assembled client-side, run it through the same gate without persisting.
  • POST /v1/worlds/{id}/validate runs the strict gate. It returns 200 with the resolved world when clean, or 422 with diagnostics when not.
  • POST /v1/worlds/{id}/lint is advisory: it never returns 4xx on doctrine hits. It reports every finding plus counts by severity and the promptBudget (1900) your assembled prompts must stay under.
Both accept an optional inline world (or data) in the body to check a candidate world; omit it to check the stored one.

Optimistic concurrency

The editor, your scripts, and (soon) the agent can all edit one world. To keep a stale write from clobbering a fresh one, every write is guarded by a revision token, rev. Each read and each successful write returns the current rev, in the body and as the ETag header. Pass it back on your next write, either as the If-Match request header or as an expectedRev body field. If it no longer matches the stored revision, the write is rejected with 409 and you should reload and retry.

Arcadeify

POST /v1/worlds/{id}/arcadeify turns any world into a shoot-for-score arcade in one call: a progression, hostiles, and a win condition derived from the world’s own fiction, not a bolted-on template. It runs the exact same canned kernel-agent ask as the Studio’s one-click Arcadeify button, so a world converted over the API and one converted in the editor come out identical. Like /edit, it’s the agent authoring behind the same fail-closed gate: the result persists only after passing the kernel grammar and doctrine suite, and the response is result-only (world, diagnostics, reply, plus the versionId of the snapshot). Because it invokes the authoring agent, it meters against your daily edit quota: the worst case (a few agent rounds) is reserved before the agent runs. A reject or agent error refunds the whole reservation; on a committed edit, a no-change run, or a concurrency conflict the agent rounds actually burned are metered and only the unused slice is refunded: not the generation quota. Send an Idempotency-Key so a retry never double-charges.
force semantics. If the world already has a progression, the call is refused 409 (world already has progression, pass force:true to rebuild it) before any quota is reserved, so you never pay to clobber an arcade by accident. Pass { "force": true } to rebuild it deliberately. The other 409 cause is the usual stale-rev concurrency conflict. Rollback. Every success snapshots a version titled arcadeify (source ai) onto HEAD, so the conversion is one step from undo: list the tree with GET /v1/worlds/{id}/versions, then POST /v1/worlds/{id}/checkout the parent version to restore the pre-arcade graph. (If that post-persist snapshot fails, the conversion still persists and versionId comes back null.)

Status codes