Development Patterns

Recurring patterns and conventions for extending the AgentUX plugin.

Table of Contents

  1. Handler Registration Pattern
  2. Parameter Parsing Pattern
  3. Response Building Pattern
  4. Asset Resolution Pattern
  5. Subsystem Access Pattern
  6. Error Handling Pattern
  7. Test Cleanup Pattern
  8. Recipe Authoring Pattern
  9. Python Test Base Class Pattern
  10. Transaction Wrapping Pattern
  11. Batch Request Pattern
  12. Event Streaming Pattern
  13. Reflection-Based Plugin Access Pattern
  14. Safety Guards Pattern

1. Handler Registration Pattern

Every handler implements IAgentUXHandler, which provides a namespace, a list of method names, and a dispatch function. The router maps each method name to its handler.

Interface

// Source/AgentUX/Public/Handlers/IAgentUXHandler.h
class IAgentUXHandler
{
public:
    virtual ~IAgentUXHandler() = default;

    /** Returns the namespace prefix (e.g. "editor.actor") */
    virtual FString GetNamespace() const = 0;

    /** Returns fully-qualified method names (e.g. "editor.actor.spawn") */
    virtual TArray<FString> GetMethods() const = 0;

    /** Handle an incoming JSON-RPC method call */
    virtual FAgentUXResponse HandleMethod(const FString& Method, TSharedPtr<FJsonValue> Params) = 0;
};

Implementing a Handler

// MyHandler.h
class FMyHandler : public IAgentUXHandler
{
public:
    virtual FString GetNamespace() const override;
    virtual TArray<FString> GetMethods() const override;
    virtual FAgentUXResponse HandleMethod(const FString& Method, TSharedPtr<FJsonValue> Params) override;

private:
    FAgentUXResponse HandleGetInfo(TSharedPtr<FJsonValue> Params);
    FAgentUXResponse HandleDoSomething(TSharedPtr<FJsonValue> Params);
};
// MyHandler.cpp
FString FMyHandler::GetNamespace() const
{
    return TEXT("editor.myfeature");
}

TArray<FString> FMyHandler::GetMethods() const
{
    return {
        TEXT("editor.myfeature.getInfo"),
        TEXT("editor.myfeature.doSomething"),
    };
}

FAgentUXResponse FMyHandler::HandleMethod(const FString& Method, TSharedPtr<FJsonValue> Params)
{
    if (Method == TEXT("editor.myfeature.getInfo"))
    {
        return HandleGetInfo(Params);
    }
    else if (Method == TEXT("editor.myfeature.doSomething"))
    {
        return HandleDoSomething(Params);
    }

    return FAgentUXResponse::Error(AgentUXJsonRpcError::MethodNotFound,
        FString::Printf(TEXT("Method not found: '%s'. Use system.methods to list available methods."), *Method));
}

Router Registration

Handlers are registered in AgentUXModule.cpp during StartupModule():

Router.RegisterHandler(MakeShared<FMyHandler>());

The router maps each method string to its handler:

void FAgentUXRouter::RegisterHandler(TSharedPtr<IAgentUXHandler> Handler)
{
    TArray<FString> Methods = Handler->GetMethods();
    for (const FString& Method : Methods)
    {
        MethodMap.Add(Method, Handler);
    }
}

Usage notes: Each handler owns a namespace and registers all methods within it. The router performs a simple string lookup at dispatch time, so method routing is O(1). When adding a new handler, register it alongside the existing handlers in StartupModule().


2. Parameter Parsing Pattern

JSON-RPC params arrive as TSharedPtr<FJsonValue>. Every handler method first validates that params exist and are an object, then extracts fields.

Params Validation Preamble

Every handler method starts with this check:

FAgentUXResponse FMyHandler::HandleGetInfo(TSharedPtr<FJsonValue> Params)
{
    if (!Params || Params->Type != EJson::Object)
    {
        return FAgentUXResponse::Error(-32602,
            TEXT("Invalid params: expected object with 'path' field"));
    }
    const TSharedPtr<FJsonObject>& ParamsObj = Params->AsObject();
    // ...
}

Required String Parameter

FString AssetPath;
if (!ParamsObj->TryGetStringField(TEXT("path"), AssetPath) || AssetPath.IsEmpty())
{
    return FAgentUXResponse::Error(-32602, TEXT("Missing required param: 'path'"));
}

Optional Boolean Parameter (with default)

bool bEditableOnly = true;
ParamsObj->TryGetBoolField(TEXT("editable_only"), bEditableOnly);

Optional Numeric Parameter

double Value = 0.0;
if (!ParamsObj->TryGetNumberField(TEXT("value"), Value))
{
    return FAgentUXResponse::Error(-32602, TEXT("Missing required param: 'value' (number)"));
}
float FloatValue = static_cast<float>(Value);

Optional Object Parameter (e.g., vector)

FVector Location = FVector::ZeroVector;
if (ParamsObj->HasField(TEXT("location")))
{
    const TSharedPtr<FJsonObject>* LocObj = nullptr;
    if (ParamsObj->TryGetObjectField(TEXT("location"), LocObj) && LocObj)
    {
        Location.X = (*LocObj)->GetNumberField(TEXT("x"));
        Location.Y = (*LocObj)->GetNumberField(TEXT("y"));
        Location.Z = (*LocObj)->GetNumberField(TEXT("z"));
    }
}

Array Parameter

const TArray<TSharedPtr<FJsonValue>>* ItemsArray = nullptr;
if (ParamsObj->TryGetArrayField(TEXT("items"), ItemsArray) && ItemsArray)
{
    for (const TSharedPtr<FJsonValue>& Item : *ItemsArray)
    {
        FString ItemStr = Item->AsString();
        // ...
    }
}

Usage notes: Always validate params at the top of every handler method using the preamble pattern. Use TryGet*Field methods rather than Get*Field to avoid assertions on missing fields. For optional parameters, initialize defaults before attempting to read them.


3. Response Building Pattern

All handler methods return FAgentUXResponse. Use the static Success() and Error() constructors.

Success with Object

