AAurora Workflows
Get started
Documentation

Writing Custom Tasks

Extend Aurora Workflows with your own task types

Overview

Any .NET class that implements IWorkflowTaskBase can be used as a task. In practice you always extend one of the provided abstract base classes so you only need to implement the three lifecycle methods.

Base ClassUse when…
WorkflowTaskBaseThe standard base class for all tasks. Handles input/output, error handling, retry, cancellation, and expression evaluation via Resolve<T>(). Always extend this.
ExpressionWorkflowTaskBaseDeprecated – will be removed in a future version. Adds a dedicated Expression property with ResolveExpression() helpers. For new tasks, extend WorkflowTaskBase and use Resolve<T>() directly on any string property.
TransactionScopeTaskBaseYour task contains child tasks that run inside a private scope (loops, conditionals, etc.).
Critical rule: never access the data context directly inside task code.
Do not call context.Get(), context.Set(), or context[key] inside DoAsync, DoSetupAsync, or DoTearDownAsync.
Always use the inherited helper methods instead:
  • GetInput<T>(context) — read a typed value from the context, respecting scope and variable name settings.
  • GetInput(context) — read an untyped value.
  • SetOutput(context, value) — write a result to the context, respecting scope and variable name settings.
  • Resolve<T>(rawValue, context) — evaluate a string property as a plain value or a C# expression and return the typed result.
  • GetPreviousTaskOutput(context) — read the output of the immediately preceding task.
This contract is enforced by design and accessing the context directly can cause incorrect scope resolution and will break when the data context API changes.

Minimal Example: WorkflowTaskBase

Extend WorkflowTaskBase and implement the three lifecycle methods. Use Resolve<T>(property, context) to evaluate any string property that should support C# expressions, GetInput<T>(context) to read variables from the data context, and SetOutput(context, value) to write results.

using System.Threading.Tasks;
using Aurora.Workflows.Contracts;
using Aurora.Workflows.Contracts.Attributes;

namespace MyCompany.Workflows.Tasks
{
    [WorkflowTaskCategory("My Category")]
    [WorkflowTaskOutputDefinition(typeof(string), IsDefault = true,
        Description = "Returns a greeting string.")]
    public sealed class GreetTask : WorkflowTaskBase
    {
        // String properties support plain values OR '=' prefixed C# expressions.
        // Always resolve them with Resolve<T>() – never read them raw.
        public string RecipientName { get; set; } = "World";

        public GreetTask()
        {
            Name = "Greet Task";
        }

        protected override Task<bool> DoAsync(IWorkflowDataContext context)
        {
            // Resolve evaluates the property – plain text or '=expression'.
            // Never use context.Get() / context.Set() here directly.
            var name = Resolve<string>(RecipientName, context);
            var greeting = $"Hello, {name}!";

            // Write the result back via SetOutput – never via context[key] = ...
            SetOutput(context, greeting);
            return Task.FromResult(true);
        }

        protected override Task<bool> DoSetupAsync()
            => Task.FromResult(true);

        protected override Task<bool> DoTearDownAsync()
            => Task.FromResult(true);
    }
}

ExpressionWorkflowTaskBase ⚠️ Deprecated

ExpressionWorkflowTaskBase is deprecated and will be removed in a future version. For new tasks, extend WorkflowTaskBase directly and call Resolve<T>(myStringProperty, context) on any property that should support expressions. The section below is kept for reference only.

Extending ExpressionWorkflowTaskBase adds a dedicated Expression string property and the ResolveExpression() / ResolveExpression<T>() helpers. All of this functionality is available in WorkflowTaskBase directly via Resolve<T>().

using System.ComponentModel;
using System.Threading.Tasks;
using Aurora.Workflows.Contracts;
using Aurora.Workflows.Contracts.Attributes;

namespace MyCompany.Workflows.Tasks
{
    [WorkflowTaskCategory("My Category")]
    [WorkflowTaskOutputDefinition(typeof(string), IsDefault = true)]
    public sealed class FormatMessageTask : ExpressionWorkflowTaskBase
    {
        // Expose the base Expression property under a semantic name. Like every string task
        // property it is expression-capable: plain text is a literal, "=..." is a C# expression.
        [Category(TaskSpecificCategory)]
        [DisplayName("Template")]
        public string Template
        {
            get => base.Expression;
            set => base.Expression = value;
        }

        // Hide the generic base property from IntelliSense and the designer.
        [Browsable(false)]
        [EditorBrowsable(EditorBrowsableState.Never)]
        public new string Expression
        {
            get => base.Expression;
            set => base.Expression = value;
        }

        public bool ShouldSerializeExpression() => false;

        public FormatMessageTask()
        {
            Name = "Format Message Task";
        }

        protected override Task<bool> DoAsync(IWorkflowDataContext context)
        {
            // ResolveExpression evaluates Template: literal text as-is, "=..." as C#
            var result = ResolveExpression<string>();
            SetOutput(context, result);
            return Task.FromResult(true);
        }

        protected override Task<bool> DoSetupAsync()  => Task.FromResult(true);
        protected override Task<bool> DoTearDownAsync() => Task.FromResult(true);
    }
}

Reading Input and Writing Output

Inside DoAsync always use the helper methods inherited from WorkflowTaskBase. Never access context directly with context.Get(), context.Set(), or the indexer — these bypass scope resolution and variable name configuration.

