Skip to content

Handler Conventions

How mutating C++ handlers participate in idempotency (safe replay) and rollback (failure recovery).

Why

Flows mutate editor state. When a flow fails partway, the user wants two guarantees:

  1. Rerun is safe — running the same flow again doesn't duplicate work or explode on "already exists" errors.
  2. Failure is recoverable — the user can opt into automatic rollback that undoes completed mutations in reverse order.

Both are properties of each individual handler. The runner coordinates across handlers; each handler decides what the natural key is, how to detect existing state, and what the inverse operation looks like.

The contract

Every mutating handler (create, modify, delete) follows this shape:

Natural key

Each handler accepts a parameter identifying the entity it operates on. Examples:

Entity Natural key param
Actor actorLabel (or label shorthand on creates)
Asset (material, texture, mesh, datatable…) assetPath or path
Blueprint variable blueprintPath + variableName
Blueprint function blueprintPath + functionName
Component parent + componentName
Material parameter materialPath + parameterName

Handlers without a natural key (e.g., execute_command, shell) cannot be idempotent or reversible — document them as such, do not emit rollback records.

onConflict — creates only

Create handlers accept an optional onConflict parameter controlling what happens when the natural key already resolves to an existing entity:

Value Behavior
"skip" (default) Return the existing entity, set existed: true, no rollback
"update" Reconcile the existing entity to the desired state (if applicable), set updated: true
"error" Return an MCPError ("already exists")

Return shape

Creates and modifies populate one of:

{ "success": true, "created": true,  "existed": false, /* entity fields */ }
{ "success": true, "created": false, "existed": true,  /* entity fields */ }
{ "success": true, "updated": true,                     /* entity fields */ }

Deletes return:

{ "success": true, "deleted": true }               /* actually removed something */
{ "success": true, "alreadyDeleted": true }        /* nothing to do */

Rollback record

On a successful mutation that actually changed state, the handler attaches a rollback record naming the inverse handler and the payload needed to call it:

// In the handler, after a successful create:
TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
Payload->SetStringField(TEXT("actorLabel"), NewActor->GetActorLabel());
MCPSetRollback(Result, TEXT("delete_actor"), Payload);

The TS bridge lifts the rollback field onto TaskResult.rollback. When rollback_on_failure: true is set on a flow and a later step fails, flowkit invokes these records in reverse order.

Key rules:

  • Only emit a rollback record when the handler actually mutated state. An existed: true result means nothing was changed, so there's nothing to undo — do NOT emit a record.
  • The inverse must be another registered handler. Don't invent bespoke inverse handlers unless necessary; for creates, it's almost always the paired delete_X. For modifies, it's the same handler called with the previous value (self-inverse).
  • Modifies capture the previous value before mutation. The rollback payload restores exactly that value.

Helpers

HandlerUtils.h provides:

MCPSuccess()                                  // { success: true }
MCPError(Message)                             // { success: false, error }
MCPResult(Obj)                                // wrap FJsonObject as FJsonValue

MCPSetCreated(Result)                         // { created: true,  existed: false }
MCPSetExisted(Result)                         // { created: false, existed: true  }
MCPSetUpdated(Result)                         // { updated: true }
MCPSetRollback(Result, InverseMethod, Payload)
MCPSetDeleteAssetRollback(Result, AssetPath)  // shorthand for delete_asset rollback

// Existence probes - return a ready-to-return Existed/Error JSON value
// on hit, an unset shared pointer on miss.
MCPCheckAssetExists(PackagePath, Name, OnConflict, FriendlyType?)
MCPCheckActorLabelExists(World, Label, OnConflict, FriendlyType?)

// Actor lookup
FindActorByLabel(World, Label)                // canonical label lookup
FindActorByLabelOrName(World, Token)          // PIE: label OR internal name
FindActorByLabelOrPath(World, Label, Path)    // get_actor_details: one of two
FindActorByLabelNameOrPath(World, Token)      // PIE invoke: any of three

// Blueprint CDO load + cast with structured error
LoadBlueprintCDO<TActor>(Path, OutError)

