This guide is for a terminal agent — any coding-agent session that has to
manage the CozyClay Studio without a human at the screen. Everything the
Studio can do through its panels has a terminal surface through cclay live:
read the scene, place and move things, frame and key shots, verify the result
with a picture, and undo. The CLI is JSON in, JSON out, one object per
invocation, with stable error codes — built so a program, not a person, is the
primary reader.
The one rule that differs from browsing: visual checks are capture PNGs
read as images — never screenshot the browser. cclay live capture renders
the shot camera through the same pipeline the editor's own exports use, at
full resolution, and writes it to a path you name. Screenshotting the browser
gives you the UI chrome, the wrong viewport, and a stale frame; capture gives
you the shot.
One process owns the hub; everything else just connects to it:
-
Start the Studio —
npm run devfrom the checkout, orcclayfrom the installed package. The dev runner and the launcher already host the live hub through their agent sidecar, so this one process is both the Studio and the hub owner. SetCOZYCLAY_LIVE_PORTso the hub is yours, not somebody else's on the default 5184; the hub publishes its address and token in a mode-0600 endpoint file under your config home, which is howcclay livefinds it:COZYCLAY_LIVE_PORT=5629 npm run dev -- --port 5209 # source checkout, hub owner "dev-full" cclay # installed package, hub owner "cozyclay"
-
Open a browser tab at the Studio URL (
http://127.0.0.1:5209/app/). A headless Chromium with remote debugging is fine — the editor is the page, not the window. -
Gate on an editor with
status --wait(below).
The rule is one hub owner per port, never both: if you also start
node mcp/server.mjs (or cclay mcp) on the same COZYCLAY_LIVE_PORT, the
second binder silently loses the port — the Studio's sidecar is then left
without its hub, so the Agent panel is dead while cclay live still answers
through the MCP-owned one. Run the MCP server as the hub owner only for
MCP-client sessions, and then do not start npm run dev / cclay on the
same port.
Then wait until an editor is actually attached, which is what --wait is
for. Without an editor every mutation verb fails NO_EDITOR, so a script
should always begin with this gate:
cclay live status --wait{"server":{"port":5629,"owner":"dev-full","pid":95307},"editors":[{"handle":"8306b693-6d28-4391-a37d-9a90bdf61fa4","project":"QA","scene":"SCENE 01","cast":1,"embed":false,"connectedAt":1789548461809,"lastSeenMs":44,"inFlight":0}],"selected":"8306b693-6d28-4391-a37d-9a90bdf61fa4"}The handle is the tab's stable identity: it survives reloads and hub
restarts, and it is what --workspace takes. Note the exit code —
status --wait exits 0 only once a selectable editor exists.
Every session is the same shape:
status → inspect → act (a receipt verb) → capture / verify → undo
- status — which hub, which editors, which one is selected.
- inspect — the scene context: ids, entity tokens, the current shot, revisions. Never act on remembered state; read it first.
- act — a verb that produces a receipt (
arrange-objects,arrange-characters,patch,frame-shot,operate,undo). The receipt is the ground truth of what changed: affected ids, revision before/after, undo entry, warnings. - capture / verify —
capture --framingwrites the PNG of the shot camera;verify --receipt … --visual framewrites the receipt's evidence image. Read them as images — that is your only honest visual check. - undo —
undo --receipt <id>restores the document through the editor's own native history, exactly one entry, when you got the framing wrong.
Mutations are admitted: the CLI reads the scene context first and sends the
document identity and the expected scene revision with the command — no
per-entity tokens, because that revision already bumps on every authored
change. If anything moved under you — the operator dragged a gizmo, another
edit landed — the editor refuses with STALE_SCENE / TARGET_BUSY instead
of guessing. The response to a refusal is always inspect again and re-issue,
never a blind retry.
The default scene has one character and nothing else. Read the entities to get ids and the layout:
cclay live inspect --scope entities{"context":{"schema":"studio-context-v1","host":{…},"revision":{"scene":3,"physics":3,"view":1},"units":{"distance":"m","angle":"deg","up":"+Y","yawZero":"+Z","yawPositiveToward":"+X","fps":24,"rangeEnd":"exclusive"},"scene":{"name":"SCENE 01","aspect":"16:9","floorY":0,"frameCount":432,"objectCount":0,"characterCount":1},"selection":{"kind":"character","id":"char-a","hierarchyId":"characterA"},"activeCharacterId":"char-a","view":{…},"shot":null,"camera":{…},"entities":[{"id":"char-a","kind":"character","token":"target-2","name":"a young woman in a tan coat",…}],"entityPage":{…},"shots":[],…,"recentReceipts":[…],"jobs":[],"capabilities":{…}},"entities":[{"id":"char-a","kind":"character","name":"a young woman in a tan coat","token":"target-2"}],"total":1,"nextCursor":null}Place a chair next to her — relativeTo positions it in the subject's own
basis, and support: "floor" stands it on the ground, so you never have to
compute a y:
cclay live arrange-objects --op '{"op":"create","source":{"kind":"chair"},"name":"Side chair","position":{"relativeTo":"char-a","basis":"subject","side":"left","gapM":1.4,"support":"floor"}}'{"ok":true,"commandId":"5aaf07d8-5b11-4cbc-8c7b-a23d6901cb7e","receiptId":"receipt-mu3uybgp-2","host":{…},"status":"applied","authored":true,"revision":{"before":3,"after":4},"affectedIds":["chair"],"delta":[{"id":"chair","after":{"position":{"x":2.0380938133472153,"y":0,"z":0},"yawDeg":0,"rotationDeg":{"x":0,"y":0,"z":0},"scale":{"x":1,"y":1,"z":1},"name":"Side chair","color":"#b9855d","renderer":"chair","parentId":null}}],"checks":{"coverage":"same-frame-world-AABB-proxies","relationSatisfied":true,"overlapIds":[],"actualGapM":1.4000000000000001,"requestedGapM":1.4,"maximumFootprintOverlapM":0,"basis":"subject","support":"floor","baseY":0},"undo":{"historyEntryId":"376e2467-5660-40ac-ad11-4e8821021066","entries":1,"canUndoDirect":true},"warnings":[],"detailCursor":"5aaf07d8-5b11-4cbc-8c7b-a23d6901cb7e"}The receipt is the verification contract: the gap asked for (1.4 m) and the
gap measured (actualGapM) match, nothing overlaps, and one undo entry exists.
verify re-runs that evidence and adds a picture of the current state:
cclay live verify --receipt receipt-mu3uybgp-2 --checks placement --visual frame --out /tmp/329-doc/shots/f-a-place-check.png{"receiptId":"receipt-mu3uybgp-2","revision":4,"checks":{"coverage":"same-frame-world-AABB-proxies","relationSatisfied":true,"overlapIds":[],"actualGapM":1.4000000000000001,"requestedGapM":1.4,"maximumFootprintOverlapM":0,"basis":"subject","support":"floor","baseY":0},"verification":null,"semanticStatus":"unavailable","visualRefs":[{"imageId":"ffdcee4e-bf4b-432c-82be-8b7b92c419b3"}],"unsupportedChecks":[],"visual":[{"imageId":"ffdcee4e-bf4b-432c-82be-8b7b92c419b3","path":"/tmp/329-doc/shots/f-a-place-check.png","width":1920,"height":1080,"bytes":684177}]}Now frame the shot — camera vocabulary, not coordinates. This scene has no
shots yet, so the first frame-shot creates one spanning the timeline:
cclay live frame-shot --subject char-a --size "wide shot" --view front --level hip{"ok":true,"commandId":"c836cddc-9861-4ec0-87e3-fec764666ed7","receiptId":"receipt-mu3uybub-4","host":{…},"status":"applied","authored":true,"revision":{"before":4,"after":5},"affectedIds":["shot-mu3uybub-3"],"delta":[{"id":"shot-mu3uybub-3","after":{"range":{"startFrame":0,"endFrameExclusive":432},"camera":{"position":{"x":-0.011522021105043662,"y":1.0621432515131122,"z":5.077024270710456},"lookAt":{…},"focalMm":24.44391231902759,"sensorId":"fullFrame","slate":"wide shot"},"subjectIds":["char-a"],"shotId":"shot-mu3uybub-3"}}],"checks":{"coverage":"same-frame-subject-bounds-projection","clipped":false,"behindCamera":false,"screenFraction":0.44729099405708983,"derivedSize":"wide shot"},"undo":{"historyEntryId":"be5d9c6f-0bb7-4e5c-be4d-3b1fe7dc887c","entries":1,"canUndoDirect":true},"warnings":[{"code":"OCCLUSION_UNMEASURED"}],"detailCursor":"c836cddc-9861-4ec0-87e3-fec764666ed7"}derivedSize is what the lens actually produced, measured by projection:
"wide shot" requested, "wide shot" derived, clipped: false. Then look at it:
cclay live capture --framing --out /tmp/329-doc/shots/f-a-wide-hip.png{"path":"/tmp/329-doc/shots/f-a-wide-hip.png","width":1920,"height":1080,"bytes":344608}To check the blocking from above instead, ask verify for the plan:
cclay live verify --receipt receipt-mu3uybub-4 --checks framing --visual plan --out /tmp/plan.png
writes the Top View at 1280×720: a 2D diagram drawn from the scene state
(src/plan-diagram.js), not a render. It shows a 1 m grid labelled every 2 m
with the origin marked, +X to the right and +Z down the image; object
footprints as rotated tan rectangles (a group's parts share one hue);
characters as blue circles with a facing arrow; every shot of the cut as
S1…Sn with a 3 m wedge as wide as its horizontal field of view and a line to
where it looks (the current shot filled red and labelled with its side of the
action line, L/R); the current shot's action line dashed with the
distance between its two subjects; and any prop blocking a camera's sight line
to a subject outlined red with the blocked segment. The framing fits all of it
with a 1.5 m margin, capped at 30 m. Its visualRefs entry carries
kind: "plan". A Studio Agent turn gets the same picture automatically, as a
"Studio plan observation (top view)", when the scene has two or more
characters, at least one shot, or the request talks about shots, cameras,
coverage or framing (never alongside an attached frame; a failed capture just
leaves it out). A framing check also returns geometry: per shot
the two leading characters, the camera's side of the line between them
(cameraSide: left, right or on-axis within 5 cm), the props crossing
the sight line (occluders), and axisConsistent across the cut (false when
a shot of the same pair crosses the line, null with fewer than two sided
shots). The frame-shot receipt itself reports occluded and cameraSide in
checks, and one OCCLUDED warning per prop in the way.
Reading that PNG as an image shows the character standing center-frame, head to feet unclipped, the chair fully visible beside her, camera at hip height — the framing the receipt promised. Session (a) is done; the prop stays in the scene.
Continuing in the same scene. frame-shot moved the camera but committed no
key — the shot's keyCount is still 0, so the framing is not yet authored at
a frame. Keying needs the keyAtFrame argument, which the frame-shot verb
does not surface; this is what the cmd escape hatch is for. Any
live-protocol command can be sent raw, but the admitted commands expect the
full admission envelope — which you build from one inspect:
cclay live inspect --scope shot{"context":{"schema":"studio-context-v1","host":{"surface":"studio","workspaceId":"8306b693-…","documentEpoch":"9110399f-…","sceneId":"scene-mu3uy6d6-1","sceneEpoch":"69c39cf9-…","workspaceHandle":"8306b693-…"},"revision":{"scene":5,"physics":4,"view":3},"units":{…},"scene":{"name":"SCENE 01","aspect":"16:9","floorY":0,"frameCount":432,"objectCount":1,"characterCount":1},"selection":{"kind":"character","id":"char-a","hierarchyId":"characterA"},"activeCharacterId":"char-a","view":{…},"shot":{"id":"shot-mu3uybub-3","name":"Shot 1","range":{"startFrame":0,"endFrameExclusive":432},"mode":"keys"},"camera":{…},"entities":[{"id":"char-a","kind":"character","token":"target-2",…},{"id":"chair","kind":"object","token":"target-5",…}],"entityPage":{"returned":2,"total":2,"truncated":false,"nextCursor":null},"shots":[{"id":"shot-mu3uybub-3","name":"Shot 1","range":{"startFrame":0,"endFrameExclusive":432},"keyCount":0}],"shotsTruncated":false,"assets":[],"recentReceipts":[{"id":"receipt-mu3uybub-4","summary":"applied","canUndoDirect":true},{"id":"receipt-mu3uybgp-2","summary":"applied","canUndoDirect":false},…],"jobs":[],"capabilities":{…}},"entities":[{"id":"char-a","kind":"character","name":"a young woman in a tan coat","token":"target-2"},{"id":"chair","kind":"object","name":"Side chair","token":"target-5"}],"total":2,"nextCursor":null}Build the envelope from that context — the four host identity fields and the
scene revision you saw. Mint your own commandId; the editor journals it,
which is what makes the command reconcilable later:
cclay live inspect --scope shot > /tmp/329-doc/f-b-inspect.json
node -e '
const c = require("/tmp/329-doc/f-b-inspect.json").context;
const host = (({workspaceId, documentEpoch, sceneId, sceneEpoch}) => ({workspaceId, documentEpoch, sceneId, sceneEpoch}))(c.host);
const envelope = {
name: "frame_shot",
args: {
subjectIds: ["char-a"],
shotId: c.shot.id,
keyAtFrame: 0,
framing: { intent: { size: "medium shot", view: "front", level: "eye", side: "left" } },
},
commandId: crypto.randomUUID(),
host,
expectedRevision: c.revision.scene,
};
require("node:fs").writeFileSync("/tmp/329-doc/f-b-envelope.json", JSON.stringify(envelope));
'Send it as one raw command:
cclay live cmd frame_shot --args "$(cat /tmp/329-doc/f-b-envelope.json)"{"ok":true,"commandId":"e94748b1-5df7-4859-a1f3-baf9cb20067a","receiptId":"receipt-mu3uyc8d-6","host":{…},"status":"applied","authored":true,"revision":{"before":5,"after":6},"affectedIds":["shot-mu3uybub-3","camera-key-mu3uyc8d-5"],"delta":[{"id":"shot-mu3uybub-3","after":{"range":{"startFrame":0,"endFrameExclusive":432},"camera":{"position":{"x":-0.011522021105043662,"y":1.5894328518973564,"z":2.109947017309772},"lookAt":{…},"focalMm":24.44391231902759,"sensorId":"fullFrame","slate":"medium shot"},"subjectIds":["char-a"],"shotId":"shot-mu3uybub-3"}},{"id":"camera-key-mu3uyc8d-5","after":{…,"keyId":"camera-key-mu3uyc8d-5","frame":0,…}}],"checks":{"coverage":"same-frame-subject-bounds-projection","clipped":true,"behindCamera":false,"screenFraction":1.1027891282716213,"derivedSize":"medium shot"},"undo":{"historyEntryId":"47bcf6d7-2a8e-44bb-b40e-25a3180e7d57","entries":1,"canUndoDirect":true},"warnings":[{"code":"OCCLUSION_UNMEASURED"}],"detailCursor":"e94748b1-5df7-4859-a1f3-baf9cb20067a"}Two affected ids this time: the shot and the new camera key
(camera-key-mu3uyc8d-5). The camera rose from y 1.06 to y 1.59 — eye level
for this character — and the derived size tightened to "medium shot"
(clipped: true honestly reports the feet crossing the bottom edge; a medium
shot on a 24 mm lens does crop). Look at it:
cclay live capture --framing --out /tmp/329-doc/shots/f-b-eye-keyed.png{"path":"/tmp/329-doc/shots/f-b-eye-keyed.png","width":1920,"height":1080,"bytes":588902}The PNG reads as the medium shot at eye level the receipt described. If the operator dislikes it, the receipt's undo entry takes the whole thing — reframe and key — back in one command:
cclay live undo --receipt receipt-mu3uyc8d-6{"ok":true,"commandId":"0de3e0ee-b32b-41ef-84e9-d4000813036e","receiptId":"e7ab0f0e-b86f-4d89-8ab5-6e4765c3b26f","host":{…},"status":"undone","authored":true,"revision":{"before":6,"after":7},"affectedIds":["shot-mu3uybub-3","camera-key-mu3uyc8d-5"],"delta":[{"id":"shot-mu3uybub-3","after":{"token":"target-8"}},{"id":"camera-key-mu3uyc8d-5","after":{"token":"removed-9"}}],"checks":{"coverage":"native-history-restoration"},"undo":{"historyEntryId":"47bcf6d7-2a8e-44bb-b40e-25a3180e7d57","entries":1,"canUndoDirect":false},"warnings":[],"undoneReceiptId":"receipt-mu3uyc8d-6","restoredTargets":[{…,"targetId":"shot-mu3uybub-3","token":"target-8"},{…,"targetId":"camera-key-mu3uyc8d-5","token":"removed-9"}]}status: "undone" with undoneReceiptId naming the reframe, and
restoredTargets handing back fresh tokens for the next command — the removed
key even gets a retired-identity token so a follow-up cannot address it by
stale id.
A mutation that loses its answer is the one failure you must never retry blindly: it may already be applied, and a retry would apply it twice. The protocol's answer is reconcile, and the CLI wires it in two ways.
A timeout is an uncertain apply. Give a mutation a --timeout the editor
cannot meet and the hub answers UNCERTAIN_APPLY — the CLI then reconciles
against the editor's journal itself and attaches the result:
cclay live arrange-objects --timeout 200 --op '{"op":"update","id":"chair","name":"Stand-in chair"}'; echo "exit=$?"exit=5
{"ok":false,"error":{"code":"UNCERTAIN_APPLY","message":"Live editor timed out running arrange_objects. The mutation may have been applied. Do not retry it; describe the scene before choosing a recovery action.","recovery":{"action":"reconcile","hint":"Do not retry the mutation; describe the scene first and choose a recovery action from what it reports."},"details":{"commandId":"951b6678-2a44-45fd-8783-36c94dc0ccdc","reconcile":{"status":"applied","receipt":{"ok":true,"commandId":"951b6678-2a44-45fd-8783-36c94dc0ccdc","receiptId":"receipt-mu3vbw54-3","host":{…},"status":"applied","authored":true,"revision":{"before":4,"after":5},"affectedIds":["chair"],"delta":[{…,"name":"Stand-in chair",…}],"checks":{…},"undo":{…},"warnings":[],"detailCursor":"951b6678-2a44-45fd-8783-36c94dc0ccdc"}}}}}error.details.reconcile.status: "applied" settles it: the edit landed, the
answer was merely late. There is nothing to redo — the receipt is right there,
undoable like any other.
A hub that dies mid-command is the same uncertainty without the courtesy of an error code. The CLI only learns the socket is gone, and says so:
cclay live cmd arrange_objects --args "$(cat /tmp/329-doc/f-c-envelope.json)" &
CLI_PID=$!
sleep 0.15 && kill -9 "$(lsof -tiTCP:5629 -sTCP:LISTEN)" # kill the hub mid-command
wait $CLI_PID; echo "exit=$?"exit=3
{"ok":false,"error":{"code":"NO_SERVER","message":"Nothing answered a live controller on ws://127.0.0.1:5629/live.","recovery":{"action":"start","hint":"run `npm run dev` or `cclay`"}}}Exit 3 means no hub — but the command you minted a commandId for may have
reached the editor before the hub died. Bring the hub back (same port, new
process), and gate on the editor reconnecting — the tab retries on its own
backoff, --wait catches it:
COZYCLAY_LIVE_PORT=5629 npm run dev -- --port 5209 & # hub restart
cclay live status --wait{"server":{"port":5629,"owner":"dev-full","pid":4315},"editors":[{"handle":"a881c22c-233b-4953-81ab-84fd0ab6a5b1","project":"QA","scene":"SCENE 01","cast":1,"embed":false,"connectedAt":1789549104568,"lastSeenMs":1,"inFlight":0}],"selected":"a881c22c-233b-4953-81ab-84fd0ab6a5b1"}Same handle, new server pid — the editor's identity survived the restart. Now ask the editor's journal what happened to that commandId:
cclay live cmd reconcile_studio_command --args "$(node -e '
const e = require("/tmp/329-doc/f-c-envelope.json");
console.log(JSON.stringify({ commandId: e.commandId, host: e.host }));
')"{"status":"unknown"}unknown here is honest, and it is the answer to expect in this setup: the
journal lives in the editor page, and killing the dev runner took the web
server with it, so the tab reloaded and the journal went with it (the timeout
case above reconciles applied because there the page never reloaded).
reconcile answers applied, not_applied or unknown; unknown and
not_applied both mean the same next step — a fresh inspect, then choose:
cclay live inspect --scope selection{"context":{"schema":"studio-context-v1","host":{…},"revision":{"scene":3,"physics":3,"view":1},"units":{…},"scene":{"name":"SCENE 01","aspect":"16:9","floorY":0,"frameCount":432,"objectCount":1,"characterCount":1},"selection":{…},"activeCharacterId":"char-a","view":{…},"shot":null,"camera":{…},"entities":[{…},{"id":"chair","kind":"object","token":"target-1","name":"Stand-in chair",…}],"entityPage":{…},"shots":[],…,"recentReceipts":[…],"jobs":[],"capabilities":{…}},"entities":[{"id":"char-a","kind":"character","name":"a young woman in a tan coat","token":"target-3"},{"id":"chair","kind":"object","name":"Stand-in chair","token":"target-1"}],"total":2,"nextCursor":null}The reload reopened the same project and the chair is still there, renamed
Stand-in chair — the timeout edit above had landed. Whatever the killed
command did, the state you just read is the truth now, so close the loop on
it: issue a fresh admitted command built from that inspect's revision (here a
new colour; if it had re-issued the identical colour, the editor would have
answered noop — also fine, just nothing to undo):
node -e '
const c = require("/tmp/329-doc/f-c-inspect2.json").context;
const host = (({workspaceId, documentEpoch, sceneId, sceneEpoch}) => ({workspaceId, documentEpoch, sceneId, sceneEpoch}))(c.host);
const envelope = {
name: "arrange_objects",
args: { ops: [{ op: "update", id: "chair", color: "#3d5a80" }] },
commandId: crypto.randomUUID(),
host,
expectedRevision: c.revision.scene,
};
require("node:fs").writeFileSync("/tmp/329-doc/f-c-envelope2.json", JSON.stringify(envelope));
'
cclay live cmd arrange_objects --args "$(cat /tmp/329-doc/f-c-envelope2.json)"{"ok":true,"commandId":"53f3bb46-b939-4d57-811d-04701ba0bebf","receiptId":"receipt-mu3vc2ed-2","host":{…},"status":"applied","authored":true,"revision":{"before":3,"after":4},"affectedIds":["chair"],"delta":[{"id":"chair","after":{"position":{"x":2.0380938133472153,"y":0,"z":0},"yawDeg":0,"rotationDeg":{"x":0,"y":0,"z":0},"scale":{"x":1,"y":1,"z":1},"name":"Stand-in chair","color":"#3d5a80","renderer":"chair","parentId":null}}],"checks":{"coverage":"same-frame-world-AABB-proxies","overlapIds":[],"maximumFootprintOverlapM":0},"undo":{"historyEntryId":"7ada6c36-2b26-460d-874f-c70ef47a698c","entries":1,"canUndoDirect":true},"warnings":[],"detailCursor":"53f3bb46-b939-4d57-811d-04701ba0bebf"}status: "applied" with a receipt, rev 3→4 — the loop is closed on live
state, never on a guess about what the dead hub saw.
| verb | what it does | receipt? |
|---|---|---|
status [--wait] |
the hub, its editors, and the selected one; --wait blocks until a selectable editor connects |
no |
describe |
the full live scene document (scenes, objects, characters, camera, timeline) | no |
inspect --scope selection|scene|entities|shot|motion|catalogue [--ids a,b] [--query text] |
the scene context: identity, revisions, entity tokens, shots, receipts — the source for admission envelopes | no |
capture --out frame.png [--framing] |
the editor's 640×360 viewport PNG, or with --framing the full-resolution shot-camera PNG |
no |
arrange-objects --op '<json>' | -f ops.json |
create / update / remove / group / ungroup set pieces, up to 100 ops, with relative placement and collision policy | yes |
arrange-characters --op '<json>' | -f ops.json |
create / update / remove cast, up to 8 ops | yes |
patch --target <kind>[:<id>] --set '<json>' |
set declared authored fields by path on one character / object / shot / stage target; inspect --scope catalogue lists the patchable paths |
yes |
frame-shot --subject <id> --size … --view … --level … [--side] [--focal] or --exact px,py,pz,lx,ly,lz,focal |
move the shot camera by film vocabulary or to an exact pose; keyAtFrame (via cmd) also authors a camera key |
yes |
operate [--select object:<id>] [--frame N] [--mode scene|camera|motion] [--play|--pause] |
transient editor state — selection, playhead, mode; nothing authored | yes (transient) |
verify --receipt <id> --checks placement,framing [--visual frame|contact_sheet|plan --out check.png] |
re-run a receipt's evidence, optionally writing its visual proof (plan is the Top View) |
no |
undo --receipt <id> |
restore the document through the editor's native history, one entry | yes |
cmd <name> --args '<json>' |
any live-protocol command, raw (no admission, no auto-receipt) | per command |
tool <name> --args '<json>' |
any registry tool the hub serves, e.g. describe_shot |
no |
patch carries one target kind per command — its commit domain — because one
receipt is one revision step and one undo entry; a character patch and a
stage patch are two commands. --target is kind (stage, or shot for
the current shot) or kind:id (character:char-a, object:chair,
shot:shot-1). --set is a JSON object keyed by the element path inside that
kind, e.g. {"keyLight.intensity": 2.4} for stage.keyLight.intensity. A
value the editor's own persistence normalizer refuses to keep is reported, not
assumed: the receipt's ops[] names it in droppedPaths and the receipt
status becomes partial. A value outside a path's declared range never
reaches the normalizer at all — the command is refused with
INVALID_ARGUMENT before admission.
cclay live inspect --scope catalogue | jq '.patchable.stage'[{"path":"stage.keyLight.x","type":"number","min":-30,"max":30},{"path":"stage.keyLight.y","type":"number","min":0.5,"max":30},{"path":"stage.keyLight.z","type":"number","min":-30,"max":30},{"path":"stage.keyLight.intensity","type":"number","min":0,"max":4},{"path":"stage.keyLight.warmth","type":"number","min":0,"max":1},…]A vec3 path, e.g. object.scale, carries min/max as {x,y,z} objects —
one limit per axis — instead of a single number.
cclay live patch --target stage --set '{"keyLight.intensity":2.4,"keyLight.warmth":0.2}'{"ok":true,"commandId":"0f0f6f3e-…","receiptId":"receipt-mu9p1c2-7","host":{…},"status":"applied","authored":true,"revision":{"before":6,"after":7},"affectedIds":["scene-mu3uy6d6-1"],"delta":[{"id":"scene-mu3uy6d6-1","after":{"patched":[{"path":"stage.keyLight.intensity","number":2.4},{"path":"stage.keyLight.warmth","number":0.2}]}}],"checks":{"coverage":"declared-element-readback"},"undo":{"historyEntryId":"8f2c…","entries":1,"canUndoDirect":true},"warnings":[],"ops":[{"index":0,"status":"applied"}],"detailCursor":"0f0f6f3e-…"}That one receipt is one Cmd+Z in the editor: undo --receipt receipt-mu9p1c2-7
and the key light goes back exactly where it was.
A registry tool that does not apply to the project or to its target answers a
structured refusal instead of acting: NOT_IN_MODE for an animation-only tool
in a Storyboard project, TARGET_NOT_READY for motion or a take on a capsule
figure (model: "proxy-figure"). Branch on code; reason is the sentence to
show. Here a capsule figure is cast, selected, and asked to move:
cclay live tool add_character --args '{"subject":"a stand-in","model":"proxy-figure","posture":"sit","x":1.2,"z":0.5}'
cclay live operate --select character:character-mv17e47z-3
cclay live tool generate_motion --args '{"phases":["A person walks forward."]}'{"content":[{"type":"text","text":"{\"ok\":false,\"code\":\"TARGET_NOT_READY\",\"reason\":\"Capsule figures have no rig - Motion generation works on rigged characters only.\"}"}],"isError":true}Nothing reached the editor. Select a rigged character and the same call goes to the editor's own motion readiness checks.
The op JSON for arrange-* is one operation, an array of them, or
{"ops":[…], "collisionPolicy":"report"|"avoid"}. Placement is world
coordinates or relativeTo an entity with basis (world / subject /
shot_camera), side and gapM; support stands things on the floor or on
an object. Sizes: extreme close-up … extreme wide shot. Views: front, front
three-quarter, profile, rear three-quarter, back. Levels: ground, low, hip,
eye, high, overhead. Sides: left, right.
| flag | meaning |
|---|---|
--workspace <handle|project> |
which editor tab a command reaches |
--timeout <ms> |
bound for one command round trip (max 300 000) |
--pretty |
indent the JSON object |
--live-port <port> |
hub port; else COZYCLAY_LIVE_PORT, else 5184 |
stdout is exactly one JSON object per invocation; progress goes to stderr,
so piping through jq is always safe.
| code | meaning | next step |
|---|---|---|
| 0 | ok | — |
| 1 | editor or receipt error | read the message; the receipt in error.details.receipt is the evidence |
| 2 | usage error | cclay live --help |
| 3 | no hub (NO_SERVER) |
start the hub, then status --wait |
| 4 | workspace problem (NO_EDITOR, AMBIGUOUS_WORKSPACE, STALE_HANDLE) |
open a tab / name one with --workspace |
| 5 | TIMEOUT or UNCERTAIN_APPLY |
do not retry; reconcile or re-inspect |
| 6 | STALE_SCENE, STALE_TARGET, TARGET_BUSY |
inspect again and re-issue |
With exactly one editor connected, commands route to it automatically. With
two or more, the hub refuses to guess: every verb fails AMBIGUOUS_WORKSPACE
listing the candidate handles until you pass --workspace <handle> (a project
name works too, if it names exactly one tab). A handle from before a reload
still names the same tab — but while that tab is away it fails STALE_HANDLE
and is never silently rerouted to a different editor. status never fails on
ambiguity; it just reports selected: null.
- Default editor command bound: 5 s (30 s for motion installs).
--timeoutoverrides it per command, capped at 300 s. - The CLI additionally abandons a request that gets no hub answer at all (default ceiling 330 s).
status --waitgives up after 30 s unless--timeoutsays otherwise.- A timeout on a mutation is an uncertain apply — see session (c).
Metres (x right, z toward the default camera, y up), degrees of yaw,
frames at 24 fps, frame ranges end-exclusive. Ids are the ids the scene
document uses — take them from inspect, never from memory.
The other prompt surface, the Agent panel (Studio and Workflow), runs on the
pi agent harness and can talk to six providers. A model id on the wire is
provider/model, so openai-codex/gpt-6-astra and anthropic/claude-fable-5
name two different models without ambiguity. A bare id with no slash still
works and means openai-codex/<id>, which is why older saved model choices
keep resolving.
| provider id | shows up as | how it authenticates |
|---|---|---|
openai-codex |
ChatGPT (OpenAI Codex) | your ChatGPT sign-in, unchanged |
anthropic |
Anthropic | API key, ANTHROPIC_API_KEY |
openai |
OpenAI | API key, OPENAI_API_KEY |
google |
Google Gemini | API key, GEMINI_API_KEY or GOOGLE_API_KEY |
openrouter |
OpenRouter | API key, OPENROUTER_API_KEY |
cliproxy |
CLIProxyAPI | API key, CLIPROXY_API_KEY (base URL CLIPROXY_BASE_URL, default http://127.0.0.1:8317) |
ChatGPT sign-in did not change: the panel still runs the Codex OAuth flow,
the token still lives in ~/.config/cozyclay/codex-auth.json, and it is still
the only provider you sign in to rather than paste a key for. There are no
OAuth flows for the other five.
An API-key provider is read from its environment variable first, and from
~/.config/cozyclay/providers.json second. That file is written atomically at
mode 0600 and is a flat map of provider id to key:
{"anthropic":"sk-ant-…","openrouter":"sk-or-…"}The environment variable wins when both are set, so a key exported in the
shell that started the Studio temporarily overrides the saved one without
touching the file. COZYCLAY_CONFIG_DIR moves the whole config home,
providers.json and codex-auth.json together, which is what the tests use.
Three routes manage the file. They sit behind the same origin guard as the
rest of /agent/*: a browser request must carry an Origin of the Studio's
own loopback address, and a header-less curl is accepted for GET only, so
the writing calls need the header spelled out.
curl -s http://127.0.0.1:5180/agent/providers
curl -s -X PUT http://127.0.0.1:5180/agent/providers/anthropic \
-H 'origin: http://127.0.0.1:5180' -H 'content-type: application/json' \
-d '{"key":"sk-ant-…"}'
curl -s -X DELETE http://127.0.0.1:5180/agent/providers/anthropic \
-H 'origin: http://127.0.0.1:5180'{"providers":[{"id":"openai-codex","label":"ChatGPT (OpenAI Codex)","authSource":"chatgpt","signedIn":true},{"id":"anthropic","label":"Anthropic","authSource":"file","signedIn":true},{"id":"openai","label":"OpenAI","authSource":null,"signedIn":false},…]}GET /agent/providers never echoes key material: authSource tells you where
a key came from (chatgpt, env, file) and that is all. PUT on
/agent/providers/openai-codex is refused with 400 and a message naming the
ChatGPT sign-in, because that provider has no key to store.
GET /agent/models is the catalogue the panel's dropdown reads: every
provider with its sign-in state, each model key-addressed as provider/id,
and the reasoning-effort levels that model actually supports. A provider you
have no key for is still listed, its models with it and signedIn: false, so
the panel can show what a key would buy you.
The model is picked per turn, so one session can start on ChatGPT and continue on Anthropic without losing its history.
On an anthropic-messages model the request already carries Anthropic cache
breakpoints from pi-ai 0.85.1 itself (default retention "short", i.e.
{"type":"ephemeral"}): every system block
(node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js:801-826),
the last tool definition (:1133, unless the model's
compat.supportsCacheControlOnTools is false) and the last block of the last
user message (:1066-1085). The runner's before_payload hook only fills a
missing mark on the last system block and the last tool, never past
Anthropic's four breakpoints. The Studio's <actions> block sits at the end
of the system prompt, so the system prompt and the tool schemas form a stable
cacheable prefix; whether a turn hit the cache shows in the done frame's
usage.cacheRead / usage.cacheWrite (token counts summed over the turn's
provider requests, with usage.requests the number of requests).
On the Workflow canvas you can add to a turn while it is still running, instead of stopping it and starting over:
curl -s -X POST http://127.0.0.1:5180/agent/turn/<turnId>/steer \
-H 'origin: http://127.0.0.1:5180' -H 'content-type: application/json' \
-d '{"text":"make it a profile instead"}'{"ok":true,"queued":true}<turnId> is the id the Workflow client minted for the running turn.
attachments is accepted too, the same short list of {dataUrl} images the
composer sends. queued: true is literal: the harness takes one steer at a
time and hands the text to the model at the next step of the same turn, so a
tool call already in flight finishes first and nothing is cancelled.
Studio turns are steerable the same way. The Studio turn id is the turnId
of its envelope, and the request must carry the session's studio_owner
cookie (the one the turn's response set, scoped to /agent); without it the
route answers 403 AUTH_REQUIRED, exactly like the turn's event stream.
The other refusals: 404 when no Workflow or Studio turn carries that id, 409
NO_ACTIVE_TURN when the turn has already ended (or was stopped), 400 when
the text is missing or empty or an attachment is not an inline data URL.
After a Studio turn ends, the sidecar reviews it once in the background and
leaves the result as a note card in the Agent panel. The review never delays
the turn and never talks back to the agent: the turn's stream ends with its
done frame exactly as before, and only then does the review start. It runs
only when the turn authored something (a tool result with authored: true)
or met a refusal (TARGET_NOT_READY, CAPABILITY_MISSING,
CONFIRMATION_REQUIRED), and was not stopped; read-only turns and questions
are not reviewed.
The reviewer is the turn's own model, at reasoning effort high (clamped to
the nearest level the model supports), in one request with a 120 s
timeout. It sees the user request, every receipt of the turn (bounded: no
image bytes, 8 delta rows, 12 warnings), the refusals, the last reply, the
geometry facts of a framing verify_result the sidecar issues itself for the
shots the turn touched, and up to five pictures: the current shot frame, the
Top View, and the start frame of each shot the turn created (the playhead is
put back afterwards). It judges seven items: request coverage, refused versus
missing deliverables, spatial sanity, framing, continuity across shots, undo
integrity and report honesty.
| variable | effect |
|---|---|
COZYCLAY_SUPERVISOR=off |
no review at all; the route answers 204 |
COZYCLAY_SUPERVISOR_EFFORT |
the reasoning effort, default high |
COZYCLAY_SUPERVISOR_AUTOFIX=blocker |
a note whose blockers carry fixes is marked autofix: { eligible: true, severity: "blocker" }; default off |
The note is one more sequenced event on the turn's record, so
GET /agent/turn/<turnId>/events?after=N replays it after done. The panel
reads it from its own route, which takes the same studio_owner cookie:
curl -s http://127.0.0.1:5180/agent/turn/<turnId>/supervisor -H 'cookie: studio_owner=…'{"type":"supervisor","turnId":"…","verdict":"reviewed","model":"cliproxy/claude-opus-5-5","effort":"high","elapsedMs":41210,"summary":"…","items":[{"text":"…","status":"done","evidence":"…"}],"issues":[{"severity":"concern","kind":"framing","text":"…","evidence":"…","fix":{"action":"shot.frame","args":{"shotId":"shot-1","subjectIds":["char-a","char-b"],"framing":{"intent":{"size":"medium shot"}}}}}],"fixes":[{"issueIndex":0,"severity":"concern","action":"shot.frame","args":{"shotId":"shot-1","subjectIds":["char-a","char-b"],"framing":{"intent":{"size":"medium shot"}}}}],"droppedFixes":[],"counts":{"blocker":0,"concern":1,"note":0,"fixes":1},"eventSeq":14}status is done, partial, refused or missing; severity is
blocker, concern or note; kind is coverage, disclosure,
spatial, framing, continuity, undo or honesty. A review that could
not run or did not answer with a valid verdict is still a note, with
verdict: "unavailable", empty lists and a reason. While the review is
running the route holds the request up to 180 s; it answers 204 when the wait
runs out, when the turn was not reviewed or the switch is off, 404 for an
unknown turn and 403 for another session's cookie. The card's
Send as next prompt button puts the blockers and concerns into the
composer and sends them as an ordinary turn; nothing is sent on its own.
An issue may carry a fix: one run_action call, { "action", "args" },
written with the real ids and values from the receipts and geometry. The
reviewer's digest lists what a fix may name: ACTIONS is the turn's own
action index (id and label), and ACTION SCHEMAS holds the input schemas of
the pre-selected actions the index declares (character.setPose,
character.move, character.update, shot.frame, shot.set,
shot.setRange, object.update, object.remove, objects.arrange,
stage.set, edit.undo, view.update), read with
inspect_studio { scope: "actions", ids } and bounded to 6 KB (whole schemas
only; the ids that did not fit are listed under omitted).
Every fix is validated before the note is stored: its action must be in the
action index, its args must be an object, and when the action's schema was
read every key in its required list must be present. A fix that fails is
removed from its issue and reported in droppedFixes as
{ issueIndex, action, reason } (unknown action, args is not an object,
missing required key "<key>", malformed fix); the issue itself stays.
The kept ones are repeated in fixes as
{ issueIndex, severity, action, args }, and counts.fixes counts them.
When a note has fixes, the expanded card shows each one as a code line under
its issue and the button reads Apply fixes as next prompt. The prompt it
sends asks for exactly those calls, in order, in one script call followed
by verify_result, one line per fix (- [severity] <issue> -> run_action {"action":…,"args":…}); blockers and concerns without a fix follow under
Also check:. COZYCLAY_SUPERVISOR_AUTOFIX=blocker never starts a turn:
turns belong to the panel, and the sidecar only marks the note autofix, which
the card mentions in the button's tooltip.
Conversations live in ~/.config/cozyclay/agent-sessions/
(COZYCLAY_AGENT_SESSIONS_DIR overrides the directory), one
<sessionId>.jsonl transcript plus a <sessionId>.meta.json index entry per
session, both mode 0600. GET /agent/sessions lists the 50 most recent,
GET /agent/sessions/<id> returns one as a transcript.
The transcript is the v2 format: a header line, then one message per line.
{"format":"cozyclay-agent-v2","version":2,"sessionId":"wf-3a91"}
{"kind":"message","message":{"role":"user","content":[{"type":"text","text":"Give me a wide two-shot"}]}}The stored messages are the harness's own messages, written back verbatim, including the provider-opaque reasoning fields an assistant message carries. That is what lets a resumed session continue on the same provider instead of replaying a lossy summary. Inline images over 2 MiB are dropped on write and the text around them is kept. Nothing compacts or summarises a transcript behind your back.
Sessions saved before this version have no v2 header, and they are ignored,
not migrated. The files stay on disk untouched; list skips them, a direct
read answers 404, and the sidecar logs [agent] skipping legacy session <id>
once per session per process. If you want one of those conversations back,
read the old file yourself: nothing in the Studio will convert it.
Motion generation is not on the CLI yet: generate_motion and the motion
install verbs are served by the MCP server's own runtime, not the live hub
(deferred to part E of this plan). Until then, an editor connected to the hub
refuses those commands with CAPABILITY_MISSING, and motion work stays on
the surfaces that own it — the Studio's motion panels, or a prompt-driven
session through the Agent panel (View ▾ → Panels → Agent panel), which
remains available and works the same scene.