MethodDescription
GetInput<T>(context)Reads a typed value from the context, honouring CustomInputVariableName and all scope settings (UseGlobalScopeForInput, UseParentScopeForInput).
GetInput(context)Reads an untyped value, same scope rules as above.
SetOutput(context, value)Writes value to the context, honouring CustomOutputVariableName and all scope settings. Also sets the internal __LASTOUTPUT marker so the next task can pick it up automatically.
GetPreviousTaskOutput(context)Reads the __LASTOUTPUT value written by the immediately preceding sibling task.
Resolve<T>(rawValue, context)Evaluates a string property: plain text is returned as-is; a value prefixed with = is compiled and run as a C# expression by the Roslyn engine. Use this for every task property that should support expressions.
Resolve(rawValue, context)Same as above, untyped overload.
DataA ScopedDataAccessor bound to the context of the current execution. Offers the same semantics as the helpers above plus scope resolution helpers (GetInputScope(), GetOutputScope(), ...). Equivalent to context.For(this). See Data Context.

All helpers delegate to ScopedDataAccessor, so GetInput<T>(context) and context.For(this).GetInput<T>() are guaranteed to behave identically.

Attributes

Attributes control how the designer and the runtime discover and categorise your task.

AttributeTargetDescription
[WorkflowTaskCategory(category, subCategory?)]ClassPlaces the task in the designer palette under the given category. Use the constants in WorkflowTaskCategories or any string.
[WorkflowTaskInputDefinition(type, ...)]ClassDeclares an expected input type and optional description. Can be applied multiple times.
[WorkflowTaskOutputDefinition(type, ...)]ClassDeclares a produced output type. Mark the primary output with IsDefault = true.
[Description("...")]Class / PropertyShown in the designer as a tooltip or description.
[DisplayName("...")]PropertyOverrides the property name shown in the designer property panel.
[Category("...")]PropertyGroups the property under a named section in the property panel.
[Browsable(false)]PropertyHides the property from the designer.
[NoExpression]PropertyMarks a property as not supporting Roslyn expression evaluation.
[Experimental]Class / PropertyMarks a feature as experimental; shown with a warning badge in the designer.

Scope Tasks (TransactionScopeTaskBase)

A scope task hosts child tasks in a dedicated scope. Extend TransactionScopeTaskBase and implement two methods:

public sealed class MyRepeatTask : TransactionScopeTaskBase
{
    public int Count { get; set; } = 3;

    public MyRepeatTask() { Name = "My Repeat Task"; }

    // Called after the scope body executes; return true to continue.
    protected override Task<bool> DoConclusionAsync(
        IWorkflowDataContext context,
        IWorkflowDataContext scopedContext)
        => Task.FromResult(true);

    // The main scope logic. Call DoExecuteScopeBranchAsync to run children.
    protected override async Task<IWorkflowDataContext> DoScopedAsync(
        IWorkflowDataContext context)
    {
        IWorkflowDataContext result = null;
        for (int i = 0; i < Count; i++)
        {
            result = await DoExecuteScopeBranchAsync(context);
        }
        return result;
    }
}
Scope tasks must always have child elements placed in the Scope area of the designer.

Packaging and Loading Custom Tasks

Custom tasks are loaded into Aurora Workflows as standard .NET assemblies. There are two approaches:

1. Reference the assembly directly (design-time)

In the Desktop Application, add a reference to your assembly through the project settings. The designer will discover all task classes via reflection.

2. Bundle with a NuGet package (runtime)

Pack your task library as a NuGet package and place the .nupkg file in the .packages/ folder next to the CLI Runner. The workflow engine will load it automatically when the .awfc bundle references it.

3. Load at runtime with LoadAssemblyTask

Use the built-in LoadAssemblyTask inside a workflow to load a custom assembly from a file path at runtime before the tasks in that assembly are used.

Using the Logger

Inside any task method, use the inherited Logger property (type ILogger) to write structured log output:

protected override Task<bool> DoAsync(IWorkflowDataContext context)
{
    Logger.LogInformation("Processing item {Item}", myItem);
    // ...
    return Task.FromResult(true);
}

The logger is automatically injected by the workflow engine when the task is initialised. You do not need to configure it yourself.

Cancellation

All task base classes expose a CancellationToken property that is signalled when the workflow is stopped. Pass it to any async operations that support cancellation:

await Task.Delay(5000, CancellationToken);
await httpClient.GetAsync(url, CancellationToken);

When a stop signal has already been received, ExecuteAsync skips the task silently instead of throwing. An OperationCanceledException thrown from DoAsync puts the task into the Cancelled state.

Status and Error Handling

WorkflowTaskBase tracks a single Status; the boolean properties IsRunning, HasCompleted, HasError and HasCancelled are derived from it.

StatusWhen
IdleNever executed, reset, or skipped.
RunningDoAsync is executing.
CompletedDoAsync finished without an exception. Set before child tasks run so joining tasks can observe the parent.
CompletedWithErrorAn exception was thrown but swallowed because ContinueOnError is enabled (or all retries failed with ContinueOnError). HasCompleted and HasError are both true; LastThrownException holds the exception and the context is flagged via __ERROROCCURED.
ErrorAn exception escaped retry/continue-on-error handling. The exception is re-thrown to the caller and the workflow stops. HasCompleted is false.
CancelledThe task was cancelled or auto-cancelled because its parent returned false from DoAsync.

Retry (RetryEnabled, MaxRetries, RetryInterval) is applied first; ContinueOnError only kicks in after the last retry has failed. Every new execution clears LastErrorMessage and LastThrownException.

CancellationStatus and Error Handling