// Parameter extraction (Vec3 / Rotator / Color / Transform helpers)
RequireString, OptionalString, OptionalNumber, OptionalInt, OptionalBool
OptionalVec3, RequireVec3, OptionalRotator, RequireRotator, OptionalTransform
MCPVec3ToJsonObject, MCPRotatorToJsonObject, MCPLinearColorToJsonObject

HandlerAssetCreate.h adds:

// Probe-then-create using AssetTools. Returns FMCPAssetCreate<T> with either
// an EarlyReturn JSON value (caller just returns it) or an Asset pointer.
// Two overloads: static class (TAsset::StaticClass()) or runtime UClass*.
MCPCreateAssetIdempotent<TAsset>(Name, PackagePath, OnConflict, Label, Factory)
MCPCreateAssetIdempotent<TAsset>(Name, PackagePath, OnConflict, Label, UClass*, Factory)

// Probe-then-create via raw NewObject<> on a fresh UPackage + AssetCreated.
// Used by AnimSequence / AnimComposite / LevelSequence / PoseSearchDatabase /
// NiagaraSystem-from-spec where AssetTools.CreateAsset isn't the right entry
// point (factory configuration must happen on the constructed object first).
MCPCreateAssetIdempotentNewObject<TAsset>(Name, PackagePath, OnConflict, Label)

Patterns

Spawn an actor with natural-key idempotency

TSharedPtr<FJsonValue> FLevelHandlers::PlaceActor(const TSharedPtr<FJsonObject>& Params)
{
    FString Label = OptionalString(Params, TEXT("label"));
    const FString OnConflict = OptionalString(Params, TEXT("onConflict"), TEXT("skip"));

    REQUIRE_EDITOR_WORLD(World);

    // Idempotency: if an actor with this label exists, return Existed JSON.
    if (auto Existing = MCPCheckActorLabelExists(World, Label, OnConflict, TEXT("Actor")))
    {
        return Existing;
    }

    AActor* NewActor = /* spawn */;
    if (Label.IsEmpty()) Label = NewActor->GetActorLabel();

    auto Result = MCPSuccess();
    MCPSetCreated(Result);
    Result->SetStringField(TEXT("actorLabel"), Label);

    TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
    Payload->SetStringField(TEXT("actorLabel"), Label);
    MCPSetRollback(Result, TEXT("delete_actor"), Payload);

    return MCPResult(Result);
}

Create an asset with natural-key idempotency

TSharedPtr<FJsonValue> FMaterialHandlers::CreateMaterial(const TSharedPtr<FJsonObject>& Params)
{
    FString Name;
    if (auto Err = RequireString(Params, TEXT("name"), Name)) return Err;
    const FString PackagePath = OptionalString(Params, TEXT("packagePath"), TEXT("/Game/Materials"));
    const FString OnConflict = OptionalString(Params, TEXT("onConflict"), TEXT("skip"));

    UMaterialFactoryNew* Factory = NewObject<UMaterialFactoryNew>();
    auto Created = MCPCreateAssetIdempotent<UMaterial>(Name, PackagePath, OnConflict, TEXT("Material"), Factory);
    if (Created.EarlyReturn) return Created.EarlyReturn;  // Existed or Error

    SaveAssetPackage(Created.Asset);
    const FString AssetPath = Created.Asset->GetPathName();

    auto Result = MCPSuccess();
    MCPSetCreated(Result);
    Result->SetStringField(TEXT("path"), AssetPath);
    Result->SetStringField(TEXT("name"), Name);
    Result->SetStringField(TEXT("packagePath"), PackagePath);
    MCPSetDeleteAssetRollback(Result, AssetPath);
    return MCPResult(Result);
}

Modify with before-state capture

TSharedPtr<FJsonValue> FLevelHandlers::SetActorMaterial(const TSharedPtr<FJsonObject>& Params)
{
    // Capture previous material BEFORE changing
    FString PreviousMaterialPath;
    if (UMaterialInterface* Prev = PrimComp->GetMaterial(SlotIndex))
    {
        PreviousMaterialPath = Prev->GetPathName();
    }

    PrimComp->SetMaterial(SlotIndex, NewMaterial);

    auto Result = MCPSuccess();
    MCPSetUpdated(Result);

    TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
    Payload->SetStringField(TEXT("actorLabel"), ActorLabel);
    Payload->SetNumberField(TEXT("slotIndex"), SlotIndex);
    Payload->SetStringField(TEXT("materialPath"), PreviousMaterialPath);
    MCPSetRollback(Result, TEXT("set_actor_material"), Payload);

    return MCPResult(Result);
}