TSharedRef<FJsonObject> ResultObj = MakeShared<FJsonObject>();
ResultObj->SetBoolField(TEXT("success"), true);
ResultObj->SetStringField(TEXT("name"), Actor->GetActorLabel());
ResultObj->SetStringField(TEXT("class"), Actor->GetClass()->GetName());

return FAgentUXResponse::Success(MakeShared<FJsonValueObject>(ResultObj));

Success with Array

TArray<TSharedPtr<FJsonValue>> Items;

for (AActor* Actor : Actors)
{
    TSharedRef<FJsonObject> ActorObj = MakeShared<FJsonObject>();
    ActorObj->SetStringField(TEXT("name"), Actor->GetActorLabel());
    ActorObj->SetStringField(TEXT("path"), Actor->GetPathName());
    Items.Add(MakeShared<FJsonValueObject>(ActorObj));
}

return FAgentUXResponse::Success(MakeShared<FJsonValueArray>(Items));

Success with Nested Objects (e.g., transform)

TSharedRef<FJsonObject> LocObj = MakeShared<FJsonObject>();
LocObj->SetNumberField(TEXT("x"), Location.X);
LocObj->SetNumberField(TEXT("y"), Location.Y);
LocObj->SetNumberField(TEXT("z"), Location.Z);

TSharedRef<FJsonObject> ResultObj = MakeShared<FJsonObject>();
ResultObj->SetObjectField(TEXT("location"), LocObj);

return FAgentUXResponse::Success(MakeShared<FJsonValueObject>(ResultObj));

Success with Null Field

Result->SetField(TEXT("expression"), MakeShared<FJsonValueNull>());

Error Response

Error codes follow JSON-RPC 2.0 standard plus AgentUX custom codes (see Error Handling Pattern for the full reference):

  • -32602 (InvalidParams): Missing required fields, invalid values
  • -32001 (ResourceNotFound): Named resource (actor, asset, property) not found
  • -32000 (EditorNotReady): Editor subsystem unavailable
  • -32601 (MethodNotFound): Unknown method name
  • -32603 (InternalError): Operation failed unexpectedly
// Missing parameter
return FAgentUXResponse::Error(AgentUXJsonRpcError::InvalidParams,
    TEXT("Missing required param: 'path'"));

// Resource not found
return FAgentUXResponse::Error(AgentUXJsonRpcError::ResourceNotFound,
    FString::Printf(TEXT("Actor not found at path: '%s'"), *ActorPath));

// Subsystem unavailable
return FAgentUXResponse::Error(AgentUXJsonRpcError::EditorNotReady,
    TEXT("EditorActorSubsystem not available"));

// With suggestion
return FAgentUXResponse::ErrorWithSuggestion(
    AgentUXJsonRpcError::MethodNotFound,
    FString::Printf(TEXT("Method not found: '%s'"), *Method),
    TEXT("Use 'system.methods' to list available methods."));

Usage notes: Always wrap result data in FJsonValue subclasses (FJsonValueObject, FJsonValueArray, FJsonValueNull). Use the appropriate error code constant from AgentUXJsonRpcError rather than raw integers to keep error categorization consistent.


4. Asset Resolution Pattern

Assets in Unreal are loaded by path. The pattern varies by whether you are loading an existing asset, finding an object in the current world, or finding a class by name.

Load Asset by Path (StaticLoadObject)

Used for persistent assets like materials, meshes, and blueprints:

UObject* Asset = StaticLoadObject(UMaterial::StaticClass(), nullptr, *AssetPath);
if (!Asset)
{
    return FAgentUXResponse::Error(-32602,
        FString::Printf(TEXT("Could not load asset at path: '%s'"), *AssetPath));
}

UMaterial* Material = Cast<UMaterial>(Asset);
if (!Material)
{
    return FAgentUXResponse::Error(-32602,
        FString::Printf(TEXT("Asset at '%s' is not a Material"), *AssetPath));
}

Find Actor in World (FindObject)

Used for actors currently in the editor world:

UWorld* EditorWorld = GEditor->GetEditorWorldContext().World();
AActor* FoundActor = FindObject<AActor>(EditorWorld, *ActorPath);
if (!FoundActor)
{
    // Fallback: search globally
    FoundActor = FindObject<AActor>(nullptr, *ActorPath);
}

Find Class by Name

Used when spawning actors or validating class parameters:

UClass* ActorClass = FindObject<UClass>(nullptr, *ClassName);
if (!ActorClass)
{
    ActorClass = FindFirstObject<UClass>(*ClassName, EFindFirstObjectOptions::EnsureIfAmbiguous);
}

if (!ActorClass || !ActorClass->IsChildOf(AActor::StaticClass()))
{
    return FAgentUXResponse::Error(-32602,
        FString::Printf(TEXT("Actor class not found: '%s'"), *ClassName));
}

Create New Asset (Factory Pattern)

Used when creating new assets (materials, blueprints, curves, etc.):

FString PackagePath = Path / Name;
UPackage* Package = CreatePackage(*PackagePath);

UMaterialFactoryNew* Factory = NewObject<UMaterialFactoryNew>();
UMaterial* NewMaterial = Cast<UMaterial>(Factory->FactoryCreateNew(
    UMaterial::StaticClass(), Package, FName(*Name),
    RF_Public | RF_Standalone, nullptr, GWarn));

Package->MarkPackageDirty();
FAssetRegistryModule::AssetCreated(NewMaterial);

Enumerate Assets (Asset Registry)

IAssetRegistry& Registry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry").Get();

TArray<FAssetData> Assets;
Registry.GetAssetsByClass(FTopLevelAssetPath(USoundWave::StaticClass()), Assets);

Usage notes: Always validate both the load result and the cast result separately: an asset may exist but be the wrong type. Use FindObject with the editor world first, then fall back to a global search. For new assets, always call MarkPackageDirty() and AssetCreated() to ensure the asset registry stays in sync.


5. Subsystem Access Pattern

UE editor subsystems provide clean APIs for editor operations. Always null-check GEditor and the subsystem.

Basic Subsystem Access

if (!GEditor)
{
    return FAgentUXResponse::Error(-32603, TEXT("Editor not available"));
}

