Skip to content

Authoring with an AI Agent ProOnly

An MCP tool is self-describing, so an agent can read each Logic Driver Assist operation's arguments from its schema. What an agent cannot read off a schema is the orchestration knowledge: which surface to prefer when two are present, how to lay a graph out so it looks human-authored, and the ordering that keeps a build compiling and verifiable. Give it those conventions explicitly.

Paste the block below into the consuming session's instructions: a project CLAUDE.md, an AGENTS.md, or your MCP client's system prompt. The same content ships with the plugin at Docs/agent-authoring-conventions.md. The sections after it explain each rule.

Paste-in rules

Logic Driver authoring (via the LogicDriver-Assist ld.* operations):

- Prefer the ld.* surface for all Logic Driver work. If both an `ld` and a
  `logicdriver` namespace are present (the Monolith bridge exposes both),
  treat logicdriver.* as a last-resort fallback and log a one-line note
  whenever you use it.
- List and describe the tools before authoring. The operation descriptions
  and JSON schemas are authoritative; do not assume argument names or shapes.
- Address assets by the full object path (/Game/.../SM_Foo.SM_Foo) that
  create_blueprint and get_asset return, not the bare package path. Thread the
  returned asset_path forward rather than rebuilding it.
- Wherever an operation accepts a CLASS token (target_class, cast_class,
  state_class, transition_class, a variable's object type), pass the full
  object path form (/Game/.../BP_Foo.BP_Foo_C), never the bare class name.
  Bare names resolve by global search and can silently bind a same-named class
  from elsewhere in the project; the failure surfaces later as wrong pin names
  or "function not found", not at bind time.
- Author into a clean folder such as /Game/MCP/<Feature>/. Do not mutate the
  user's existing reference assets unless they ask for it.
- For greenfield graphs, lay out with ld.layout_states apply=true, once, at the
  end. It measures every graph in scope as rendered and spaces by that. It
  starts each graph one column gap past that graph's own Entry node. It carries
  any edge that would draw its marker over a state on a rail of reroute nodes.
  It lays out a second time if a node grew after it was measured, and 'passes'
  says whether it did. Inside a layer it keeps the order the nodes are already
  in; where they are all at one point, as they are in a graph you have just
  built, it orders one state's outgoing siblings by ascending transition
  priority and then by name. It reads transition priority and never writes it.
  By default it leaves a graph alone when it cannot improve it, and that graph
  reports declined=true with a warning giving both sets of numbers; pass
  only_if_improved=false if you want the layout regardless. Check the top-level
  'measurement_warnings' for anything it could not measure, and each
  graphs[].warnings for what it had to flow around. Hand coordinates are for targeted tweaks only: positive X, and
  >=150 units between parallel rows. Budget much more for a state that displays
  property widgets or dialogue text: a node's size grows with its DisplayName and
  with everything its body draws, so a state showing a few properties runs
  300-380 wide and 100-280 tall against roughly 70-160 x 44 for a bare one, where
  a bare state's width is almost entirely its name: a two-character name measures
  69 wide and a thirty-six-character one measures 417.
- Find collisions with ld.get_graph_view rather than a screenshot. Its
  'overlaps' array lists every intersecting pair among the nodes a layout places
  (states, conduits, references, link states, any states) plus the Entry node,
  which a layout flows around rather than moves and which a state dropped on top
  of hides completely; its 'transition_overlaps' array lists transition markers
  and reroutes stacked on each other, which spacing does not fix and a reroute
  does; and its widget_size is measured at 1:1 zoom whatever the panel is
  showing. Read 'measurement_warnings' first: the arrays mean nothing while it is
  non-empty, because the unmeasured part of the graph contributes no overlaps to
  either.
- Judge readability with a ld.capture_graph_view, which is a different question
  from collisions and the one the arrays cannot answer: a transition line routed
  across intervening states shows up in neither. Empty arrays mean nothing
  collides, not that the graph reads well. Capture to look at colors or titles,
  to judge a finished layout, or to show a result; not to find collisions.
- Wire every state into the flow, and make exactly one of them the initial
  state, connected from Entry (add_state is_entry=true, or ld.set_initial_state).
  Every conduit, reference, link state, and any state must be wired in the same
  step you add it; orphan nodes are a failure. Model an always-true entry gate
  as an empty state, not a conduit.
- A transition with no condition and no transition class never fires. For an
  unconditional edge, set it default-true (what ld.set_transition_condition does:
  it writes a constant true/false onto the eval pin, valid only when there is no
  node class). For a real gate, assign a transition class, or author the
  transition graph inline: ld.get_local_graph (returns the result pin to wire
  into) -> ld.spawn_local_graph_read_node (e.g. TimeInState) ->
  ld.add_local_graph_node (call_function Greater_DoubleDouble) ->
  ld.set_local_graph_pin_default (threshold on pin B) ->
  ld.connect_local_graph_pins twice (read output -> compare A, compare
  ReturnValue -> result pin) -> ld.compile. The add and spawn ops return the new
  node's id and pins, so no intervening re-read is needed.
  ld.set_transition_condition writes only a constant, so it does not combine with
  a wired result pin.
- A gate that reads a variable needs something to WRITE that variable, or every
  such gate reads the default and the branch never varies at runtime. If a
  transition reads a player's choice or a visit count, author the writer too (a
  state's OnStateBegin, a driver, or player input), or acknowledge a fixed
  default. A clean compile does not prove the branch varies.
- Transitions have two independent axes: the CONDITION (the gate above) and the
  TRIGGER (when it is checked). By default a transition polls every tick. To fire
  it from an event, bind one with ld.configure_transition_event
  (delegate_property_name on a delegate_owner_instance of This/Context/
  PreviousState; Context also needs delegate_owner_class), then pick
  event_triggers_targeted_update (this edge + destination, preferred) or
  event_triggers_full_update (whole machine, legacy). It auto-places the return
  node; never spawn that yourself. Binding leaves the edge tick+event; to make it
  event-ONLY, turn tick off via ld.set_node_property (bCanEvaluate=false on the
  transition, or bDisableTickTransitionEvaluation=true on the from-state, which
  suppresses tick for all its outgoing edges). An event-only edge only fires
  when something broadcasts the bound delegate, so author or confirm that
  broadcaster just as a gated variable needs a writer. Verify with
  ld.get_asset: each transition reports evaluation (tick/event/tick+event/none)
  and, when bound, an event object. Graph logic calling
  EvaluateFromManuallyBoundEvent is not reflected in those fields.
- Node logic lives in two graphs, reached differently. A state's entry/update/end
  logic is a bound graph, authored with the ld.* local-graph ops (ld.get_local_graph
  on the state node, then wire off the "On State Begin" entry node's then pin). All
  three entry nodes already exist in every state graph; On State Update and On State
  End show dimmed until something wires into them, and connecting enables them.
  ld.spawn_local_graph_event_node refuses all three.
  The machine's own OnStateMachineStart lives in the blueprint's top-level event graph,
  reached with generic blueprint tools; it ships already placed (shown disabled), so
  wire off its then pin to activate it. Do NOT add a new override; it already exists
  and the add fails. The same rule generalizes: new blueprints ship with their
  common override events pre-placed (BeginPlay and Tick on an actor), so when an
  add reports "already exists", wire the node id the error names.
- Compile a blueprint before referencing its newly added functions from another
  blueprint's graph; the generated class does not carry them until compiled.
- The state machine graph is authored via ld.*, but the actor blueprint and its
  USMStateMachineComponent are set up on the editor surface (by hand or generic
  engine tools), not via ld.*. ld.configure_sm_component_on_actor then configures
  that existing named component's template: it sets StateMachineClass (persisting
  to every placed actor) plus lifecycle and replication config.
- To run on begin play, pass b_start_on_begin_play=true to
  ld.configure_sm_component_on_actor (the runtime default is false). Leave other
  config fields unset to keep the template's existing values.
- Batch graph edits, then ld.compile once. Compiling per edit is slow.
- Verify: compile clean, then (an editor-surface step) place the actor in a
  level and start PIE, and use ld.runtime_get_state to confirm the active state.

The conventions, and why they matter

Prefer the ld.* surface

On the Monolith bridge the client sees two Logic Driver namespaces at once, Assist's ld.* and Monolith's native logicdriver.*. Prefer ld.*: it is maintained alongside the plugin and routes through Logic Driver's own editor APIs. Tool descriptions alone do not disambiguate the overlap, which is why the rule above names the namespace explicitly. The bridges page covers the overlap in full, including how to silence the native namespace outright.

Discover before authoring

The operation set is experimental and evolving. Have the agent enumerate and read operation descriptions before calling, rather than relying on a remembered signature. ld.find_node_types reports the node classes available to place, and ld.get_asset reads back an asset's current topology. Address assets by the full object path that ld.create_blueprint and ld.get_asset return (/Game/.../SM_Foo.SM_Foo), not the bare package path, and thread that returned asset_path forward rather than rebuilding it. This is the right habit on every transport; the ToolsetRegistry transport additionally rejects a bare path before the operation runs.

The same discipline applies to class tokens. Any argument that names a class (state_class, transition_class, a target_class or cast_class on the generic surfaces, an object-typed variable's type) should carry the full object path form (/Game/.../BP_Foo.BP_Foo_C). A bare class name resolves by global search, and when two folders hold same-named classes it can silently bind the wrong one; nothing fails at bind time, and the damage surfaces later as wrong pin names, "function not found" on a class that has the function, or an object-compatibility error between two types with the same display name.

Nested state machines

A nested state machine is one asset, not many. ld.get_asset reports the root graph by default and shows a container as a single state_machine_state entry. Pass scope="all" to walk every nesting level; each state and transition then also carries graph_path and parent_state_guid, so levels can be told apart. is_entry is always relative to a node's own graph, while the top-level entry_state_guids stays the root graph's.

To author inside a container, pass its guid as parent_state_guid to ld.add_state, ld.add_conduit, ld.add_any_state, ld.add_link_state, ld.add_reference, or ld.add_transition_reroute; omitting it targets the root graph. Every other operation already resolves a guid at any depth, so ld.add_transition, ld.set_initial_state, ld.set_node_property, ld.rename_state, and ld.collapse_to_state_machine need no extra argument. Transitions must stay within one graph. A state machine reference delegates its states to another asset, so target that asset directly rather than the reference node.

For a visual check, ld.get_graph_view takes the same parent_state_guid to measure a nested graph, and ld.capture_graph_view takes it to screenshot one. ld.get_local_graph on a container's guid enumerates the states and transitions inside it. ld.layout_states with scope="all" lays out every level in one transaction, measuring each on its own panel and restoring the tab you started on when it is done.

Collapsing keeps identity. ld.collapse_to_state_machine moves the same nodes rather than cloning them, so guids recorded beforehand stay valid. Its node_guids response lists what the container now holds, which is not the set you passed, because boundary transitions stay in the parent graph. Pass every interior transition too; one whose endpoints both moved is deleted rather than carried in. There is no un-collapse operation.

Clean-room authoring

Author into a dedicated folder such as /Game/MCP/<Feature>/, and treat the user's existing assets as a behavior reference rather than something to edit in place (unless they ask). This keeps a build reproducible and easy to discard.

Layout

Generated graphs should read like a person laid them out. For a greenfield graph, run ld.layout_states with apply=true once, after the last edit; hand placement is for targeted adjustments afterward. One call does three things:

  • It measures, then spaces. Every graph in scope is measured as rendered, on its own panel, and spaced by those sizes. The operation opens the graph editor itself, so nothing has to be opened first. A node's size grows with its display name and with everything its body draws, so a state showing a few properties or a line of dialogue is several times taller and wider than a bare one. If a node grew after it was measured, the layout runs a second pass, and passes reports 2. Each graph is anchored one column gap past its own Entry node, so the first state never lands on top of it. column_gap is the smallest gap between two layers, not the gap every boundary ends up with; a single boundary is stretched past it when that keeps a wire off the states it passes.
  • It orders each layer from the graph, not the alphabet. Where the author already arranged the nodes, the layout starts from that order. In a graph you have just built, one state's outgoing siblings are ordered by ascending transition priority, then by name. The layout reads PriorityOrder and never writes it; which transition fires first is the author's decision.
  • It carries edges that would cross a state on rails. A transition whose wire would be drawn through a state, or whose marker would land on one, is carried on a rail of two reroute nodes clear of the flow. An edge that runs clear of every state gets no rail, however many layers it spans. One state feeding three or more siblings is laid out as a fan: a trunk with one reroute per sibling at that sibling's own row. Reroutes are cosmetic and change nothing at runtime. The operation adds and repositions them but never removes one, because a reroute already in the graph may be yours. Pass route_edges=false to leave those edges drawn straight.

By default the operation will not make a graph worse. It compares the arrangement the nodes are already in against the computed one on four counts, in this order: overlapping node pairs, transitions drawn through a state, transition markers drawn on a state, and reroute nodes needed. When the current arrangement wins, that graph reports declined=true with a warning giving both sets of numbers, and nothing is written for it. This matters most on a graph someone arranged by hand. Pass only_if_improved=false to apply the computed layout regardless.

Read the result back in two steps. ld.get_graph_view finds collisions without a screenshot. Its overlaps array lists every pair of intersecting node boxes, including the Entry node. Its transition_overlaps array lists transition markers and reroutes stacked on each other. Check measurement_warnings first, on both operations. While it is non-empty, part of the graph was not measured, and an empty array then means unmeasured rather than clean. Empty arrays mean nothing collides, not that the graph reads well: a transition routed across an intervening state shows up in neither. ld.capture_graph_view answers that second question, so capture to judge a finished layout or to show a result, not to find collisions.

When placing by hand, Entry sits at (0, 0) in a fresh graph and the flow runs left to right with positive X. Put the first state far enough right to clear the Entry node, keep at least about 400 units of X between states, and reserve Y for deliberate parallel rows at least 150 units apart. A bare state is roughly 70 to 160 units wide and about 44 tall, and its width is almost entirely its name. A state showing a few properties runs 300 to 380 wide and 100 to 280 tall, so rows carrying visible data need far more than the minimum.

No orphans, and gates as states

Every state must be wired into the flow, and exactly one must be the initial state, connected from Entry (is_entry=true on ld.add_state, or ld.set_initial_state); without one the machine compiles but has no active state at runtime. Conduits, references, link states, and any states must likewise be wired in the same step that creates them. A node with no connections is a failure. An always-true entry gate is better modeled as an empty state than as a conduit.

Any States

Use an Any State when most or all of the states need the same outgoing transition. It saves authoring that transition by hand from every state, so use it when the alternative is the same edge repeated from most states, for example a death transition that every state needs. When only two or three states need the transition, author those edges directly. An Any State fed into one target from a few sources costs a node, draws long wires, and tells a reader the transition applies everywhere when it does not.

No operation is dedicated to restricting an Any State to a subset of states. The node's AnyStateTags and AnyStateTagQuery properties are reachable only through ld.set_node_property, as serialized struct text, which the ld.add_any_state description points to. When that is impractical, a subset means explicit transitions from the states in it.

Transitions fire only with a condition

A transition with no authored condition and no transition class evaluates to false, so the machine never leaves the source state. There are three ways to make it fire:

  • Unconditional edge: set it default-true. That is what ld.set_transition_condition does: it writes a constant true or false onto the evaluation pin, valid only when the transition has no node class.
  • Reusable logic: assign a transition class whose CanEnterTransition evaluates the condition.
  • Inline logic (such as a time-in-state gate): author the transition graph directly. Read it with ld.get_local_graph (which returns the result pin to wire into), place the state read with ld.spawn_local_graph_read_node (TimeInState), place the comparison with ld.add_local_graph_node (call_function Greater_DoubleDouble), seed the threshold with ld.set_local_graph_pin_default, then wire twice with ld.connect_local_graph_pins (the read into the comparison, the comparison's ReturnValue into the result pin), and ld.compile. Both the spawn and add ops return the new node's id and pins, so no intervening re-read is needed.

A wired result pin and ld.set_transition_condition are mutually exclusive: once the result pin is wired, its constant default is ignored.

Feed the variables a gate reads

A transition can compile, evaluate every frame, and still never change which branch it takes. A gate that reads a variable (a player's choice, a visit count, a flag) only does something once something writes that variable. If nothing does, every such gate reads the default: the machine always takes the same branch, or, when the default satisfies no edge, sits at the source state. Authoring the read is half the job. Author the write too: a state's OnStateBegin that increments a counter on entry, a driver that sets the choice before a hub, or real player input. When a genuine runtime input is out of scope, pick and state a fixed default rather than leaving the variable unbacked.

Treat a clean compile as "the class built," never as "the branch varies at runtime." The dialogue system prevents this failure by naming the writer in the prompt itself: the player's clicks and presses are recorded on the host, and the gates read and consume that record.

Event transitions

A transition has two independent axes. The condition ("what must be true") is the gate above. The trigger ("when is it checked") is separate: by default a transition polls every tick, but it can fire from an event instead, or do both. Setting one never constrains the other.

  • Binding: ld.configure_transition_event attaches a multicast delegate (delegate_property_name) living on a delegate_owner_instance of This (the SM instance), Context, or PreviousState; a Context owner also needs delegate_owner_class. It mirrors a Details-panel edit, cascading resets and all, and auto-places the TransitionEventReturn node in the bound graph. Never place that node yourself. An empty delegate_property_name clears the binding while keeping any logic wired off the return node.
  • Update mode: event_triggers_targeted_update re-evaluates just this transition and its destination (focused, preferred); event_triggers_full_update runs a whole-machine update (broader, legacy, applied after the targeted one). These are the transition's "Targeted Update" and "Full Update" settings.
  • Event-only: turn the tick side off with ld.set_node_property: bCanEvaluate=false on the transition, or bDisableTickTransitionEvaluation=true on the from-state (which suppresses tick for every edge leaving it). Binding alone leaves the edge tick+event, and an event-only edge fires only when something broadcasts the bound delegate, so author or confirm that broadcaster just as a gated variable needs a writer.
  • Read-back: ld.get_asset reports, per transition, an evaluation field (tick, event, tick+event, none) and, when bound, an event object mirroring the configure fields, so you can confirm the binding without opening a bound graph.

One case stays invisible to those fields: a transition whose graph logic calls EvaluateFromManuallyBoundEvent directly, with no auto-bound delegate, has nothing static to report, so evaluation reflects only the tick and auto-event configuration. Separately, ld.set_node_property writes these flags' authoring-time defaults; to flip CanEvaluate / CanEvaluateFromEvent at runtime from graph logic, spawn the matching node with ld.spawn_local_graph_write_node (read counterparts via ld.spawn_local_graph_read_node).

Where entry and start logic live

Node logic lives in two different graphs, reached by two different surfaces. A state's entry, update, and end logic is a bound graph, authored with the ld.* local-graph ops: ld.get_local_graph on the state node returns its On State Begin / On State Update / On State End entry nodes, and you wire your logic off the matching node's output, found by its title. All three exist in every state graph from the moment the state does, and none of them can be deleted. On State Update and On State End are drawn dimmed until something wires into them, and connecting to one enables it, so there is nothing to create. ld.spawn_local_graph_event_node recognizes those three names but refuses them, and its error names the node to wire from instead. That operation is for the entries that are absent until spawned: OnInitialized and OnShutdown (valid in a state, transition, or conduit graph), OnTransitionEntered, OnTransitionPreEvaluate, OnTransitionPostEvaluate, OnRootStateMachineStart, and OnRootStateMachineStop. The machine's own OnStateMachineStart (and Tick) live in the blueprint's ordinary top-level event graph, so they are reached with the generic engine blueprint tools, not ld.*.

A fresh state machine ships with OnStateMachineStart already placed there, shown disabled ("This node is disabled and will not be called") until it is used. Do not add the override again; it already exists and the add fails. Wire your logic off its execution pin instead, which activates it on the next compile. For logic that should run once when the machine begins, OnStateMachineStart or the entry state's OnStateBegin both work; choose by whether the logic is machine-wide or specific to that first state.

Components and running the machine

The state machine graph is authored through ld.*, but running it on an actor is partly a job you do in the editor by hand. The actor blueprint and its state machine component are set up in the editor, by hand or with generic engine tools, not through ld.*. ld.configure_sm_component_on_actor then configures that existing component's template, matched by the SCS variable name you pass as component_name (it errors if no such component exists). It sets StateMachineClass, so every placed actor runs that machine, plus lifecycle and replication config. No ld.* operation writes a per-instance override, so if different placed actors need different machines, set StateMachineClass per instance in the editor. The runtime default for bStartOnBeginPlay is false, so pass b_start_on_begin_play=true to run a placed machine on begin play. Leave other config fields unset to keep the template's existing values.

Compile and verify

Batch the graph edits and ld.compile once at the end; compiling is the expensive step. The one thing that forces an earlier compile is a cross-blueprint reference: a blueprint's newly added functions do not exist on its generated class until it compiles, so compile a class before wiring calls to it from another blueprint's graph. Confirm the result two ways: the compile reports clean, and, after placing the actor in a level and starting PIE (done in the editor by hand), ld.runtime_get_state returns the expected active state. A machine that compiles but sits in the wrong state, or reports no active state at all, usually has an unwired transition, no initial state, or a missing start flag.