DOCUMENTATION

Workflows

A workflow is a JSON document that lists steps, each one call to an Unreal endpoint or a Blender op, and mif_workflow runs them with retries, a dry run and automatic rollback when a step fails. Read this if you want a multi-step job to either finish or leave your project as it was.

What a flow does

A flow is an ordered list of steps. Each step calls one existing endpoint or Blender op, and its arguments can name an earlier step's output. mif_workflow checks the whole document before anything runs, then runs the steps in order. A step can be skipped by a condition, retried with a delay, or expected to fail.

When a step fails, the flow stops and takes back what it did: the failing step's partial writes first, then every earlier step, newest first. It uses the editor's own undo (the whole flow is one group_undo group) and Blender's own undo. The reply says plainly what it could not take back.

dry_run runs nothing that writes. It checks every step's endpoint and keys against the live editor, runs each write's own preview where the endpoint has one, and lists what each step could and could not roll back.

A flow adds no new way to write. Every call it makes is an ordinary bridge call, so the safety gate and write modes, the change cap, protected roots, asset locks and the call journal apply to each step exactly as they do to a call you make by hand.

A first flow

{
  "name": "light_row",
  "inputs": {"folder": "WorkflowDemo", "count": 2},
  "steps": [
    {"id": "first",  "call": "spawn_actor", "args": {"actorClass": "PointLight", "label": "Lamp_A", "location": [0, 0, 300]}},
    {"id": "second", "call": "spawn_actor", "args": {"actorClass": "PointLight", "label": "Lamp_B", "location": [200, 0, 300]},
     "retry": {"attempts": 3, "delaySeconds": 0.5, "backoff": 2.0}},
    {"id": "dim",    "call": "set_property", "args": {"objectPath": "@second.actorPath", "propertyPath": "...", "value": "500"},
     "when": {"ref": "@inputs.count", "gte": 2}},
    {"id": "guard",  "call": "spawn_actor", "args": {"actorClass": "NoSuchClass"}, "expectFailure": "class"},
    {"id": "mesh",   "blender": "add_primitive", "args": {"type": "CUBE", "name": "Probe"}}
  ],
  "outputs": {"lamp": "@second.actorPath"}
}

The tool takes flow (a dict, JSON text, a YAML document or a path to a .json or .yaml file), inputs, dry_run, rollback (on by default), commits, detail (summary or full) and library.

Step keys

Key Meaning
id the step's name: letters, digits and _; unique; not inputs or self
call / blender exactly one of the two: an Unreal endpoint, or a Blender op. Arguments use the endpoint's own camelCase keys, the ones describe_endpoint and bl_describe_op list, because a flow names endpoints and is checked against each endpoint's own parameter guard
args the request body. A value that is exactly "@step.path" is replaced by that earlier step's output at that path (dots; a number indexes a list). "@inputs.name" reads an input. A string holding {@step.path} has each placeholder replaced by the value as text
when run the step only if the condition holds: "@ref" (truthy), {"ref", equals, notEquals, gt, gte, lt, lte, in or exists}, {"all": [...]}, {"any": [...]}, {"not": c}, {"ok": "step"}, {"failed": "step"}
retry {attempts 1-10, delaySeconds 0-60, backoff 1-10, maxDelaySeconds, on: "transient" or "failure"}
expectFailure true, or text the error must contain: the step passes only when the call answers ok:false
undo a list of {call or blender, args} compensations, run during rollback after the undo stack, for what undo cannot reach (an asset deleted from disk, a file). @self.path names this step's own output
external text saying this step does something no undo takes back (a file written, a message sent). Rollback reports it by that text
wrap false sends an Unreal step as a plain call even when it could be wrapped (see Rollback)
timeoutSeconds the read timeout for this step's call
continueOnFailure true records a failure of this step and goes on. Nothing is rolled back for it beyond its own partial writes

Before anything runs the document is checked, and every problem is listed. Refused: an unknown key at either level, a duplicate or reserved id, a step with both or neither of call and blender, a reference to a step that is not earlier, a reference to an input the flow does not declare, a malformed condition, and retry bounds out of range.

Rollback

Unreal

A plain call that fails halfway can leave partial writes that no undo reaches, because the engine throws the failed call's undo record away without reverting the writes. A step whose endpoint is transacted and can run inside batch is therefore sent as a one-op batch, whose undo record survives a failing op. It is the same handler, behind the same gate, and the step's output is that op's own result. If a handler refuses a branch only inside batch, the flow sends the step again as a plain call once and says so (wrapFallback). wrap: false does that from the start.

Every other step is sent plainly. An endpoint that opens its own transactions records them like any other, and a read records none.

