Development Patterns
Recurring patterns and conventions for extending the AgentUX plugin.
Table of Contents
- Handler Registration Pattern
- Parameter Parsing Pattern
- Response Building Pattern
- Asset Resolution Pattern
- Subsystem Access Pattern
- Error Handling Pattern
- Test Cleanup Pattern
- Recipe Authoring Pattern
- Python Test Base Class Pattern
- Transaction Wrapping Pattern
- Batch Request Pattern
- Event Streaming Pattern
- Reflection-Based Plugin Access Pattern
- 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,getTracksreturn flat arrays as theresult, not wrapped objects like{"variables": [...]}editor.asset.deleteexpectspath, notasset_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
- New Actor Blueprints have default events. BeginPlay, ActorBeginOverlap, and Tick already exist in the EventGraph. Calling
addEventNode("BeginPlay")will fail with "already exists". Useeditor.graph.getNodesto find existing events by theirtitlefield (e.g."Event BeginPlay"), and note that the node ID field isid, notnode_id. - Custom functions require compile before reference. After
addFunction("MyFunc"), the UFunction doesn't exist until the Blueprint is compiled. AnyaddNodewithfunction_name: "MyFunc"will fail with "Could not find function" until you calleditor.blueprint.compilefirst.
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
pathfrom eachspawnresponse 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.deletewithpath: "/Game/..."(notasset_path) - Actors:
editor.actor.destroywithpath: "<stored_path>" - Camera: If you call
setCameraorfocusOnActor, 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:
getStatisticsshader instruction counts vary by platform and material complexity: use them to confirm the material compiled (non-error response) rather than asserting exact valuesgetCameraposition afterfocusOnActordepends 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
TOptional<FScopedTransaction>: The transaction is only created for mutating methods. For read-only methods,Transactionremains unset and no transaction overhead is incurred.- Lifetime: The
TOptionallives on the stack for the duration ofHandleMethod(), so it automatically commits when the function returns. - Nesting is safe:
FScopedTransactionusesActiveCountref-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. - Custom descriptions: Callers can override the default description by passing
"transaction_description": "My custom action"in the JSON-RPC params. Modify()calls: Methods that directly mutate UObjects without going through UE APIs that callModify()internally must callObject->Modify()before the mutation.
When Adding New Mutating Methods
- Add the method name and description to the handler's
MutatingMethodDescsmap. - If the method directly modifies a UObject (e.g., setting a property without
PreEditChange/PostEditChange), addObject->Modify()before the mutation. - 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)
- Notifications (requests with no
idfield) are processed but produce no response entry. - Empty array
[]returns an Invalid Request error. - Invalid elements (non-objects in the array) each produce an Invalid Request error response.
- All-notification batch produces no response at all (no message sent back).
- 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
- Subscribe: Call
agentux.events.subscribewith desired event types - Receive: Server pushes JSON-RPC 2.0 notifications automatically
- Unsubscribe: Call
agentux.events.unsubscribeto stop receiving events - Query: Call
agentux.events.listto see available and subscribed events
Implementation Architecture
- Subscription storage:
TMap<FGuid, TSet<FString>> EventSubscriptionsinFAgentUXServermaps connection IDs to subscribed event types - Editor delegate binding:
BindEditorEvents()connects toUSelection::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:
FFabBrowseris a plain C++ class (noFAB_APImacro)UFabBrowserApimethods likeAddToProject()andAddSignedUrlCallback()are notUFUNCTIONs
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_APImacros: just add it as a module dependency - If the target class has
UFUNCTIONmethods: useUObject::ProcessEvent()for calling them - For simple data reads: prefer reading config/save files over reflection
Key Constraints
- No compile-time type safety: typos in class/property names fail silently at runtime
- Fragile across UE versions: class names, property names, and widget hierarchies may change
- Thread safety: widget tree walking must happen on the Game Thread
- 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
- Add the guard method to
FAgentUXSafetyGuardsinSafetyGuards.h/.cpp - Return actionable error messages: always include what failed, why, and what to do instead
- Apply in the handler (for operation-specific guards) or add to
ValidateOperation()(for guards that apply to many methods) - 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
- 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
- Quick Start: Get running in under 10 minutes
- Installation: Detailed setup guide
- GraphRAG Guide: Knowledge base setup and MCP tools
- API Reference: All 558+ editor control methods
- Troubleshooting: Common issues and solutions