Flows¶
Flows let you define multi-step workflows in YAML and run them as a single operation. They're powered by @db-lyon/flowkit and are fully customizable — you can chain built-in tasks, run shell commands, override tasks with your own implementations, or compose tasks together.
Quick Start¶
Create a ue-mcp.yml in your Unreal project root (next to the .uproject):
ue-mcp:
version: 1
flows:
build_and_check:
description: Build the project and verify the editor is connected
steps:
1:
task: project.build
options:
configuration: Development
2:
task: project.get_status
Run it from the AI:
That's it. The config is hot-reloaded on every call — edit the YAML and run again without restarting the MCP server.
Concepts¶
Tasks¶
A task is a named unit of work. UE-MCP ships with 300+ built-in tasks across 19 categories — every action available through the MCP tools is also a flow task.
Tasks are defined in the tasks: section of your config:
tasks:
project.build:
class_path: ue-mcp.bridge
group: project
description: "Build C++ project. Params: configuration?, platform?, clean?"
options:
method: build_project
The fields:
| Field | Required | Description |
|---|---|---|
class_path |
Yes | How the task is resolved — a registered name, a built-in class path, or a path to your own .js/.ts file |
description |
No | Human-readable description |
group |
No | Category for organization |
options |
No | Default options passed to the task (can be overridden per-step) |
You rarely need to define tasks yourself — the built-in defaults cover all 300+ actions. You define tasks when you want to override or add custom ones.
Flows¶
A flow is an ordered sequence of steps. Each step runs a task or a nested flow:
flows:
setup_scene:
description: Create a basic lit scene
steps:
1:
task: level.place_actor
options:
className: DirectionalLight
location: { x: 0, y: 0, z: 500 }
2:
task: level.place_actor
options:
className: SkyAtmosphere
3:
task: level.place_actor
options:
className: ExponentialHeightFog
Steps execute in numeric order. If a step fails, the flow stops.
Step Types¶
A step must have exactly one of task or flow:
steps:
1:
task: asset.list # Run a task
options:
directory: /Game/
2:
flow: setup_scene # Run another flow (nested)
3:
task: shell # Run a shell command
options:
command: npm run build
4:
task: None # Skip marker (no-op)
Option Merging¶
Options are merged in two layers:
- Task definition — default options in the
tasks:section - Step — per-step overrides in the
flows:section
Step options win:
tasks:
asset.list:
class_path: asset.list
options:
recursive: true # default
flows:
quick_scan:
description: List top-level game assets
steps:
1:
task: asset.list
options:
directory: /Game/
recursive: false # overrides the default
Built-in Tasks¶
Every MCP action is registered as a task using its category.action name. Some examples:
| Task | What it does |
|---|---|
project.get_status |
Check server mode and editor connection |
project.build |
Build the C++ project |
asset.list |
List assets in a directory |
asset.search |
Search by name, class, or path |
blueprint.read |
Read a blueprint's structure |
blueprint.compile |
Compile a blueprint |
level.place_actor |
Spawn an actor in the level |
material.create |
Create a material asset |
editor.execute_console |
Run a console command |
editor.start_editor |
Launch the Unreal Editor |
shell |
Run a shell command |
See the full list in dist/ue-mcp.default.yml or run flow(action="list").
Task Types¶
Built-in tasks fall into two categories:
- Bridge tasks — forwarded to the C++ plugin over WebSocket. Defined with
class_path: ue-mcp.bridgeand amethodoption. - Handler tasks — executed locally in Node.js (filesystem operations like config parsing, asset directory scanning).
The shell task is also built in — it runs a command via child_process:
steps:
1:
task: shell
options:
command: npm run lint
cwd: /path/to/project # optional, defaults to cwd
timeout: 300000 # optional, defaults to 5 minutes
Runtime Parameters¶
Hardcoding every option in YAML gets tedious. Pass params at call time to override step options for that run:
flow(action="run", flowName="beacon", params={
levelPath: "/Game/MyCustomLevel",
configuration: "Shipping"
})
Runtime params merge into every step's options with highest priority:
So a step with options: { levelPath: "/Game/Flows/Beacon" } in the YAML will use /Game/MyCustomLevel if you pass params: { levelPath: "/Game/MyCustomLevel" } at runtime.
Params apply to every step — steps that don't use a given key simply ignore it. This makes flows fully parameterizable without templating syntax.
Step References¶
When one step needs the output of an earlier step, reference it with ${steps.<id>.<path>}:
flows:
build_and_open:
description: Build, then open the packaged artifact
steps:
1:
task: project.build
options:
configuration: Development
2:
task: asset.list
options:
directory: ${steps.1.outputDir} # whole-value → raw type preserved
3:
task: editor.execute_console
options:
command: "echo built ${steps.project.build.version}" # embedded → stringified
<id>— step number (1) or task name (project.build). For task names that contain dots, the longest prefix that matches a step wins.<path>— dot path into that step'sresult.data.- If a task name appears in multiple steps, references resolve to the most recently completed one.
- If the whole option value is a single
${...}reference, the raw value is substituted (objects and arrays round-trip). Embedded references inside a larger string are stringified. - References that can't be resolved fail the step.
Precedence (highest wins):
References in any of those layers resolve at step-execution time against already-completed steps in the same flow. Nested flows have their own scope — a nested step cannot reference a step in the enclosing flow.
Flow-level Hooks¶
A flow can attach steps that run around the main sequence, keyed by outcome:
flows:
deploy:
description: Build and push the plugin
on_start: [ { task: editor.execute_console, options: { command: "echo starting" } } ]
on_success: [ { task: editor.execute_console, options: { command: "echo done ${steps.build.version}" } } ]
on_failure: [ { task: agent_prompt, options: { prompt: "Triage: ${error.message}" } } ]
finally: [ { task: project.get_status } ]
steps:
1: { task: project.build }
2: { task: asset.save }
on_start— before the first step. Failure aborts the flow.on_success— after all steps succeed.on_failure— after any step fails. The${error.message|name|stack|step}namespace resolves inside this phase.finally— afteron_success/on_failure, regardless of outcome.
Hook steps share the full execution model — same references, same option merging, same runtime params. Hook failures appear in result.hookErrors but don't change the primary success/failure outcome.
Per-step Retry¶
A step can retry itself on failure:
steps:
1:
task: project.build
retries: 2 # up to 3 total attempts
retryDelay: 1000 # ms between attempts
retryOn: "timeout" # only retry when error message contains this substring
Omit retryOn to retry on any error. The actual attempt count surfaces on result.steps[i].attempts.
Rollback on Failure¶
Mutating bridge handlers emit a rollback: { method, payload } record on success. When a flow sets rollback_on_failure: true (or the caller passes it) and a later step fails, the runner invokes the collected inverses in reverse order, best-effort, and reports the outcome in result.rollback:
flows:
safe_scene:
description: Place pillars with automatic cleanup on failure
rollback_on_failure: true
steps:
1: { task: level.place_actor, options: { actorClass: StaticMeshActor, label: A } }
2: { task: level.place_actor, options: { actorClass: StaticMeshActor, label: B } }
3: { task: some_fragile_step }
If step 3 fails: the delete_actor inverses for B and A run, leaving the level as it was. Handlers without an inverse (execute_console, shell, some deletes) simply don't contribute records; their steps are left as-is when rollback runs.
Conventions for handlers — natural keys, the onConflict: skip|update|error option, and rollback record shape — live in docs/handler-conventions.md.
Git Snapshot Safety Net¶
Per-handler rollback covers in-memory state (selection, PIE, unsaved actors). For anything that touched disk (new .uasset files, modified .ini config, deleted packages), enable the opt-in git snapshot. On flow start the runner snapshots Content/ and Config/ into a shadow bare git repo, and on failure runs git read-tree --reset -u to restore, then asks the editor to reload affected packages.
Enable it in ue-mcp.yml:
git_snapshot:
enabled: true
paths: [Content, Config] # defaults shown
snapshot_dir: .ue-mcp/snapshot.git # relative to project root
max_age_hours: 24 # prune older snapshot refs on each run
The shadow repo is completely separate from any project-level git — your real history isn't touched. Snapshot failure doesn't fail the flow; handler-level rollbacks still apply. Restore outcomes surface in result.snapshotRestore.
agent_prompt — LLM-backed steps¶
When ANTHROPIC_API_KEY is set, the agent_prompt task is available. It calls Claude and returns the response as the step's data:
steps:
1:
task: agent_prompt
options:
system: "You triage UE flow failures."
prompt: "Last error: ${error.message}. Suggest a one-line fix."
model: claude-opus-4-6
maxTokens: 512
schema: { type: object, properties: { fix: { type: string } } } # optional — parses JSON response
Returns { text, parsed?, usage }. parsed is populated when a schema is provided and the model's output is valid JSON.
Pairs naturally with on_failure for auto-triage:
on_failure:
- task: agent_prompt
options:
prompt: "Flow ${error.step} failed with: ${error.message}. What should the user change?"
Skip this section if you're not using the MCP server's Anthropic-backed provider. Swap in your own by attaching a LLMProvider to the flow context.
Skipping Steps¶
Pass step names or numbers in the skip array:
Execution Plan¶
Preview what a flow will do without running it:
Returns each step with its task name, type, and skip status.
Built-in Flows¶
UE-MCP ships with a default flow you can run out of the box.
Beacon¶
A 56-step demo that builds a complete shrine scene from scratch — geometry, materials, lighting, atmosphere, and camera.
What it creates:
| Steps | Category | What |
|---|---|---|
| 1–4 | level | New level, SkyAtmosphere, ExponentialHeightFog, SkyLight |
| 5–7 | material | M_Floor — dark stone base color |
| 8–16 | material | M_Pillar — brushed metallic (Metallic=1, Roughness=0.3) |
| 17–19 | material | M_Pedestal — warm stone |
| 20–28 | material | M_Glow — parameterized emissive (VectorParameter × 50 → EmissiveColor) |
| 29 | level | Floor slab (scaled Cube with M_Floor) |
| 30 | level | Center pedestal (Cylinder with M_Pedestal) |
| 31 | level | Glowing orb (Sphere with M_Glow) |
| 32–36 | level | 5 pillars in a pentagon (Cubes with M_Pillar) |
| 37–39 | level | Sunset directional light |
| 40–49 | level | 5 colored point lights at pillar tops (cyan, magenta, gold, green, violet) |
| 50–51 | level | Center spotlight pointing down at orb |
| 52–55 | level | Warm and cool fill lights |
| 56 | editor | Viewport camera framing the scene |
Preview the execution plan without running:
The beacon flow is defined in dist/ue-mcp.default.yml. Users can override any of its steps by redefining the beacon flow in their project's ue-mcp.yml.
Customization¶
Overriding a Built-in Task¶
To change how a built-in task behaves, redefine it in your ue-mcp.yml. Your definition merges on top of the defaults (your fields win):
tasks:
asset.list:
class_path: ./tasks/FilteredAssetList.js
description: Asset list with custom filtering
The built-in asset.list is now replaced by your class. The dynamic loader will import ./tasks/FilteredAssetList.js from your project root.
Writing a Custom Task¶
Create a file that exports a class extending BaseTask:
// tasks/FilteredAssetList.ts
import { BaseTask, type TaskResult } from '@db-lyon/flowkit';
export default class FilteredAssetList extends BaseTask {
get taskName() {
return 'asset.filtered_list';
}
async execute(): Promise<TaskResult> {
// Call the original asset.list via the registry
const result = await this.call('asset.list', {
directory: (this.options as any).directory ?? '/Game/',
recursive: true,
});
if (result.success && result.data?.assets) {
const exclude = (this.options as any).excludePrefix ?? '/Game/Developers/';
result.data.assets = (result.data.assets as any[])
.filter(a => !a.path?.startsWith(exclude));
}
return result;
}
}
Register it in your config:
tasks:
asset.list:
class_path: ./tasks/FilteredAssetList
description: Asset list that filters out developer content
options:
excludePrefix: /Game/Developers/
Key points:
- Export as default — the loader looks for a default export, or a named export matching the filename.
- Must extend
BaseTask— the registry validates this at load time. this.options— receives the merged options (task defaults + step overrides).this.ctx— the shared context withbridge(editor WebSocket) andproject(path resolution).this.call(name, opts)— resolve and execute another task by name. The original built-in task is still in the registry even when you override it via YAMLclass_path.this.resolve(name, opts)— likecall()but returns the task instance without running it, in case you need to inspect or configure it first.
Extending a Bridge Task¶
If your custom task needs to call the editor, extend BridgeTask:
// tasks/SafeBuild.ts
import { BaseTask, type TaskResult } from '@db-lyon/flowkit';
export default class SafeBuild extends BaseTask {
get taskName() {
return 'safe_build';
}
async execute(): Promise<TaskResult> {
// Check status first
const status = await this.call('project.get_status');
if (!status.success || !status.data?.connected) {
return {
success: false,
error: new Error('Editor not connected — cannot build'),
};
}
// Run the actual build
return this.call('project.build', {
configuration: (this.options as any).configuration ?? 'Development',
});
}
}
tasks:
safe_build:
class_path: ./tasks/SafeBuild
description: Build with connection check
flows:
safe_build_flow:
description: Safely build the project
steps:
1:
task: safe_build
options:
configuration: Shipping
Composing Tasks¶
A custom task can orchestrate multiple tasks:
// tasks/FullSetup.ts
import { BaseTask, type TaskResult } from '@db-lyon/flowkit';
export default class FullSetup extends BaseTask {
get taskName() {
return 'full_setup';
}
async execute(): Promise<TaskResult> {
// Place a bunch of actors
const actors = [
{ className: 'DirectionalLight', location: { x: 0, y: 0, z: 500 } },
{ className: 'SkyAtmosphere' },
{ className: 'ExponentialHeightFog' },
{ className: 'SkyLight' },
];
const placed = [];
for (const actor of actors) {
const result = await this.call('level.place_actor', actor);
if (!result.success) return result;
placed.push(result.data);
}
return {
success: true,
data: { placed, count: placed.length },
};
}
}
Wrapping a Task (Programmatic)¶
If you're building on top of ue-mcp in code, the registry supports wrapping any registered task:
import { buildFlowRegistry } from 'ue-mcp';
const registry = buildFlowRegistry(tools);
// Wrap asset.list with logging
registry.wrap('asset.list', (Original) => {
return class extends Original {
get taskName() { return 'asset.list:logged'; }
async execute() {
console.log(`Listing assets with options:`, this.options);
const result = await super.execute();
console.log(`Found ${result.data?.assets?.length ?? 0} assets`);
return result;
}
};
});
Multiple wraps compose — each layer sees the previously wrapped version as its parent:
// First wrap adds logging
registry.wrap('asset.list', (Original) => class extends Original { /* log */ });
// Second wrap adds caching — it wraps the logged version
registry.wrap('asset.list', (Original) => class extends Original { /* cache */ });
Config Layering¶
Configuration is loaded with @db-lyon/flowkit's config loader, which supports layered YAML files:
| Layer | File | Purpose |
|---|---|---|
| 1 (base) | Built-in defaults | All 300+ tasks, no flows |
| 2 | ue-mcp.yml |
Your project config |
| 3 | ue-mcp.{env}.yml |
Environment overlay (set UE_MCP_ENV) |
| 4 | ue-mcp.local.yml |
Local-only overrides (gitignore this) |
Each layer deep-merges on top of the previous. Later layers win for scalar values; objects merge recursively.
Example: keep your shared flows in ue-mcp.yml and machine-specific overrides in ue-mcp.local.yml:
# ue-mcp.local.yml — not committed
tasks:
shell:
options:
timeout: 600000 # slow machine, need longer builds
Environment Overlays¶
Set the UE_MCP_ENV environment variable to load an environment-specific layer:
This loads ue-mcp.ci.yml on top of ue-mcp.yml.
Hot Reload¶
The config is reloaded from disk on every flow call. Edit ue-mcp.yml, save, and run the flow again — no server restart needed. This makes it easy to iterate on flow definitions.
Dynamic Class Loading¶
When you set class_path to a file path (e.g., ./tasks/MyTask), the registry resolves it relative to the current working directory. It tries these candidates in order:
{cwd}/tasks/MyTask.ts{cwd}/tasks/MyTask.js{cwd}/tasks/MyTask/index.ts{cwd}/tasks/MyTask/index.js
The loaded module must export a class that extends BaseTask, either as the default export or as a named export matching the filename.
Dynamically loaded classes are cached — the file is only imported once per class path per session.
BaseTask Reference¶
All custom tasks extend BaseTask from @db-lyon/flowkit:
abstract class BaseTask<TOpts = Record<string, unknown>> {
protected ctx: TaskContext; // Shared context (bridge, project, registry)
protected options: TOpts; // Merged options for this execution
protected logger: Logger; // Scoped logger
abstract get taskName(): string; // Descriptive name for logging
abstract execute(): Promise<TaskResult>; // Your task logic
protected validate(): void; // Option validation (override, called before execute)
// Composition — resolve/run other tasks from within your task
protected resolve(taskName: string, options?: Record<string, unknown>): Promise<BaseTask>;
protected call(taskName: string, options?: Record<string, unknown>): Promise<TaskResult>;
// Lifecycle — called by the engine, not by you
run(): Promise<TaskResult>; // validate → execute → catch errors → return result
}
TaskResult¶
interface TaskResult {
success: boolean;
data?: Record<string, unknown>;
error?: Error;
duration?: number; // Set automatically by run()
}
TaskContext¶
In ue-mcp, the context includes:
| Property | Type | Description |
|---|---|---|
bridge |
IBridge |
WebSocket connection to the Unreal Editor |
project |
ProjectContext |
Path resolution, project info |
registry |
TaskRegistry |
Task registry for resolving other tasks |
logger |
Logger |
Structured logger |