UEditorActorSubsystem* ActorSubsystem = GEditor->GetEditorSubsystem<UEditorActorSubsystem>();
if (!ActorSubsystem)
{
    return FAgentUXResponse::Error(-32603, TEXT("EditorActorSubsystem not available"));
}

// Use the subsystem
AActor* NewActor = ActorSubsystem->SpawnActorFromClass(ActorClass, Location, Rotation);

World Context Access

UWorld* EditorWorld = GEditor->GetEditorWorldContext().World();
if (!EditorWorld)
{
    return FAgentUXResponse::Error(-32603, TEXT("Editor world not available"));
}

Module-Based Subsystem (non-GEditor)

Some features are accessed via module singletons:

// Property editor
FPropertyEditorModule& PropertyEditor =
    FModuleManager::LoadModuleChecked<FPropertyEditorModule>("PropertyEditor");

// Tool menus
UToolMenus* ToolMenus = UToolMenus::Get();

// Asset tools
FAssetToolsModule& AssetToolsModule =
    FModuleManager::LoadModuleChecked<FAssetToolsModule>("AssetTools");
IAssetTools& AssetTools = AssetToolsModule.Get();

Common Subsystems Used in AgentUX

Subsystem Header Use Case
UEditorActorSubsystem EditorActorSubsystem.h Spawn, destroy, select actors
ULevelEditorSubsystem LevelEditorSubsystem.h Level operations, PIE
UAssetEditorSubsystem AssetEditorSubsystem.h Open/close asset editors
UDataLayerEditorSubsystem DataLayerEditorSubsystem.h Data layer state management
ULandscapeSubsystem LandscapeSubsystem.h Landscape build operations

Usage notes: Always null-check both GEditor and the subsystem pointer. Use GetEditorSubsystem<T>() for editor subsystems and FModuleManager::LoadModuleChecked for module-based singletons. Return EditorNotReady (-32000) when subsystems are unavailable.


6. Error Handling Pattern

AgentUX uses early-return error handling with standardized error codes. Each validation step returns an error response immediately if it fails. This keeps the happy path readable.

Error Code Reference

Constant Code When to Use
InvalidParams -32602 Missing/invalid parameter values (bad input from caller)
ResourceNotFound -32001 Named resource doesn't exist (actor, asset, property, node)
EditorNotReady -32000 Editor/subsystem not available (GEditor null, subsystem missing)
MethodNotFound -32601 Unknown method name in dispatch
InternalError -32603 Operation failed unexpectedly (spawn failed, save failed)

Key distinction: -32602 means the caller sent bad params. -32001 means the params were valid but the referenced resource doesn't exist. This lets callers programmatically distinguish "I sent the wrong field name" from "the actor was deleted."

Error Response with Suggestion

Use ErrorWithSuggestion() to include actionable hints in the error's data field:

return FAgentUXResponse::ErrorWithSuggestion(
    AgentUXJsonRpcError::MethodNotFound,
    FString::Printf(TEXT("Method not found: '%s'"), *Method),
    TEXT("Use 'system.methods' to list all available methods.")
);

This produces a JSON-RPC error with a data.suggestion field:

{
  "jsonrpc": "2.0",
  "error": {
    "code": -32601,
    "message": "Method not found: 'editor.actor.foo'",
    "data": { "suggestion": "Available methods in 'editor.actor': editor.actor.spawn, ..." }
  },
  "id": "1"
}

Cascading Null Checks

// 1. Validate params exist
if (!Params || Params->Type != EJson::Object)
{
    return FAgentUXResponse::Error(AgentUXJsonRpcError::InvalidParams,
        TEXT("Invalid params: expected object"));
}
const TSharedPtr<FJsonObject>& ParamsObj = Params->AsObject();

// 2. Validate required fields
FString ActorPath;
if (!ParamsObj->TryGetStringField(TEXT("path"), ActorPath) || ActorPath.IsEmpty())
{
    return FAgentUXResponse::Error(AgentUXJsonRpcError::InvalidParams,
        TEXT("Missing required param: 'path'"));
}

// 3. Resolve the object (use ResourceNotFound, not InvalidParams)
AActor* FoundActor = FindActorByPath(ActorPath);
if (!FoundActor)
{
    return FAgentUXResponse::Error(AgentUXJsonRpcError::ResourceNotFound,
        FString::Printf(TEXT("Actor not found at path: '%s'"), *ActorPath));
}

// 4. Validate subsystem (use EditorNotReady)
UEditorActorSubsystem* Subsystem = GEditor->GetEditorSubsystem<UEditorActorSubsystem>();
if (!Subsystem)
{
    return FAgentUXResponse::Error(AgentUXJsonRpcError::EditorNotReady,
        TEXT("EditorActorSubsystem not available"));
}

// 5. Happy path: all validated
// ...

Helper Function with OutError

When asset loading is reused across multiple methods, extract it into a helper that returns nullptr on failure and populates an error:

UMaterial* FMaterialHandler::LoadMaterialFromPath(const FString& AssetPath, FAgentUXResponse& OutError)
{
    UObject* Asset = StaticLoadObject(UMaterial::StaticClass(), nullptr, *AssetPath);
    if (!Asset)
    {
        OutError = FAgentUXResponse::Error(AgentUXJsonRpcError::ResourceNotFound,
            FString::Printf(TEXT("Could not load Material at path: '%s'"), *AssetPath));
        return nullptr;
    }

    UMaterial* Material = Cast<UMaterial>(Asset);
    if (!Material)
    {
        OutError = FAgentUXResponse::Error(AgentUXJsonRpcError::ResourceNotFound,
            FString::Printf(TEXT("Asset at '%s' is not a Material"), *AssetPath));
        return nullptr;
    }

    return Material;
}

// Usage:
FAgentUXResponse LoadError;
UMaterial* Material = LoadMaterialFromPath(AssetPath, LoadError);
if (!Material)
{
    return LoadError;
}

Property Edit Change Notification

When modifying UObject properties, always bracket the change with Pre/PostEditChange:

FProperty* Property = Actor->GetClass()->FindPropertyByName(FName(*PropertyName));
if (!Property)
{
    return FAgentUXResponse::Error(AgentUXJsonRpcError::ResourceNotFound,
        FString::Printf(TEXT("Property not found: '%s'"), *PropertyName));
}