The undo mechanism is group_undo. A flow opens one group before its first step, and every group_undo answer lists the group's transactions by id, so the flow measures which transactions each step made. Two options exist for this:

  • keep takes back everything in the group except its first keep transactions, and leaves the group open. One failed try is undone without ending the flow's group.
  • expectIds names the exact transactions the undo must take back, oldest first. If they are not the ones it would take back, nothing is undone.

expectIds is what keeps a flow from undoing your own edit. If you change something in the editor while a flow runs, your edit lands inside the group's range. The rollback names only the flow's transactions and the editor checks them in the same game-thread call as the undo. When the range holds a transaction the flow did not make, rollback takes back the flow's transactions above it, stops, and names the step it could not reach and what is in the way. The rest is for you (list_transactions, then undo_transactions).

A flow that finishes ends its group, so group_undo with action: undo takes the whole flow back later in one call. Only one group is open in an editor at a time. With rollback on, a flow refuses to start while another group is open and names it.

Blender

Every Blender op that can change the scene pushes one undo step after it runs, whether it succeeded or refused. The flow reads undo_history before and after each Blender step and keeps the steps that appeared. Rollback undoes that many and checks the step names it took back against the ones it recorded. Blender keeps 32 undo steps by default (preferences.edit.undo_steps). A flow that needs more is told so before it runs, when the dry run can count it.

What rollback cannot take back

The reply says so for each one and never hides it.

Effect Why How the reply says it
A save, a cook, the console, Play-In-Editor, an import into the project, a source-control checkout, a collection write they reach disk or leave the process; the write gate lists them notRolledBack[] with {step, why}
A file a step wrote (an export, a capture, a render) no undo record exists for a file notRolledBack[] with the step's external text, or "if it wrote a file, the file stays"
A Blender export, save, render, bake or generation outside the scene; bl_describe_op names the class notRolledBack[]
A Blender step taken while undo recording is off (a fluid domain, MIF_BLENDER_UNDO=0, edit mode) no undo step was pushed notRolledBack[] with the reason
A new asset's package creating an asset is not fully undoable in the engine the step's undo compensations (for example delete_asset) run. Without one, notRolledBack[] names the path the step reported
A compile that a self-managed step ran compiles run outside any transaction undoing the graph edits leaves the Blueprint needing a compile, and the rollback note says so

rollback.complete is true only when every step that wrote was taken back and nothing is in notRolledBack.

Retries

