Write a custom action executor
Connect custom movement, inventory, UI, or physics behaviors to the Convai action pipeline by writing your own executor component.
When the built-in executors don't match your project's movement system, interaction model, or gameplay rules, implement IConvaiActionExecutor. A custom executor is a standard C# MonoBehaviour with a single async method. The dispatcher treats it identically to any built-in executor — all policies, events, and cancellation behavior apply automatically.
When to build a custom executor
Build a custom executor when:
Your project uses a custom movement system (root motion,
CharacterController, steering behaviors)An action modifies inventory, UI state, quest flags, or physics objects
An action calls an external service or triggers a coroutine-based animation system
You need conditional logic — for example, an action that behaves differently depending on character state
The action finds something out rather than performs a visible act — see Answer a question instead of acting below
The IConvaiActionExecutor interface
public interface IConvaiActionExecutor
{
Task<ConvaiActionExecutionResult> ExecuteAsync(
ConvaiActionInvocation invocation,
CancellationToken cancellationToken);
}Implement this interface on any MonoBehaviour. The dispatcher calls ExecuteAsync for each step and awaits the result before proceeding to the next step. Keep your task alive until the gameplay work is complete — returning early ends the step, even if the animation or movement is still running.
Choose a base class instead of the raw interface
Every shipped executor derives from ConvaiActionExecutorBase rather than implementing IConvaiActionExecutor directly, and your own custom executors should too — deriving gives the component the Convai inspector automatically (sectioned fields, tooltips, and an action-binding status block) with no editor code of your own.
ConvaiActionExecutorBase
You want full manual control over the invocation
CharacterTransform, ResolvePlayer(), DeclaredButNotSent(...)
ConvaiTargetedActionExecutor
The behavior acts on a resolved target through a hierarchy peer (a controller, locomotion, or rig)
Target validation, peer resolution/caching, once-logged missing-peer diagnostics, invocation parameter overrides
ConvaiCharacterActionExecutor<TPeer>
The behavior only ever needs one specific component on the character
Everything ConvaiTargetedActionExecutor adds, plus the resolved TPeer component handed straight to your method
ConvaiActionExecutor<TParameters>
You want a typed parameter DTO instead of manual dictionary access
Binds invocation parameters onto a TParameters object by name before your method runs
ConvaiTargetedActionExecutor, ConvaiCharacterActionExecutor<TPeer>, and ConvaiActionExecutor<TParameters> all derive from ConvaiActionExecutorBase, so every one of them already gets the Convai inspector and the members in the next section. Group inspector fields with ConvaiInspectorSectionAttribute and give every serialized field a [Tooltip] — the Convai inspector renders both on any ConvaiActionExecutorBase-derived component.
Members every ConvaiActionExecutorBase gives you
CharacterTransform
Transform (protected)
The Convai Character's transform, resolved once and cached. Use this instead of the executor's own transform for any world-space read or write
ResolvePlayer()
protected virtual Transform
Where the player actually is — see Resolve the player's position below
DeclaredButNotSent(invocation, parameterName)
protected static bool
Whether the action declares parameterName and the Convai Character sent no value for it
Read the character's transform, not your own
An executor may sit on the Convai Character itself or on a child object that holds a character's behaviors (assigned as Action Behaviors Object on ConvaiActionConfigSource). Both layouts are supported, so an executor must never assume its own GameObject is the character's:
Using your own transform for anything that genuinely means "this component's object" — parenting, hierarchy walks — is still fine. The rule is about where the character is.
Resolve the player's position
ResolvePlayer() returns the transform to measure the player by. It is protected virtual (ConvaiActionExecutorBase.cs:113), so override it once if your project has split screen, several rigs, or a cutscene camera, and every executor in that hierarchy agrees about who "the player" is.
The underlying lookup lives on ConvaiPlayerBody, a public static class (ConvaiPlayerBody.cs:26) available to scene code that is not an executor:
ConvaiPlayerBody.Resolve() prefers a ConvaiPlayer in the scene, then falls back to Camera.main. When it finds a ConvaiPlayer, it resolves the transform the player's rig actually moves — a CharacterController or Rigidbody inside the rig, not the prefab root — because first-person controllers commonly leave the root parked at the spawn point for the whole session. Reading the root instead reports where the player started: a character leading somebody somewhere stops to wait, the player walks up beside it, and it stays put, with nothing logged to explain why.
The ConvaiActionInvocation object
Every ExecuteAsync call receives a ConvaiActionInvocation with everything needed to perform the behavior:
Command
ConvaiActionCommand
Raw backend command — Name, Target, HasTarget
Definition
ConvaiActionDefinition
Local definition — ActionName, TargetRequirement, Executor, TimeoutSeconds
ResolvedTarget
ConvaiResolvedActionTarget
Resolved target binding — Kind, Name, ObjectBinding, CharacterBinding, GameObjectReference, InteractionPoint
Character
ConvaiCharacter
The executing NPC
BatchIndex
int
Sequential index of this batch across the dispatcher's lifetime
StepIndex
int
Index of this step within the current batch (0-based)
Access the target GameObject with:
Prefer invocation.ResolvedTarget?.InteractionPoint over GameObjectReference's own transform for move, point, gaze, or anchor behaviors — it resolves to the target's explicit interaction point when one is authored, or falls back to the GameObjectReference's transform otherwise. An author who set an interaction point expects every executor to respect it.
Do not re-parse invocation.Command.Name or invocation.Command.Target to re-derive what to do. Use invocation.Definition and invocation.ResolvedTarget — they are already resolved and validated.
Execution result types
Return one of these factory methods from ExecuteAsync:
ConvaiActionExecutionResult.Succeeded(string message = null)
The behavior completed successfully and has nothing the player needs to hear
ConvaiActionExecutionResult.Answered(string answer, string message = null)
The behavior found something out — see Answer a question instead of acting
ConvaiActionExecutionResult.Failed(string message, ConvaiActionFailureReason reason, Exception exception = null)
A genuine error occurred; prefer this overload so callers can react to a structured reason
ConvaiActionExecutionResult.Unhandled(string message = null)
This executor intentionally declines to handle the invocation (wrong context or target type)
ConvaiActionExecutionResult.Canceled()
Returned automatically by the dispatcher when it classifies an uncaught cancellation as non-timeout-driven. Do not return this manually — let the exception propagate (see Cancellation below)
Do not return ConvaiActionExecutionResult.TimedOut() manually. The dispatcher returns TimedOut automatically when TimeoutSeconds expires and the CancellationToken is triggered. If you return it yourself, the result is ambiguous and the dispatcher's timeout tracking is bypassed.
Failed vs Unhandled: Use Failed when you tried to perform the behavior and something went wrong. Use Unhandled when this executor should not handle this particular invocation at all — for example, if the target is the wrong type. The dispatcher fires OnStepFailed for Failed and OnStepUnhandled for Unhandled; both are treated as non-success for the StopBatch failure policy.
Answer a question instead of acting
Some actions perform a visible act — walk, open, hand over. Others find something out: read a gauge, count a group, measure a distance. For those, the thing the player asked for is the result, and it has to reach the Convai Character:
Write the answer as one plain sentence, in the third person, true about the world rather than about your code — "Two of the four crates are still sealed.", not "count=2". Returning an answer does not make the character speak; that is decided by the action's When It Finishes setting (ConvaiActionAnswerDelivery) in the Actions Editor, falling back to the character's ConvaiActionFeedbackRelay. An answer set to anything other than Tell the player still reaches the character's own memory, so it can bring the fact up later — and an answer never has to compete for a turn: the SDK holds it until the character stops speaking rather than dropping it.
Cancellation
The CancellationToken is triggered when:
BatchPolicy.ReplaceCurrentactivates (a new batch preempts the current one)TimeoutSecondson the action definition expiresThe dispatcher is disabled or destroyed
CancelOnUserSpeechis enabled onConvaiActionDispatcherand the player starts speaking
Always check the token in any loop or after each await:
Prefer letting OperationCanceledException propagate instead of catching it yourself. The dispatcher wraps your ExecuteAsync in a try/catch and classifies an uncaught OperationCanceledException for you: if the step's own TimeoutSeconds expired, it returns TimedOut; for any other cancellation, it returns Canceled. If you catch the exception yourself and unconditionally return ConvaiActionExecutionResult.Canceled(), you misreport timeout-driven cancellations as Canceled instead of TimedOut.
If you need to run cleanup on cancellation, use finally rather than catch so the exception still propagates for correct classification:
Complete example: highlight object executor
This executor enables an outline effect on the resolved target, waits three seconds, then disables it.
This example derives directly from ConvaiActionExecutorBase for full manual control. When an executor only needs a resolved target and a hierarchy peer, deriving from ConvaiTargetedActionExecutor or ConvaiCharacterActionExecutor<TPeer> removes the target-null check and peer lookup shown in steps 1 and 2.
Compound actions
Put the entire gameplay sequence inside one ExecuteAsync. The dispatcher treats one action definition as indivisible — it waits for your task to complete before starting the next step. This is the correct pattern for actions like pick-up, inspect, open-then-take, or any sequence that involves multiple sub-behaviors.
Executor design rules
Use
invocation.ResolvedTarget, notinvocation.Command.Target. The dispatcher has already resolved the name to aGameObjectbinding — don't re-parse the raw string.Return
Unhandledwhen this executor is not appropriate. A single executor component can be shared across multiple action definitions. ReturningUnhandledsignals the dispatcher to fireOnStepUnhandledwithout treating it as a hard failure.Return
Answered, notSucceeded, for a query action. ASucceededmessage never reaches the Convai Character; onlyAnswerdoes.Set
TimeoutSecondsin the action definition. Use the timeout mechanism rather than implementing your own deadline logic inside the executor.Clean up on cancellation. If your executor enables an effect, moves an object, or holds a resource, release it in a
finallyblock before the cancellation exception propagates.Do not hold state between invocations. The same executor instance may be called for different targets across multiple batches. Do not assume the previous invocation's state is still valid.
Never read your own
transformfor world-space position or rotation. UseCharacterTransform— see Read the character's transform, not your own.
Next steps
Configure character actionsCharacter actions scripting referenceLast updated
Was this helpful?