Actor->PreEditChange(Property);
const TCHAR* Result = Property->ImportText_InContainer(*ValueStr, Actor, Actor, 0);
FPropertyChangedEvent ChangeEvent(Property);
Actor->PostEditChangeProperty(ChangeEvent);

Usage notes: Use the correct error code for each validation stage: InvalidParams for bad input, ResourceNotFound for missing resources, EditorNotReady for unavailable subsystems. Extract shared validation into helper functions that populate an OutError reference. Always bracket property mutations with PreEditChange/PostEditChange.


7. Test Cleanup Pattern

All tests must leave the editor in the same state they found it. Spawn/destroy, select/deselect, and any mutations must be reversed.

C++ Test: Spawn and Cleanup

// Helper: spawn a test actor, return its path
static FString SpawnTestActor(FAgentUXRouter& Router, FAutomationTestBase& Test)
{
    TSharedRef<FJsonObject> Params = MakeShared<FJsonObject>();
    Params->SetStringField(TEXT("class"), TEXT("PointLight"));

    FAgentUXResponse Response = Router.RouteRequest(
        MakeRequest(TEXT("editor.actor.spawn"), Params));
    Test.TestFalse(TEXT("Spawn should succeed"), Response.bIsError);

    FString Path;
    const TSharedPtr<FJsonObject>* Obj = nullptr;
    if (Response.Result->TryGetObject(Obj) && Obj && Obj->IsValid())
    {
        Path = (*Obj)->GetStringField(TEXT("path"));
    }
    return Path;
}

// Helper: destroy by path
static void DestroyTestActor(FAgentUXRouter& Router, const FString& Path)
{
    TSharedRef<FJsonObject> Params = MakeShared<FJsonObject>();
    Params->SetStringField(TEXT("path"), Path);
    Router.RouteRequest(MakeRequest(TEXT("editor.actor.destroy"), Params));
}

// Test with cleanup
bool FMyTest::RunTest(const FString& Parameters)
{
    FAgentUXRouter Router;
    Router.RegisterHandler(MakeShared<FActorHandler>());

    FString ActorPath = SpawnTestActor(Router, *this);
    TestFalse(TEXT("Path should not be empty"), ActorPath.IsEmpty());

    // ... test operations on the actor ...

    // Cleanup: always destroy what you spawned
    DestroyTestActor(Router, ActorPath);
    return true;
}

Python Test: Save/Restore with finally

def test_set_transform(self):
    """Spawn actor, set transform, verify, then clean up."""
    result = self._spawn_actor("PointLight")
    actor_path = result["path"]
    try:
        # Mutate
        self._call("editor.actor.setTransform", {
            "path": actor_path,
            "location": {"x": 100, "y": 200, "z": 300}
        })
        # Verify
        transform = self._assert_success(
            self._call("editor.actor.getTransform", {"path": actor_path})
        )
        self.assertAlmostEqual(transform["location"]["x"], 100.0)
    finally:
        # Always clean up, even if assertions fail
        self._destroy_actor(actor_path)

Python Test: Camera Position Save/Restore

def test_viewport_set_camera(self):
    """Set camera, verify, restore original."""
    # Save original state
    original = self._assert_success(
        self._call("editor.viewport.getCamera")
    )
    try:
        self._call("editor.viewport.setCamera", {
            "location": {"x": 0, "y": 0, "z": 500},
            "rotation": {"pitch": -90, "yaw": 0, "roll": 0}
        })
        # ... assertions ...
    finally:
        # Restore original camera
        self._call("editor.viewport.setCamera", {
            "location": original["location"],
            "rotation": original["rotation"]
        })

Python Test: Asset Cleanup

def test_create_material(self):
    """Create a material, verify, then delete."""
    result = self._assert_success(
        self._call("editor.material.create", {
            "path": "/Game/TestMaterials",
            "name": "M_TestTemp"
        })
    )
    try:
        self.assertTrue(result["success"])
        self.assertIn("path", result)
    finally:
        # Delete the test asset
        self._call("editor.asset.delete", {"path": result["path"]})

Usage notes: Always pair creation with destruction. In Python tests, use try/finally to guarantee cleanup runs even when assertions fail. In C++ tests, destroy actors at the end of RunTest(). For state-based tests (camera, selection), save the original state before modification and restore it in the cleanup block.


8. Recipe Authoring Pattern

Recipes are multi-step JSON-RPC workflow examples that agents follow. They must be executable as written: every request/response must match real API behavior.

Probe First, Document Second

Never guess response shapes. Always send the actual JSON-RPC call against a live editor and capture the real response before writing the recipe example. Common surprises discovered during validation:

  • getVariables, getExpressions, getBindings, getTracks return flat arrays as the result, not wrapped objects like {"variables": [...]}
  • editor.asset.delete expects path, not asset_path: the param name differs from most other methods
  • Output names like "None" (instead of "") and input names like "Coordinates" (instead of "UVs") vary from what UE documentation suggests
# Quick probe script pattern
import asyncio, json, websockets
async def probe():
    async with websockets.connect('ws://localhost:9877', open_timeout=10) as ws:
        await ws.send(json.dumps({
            'jsonrpc': '2.0', 'id': '1',
            'method': 'editor.blueprint.getVariables',
            'params': {'asset_path': '/Game/Blueprints/MyBP'}
        }))
        r = json.loads(await asyncio.wait_for(ws.recv(), timeout=10))
        print(json.dumps(r, indent=2))
asyncio.run(probe())

Blueprint Recipes: Default Events and Compile Order

  1. New Actor Blueprints have default events. BeginPlay, ActorBeginOverlap, and Tick already exist in the EventGraph. Calling addEventNode("BeginPlay") will fail with "already exists". Use editor.graph.getNodes to find existing events by their title field (e.g. "Event BeginPlay"), and note that the node ID field is id, not node_id.
  2. Custom functions require compile before reference. After addFunction("MyFunc"), the UFunction doesn't exist until the Blueprint is compiled. Any addNode with function_name: "MyFunc" will fail with "Could not find function" until you call editor.blueprint.compile first.

