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:
keeptakes back everything in the group except its firstkeeptransactions, and leaves the group open. One failed try is undone without ending the flow's group.expectIdsnames 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: truefrom the transport (editor busy, starting or unreachable, or Blender's socket closed) and the editor's ownrequestRunning.on: "failure"also retries a call that answeredok: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:
- The endpoint or op must exist, and every key the step sends must be one its guard accepts (
acceptedParamsfromdescribe_endpoint,acceptedKeysfrombl_describe_op). Unknown keys are listed. - 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,compileandvalidate, which write files or Blueprints. So a later step whose arguments need an earlier step's output shows asnotPreviewed, and a condition on one shows as "decided at run time". - A write whose endpoint takes a bulk preview is called with
dryRun: truewhen all its arguments can be filled. The step shows the endpoint's own change list andpreviewId, which the real run can commit. - Each step says how it would be rolled back:
rollbackClassisundo,none(it records nothing: a read, or a file it writes stays) orcannot, withrollbackNotenaming 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. - 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.