Delete — document as non-reversible

Delete handlers are idempotent (deleting a non-existent thing is a no-op) but not reversible by default. Undoing a delete requires snapshotting the deleted entity beforehand, which is only worthwhile for high-value handlers.

auto Result = MCPSuccess();
if (NotFound) {
    Result->SetBoolField(TEXT("alreadyDeleted"), true);
} else {
    /* delete */
    Result->SetBoolField(TEXT("deleted"), true);
    // No rollback record — delete is not reversible by default.
}
return MCPResult(Result);

Non-convertible handlers

These handlers cannot meaningfully participate:

  • shell — arbitrary command execution
  • editor.execute_command — arbitrary console commands
  • editor.take_screenshot — side-effect with no natural inverse
  • editor.start_editor, editor.quit_editor, level.save, level.load — lifecycle operations

Conversion progress

Category Done Remaining
Level place_actor, spawn_light, spawn_volume, move_actor, set_actor_material, set_light_properties, set_component_property, set_volume_properties, set_world_settings, add_component_to_actor, delete_actor
Asset duplicate_asset, rename_asset, move_asset, delete_asset, create_datatable, import_static_mesh, import_skeletal_mesh, import_animation, import_texture, set_mesh_material, set_texture_properties (partial), add_socket, remove_socket recenter_pivot, reimport_*
Blueprint create_blueprint, add_variable, add_component, create_function, rename_function, delete_function, delete_node, delete_variable, remove_component, create_blueprint_interface set_variable_properties, set_node_property, add_node, connect_pins, set_class_default, set_variable_default, add_function_parameter
Material create_material, create_material_instance, create_material_from_texture add_material_expression, set_*, connect_expression, delete_expression
Animation create_anim_blueprint, create_montage, create_blendspace, create_sequence add_anim_notify, create_state_machine, add_state, add_transition, set_*, set_bone_keyframes
Audio create_sound_cue, create_metasound_source, spawn_ambient_sound
Foliage create_foliage_type set_foliage_type_settings
Gameplay create_smart_object_definition, create_input_action, create_input_mapping_context, create_blackboard, create_behavior_tree, create_eqs_query, create_state_tree, create_game_mode/state/player_controller/player_state/hud (via CreateBlueprintWithParent), spawn_nav_modifier_volume set_collision_profile, set_physics_enabled, set_body_properties, create_ai_perception_config
GAS create_gameplay_effect, create_gameplay_ability, create_attribute_set, create_gameplay_cue, create_gameplay_cue_notify add_ability_tag, add_attribute, set_ability_tags, set_effect_modifier, add_ability_system_component
Niagara create_niagara_system, create_niagara_emitter, create_niagara_system_from_emitter spawn_niagara_at_location, set_niagara_parameter, add_emitter_to_system, set_emitter_property
PCG create_pcg_graph, spawn_pcg_volume add_pcg_node, connect_pcg_nodes, remove_pcg_node, set_pcg_node_settings
Sequencer create_level_sequence add_track, sequence_control
Spline create_spline_actor set_spline_points
Widget create_widget_blueprint, create_editor_utility_widget, create_editor_utility_blueprint set_widget_property, add_widget, remove_widget, move_widget
Landscape/Networking/Physics/Reflection create_landscape, create_landscape_layer_info, set_landscape_material, create_enum (#251/#303), set_replicates, set_collision_profile, set_simulate_physics, set_mass_override, set_linear_damping

Every handler in the "Done" column is idempotent (checks for existing entity by natural key, returns { existed: true } on replay) and emits a rollback record where a paired inverse exists. Handlers in "Remaining" are either pure modifies that need before-state capture, or pure deletes that need snapshot-before-delete to be reversible. They still work; they just don't yet participate in rollback.