Correct order:

addFunction -> compile -> addNode(CallFunction, function_name) -> connectPins -> compile (final)

Actor Properties: Actor-Level vs. Component-Level

editor.actor.setProperty uses FindPropertyByName on the actor class. It only finds UPROPERTYs declared directly on the actor, not on its components.

Property Lives On setProperty works?
bHidden AActor Yes
SpriteScale AActor Yes
Intensity ULightComponent No
LightColor ULightComponent No
StaticMesh UStaticMeshComponent No

For component properties, use editor.rc.property instead. Do not include steps that will fail.

Level Operations: Avoid Map Switching

editor.level.newBlankMap and editor.level.open can destabilize the editor when called via WebSocket: the level transition tears down and recreates subsystems while the WebSocket connection is active. For recipes, spawn actors into the current level and clean them up with editor.actor.destroy, rather than creating/switching maps.

Use Dynamic Paths, Not Hardcoded Ones

Actor paths include the level name and an auto-generated suffix. Never hardcode actor paths in recipes. Instead:

  • Use <placeholder> notation (e.g. <hero_path from step 3>) for paths that come from previous step responses
  • Store the path from each spawn response for use in later steps
  • Asset paths (e.g. /Game/Blueprints/BP_RecipeActor) are deterministic and can be hardcoded

Cleanup in Recipes

Every recipe should document cleanup in its Notes section:

  • Assets: editor.asset.delete with path: "/Game/..." (not asset_path)
  • Actors: editor.actor.destroy with path: "<stored_path>"
  • Camera: If you call setCamera or focusOnActor, note that the camera state is modified

Statistics and Verify Steps

Verification steps (getInfo, getStatistics, getCamera) return values that depend on editor state, platform, and timing:

  • getStatistics shader instruction counts vary by platform and material complexity: use them to confirm the material compiled (non-error response) rather than asserting exact values
  • getCamera position after focusOnActor depends on the actor's bounding box size and viewport dimensions: don't hardcode exact coordinates
  • Document these as approximate in the recipe response examples

Recipe Test Script Pattern

class TestRecipeN_Name(RecipeTestBase):
    def test_full_recipe(self):
        try:
            # Steps 1-N with self._assert_ok() for each
            res = self._assert_ok(
                self._call("editor.namespace.method", {params}, "1"),
                "1:description")
            # Store dynamic values (paths, IDs) from responses
            stored_path = res.get("path", "")
        finally:
            # Always clean up
            self._call("editor.asset.delete", {"path": asset_path})

Usage notes: Always probe real API responses before documenting them. Test every recipe end-to-end against a live editor. Use dynamic placeholder paths for actor references. Include cleanup steps in both the recipe notes and the corresponding test script.


9. Python Test Base Class Pattern

All Python integration tests inherit from AgentUXTestCase defined in Tests/helpers.py. This provides shared JSON-RPC communication helpers and eliminates boilerplate duplication.

DO: Inherit from AgentUXTestCase

import unittest
from Tests.helpers import AgentUXTestCase

class TestMyHandler(AgentUXTestCase):
    """Tests for editor.myhandler.* methods."""

    def test_get_info(self):
        response = self._call("editor.myhandler.getInfo", {"asset_path": "/Game/Foo"})
        result = self._assert_success(response)
        self.assertIn("name", result)

DON'T: Duplicate Boilerplate

Never copy _make_request, _send_and_receive, _call, _assert_success, _assert_error, or AGENTUX_WS_URL/TIMEOUT_SECONDS into test files. These are all provided by AgentUXTestCase.

Never use asyncio.get_event_loop().run_until_complete(): it is deprecated in Python 3.10+. The base class uses asyncio.run() internally.

Inherited Methods

Method Purpose
_call(method, params=None, request_id="1") Send JSON-RPC request, return parsed response dict
_assert_success(response) Assert success response, return result value
_assert_error(response, expected_code=None) Assert error response, return error object

When You Need Low-Level Access

If a test needs raw WebSocket access (like test_websocket.py), import the functions directly:

from Tests.helpers import AgentUXTestCase, make_request, send_and_receive, AGENTUX_WS_URL, TIMEOUT_SECONDS

Recipe Tests

Recipe tests inherit from RecipeTestBase (which itself inherits AgentUXTestCase) and add _assert_ok():

from Tests.helpers import AgentUXTestCase

class RecipeTestBase(AgentUXTestCase):
    def _assert_ok(self, resp, step):
        self.assertNotIn("error", resp, f"FAIL at {step}: {resp.get('error')}")
        return resp.get("result", {})

Usage notes: Always inherit from AgentUXTestCase for standard handler tests and from RecipeTestBase for recipe validation tests. Never duplicate the communication boilerplate. Use _assert_success for positive tests and _assert_error with an expected code for negative tests.


10. Transaction Wrapping Pattern

All mutating handler methods are wrapped in UE's FScopedTransaction so that changes made via AgentUX can be undone with Ctrl+Z (or via editor.state.undo). Read-only methods skip the transaction entirely.

Per-Handler HandleMethod() Wrapping

Each handler with mutating methods uses a static const TMap at the top of HandleMethod() to map method names to default transaction descriptions. A TOptional<FScopedTransaction> is conditionally emplaced only for mutating methods:

#include "ScopedTransaction.h"

FAgentUXResponse FActorHandler::HandleMethod(const FString& Method, TSharedPtr<FJsonValue> Params)
{
    // Transaction wrapping for mutating methods
    static const TMap<FString, FString> MutatingMethodDescs = {
        { TEXT("editor.actor.spawn"),        TEXT("AgentUX: Spawn Actor") },
        { TEXT("editor.actor.destroy"),      TEXT("AgentUX: Destroy Actor") },
        { TEXT("editor.actor.setTransform"), TEXT("AgentUX: Set Actor Transform") },
        // ... other mutating methods ...
    };
    const FString* DefaultDesc = MutatingMethodDescs.Find(Method);
    TOptional<FScopedTransaction> Transaction;
    if (DefaultDesc)
    {
        FString CustomDesc;
        if (Params.IsValid() && Params->Type == EJson::Object)
        {
            Params->AsObject()->TryGetStringField(TEXT("transaction_description"), CustomDesc);
        }
        Transaction.Emplace(FText::FromString(CustomDesc.IsEmpty() ? *DefaultDesc : CustomDesc));
    }

    // ... existing dispatch unchanged ...
}

