MCP agent workflows
Worked walkthroughs for an AI agent driving the Orkige editor over its MCP endpoint. This is the tutorial companion to Docs/mcp.md — that document is the reference (endpoint, auth, transport, the full tool table and per-tool semantics); this one shows the tools composed into the loops an agent actually runs.
Every call below is a JSON-RPC tools/call against POST /mcp. The wire form is
// request
{ "jsonrpc":"2.0", "id":7, "method":"tools/call",
"params": { "name":"<tool>", "arguments": { ... } } }
// result content (structuredContent shown; a refused verb comes back isError)
{ "jsonrpc":"2.0", "id":7, "result": { "structuredContent": { ... } } }
The walkthroughs elide the envelope and show just <tool> { arguments } → { reply }.
Evidence-first: never claim success you did not read back
The editor tools are built so an agent can observe, not guess. A mutation returning without isError means the verb was accepted — it does NOT mean the game now behaves the way you intended. Before reporting a step done, read the result back through the matching read tool:
-
authored a file →
read_project_file/list_project_files -
changed a component →
get_component(edit world) orruntime_state(live game) -
ran the game →
get_state(play mode),runtime_hierarchy,console_tail -
verified behaviour →
run_tests+get_test_results, orscreenshot_game(a frame) /
record_trace(a .jsonl of motion + events over time, read back)
All scalar fields cross the wire as STRINGS ("1"/"0" for flags), matching the debug-protocol convention. The async tools (play, run_tests, export_project, screenshot_game, record_trace) return an accepted acknowledgement and are POLLED — treat "accepted" as "started", and keep polling the stated field until it settles. Mutations need the Authorization: Bearer <token> header; pure reads are open.
1. Author a feature
Write a script, attach it to an object, run the game, confirm it does what you meant, then iterate on the script with hot-reload — all without leaving the MCP session. Assumes a project is already open (open_project).
// 1. write the game logic as a project file (jailed to the project root, LF)
write_project_file {
"path":"scripts/spinner.lua",
"content":"function update(dt)\n self.rotationY = self.rotationY + dt\nend\n" }
// → { "path":"scripts/spinner.lua", "bytes":"57" }
// 2. read it back — the evidence the write landed as intended
read_project_file { "path":"scripts/spinner.lua" }
// → { "path":"scripts/spinner.lua", "bytes":"57", "content":"function update..." }
// 3. attach a ScriptComponent to the object, then point it at the script.
// set_component writes reflected properties by NAME; run get_component first
// if you are unsure of the exact names (here: 'script').
add_component { "id":"Cube1", "component":"ScriptComponent" } // authed → {}
set_component { "id":"Cube1", "component":"ScriptComponent",
"properties": { "script":"scripts/spinner.lua", "enabled":"1" } } // authed → {}
get_component { "id":"Cube1", "component":"ScriptComponent" } // verify:
// → { "script":"scripts/spinner.lua", "enabled":"1",
// "properties":["script","enabled",...], "kinds":["asset","bool",...] }
// 4. run it. play is async — poll get_state until it settles.
play {} // authed → { "accepted":"1", "play_mode":"launching", "target":"desktop" }
get_state {} // poll → { "play_mode":"playing", "remote_connected":"1", ... }
// 5. evidence the script is live on the running object
runtime_hierarchy {} // → { "ids":["Cube1",...], "play_mode":"playing", ... }
runtime_select { "id":"Cube1" } // authed → { "selected":"Cube1" }
runtime_state {} // poll until ready="1":
// → { "object":"Cube1", "ready":"1",
// "properties":["TransformComponent.orientation",...], "values":["1 0 0 0",...] }
console_tail { "count":50 } // → { "lines":[...,"[remote] ..."], "levels":[...] }
// 6. edit the script — a write to scripts/*.lua during a live Play is picked up
// by the editor's scripts/ watcher and hot-reloaded automatically (or force
// it with reload_script).
write_project_file {
"path":"scripts/spinner.lua",
"content":"function update(dt)\n self.rotationY = self.rotationY + dt * 3\nend\n" }
// → { "path":"scripts/spinner.lua", "bytes":"61" }
reload_script { "id":"Cube1" } // authed (optional; the watcher also fires) → {}
// 7. confirm the new behaviour, then stop
runtime_state {} // poll → orientation now advancing faster
stop {} // authed → {}
A reload that fails to compile keeps the OLD instance running and surfaces a [remote] SCRIPT ERROR — so always read console_tail (or runtime_state) back after a hot-reload rather than assuming the swap took.
1b. Atomic edits: one undo step for a remote client
A multi-step edit should land as ONE undo step (and be undone as one), so a human who dislikes the result reverts it with a single Cmd+Z — and a failed build-up leaves NO partial edits. Wrap the edits in begin_transaction … end_transaction: every mutating verb in between folds into one CompositeCommand on commit=true, or unexecutes wholesale on commit=false. This is the same one-undo primitive an .editor.lua tool gets (## 6), now reachable from ANY MCP client.
begin_transaction {} // authed → {} (get_state.transaction_open == "1")
create_object { "id":"Turret", "mesh":"cube" } // authed → { "id":"Turret" }
create_object { "id":"TurretBarrel", "mesh":"cube" } // authed → { "id":"TurretBarrel" }
reparent_object { "id":"TurretBarrel", "parent":"Turret" } // authed → {}
set_component { "id":"Turret", "component":"TransformComponent",
"properties": { "position":"4 0 0" } } // authed → {}
end_transaction { "commit": true } // authed → { "committed":"1", "command_count":"4" }
// one undo now reverts ALL FOUR edits together:
undo {} // authed → {} (Turret + barrel gone in one step)
end_transaction { "commit": false } instead unexecutes everything since begin — the escape hatch when a mid-sequence read comes back wrong. Two rules: a transaction is refused if one is already open (begin) or none is (end), and it auto-aborts (rolling back, logging one Console line) if the editor switches scene/project/prefab, starts Play, or shuts down under it — so keep it short-lived and check get_state.transaction_open if in doubt. The fold is origin-blind: a manual editor edit the owner happens to make between two of your requests is adopted into the transaction too.
Because it is plain JSON-RPC over HTTP, any language drives it. A dependency-free python3 client (stdlib urllib only — no MCP SDK):
import json, urllib.request
URL, TOKEN = "http://127.0.0.1:8765/mcp", open("/path/to/token").read().split()[-1]
def call(tool, args=None, _id=[0]):
_id[0] += 1
body = json.dumps({"jsonrpc": "2.0", "id": _id[0], "method": "tools/call",
"params": {"name": tool, "arguments": args or {}}}).encode()
req = urllib.request.Request(URL, body, {
"Content-Type": "application/json",
"Authorization": "Bearer " + TOKEN}) # mutations need the bearer
res = json.load(urllib.request.urlopen(req))["result"]
if res.get("isError"):
raise RuntimeError(res["content"][0]["text"]) # honest failure, not silent
return res.get("structuredContent", {})
call("begin_transaction")
try:
call("create_object", {"id": "Turret", "mesh": "cube"})
call("create_object", {"id": "TurretBarrel", "mesh": "cube"})
call("reparent_object", {"id": "TurretBarrel", "parent": "Turret"})
call("end_transaction", {"commit": True}) # one undo step
except Exception:
call("end_transaction", {"commit": False}) # roll back, no partial edits
raise
2. The test loop
Close the loop with structured evidence: make a change, run the relevant test, read a machine-parseable verdict, fix, rerun until green. This is the cheapest form of proof because every Orkige test self-checks and exits non-zero on failure.
// 1. discover the acceptance gate for what you are editing (synchronous)
list_tests { "filter":"jumper_lua" }
// → { "tests":["player_jumper_lua_selfcheck_next"], "count":"1", "buildDir":"..." }
// 2. after editing, run it. run_tests is ASYNC and (by default) builds the tree
// first; it returns a jobId to poll.
run_tests { "filter":"player_jumper_lua_selfcheck" } // authed
// → { "accepted":"1", "jobId":"a1b2c3...", "build":"1", "buildDir":"..." }
// 3. poll for the verdict
get_test_results { "jobId":"a1b2c3..." }
// → { "jobId":"a1b2c3...", "status":"running" } // keep polling
// → { "status":"done", "total":"1", "passed":"0", "failed":"1",
// "buildFailed":"0",
// "failed_names":["player_jumper_lua_selfcheck_next"],
// "failed_durations":["2.13"],
// "failed_logtails":["...assertion failed: player never left the ground..."] }
// 4. the logtail is the "why" — read it, fix the script/code, rerun step 2.
// A build that does not COMPILE short-circuits (no ctest runs):
// → { "status":"done", "buildFailed":"1",
// "buildErrors":"player.lua:12: ... error: ..." }
// Fix the compile error first, then rerun.
// 5. iterate until green
get_test_results { "jobId":"<new job>" }
// → { "status":"done", "total":"1", "passed":"1", "failed":"0",
// "failed_names":[], "failed_logtails":[] } // green
Notes that keep the loop honest:
-
device-labelled tests (simulator/emulator) are ALWAYS excluded from bothlist_testsandrun_tests— a run never boots a device. -
Pass
build:"0"torun_teststo test an already-built tree as-is (fast) whenyou only changed a project script, not engine C++.
-
targetsscopes the incremental build (e.g. justorkige_editor_tests);presetselects a different tree ("desktop-classic", abuild/dir name or an absolute path) — default is the editor's own build.
---
3. Debug a live game
Drive and inspect the RUNNING game: watch its live hierarchy, pause and step, poke a property or a cvar, capture a frame as proof, resume, stop. The runtime_* tools serve the LIVE player; list_hierarchy/get_component always read the EDIT world even during Play, so the two never blur together.
// 1. play a specific scene on the desktop target (async; poll get_state)
play { "scene":"scenes/level1.oscene", "target":"desktop" } // authed
// → { "accepted":"1", "play_mode":"launching", "target":"desktop" }
get_state {}
// → { "play_mode":"playing", "remote_connected":"1", "remote_object_count":"12", ... }
// 2. inspect the live tree, then stream one object's component state
runtime_hierarchy {} // → { "ids":["Player","Ground",...], "selected":"", ... }
runtime_select { "id":"Player" } // authed → { "selected":"Player" }
runtime_state {} // poll until ready="1":
// → { "object":"Player", "ready":"1",
// "properties":["TransformComponent.position","RigidBodyComponent.mass",...],
// "values":["0 1 0","1",...], "kinds":["vec3","float",...], "readonly":["0","0",...] }
// 3. freeze time to examine a frame, advance exactly one physics tick
pause {} // authed → {} (get_state → play_mode:"paused")
step {} // authed → {} (one fixed tick while paused)
// 4. poke the running game live (NOT undoable — this is the live player, not the
// edit world). A bad name/value comes back as a [remote] line, not an error.
set_runtime_property {
"id":"Player", "component":"TransformComponent",
"property":"position", "value":"2 3 4" } // authed → {}
set_cvar { "name":"player_speed", "value":"8" } // authed → {}
runtime_state {} // read back → values reflect the writes
console_tail { "count":30 } // check for any [remote] rejection lines
// 5. capture the running frame as proof. screenshot_game is ASYNC: it returns a
// baseline sequence; poll get_state until screenshot_seq exceeds it.
screenshot_game { "path":"/tmp/level1.png" } // authed
// → { "accepted":"1", "path":"/tmp/level1.png", "prev_screenshot_seq":"0" }
get_state {}
// → poll until { "screenshot_seq":"1", "screenshot_ok":"1", "screenshot_path":"/tmp/level1.png" }
// 5b. for behaviour that unfolds over time, record a TRACE - a .jsonl flight
// recorder you READ back (agents can't watch video). record_trace is ASYNC
// the same way (poll record_seq), auto-stops at the time budget (or
// stop_recording), and samples the world every everyNth frame.
resume {} // authed → let it move while we record
record_trace { "path":"/tmp/level1.jsonl", "seconds":3, "everyNth":2 } // authed
// → { "accepted":"1", "path":"/tmp/level1.jsonl", "prev_record_seq":"0" }
get_state {}
// → poll until { "record_seq":"1", "record_ok":"1", "record_path":"/tmp/level1.jsonl" }
// then READ the file and ASSERT on it. Each sample line looks like:
// {"t":0.28,"frame":34,"dt":0.0166,"objects":[
// {"id":"Player","name":"Player","pos":[3.5,1.2,0],"vel":[2,4.1,0],"active":1,"visible":1}]}
// e.g. an agent proving a jump: read every sample's Player pos[1] (y) and assert
// the series rises above its start then falls back - the arc is in the numbers.
// Event lines interleave, e.g. {"t":0.5,"frame":60,"event":"contactBegin","a":"Player","b":"Ground"}.
// 5c. check the HUD respects the device safe area (notch / home indicator).
// get_safe_area reports the window size + insets; get_ui_layout the widget
// rects. Assert every VISIBLE widget lies inside the safe box.
get_safe_area {}
// → { "window_w":"1179", "window_h":"2556", "safe_top":"120", "safe_left":"0", ... }
get_ui_layout {}
// → { "ids":["hud.mode","hud.wins"], "rects":["16 120 180 28 1","959 120 200 28 1"] }
// for each rect "l t w h v" with v=1: assert l>=safe_left, t>=safe_top,
// l+w<=window_w-safe_right, t+h<=window_h-safe_bottom.
// 6. let it run again, then stop back to edit mode
resume {} // authed → {}
stop {} // authed → {}
Every runtime_* verb (and pause/resume/step/screenshot_game/ record_trace/stop_recording) returns isError with "no live player - start Play first" when nothing is playing, so the edit-world / live-game boundary is never ambiguous. screenshot_game and record_trace are desktop-play only (the path lives on the player's filesystem, which the editor shares only on desktop). Prefer record_trace when the evidence is motion or timing over a window — a jump arc, a tween, a physics settle, a contact — and read the numbers back. When you only need to confirm what's on screen right now, screenshot_game gives the frame; and the zero-cost DETERMINISTIC alternative is a pause/step/screenshot_game loop (step advances exactly one frame), which needs no real-time capture at all.
3b. Profile a live game (where does the frame go? what allocates?)
The perf instruments answer both questions from readback alone: the CPU frame profile (get_profile) breaks the frame into the canonical tick phases with a full scope hierarchy below them, and the allocation counters (get_state) count tracked allocation events at the engine's own seams per subsystem tag. The loop is: profile → find the hot subsystem → fix → re-profile.
// 1. play, then read the frame breakdown. A Debug player streams the profile
// unprompted; on a Release player the FIRST get_profile arms the profiler,
// so poll until profile_seq advances (~4 snapshots/second).
play { "scene":"scenes/level1.oscene" } // authed
get_profile {}
// → { "names":["input","scripts","objects.update","events","events.tick",
// "tweens","physics","physics.update","load","audio","present",
// "debug","render"],
// "info":["0 1 0.008 0.02","0 1 0.755 1.1","1 1 0.71 1.0", ...],
// "frame_ms":"8.001", "profile_seq":"12" }
// each info entry is "depth calls milliseconds maxMilliseconds"; depth-0 rows
// are the canonical tick phases, deeper rows the engine scopes inside them.
// Here: an 8 ms frame - 0.755 ms scripts, 6.7 ms render, the rest is cheap.
// 2. the allocation side rides get_state (streamed on the same cadence):
get_state {}
// → { ..., "frame_ms":"8.001", "alloc_per_frame":"0", "alloc_peak":"4",
// "alloc_tags":["events","gui","physics","tweens","particles","other"],
// "alloc_counts":["0","0","0","0","0","0"], "profile_seq":"13" }
// the unit is TRACKED ALLOCATION EVENTS at the engine's own seams (event
// queue nodes, container growth in gui/physics/tween/particle hot paths) -
// per-subsystem engine churn, NOT libc totals (Lua's VM churn is invisible
// here by design; mem_rss is the process-level number). A steady scene reads
// 0; a nonzero tag names the subsystem to look at.
// 3. fix the hot thing (edit the script/scene through the normal authoring
// verbs, reload_script or set_cvar to try it live), then RE-PROFILE:
reload_script {} // authed - hot-swap the Lua
get_profile {} // poll until profile_seq advanced
// → compare the same phase rows before/after; the numbers are the proof.
// 4. for a spike that unfolds over time, record_trace: every sample line now
// carries "alloc" (that frame's tracked-allocation count) and "phases"
// (the depth-0 phase times), so a hitch is findable offline:
record_trace { "path":"/tmp/perf.jsonl", "seconds":3 } // authed
// sample lines: {"t":0.5,"frame":60,"dt":0.016,"mem":214958080,"alloc":2,
// "phases":{"scripts":0.7,"physics":0.3,"render":6.7},...}
// read the file, find the frame where dt spiked, and the phases object on
// that line says which phase paid for it.
Honesty notes: scope timings measure the INSTRUMENTED code (OPROFILE sites - the tick phases plus the engine's own scopes); the profiler itself costs ~70 ns per scope when on, ~8 ns when off (Release default off, which is why the first Release get_profile returns nothing yet). The editor's own frame is not profiled - these instruments serve the running GAME.
4. Author levels
Build a tile-based level by stamping prefabs onto a snap grid, then wire the finished scene into the game's level sequence and play it — the same grid-paint loop the editor's paint tool drives, done over MCP. Assumes a project is open and a scene is loaded (the grid coincides with the scene's slots when it carries a level, otherwise it is the translate snap step at the world origin).
// 1. see the tile palette and the grid the paint verbs snap to
list_paint_prefabs {}
// → { "paths":["assets/Wall.oprefab","assets/Floor.oprefab"],
// "names":["Wall","Floor"], "count":"2",
// "origin_x":"0", "origin_y":"0", "cell_size":"0.5" }
// 2. stamp a few cells. Give a cell by { col, row }, or a world "position" that
// snaps to the nearest cell — either way the reply reports the SNAPPED cell.
// Painting the same cell replaces its occupant; the identical tile again is a
// no-op (painted:"0"), so a sweep never churns the undo stack.
paint_prefab { "prefab":"assets/Floor.oprefab", "cell": { "col":0, "row":0 } } // authed
// → { "id":"Floor", "painted":"1", "col":"0", "row":"0", "x":"0", "y":"0" }
paint_prefab { "prefab":"assets/Floor.oprefab", "cell": { "col":1, "row":0 } } // authed
// → { "id":"Floor 2", "painted":"1", "col":"1", "row":"0", "x":"0.5", "y":"0" }
paint_prefab { "prefab":"assets/Wall.oprefab", "position":"0 0.5" } // authed
// → { "id":"Wall", "painted":"1", "col":"0", "row":"1", "x":"0", "y":"0.5" }
// 3. evidence the tiles landed (the painted roots appear in the EDIT hierarchy)
list_hierarchy {} // → { "ids":[...,"Floor","Floor 2","Wall"], ... }
// 4. drop a wrong tile? erase that cell (undoable). undo/redo work too.
erase_cell { "cell": { "col":0, "row":1 } } // authed → { "erased":"1", "col":"0", "row":"1", ... }
undo {} // authed → the erased tile comes back
// 5. save, then append the scene to the project's level sequence (levels.olevels;
// mints the manifest "levels" setting the first time). NOT undoable, and it
// needs a SAVED scene inside the project — save_scene first.
save_scene { "scene":"scenes/level2.oscene" } // authed → { "scene_path":".../level2.oscene" }
add_scene_to_levels {} // authed → { "scene_path":".../level2.oscene" }
// 6. play the level for proof (async; poll get_state), or run its selfcheck
play { "scene":"scenes/level2.oscene", "target":"desktop" } // authed
get_state {} // → poll until play_mode:"playing"
add_scene_to_levels refuses (isError) when no project is open, the scene is unsaved or outside the project root, or the scene is already in the sequence — read the reply back rather than assuming the append took.
4b. Edit a prefab asset in isolation
Change a .oprefab ONCE and every instance across every scene picks the edit up (instances re-instantiate from the file at their next load, per-instance overrides re-applied). open_prefab swaps the live scene aside into a temp snapshot and loads the prefab subtree into the ONE edit world, so every ordinary editing verb works on the prefab UNCHANGED — this is the single-world payoff, not a second set of verbs. While staged, the scene/project lifecycle, play, add_scene_to_levels and the paint/instance verbs are refused with a clear prefab-mode error (read the reply back — you get an honest message, not a silent no-op).
// 1. open the prefab (by project path, or by stable "asset" id). The stage root
// is the file stem; get_state now reports the prefab context.
open_prefab { "path":"assets/Enemy.oprefab" } // authed → { "root_id":"Enemy", "prefab_path":".../Enemy.oprefab" }
get_state {} // → { "edit_context":"prefab", "prefab_root":"Enemy",
// "prefab_path":".../Enemy.oprefab", "prefab_dirty":"0", ... }
// 2. the world is the prefab subtree only — edit it with the NORMAL verbs.
list_hierarchy {} // → the prefab's objects (the scene is swapped out)
set_component { "id":"Enemy/Body", "component":"SpriteComponent",
"properties": { "zOrder":"3" } } // authed — edits a child of the prefab
// 3. a scene/project verb is refused while staged (honest prefab-mode error):
save_scene {} // authed → isError: "save_scene is unavailable while a prefab is open ..."
// 4. write the asset, then pop back to the scene. close_prefab REQUIRES a policy:
// "save" writes first (a refused save cancels the close), "discard" drops
// unsaved stage edits. A policy-less close is refused.
save_prefab {} // authed → { "prefab_path":".../Enemy.oprefab" }
close_prefab { "policy":"discard" } // authed → { "scene_path":".../level.oscene" }
get_state {} // → { "edit_context":"scene", ... } (the scene is back;
// its Enemy instances refreshed from the edited file)
Entering prefab mode drops the current scene's undo history (per-context scope, like opening another scene); save_prefab refuses (isError) when the stage root was deleted or objects exist OUTSIDE the single root (parent them under the root first). The scene_path/scene_dirty in get_state keep reporting the STASHED scene while staged, so your model of the document you will return to stays truthful.
5. Design a UI screen (the collaborative preview loop)
Author a .oui screen, see it rendered at real device sizes WITHOUT booting the game, and iterate — while a human watching the editor's GUI Preview tab sees every edit live (the tab shares the same offscreen stage and reloads the file on change). preview_ui renders through the real gui stack into an offscreen target, so it needs no running player; it is Ogre-Next only (the classic editor errors honestly). Assumes a project carrying a gui_default atlas is open.
// 1. author a screen as plain text (jailed to the project root)
write_project_file { // authed
"path":"screens/title.oui",
"content":"[Layout]\natlas = gui_default\n\n[Button play]\nz = 2\nsprite = button\nfont = 9\ntext = Play\nposition = 40 40\nsize = 300 96\n"
}
// → { "path":"screens/title.oui", "bytes":"..." }
// 2. preview it at a phone context; get a screenshot + the resolved rects
preview_ui { // authed
"file":"screens/title.oui",
"width":1179, "height":2556, "scale":3, "insets":"0 141 0 102"
}
// → { "path":"/tmp/orkige_preview_ui.png", "width":"1179", "height":"2556",
// "batch_count":"1",
// "ids":["play"], "rects":["40 40 300 96 1 1 0"] }
// the render comes back INLINE as an image content block - you SEE it in the
// tool result directly (no read-back hop); the path is still there for pixel
// diffing. read the rects to CHECK the layout ("left top w h visible enabled
// modal"). inline:false (or a PNG over 4 MiB) returns path-only.
// 3. a device-matrix sweep in ONE call (phone + tablet) - one png per context
preview_ui { // authed
"file":"screens/title.oui",
"contexts":"1179x2556@3/0,141,0,102; 2048x1536@2"
}
// → { "paths":["/tmp/orkige_preview_ui_0.png","/tmp/orkige_preview_ui_1.png"],
// "context_labels":["1179x2556@3","2048x1536@2"],
// "ids":["play"], "rects":[...] } // rects are the FIRST context
// 4. the button sits too high on the tablet? edit the file and preview again -
// the human's GUI Preview tab updates live (it watches the file's mtime)
write_project_file { "path":"screens/title.oui", "content":"...position = 40 200..." } // authed
preview_ui { "file":"screens/title.oui", "width":2048, "height":1536, "scale":2 } // authed
// → the returned rect for "play" moved - proof the edit took, no player booted
// 5. preview a LOCALISED screen (its @key captions) in a target language, no
// play session: the result lists the loaded languages and echoes the applied
// one (a project with no loc/ directory ignores 'language' with a note)
preview_ui { "file":"screens/title.oui", "language":"de" } // authed
// → { ..., "language":"de", "languages":["de","en","en-XA"] }
// read the png back to SEE the German text; omit 'language' for the source.
Evidence, not assumption: preview_ui returns batch_count (> 0 means the gui actually submitted geometry) and the resolved rects; compare the rects across edits/contexts to confirm the layout holds. A missing file returns isError (an honest error, not a silent success). The whole loop is exercised end to end by the editor_control self-test (write → preview → edit → preview → assert the rect moved → a missing file errors).
The animation twin is preview_animation { "asset":"assets/hero.oanim", "clip":"walk", "time":0.4, "blendClip":"run", "blendWeight":0.3 } (authed): it renders a .oanim pose (import a Lottie .json with import_asset to cook one, or write_project_file the text directly) at a chosen clip/time — optionally blending a second same-rig clip — to a PNG plus a readback (clips, frame, duration, layer_count, vertex_count, visible_pixel_count, coloured_pixel_count), so an agent scrubs a cycle (t=0 / mid / end differ), rejects blank/all-white output and validates a blend without a play session.
screenshot, preview_ui and preview_animation inline the captured PNG as an MCP image content block alongside the text/structuredContent — you see the render in the tool result itself, no read-back hop. For a preview_ui sweep only the FIRST context's image is inlined (to bound payload; the row notes it); the other paths are on disk. Pass inline:false (or exceed the 4 MiB cap — noted as "inline_skipped":"too_large") to get path-only. screenshot_game is async (the player writes the file after the accepted reply), so it has no image at reply time and stays path-only — poll get_state for the confirmation, then read the path off the shared filesystem.
6. Leave the human a reusable tool (editor scripts)
Some chores repeat: reframe a level, re-tag a batch of tiles, generate a grid. Instead of doing them by hand every time, author them ONCE as an EDITOR TOOL — a scripts/<name>.editor.lua file. It runs in the editor (not the game) through the SAME editor.* surface you already drive here (each function mirrors an MCP verb), its whole run folds into ONE undo step, and afterwards a HUMAN runs it from the editor's Tools menu forever after — or you re-trigger it with run_editor_script. This is the durable half of the collaboration: the agent leaves behind a button, not just an edit.
// 1. author the tool (a plain project file - the jail + LF rules apply)
write_project_file { "path":"scripts/frame_level.editor.lua",
"content":"-- tool: Frame The Level\nlocal function findLevel()\n for _,id in ipairs(editor.list_hierarchy().ids or {}) do\n for _,c in ipairs(editor.get_object{id=id}.components or {}) do\n if c=='LevelComponent' then return id end\n end\n end\nend\nlocal id=findLevel(); if not id then editor.log('no level'); return end\nlocal l=editor.get_component{id=id,component='LevelComponent'}\nlocal cols,rows=math.floor(tonumber(l.cols)),math.floor(tonumber(l.rows))\nfor c=0,cols-1 do for r=0,rows-1 do\n if c==0 or c==cols-1 or r==0 or r==rows-1 then\n editor.paint_asset{asset='assets/wall.png',cell={col=c,row=r}}\n end\nend end\n" }
// → { "path":"scripts/frame_level.editor.lua", "bytes":"..." } // it now shows in the Tools menu
// 2. run it (authed) - the edits fold into ONE undo step
run_editor_script { "name":"frame_level" }
// → { "name":"frame_level", "command_count":"8" } // 8 wall tiles, one Cmd+Z reverts all
// 3. a BROKEN tool fails honestly and leaves NO partial edits
run_editor_script { "name":"frame_level" }
// → isError: "editor tool 'frame_level' failed: .../frame_level.editor.lua:6: attempt to ..."
// (nothing half-applied; fix the file with write_project_file and re-run)
The editor.* verbs a tool can call, and the one-undo / rollback / noscript contract, are documented in Docs/lua-api.md (Editor scripts). The projects/roller/scripts/border_walls.editor.lua sample is the living example; the editor_scripts selfcheck and the editor_control run_editor_script leg exercise this loop end to end.