A step is tried at most attempts times, waiting delaySeconds * backoff^(n-1) between tries, capped by maxDelaySeconds. Which failures are retried:

  • on: "transient" (the default) retries only answers that say the call did not run or may be repeated safely: retryable: true from the transport (editor busy, starting or unreachable, or Blender's socket closed) and the editor's own requestRunning.
  • on: "failure" also retries a call that answered ok:false. Before the next try, the failed try's partial writes are undone, so a retry starts from where the step started.

A write whose read timed out is never sent twice. The transport answers such a call outcomeUnknown: true, and the next try re-sends it with the same request id, so the editor replays the first run's answer, or says it is still running, instead of running it again. See request ids. Each fresh try otherwise carries its own id, wf-<run>-<step>-a<n> (rollback calls use wf-<run>-rb-<n>), which the journal records as rid.

Expected-failure steps

expectFailure turns a refusal into the step's success, for a flow that checks a guard holds. If the call answers ok:true the step fails ("expected a failure and the call succeeded") and the flow rolls back, the unexpected success included. A refusal that wrote something before refusing is undone at once and the step reports partialWritesUndone.

The dry run

dry_run: true makes no write. For each step, in order:

  1. The endpoint or op must exist, and every key the step sends must be one its guard accepts (acceptedParams from describe_endpoint, acceptedKeys from bl_describe_op). Unknown keys are listed.
  2. Nothing else runs, not even what looks like a read. The Unreal read-only bucket only means "no undo transaction", and it holds export_asset, the captures, compile and validate, which write files or Blueprints. So a later step whose arguments need an earlier step's output shows as notPreviewed, and a condition on one shows as "decided at run time".
  3. A write whose endpoint takes a bulk preview is called with dryRun: true when all its arguments can be filled. The step shows the endpoint's own change list and previewId, which the real run can commit.
  4. Each step says how it would be rolled back: rollbackClass is undo, none (it records nothing: a read, or a file it writes stays) or cannot, with rollbackNote naming the mechanism or the reason. It also says whether the endpoint is on the write gate's list (writeGated). A gated step in a mode that refuses it fails the preflight, so the whole run refuses before it starts.
  5. Conditions that can be decided are decided. The rest say "decided at run time".

The reply ends with wouldRollBack (every write step could be taken back) and cannotRollBack[].

The change cap and previews

A flow does not get around the change cap. A step over the cap answers as any call does, with capped: true, a previewId and nothing written. The flow treats that as the step failing, stops and rolls back, and the reply names the step and its previewId.

Review the list (list_previews), then run the flow again with commits: {"<step id>": "<previewId>"}. That step is sent as {previewId} alone to the endpoint that made the preview, which commits exactly what was shown, refuses on drift and rolls itself back on a mismatch. commits also takes a dry run's preview ids, so "dry run, read, run" commits what you read. A flow never raises or skips the cap.

Write modes

Nothing new. A step on the write gate's list is refused in scratch and read mode exactly as a plain call is, and as an op inside batch is. The dry run reports it up front so a flow does not stop halfway for a refusal it could have seen.

Nested flows and a library

A step {id, flow, args?, when?, note?} runs another flow in its place. flow is a flow written inline, or the name of one: the document's own flows ({name: flow}), then the tool's library. A library is an object {name: flow}, a folder of .json, .yaml or .yml files (each one flow, named by its file name), or one file holding {name: flow}.

A nested flow is expanded in place, with its steps renamed <id>__<step> (nesting again: <outer>__<inner>__<step>), before the check. The runner sees one flat list, so everything above holds for nested steps unchanged: one group_undo group, one rollback that takes nested and outer steps back newest first, one dry run, every key checked against the live endpoint. Each step's row says inFlow.

Inputs the step's args are the nested flow's inputs, and each must be one the nested flow declares (inputs as {name: default}). A value may be a literal or a reference into the calling flow (@earlier.path, @inputs.x)
Outputs @<id>.<output> in a later step of the calling flow reads the nested flow's outputs.<output>. An output it does not declare is refused before anything runs
Conditions when on the nested step applies to every one of its steps. {ok: "<id>"} holds when one of its steps ran and none failed. {failed: "<id>"} holds when one failed
Not on the step retry, expectFailure, undo and external belong on the nested flow's own steps (refused as unknown keys)
Refused before anything runs a flow that calls itself, directly or through another; a name no flow carries; an input the nested flow does not declare; nesting deeper than 8; more than 200 steps once expanded

Parallel steps

A step {id, parallel: {unreal: [steps], blender: [steps]}, when?, note?} runs two lanes at the same time, each lane's steps in order. There is exactly one Unreal lane and one Blender lane. Every Unreal call runs on the editor's game thread one at a time, so two Unreal lanes would only take turns. The gain is Blender working beside the editor, for example a mesh built in Blender while the level is laid out in Unreal. Both lanes are required, and a group with one is refused.

  • A lane holds ordinary steps and nested flows whose steps all belong to that lane's backend. An Unreal step in the Blender lane is refused. Groups do not nest.
  • A lane's step may read steps before the group and earlier steps of its own lane, never the other lane's, since those outputs are not there yet. A step after the group reads either lane.
  • Rollback is unchanged. The two lanes measure different undo records, so neither lane's measurement can see the other's work.
  • A lane that fails stops the other lane before its next step. The step already in flight finishes, because neither backend can be stopped mid-call, and the steps not reached say notRun. Then the flow rolls back as usual, on both backends.
  • The reply's parallel.groups[] gives each group's wall time beside each lane's.

YAML documents

A flow or a library file may be YAML, given as YAML text or a .yaml or .yml path. It is read when PyYAML is installed in the MCP server's Python. Without it a YAML document is refused, and the refusal names the missing module and says to install it with pip or pass the flow as JSON. JSON reads as before, and text that starts with { is always JSON.

When things go wrong

A just-started editor answers its first calls late. On 5.3.2 the first describe_endpoint after the bridge came up timed out at 30 s while the asset registry scanned. The preflight's reads are asked again up to four times, and an editor that stays busy ends the run retryable with nothing run, and the reply does not claim the endpoint is missing.

Failure What happens
The document does not check nothing runs; problems[] lists every problem
An editor or Blender is not reachable at the first step that step fails as transient (retried if asked); there is nothing to roll back
A step fails the failing step's own partial writes are undone, then every earlier step newest first, then compensations newest first
Another group_undo group is open when the flow starts with rollback on, the flow refuses to start and names it; nothing runs
The group's range holds a transaction the flow did not make rollback takes back the flow's transactions above it, then stops; rollback.blockedAt names the step and the transaction in the way
An undo itself fails rollback stops; the reply says what is left and how to take it back by hand
A Blender undo takes back different step names than recorded rollback stops and reports both lists
A compensation fails recorded in compensations[]; the rest still run, since they are independent clean-ups
The MCP server dies mid-run the journal holds every call by its wf- id and the undo stack holds the writes; nothing resumes

A flow keeps no state after the call. There is deliberately no resume file, because a resumed flow would have to trust that nothing changed in between, which is the assumption the preview handshake exists to avoid.