Key Design Points

  1. TOptional<FScopedTransaction>: The transaction is only created for mutating methods. For read-only methods, Transaction remains unset and no transaction overhead is incurred.
  2. Lifetime: The TOptional lives on the stack for the duration of HandleMethod(), so it automatically commits when the function returns.
  3. Nesting is safe: FScopedTransaction uses ActiveCount ref-counting internally. If a UE API creates its own internal transaction, it becomes a harmless ref-count bump. The outermost transaction's description becomes the undo stack entry.
  4. Custom descriptions: Callers can override the default description by passing "transaction_description": "My custom action" in the JSON-RPC params.
  5. Modify() calls: Methods that directly mutate UObjects without going through UE APIs that call Modify() internally must call Object->Modify() before the mutation.

When Adding New Mutating Methods

  1. Add the method name and description to the handler's MutatingMethodDescs map.
  2. If the method directly modifies a UObject (e.g., setting a property without PreEditChange/PostEditChange), add Object->Modify() before the mutation.
  3. No changes needed at the router level: all wrapping is handler-local.

Undo/Redo API

editor.state.undo and editor.state.redo provide programmatic access to the same undo stack that Ctrl+Z/Ctrl+Y uses. They use GEditor->UndoTransaction() and GEditor->RedoTransaction().

Usage notes: Always add new mutating methods to the MutatingMethodDescs map. Use TOptional to avoid transaction overhead on read-only methods. Call Modify() on UObjects before direct mutations to enable correct undo. Callers can pass transaction_description for context-specific undo labels.


11. Batch Request Pattern

AgentUX supports JSON-RPC 2.0 batch requests (spec section 6). Clients can send an array of request objects in a single WebSocket message to reduce round-trips.

Client Usage

Send a JSON array instead of a single object:

[
  {"jsonrpc": "2.0", "method": "editor.state.ping", "id": "1"},
  {"jsonrpc": "2.0", "method": "editor.actor.getAll", "id": "2"},
  {"jsonrpc": "2.0", "method": "editor.viewport.getCamera", "id": "3"}
]

The server returns an array of responses in the same order:

[
  {"jsonrpc": "2.0", "result": {"status": "ok"}, "id": "1"},
  {"jsonrpc": "2.0", "result": [...], "id": "2"},
  {"jsonrpc": "2.0", "result": {...}, "id": "3"}
]

Key Rules (per JSON-RPC 2.0 spec)

  1. Notifications (requests with no id field) are processed but produce no response entry.
  2. Empty array [] returns an Invalid Request error.
  3. Invalid elements (non-objects in the array) each produce an Invalid Request error response.
  4. All-notification batch produces no response at all (no message sent back).
  5. Requests within a batch are processed sequentially in order: side effects from earlier requests are visible to later ones.

Implementation

Batch detection occurs in FAgentUXServer::OnMessageReceived(). The message is checked for a leading [ character. If detected, it is parsed as a JSON array and each element is dispatched through ProcessSingleRequest(). Individual element parsing uses FAgentUXJsonRpc::ParseRequestFromObject() to avoid re-serializing.

Usage notes: Use batch requests to reduce WebSocket round-trips when multiple independent queries are needed. Requests are processed sequentially, so earlier requests' side effects are visible to later ones. Notifications produce no response entries. Batch support is transparent to handlers: it operates at the server level.


12. Event Streaming Pattern

AgentUX supports server-push event notifications using JSON-RPC 2.0 notification format (no id field). Clients subscribe to event types and receive real-time updates when editor state changes.

Available Events

Event Type Notification Method Trigger
selectionChanged agentux.event.selectionChanged Actor selection in editor changes
assetSaved agentux.event.assetSaved A package is saved
compilationComplete agentux.event.compilationComplete A Blueprint finishes compiling
levelChanged agentux.event.levelChanged Current level/map changes

Client Workflow

  1. Subscribe: Call agentux.events.subscribe with desired event types
  2. Receive: Server pushes JSON-RPC 2.0 notifications automatically
  3. Unsubscribe: Call agentux.events.unsubscribe to stop receiving events
  4. Query: Call agentux.events.list to see available and subscribed events

Implementation Architecture

  • Subscription storage: TMap<FGuid, TSet<FString>> EventSubscriptions in FAgentUXServer maps connection IDs to subscribed event types
  • Editor delegate binding: BindEditorEvents() connects to USelection::SelectionChangedEvent, UPackage::PackageSavedWithContextEvent, GEditor->OnBlueprintCompiled(), FEditorDelegates::MapChange
  • Broadcasting: BroadcastEvent() iterates subscriptions and sends serialized notifications to matching connections
  • Cleanup: Subscriptions are removed when connections close (in OnConnectionClosed)

Notification Wire Format

{
  "jsonrpc": "2.0",
  "method": "agentux.event.selectionChanged",
  "params": {
    "selected_actors": [{"name": "Cube", "path": "...", "class": "StaticMeshActor"}],
    "count": 1
  }
}

No id field: this is a JSON-RPC 2.0 notification, not a request.

Usage notes: Subscribe to only the events you need to minimize unnecessary traffic. Subscriptions are per-connection and are automatically cleaned up when the WebSocket disconnects. Event notifications use the standard JSON-RPC 2.0 notification format (no id field), so they don't require a response from the client.


13. Reflection-Based Plugin Access Pattern

When a handler needs to interact with another plugin (e.g., Fab) that does not export its symbols (no FAB_API or DLLEXPORT macros), direct #include and linking will fail with LNK2019 unresolved external. AgentUX uses UObject reflection and widget tree walking instead.

Problem

The Fab plugin's key classes (FFabBrowser, UFabBrowserApi) are not exported:

  • FFabBrowser is a plain C++ class (no FAB_API macro)
  • UFabBrowserApi methods like AddToProject() and AddSignedUrlCallback() are not UFUNCTIONs

Including Fab Private headers compiles fine but linking fails with 4 unresolved externals.

Solution: FindFirstObject + GetObjectsOfClass

For UObject-derived classes, find live instances at runtime:

UObject* FFabHandler::FindFabBrowserApi() const
{
    // Find the UClass by name (no header include needed)
    UClass* BrowserApiClass = FindFirstObject<UClass>(
        TEXT("FabBrowserApi"),
        EFindFirstObjectOptions::EnsureIfAmbiguous
    );
    if (!BrowserApiClass) return nullptr;

    // Find all live instances
    TArray<UObject*> Instances;
    GetObjectsOfClass(BrowserApiClass, Instances, true);

    // Return the first valid instance (singleton pattern)
    for (UObject* Obj : Instances)
    {
        if (IsValid(Obj)) return Obj;
    }
    return nullptr;
}

Solution: Widget Tree Walking for Browser Automation

For executing JavaScript in a CEF browser widget that is not directly accessible:

bool FFabHandler::ExecuteFabJavascript(const FString& Script) const
{
    // 1. Find the tab by registered ID
    TSharedPtr<SDockTab> FabTab =
        FGlobalTabmanager::Get()->FindExistingLiveTab(FTabId("FabTab"));
    if (!FabTab) return false;

    // 2. Walk the widget tree to find the SWebBrowser widget
    TSharedRef<SWidget> Content = FabTab->GetContent();
    // Recursive search through child widgets...
    TSharedPtr<SWebBrowser> Browser = FindWebBrowserWidget(Content);
    if (!Browser) return false;

    // 3. Execute JavaScript directly on the widget
    Browser->ExecuteJavascript(Script);
    return true;
}

Solution: Tab Manager for Opening Plugin Tabs

Open plugin tabs programmatically:

FAgentUXResponse FFabHandler::HandleEnsureBrowser(TSharedPtr<FJsonValue> Params)
{
    FLevelEditorModule& LevelEditor =
        FModuleManager::GetModuleChecked<FLevelEditorModule>("LevelEditor");
    TSharedPtr<FTabManager> TabMgr = LevelEditor.GetLevelEditorTabManager();

    // TryInvokeTab opens the tab if not already open
    TSharedPtr<SDockTab> Tab = TabMgr->TryInvokeTab(FTabId("FabTab"));

    // Return whether the browser API is now available
    UObject* Api = FindFabBrowserApi();
    // ...
}

When to Use

  • Interfacing with plugins that compile as separate modules but don't export symbols
  • Automating embedded browser operations (CEF/WebView widgets)
  • Accessing singleton UObjects owned by other plugins
  • Triggering plugin UI workflows programmatically

When NOT to Use

  • If the target plugin exports symbols with MODULENAME_API macros: just add it as a module dependency
  • If the target class has UFUNCTION methods: use UObject::ProcessEvent() for calling them
  • For simple data reads: prefer reading config/save files over reflection

Key Constraints

  1. No compile-time type safety: typos in class/property names fail silently at runtime
  2. Fragile across UE versions: class names, property names, and widget hierarchies may change
  3. Thread safety: widget tree walking must happen on the Game Thread
  4. Null checks everywhere: every step can return null if the plugin state differs

Usage notes: This pattern is a last resort for plugins that don't export their symbols. Prefer standard module dependencies and UFUNCTION-based access when available. Always add comprehensive null checks at every step. Be prepared to update class and property name strings when targeting new engine versions.


14. Safety Guards Pattern

AgentUX uses a two-layer safety system that prevents editor crashes and assertions caused by handler operations executed in unsafe editor states. Every error message is designed to be actionable by Claude: it tells the AI why the operation failed and what to do instead, enabling self-correction without human intervention.

Architecture Overview

+---------------------------------------------------+
|              Router Pre-Dispatch Hook              |
|  SafetyOracle.ValidateOperation(methodName)        |
|                                                     |
|  +------------------+  +-----------------------+   |
|  |  Layer 1          |  |  Layer 2              |   |
|  |  Hardcoded Guards |  |  GraphRAG Oracle      |   |
|  |  (always active)  |  |  (opt-in, cached)     |   |
|  |                   |  |                       |   |
|  |  - PIE check      |  |  - Neo4j precondition |   |
|  |  - GC check       |  |    queries            |   |
|  |  - WP init check  |  |  - 7,350+ assertions  |   |
|  |  - Actor dying    |  |  - Auto-remediation   |   |
|  |  - BP compiling   |  |  - Session cache      |   |
|  +------------------+  +-----------------------+   |
+---------------------------------------------------+

Layer 1 (Hardcoded): Static guards in FAgentUXSafetyGuards (Private/SafetyGuards.h/.cpp). Always active, zero external dependencies. Catches the most dangerous crash scenarios.

Layer 2 (GraphRAG): FAgentUXSafetyOracle (Private/SafetyOracle.h/.cpp) queries Neo4j for known assertions, preconditions, and remediation text extracted from UE engine source. Falls back gracefully to Layer 1 only when GraphRAG is unavailable. Results are cached per-session.

Layer 1: Hardcoded Safety Guards

FAgentUXSafetyGuards provides static methods that return TOptional<FString>: empty if safe, error message if unsafe:

#include "SafetyGuards.h"

// Check single condition
TOptional<FString> PIEError = FAgentUXSafetyGuards::IsPIEActive();
if (PIEError.IsSet())
{
    return FAgentUXResponse::Error(AgentUXJsonRpcError::InternalError, PIEError.GetValue());
}

// Combined check for level saves
TOptional<FString> SaveError = FAgentUXSafetyGuards::CanSaveLevel(World);
if (SaveError.IsSet())
{
    return FAgentUXResponse::Error(AgentUXJsonRpcError::InternalError, SaveError.GetValue());
}

Available Guard Methods

Method Checks Applied To
IsPIEActive() GEditor->PlayWorld is set All mutating handlers
IsGarbageCollecting() ::IsGarbageCollecting() Asset mutation, blueprint compile, material create
IsWorldPartitionInitializing(World) WorldPartition->IsInitialized() Level save
IsActorBeingDestroyed(Actor) Actor->IsActorBeingDestroyed() Actor property/transform/component modifications
IsBlueprintCompiling(Blueprint) bBeingCompiled or bQueuedForCompilation Blueprint compile, add variable/function/node
CanSaveLevel(World) Combines PIE + GC + WP checks Level save operations

Error Message Format

Every guard error message follows a consistent pattern: what failed + why + what to do:

"Cannot perform this operation while Play-in-Editor is active. Call editor.pie.stop first, then retry."
"Blueprint 'BP_Hero' is currently being compiled. Wait for compilation to finish, then retry."
"Actor 'Cube_3' is currently being destroyed. Wait for destruction to complete, or use a different actor."

Layer 2: GraphRAG Safety Oracle

FAgentUXSafetyOracle queries Neo4j for safety knowledge extracted from UE engine source (check(), checkf(), ensure() assertions). Results are cached for the session lifetime.

#include "SafetyOracle.h"

// The oracle is owned by the router and shared across handlers
FAgentUXSafetyOracle Oracle;

// Query preconditions for a UE function
FSafetyQueryResult Result = Oracle.QuerySafety(TEXT("SaveCurrentLevel"));
if (Result.HasViolations())
{
    // Hardcoded guard triggered: block the operation
    return FAgentUXResponse::Error(AgentUXJsonRpcError::InternalError,
        Result.HardcodedViolations[0]);
}
if (Result.HasPreconditions())
{
    // GraphRAG found known assertions: include in response for context
    for (const FSafetyPrecondition& P : Result.Preconditions)
    {
        UE_LOG(LogAgentUX, Log, TEXT("Precondition: %s (remedy: %s)"), *P.Check, *P.Remedy);
    }
}

Caching Behavior

  • GraphRAG queries are cached by function name for the session lifetime (assertions don't change during a session)
  • Cache is populated lazily on first query to each function
  • InvalidateCache() clears the cache and re-probes Neo4j availability: call this on GraphRAG reconnect
  • Neo4j availability is probed once on first query, then re-probed at most every 60 seconds if unavailable

Graceful Degradation

When Neo4j is unavailable, the oracle silently falls back to Layer 1 hardcoded guards only. No errors are logged for the absence of GraphRAG: it is purely additive.

Router Integration

// In AgentUXRouter.cpp RouteRequest():
if (SafetyOracle.IsValid() && MutatingMethods.Contains(Request.Method))
{
    TOptional<FString> SafetyError = SafetyOracle->ValidateOperation(Request.Method);
    if (SafetyError.IsSet())
    {
        return FAgentUXResponse::Error(AgentUXJsonRpcError::InternalError, SafetyError.GetValue());
    }
}
// ... proceed with dispatch

Safety checks happen automatically for all mutating methods: individual handlers don't need to call guards manually (though they can for operation-specific checks like CanSaveLevel).

API Methods for Proactive Safety

Two agentux.safety.* methods let Claude proactively check safety:

agentux.safety.check: Check preconditions for a specific operation:

{"jsonrpc": "2.0", "method": "agentux.safety.check", "params": {"operation": "editor.level.save"}, "id": "1"}
// Returns: preconditions list with pass/fail status for each

agentux.safety.status: Get the current editor safety dashboard:

{"jsonrpc": "2.0", "method": "agentux.safety.status", "id": "1"}
// Returns: PIE state, GC state, WP init, compiling BPs, GraphRAG coverage

Adding New Safety Guards

  1. Add the guard method to FAgentUXSafetyGuards in SafetyGuards.h/.cpp
  2. Return actionable error messages: always include what failed, why, and what to do instead
  3. Apply in the handler (for operation-specific guards) or add to ValidateOperation() (for guards that apply to many methods)
  4. Add GraphRAG knowledge (optional): If the guard protects against a known UE assertion, add the assertion to the safety knowledge base using the extraction, curation, and import scripts
  5. Write tests: C++ automation test in Tests/SafetyGuardsTest.cpp, Python integration test verifying the error response format

GraphRAG Safety Knowledge Pipeline

UE Source -> extract_safety_knowledge.py -> assertions.json
         -> curate_safety_knowledge.py  -> assertions + remediation
         -> import_safety_knowledge.py  -> Neo4j (:Assertion, :Precondition, :UnsafeState nodes)
         -> get_safety_preconditions()  -> MCP tool for runtime queries

As the knowledge base grows (new UE versions, more subsystem coverage), safety protection automatically improves without code changes.

Usage notes: Layer 1 hardcoded guards are always active and catch the most dangerous scenarios. Layer 2 is purely additive: it enhances coverage when GraphRAG is available but never degrades behavior when it is not. Always write actionable error messages that tell the AI what failed, why, and what to do next. See the GraphRAG Guide for setup instructions.


Summary

Pattern When to Use Key Principle
Handler Registration Adding a new API namespace Implement IAgentUXHandler, register in module startup
Parameter Parsing Every handler method Validate early, use TryGet*Field, return -32602 on failure
Response Building Every handler method return Use FAgentUXResponse::Success/Error, wrap in FJsonValue*
Asset Resolution Loading/finding UE assets StaticLoadObject for assets, FindObject for world objects
Subsystem Access Calling editor APIs Always null-check GEditor and the subsystem
Error Handling Every validation step Early return on failure, keep happy path flat
Test Cleanup Every test that mutates state Spawn/destroy pairs, finally blocks, save/restore
Recipe Authoring Writing workflow recipes Probe real responses, test end-to-end, avoid unstable ops
Python Test Base Class Every Python integration test Inherit AgentUXTestCase, never duplicate boilerplate
Transaction Wrapping Every mutating handler method Per-handler TOptional<FScopedTransaction> with static TMap
Batch Requests Sending multiple requests at once Send JSON array, receive array of responses
Event Streaming Real-time editor state updates Subscribe, receive notifications, unsubscribe on disconnect
Reflection-Based Plugin Access Plugins that don't export symbols FindFirstObject, GetObjectsOfClass, widget tree walking
Safety Guards Preventing crashes from unsafe states Two-layer: hardcoded guards + GraphRAG oracle

Related