Building Your First WitEngine Plugin: Complete Walkthrough

You've cloned WitEngine.Public, run the tests, and poked around the example controllers. Now you want to build something of your own.

This post walks through creating a complete WitEngine plugin from scratch. By the end, you'll have a working controller with custom activities, proper tests, and an understanding of the patterns that make WitEngine plugins tick.


The Architecture: Three Layers

Every WitEngine plugin has three core components:

Activity = the contract (what the operation looks like) Adapter = the implementation (what the operation does) Controller = the registration (how WitEngine finds everything)

Let's build all three.


What We're Building

We'll create a MyMath controller with two activities:

  • MyMath.Square(x) — Returns x²
  • MyMath.Factorial(n) — Returns n!

Simple math, but the patterns apply to any complexity.


Step 1: Create the Project

Create a new class library:

bash
mkdir OutWit.Controller.MyMath
cd OutWit.Controller.MyMath
dotnet new classlib -n OutWit.Controller.MyMath

Add the required NuGet packages:

bash
cd OutWit.Controller.MyMath
dotnet add package OutWit.Engine.Sdk
dotnet add package OutWit.Engine.Data
dotnet add package OutWit.Engine.Interfaces
dotnet add package MemoryPack

Your .csproj should look something like this:

xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  
  <ItemGroup>
    <PackageReference Include="OutWit.Engine.Sdk" Version="*" />
    <PackageReference Include="OutWit.Engine.Data" Version="*" />
    <PackageReference Include="OutWit.Engine.Interfaces" Version="*" />
    <PackageReference Include="MemoryPack" Version="*" />
  </ItemGroup>
</Project>

Step 2: Define the Activities

Activities are data classes that describe operations. They don't contain logic—just structure.

Square Activity

Create Activities/WitActivitySquare.cs:

csharp
using MemoryPack;
using OutWit.Engine.Data.Activities;
using OutWit.Engine.Data.Attributes;
using OutWit.Engine.Interfaces;
using OutWit.Common.Abstract;

namespace OutWit.Controller.MyMath.Activities;

[Activity("Square")]                    // Name in scripts: MyMath.Square
[MemoryPackable]                        // Enables serialization for distributed execution
public partial class WitActivitySquare : WitActivityFunction
{
    // The input parameter - will hold a reference to a variable or literal
    public IWitParameter? InputValue { get; init; }

    // Required: equality check for caching and optimization
    public override bool Is(ModelBase modelBase, double tolerance = 1E-07)
    {
        if (modelBase is not WitActivitySquare other)
            return false;
            
        return base.Is(other, tolerance) 
            && InputValue.Check(other.InputValue);
    }

    // Required: deep clone for parallel execution
    protected override WitActivitySquare InnerClone()
    {
        return new WitActivitySquare 
        { 
            InputValue = InputValue?.Clone() as IWitParameter 
        };
    }
}

Let's break down the key parts:

[Activity("Square")] — This attribute tells WitEngine the operation name. Combined with the controller name "MyMath", scripts will call it as MyMath.Square(...).

[MemoryPackable] — WitEngine uses MemoryPack for high-performance serialization. When your activity runs on a remote node, the entire activity object gets serialized, sent over the network, and deserialized. This attribute (plus partial class) enables that.

IWitParameter? InputValue — Parameters aren't the actual values—they're references. InputValue might point to a variable named "x" or contain a literal value "5". The adapter resolves these references at execution time.

Is() and InnerClone() — WitEngine needs to compare and copy activities for optimization and parallel execution. These methods enable that.

Factorial Activity

Create Activities/WitActivityFactorial.cs:

csharp
using MemoryPack;
using OutWit.Engine.Data.Activities;
using OutWit.Engine.Data.Attributes;
using OutWit.Engine.Interfaces;
using OutWit.Common.Abstract;

namespace OutWit.Controller.MyMath.Activities;

[Activity("Factorial")]
[MemoryPackable]
public partial class WitActivityFactorial : WitActivityFunction
{
    public IWitParameter? InputValue { get; init; }

    public override bool Is(ModelBase modelBase, double tolerance = 1E-07)
    {
        if (modelBase is not WitActivityFactorial other)
            return false;
            
        return base.Is(other, tolerance) 
            && InputValue.Check(other.InputValue);
    }

    protected override WitActivityFactorial InnerClone()
    {
        return new WitActivityFactorial 
        { 
            InputValue = InputValue?.Clone() as IWitParameter 
        };
    }
}

Same pattern. Once you've written one activity, they're all similar.


Step 3: Create the Adapters

Adapters contain the actual logic. This is where computation happens.

Square Adapter

Create Adapters/WitActivityAdapterSquare.cs:

csharp
using Microsoft.Extensions.Logging;
using OutWit.Controller.MyMath.Activities;
using OutWit.Engine.Data.Adapters;
using OutWit.Engine.Data.Status;
using OutWit.Engine.Interfaces;

namespace OutWit.Controller.MyMath.Adapters;

internal class WitActivityAdapterSquare : WitActivityAdapterFunction<WitActivitySquare>
{
    public WitActivityAdapterSquare(
        IWitProcessingManager processingManager,
        ILogger logger)
        : base(processingManager, logger)
    {
    }

    protected override async Task<object?> ProcessInner(
        WitActivitySquare activity,
        IWitVariablesCollection pool,
        IWitActivityStatus? activityStatus,
        WitProcessingStatus status)
    {
        // Resolve the parameter to an actual value
        if (!pool.TryGetValue(activity.InputValue, out int input))
            throw new InvalidOperationException("Failed to get input value");

        // Do the actual computation
        return input * input;
    }

    protected override WitActivitySquare CreateActivity(IWitParameter[] parameters)
    {
        // Called by the parser to create an activity from script parameters
        if (parameters.Length != 1)
            throw new ArgumentException($"Square expects 1 parameter, got {parameters.Length}");

        return new WitActivitySquare { InputValue = parameters[0] };
    }
}

Key points:

WitActivityAdapterFunction<WitActivitySquare> — Generic base class ties this adapter to its activity type.

ProcessInner() — This is where your logic lives. The method receives:

  • activity — The activity instance with parameter references
  • pool — The variable pool where you resolve parameter values
  • activityStatus — For reporting progress (optional)
  • status — Overall processing status

pool.TryGetValue() — Resolves a parameter reference to an actual value. If activity.InputValue points to variable "x" which contains 5, this returns 5.

CreateActivity() — The parser calls this when it encounters MyMath.Square(something) in a script. You receive the parameters and construct the activity.

Factorial Adapter

Create Adapters/WitActivityAdapterFactorial.cs:

csharp
using Microsoft.Extensions.Logging;
using OutWit.Controller.MyMath.Activities;
using OutWit.Engine.Data.Adapters;
using OutWit.Engine.Data.Status;
using OutWit.Engine.Interfaces;

namespace OutWit.Controller.MyMath.Adapters;

internal class WitActivityAdapterFactorial : WitActivityAdapterFunction<WitActivityFactorial>
{
    public WitActivityAdapterFactorial(
        IWitProcessingManager processingManager,
        ILogger logger)
        : base(processingManager, logger)
    {
    }

    protected override async Task<object?> ProcessInner(
        WitActivityFactorial activity,
        IWitVariablesCollection pool,
        IWitActivityStatus? activityStatus,
        WitProcessingStatus status)
    {
        if (!pool.TryGetValue(activity.InputValue, out int input))
            throw new InvalidOperationException("Failed to get input value");

        if (input < 0)
            throw new ArgumentException("Factorial requires non-negative input");

        // Compute factorial
        long result = 1;
        for (int i = 2; i <= input; i++)
        {
            result *= i;
        }

        return result;
    }

    protected override WitActivityFactorial CreateActivity(IWitParameter[] parameters)
    {
        if (parameters.Length != 1)
            throw new ArgumentException($"Factorial expects 1 parameter, got {parameters.Length}");

        return new WitActivityFactorial { InputValue = parameters[0] };
    }
}

Same structure, different logic. The pattern scales to any complexity.


Step 4: Create the Controller

The controller registers everything with WitEngine.

Create WitControllerMyMath.cs:

csharp
using Microsoft.Extensions.DependencyInjection;
using OutWit.Controller.MyMath.Activities;
using OutWit.Controller.MyMath.Adapters;
using OutWit.Engine.Interfaces;
using OutWit.Engine.Shared.Utils;

namespace OutWit.Controller.MyMath;

public class WitControllerMyMath : IWitControllerHost, IWitControllerNode
{
    // Unique identifier for this controller
    public static readonly Guid Id = Guid.Parse("12345678-1234-1234-1234-123456789ABC");
    
    // Controller name - used as prefix in scripts (MyMath.Square, MyMath.Factorial)
    public string Name => "MyMath";
    
    Guid IWitController.Id => Id;

    public void Initialize(IServiceCollection services)
    {
        // Register each activity with its adapter
        services.AddActivityAdapter<WitActivitySquare, WitActivityAdapterSquare>();
        services.AddActivityAdapter<WitActivityFactorial, WitActivityAdapterFactorial>();
    }
}

IWitControllerHost — Implement this if your controller runs on the orchestrator (host). IWitControllerNode — Implement this if your controller runs on worker nodes.

Most controllers implement both—the same code runs everywhere. For distributed operations, you might have different logic on host vs. nodes.

Name — This becomes the prefix in scripts. With Name => "MyMath", your activities become MyMath.Square and MyMath.Factorial.

Initialize() — Called when WitEngine loads your controller. Register all your activities here.


Step 5: Write Tests

Create a test project:

bash
cd ..
dotnet new nunit -n OutWit.Controller.MyMath.Tests
cd OutWit.Controller.MyMath.Tests
dotnet add reference ../OutWit.Controller.MyMath/OutWit.Controller.MyMath.csproj
dotnet add package OutWit.Engine.Sdk

Create MyMathTests.cs:

csharp
using OutWit.Engine.Sdk;
using OutWit.Engine.Data.Status;

namespace OutWit.Controller.MyMath.Tests;

[TestFixture]
public class MyMathTests
{
    [OneTimeSetUp]
    public void Setup()
    {
        // Initialize the SDK - loads all controllers
        WitEngineSdk.Instance.Reload();
    }

    [Test]
    public async Task SquareReturnsFourForTwo()
    {
        var job = WitEngineSdk.Instance.Compile(@"
            Job:Test()
            {
                Int:result = MyMath.Square(2);
                Return(result);
            }
        ");

        var status = await WitEngineSdk.Instance.ScheduleAndWaitAsync(job);
        
        Assert.That(status.Result, Is.EqualTo(WitProcessingResult.Completed));
        Assert.That(status.ReturnedValues.First(), Is.EqualTo(4));
    }

    [Test]
    public async Task SquareWorksWithVariables()
    {
        var job = WitEngineSdk.Instance.Compile(@"
            Job:Test()
            {
                Int:x = 7;
                Int:result = MyMath.Square(x);
                Return(result);
            }
        ");

        var status = await WitEngineSdk.Instance.ScheduleAndWaitAsync(job);
        
        Assert.That(status.Result, Is.EqualTo(WitProcessingResult.Completed));
        Assert.That(status.ReturnedValues.First(), Is.EqualTo(49));
    }

    [Test]
    public async Task FactorialOfFiveIs120()
    {
        var job = WitEngineSdk.Instance.Compile(@"
            Job:Test()
            {
                Long:result = MyMath.Factorial(5);
                Return(result);
            }
        ");

        var status = await WitEngineSdk.Instance.ScheduleAndWaitAsync(job);
        
        Assert.That(status.Result, Is.EqualTo(WitProcessingResult.Completed));
        Assert.That(status.ReturnedValues.First(), Is.EqualTo(120L));
    }

    [Test]
    public async Task FactorialOfZeroIsOne()
    {
        var job = WitEngineSdk.Instance.Compile(@"
            Job:Test()
            {
                Long:result = MyMath.Factorial(0);
                Return(result);
            }
        ");

        var status = await WitEngineSdk.Instance.ScheduleAndWaitAsync(job);
        
        Assert.That(status.Result, Is.EqualTo(WitProcessingResult.Completed));
        Assert.That(status.ReturnedValues.First(), Is.EqualTo(1L));
    }

    [Test]
    public async Task SquareAndFactorialChained()
    {
        var job = WitEngineSdk.Instance.Compile(@"
            Job:Test()
            {
                Int:x = 3;
                Int:squared = MyMath.Square(x);    // 9
                Long:fact = MyMath.Factorial(squared);  // 362880
                Return(fact);
            }
        ");

        var status = await WitEngineSdk.Instance.ScheduleAndWaitAsync(job);
        
        Assert.That(status.Result, Is.EqualTo(WitProcessingResult.Completed));
        Assert.That(status.ReturnedValues.First(), Is.EqualTo(362880L));
    }
}

Run the tests:

bash
dotnet test

If everything is wired correctly, all tests pass.


Debugging Tips

Use Trace for Logging

Add Trace statements to see what's happening:

csharp
var job = WitEngineSdk.Instance.Compile(@"
    Job:Test()
    {
        Int:x = 5;
        Trace(""Input value:"", x);
        Int:result = MyMath.Square(x);
        Trace(""Result:"", result);
        Return(result);
    }
");

Trace output appears in test output and debugger console.

Set Breakpoints in Adapters

Your adapter's ProcessInner() method is regular C# code. Set breakpoints, inspect variables, step through execution—all the normal debugging tools work.

Read Parser Errors Carefully

If your script doesn't compile, the error message tells you exactly what's wrong:

Error at line 4, column 15: Unknown activity 'MyMat.Square'

Typo in the controller name? Activity not registered? The parser catches these immediately.

Check Parameter Types

A common mistake is type mismatch:

Job:Test()
{
    String:x = "hello";
    Int:result = MyMath.Square(x);  // Error! Square expects Int, got String
}

The error message will tell you the expected vs. actual type.


Patterns from Real Controllers

Look at the example controllers in WitEngine.Public for production patterns:

From Variables Controller

Multiple return types: Some activities can return different types based on input:

csharp
protected override async Task<object?> ProcessInner(...)
{
    // Try int first
    if (pool.TryGetValue(activity.InputValue, out int intValue))
        return intValue * 2;
    
    // Fall back to double
    if (pool.TryGetValue(activity.InputValue, out double doubleValue))
        return doubleValue * 2;
    
    throw new InvalidOperationException("Expected numeric type");
}

From Special Controller

Side effects: Not all activities return values. Trace just logs:

csharp
protected override async Task<object?> ProcessInner(...)
{
    // Get all parameter values
    var values = new List<object>();
    foreach (var param in activity.Values)
    {
        if (pool.TryGetValue(param, out object? value))
            values.Add(value);
    }
    
    // Log them
    Logger.LogInformation(string.Join(" ", values));
    
    return null;  // No return value
}

From Matrices Controller

Complex data types: Activities can work with any serializable type:

csharp
[MemoryPackable]
public partial class SparseMatrix
{
    public int Rows { get; set; }
    public int Cols { get; set; }
    public Dictionary<(int, int), double> Values { get; set; }
}

// Activity that works with matrices
[Activity("Multiply")]
[MemoryPackable]
public partial class WitActivityMatrixMultiply : WitActivityFunction
{
    public IWitParameter? MatrixA { get; init; }
    public IWitParameter? MatrixB { get; init; }
}

Final Project Structure

After completing all steps, your project looks like this:

OutWit.Controller.MyMath/
├── OutWit.Controller.MyMath/
│   ├── Activities/
│   │   ├── WitActivitySquare.cs
│   │   └── WitActivityFactorial.cs
│   ├── Adapters/
│   │   ├── WitActivityAdapterSquare.cs
│   │   └── WitActivityAdapterFactorial.cs
│   ├── WitControllerMyMath.cs
│   └── OutWit.Controller.MyMath.csproj
│
└── OutWit.Controller.MyMath.Tests/
    ├── MyMathTests.cs
    └── OutWit.Controller.MyMath.Tests.csproj

What's Next

You now have a complete, working WitEngine plugin. It runs locally with the SDK, passes tests, and follows production patterns.

To go further:

  • Add more activities — Same pattern, different logic
  • Create custom variable types — For domain-specific data structures
  • Implement benchmarking — For intelligent load balancing in distributed execution
  • Prepare for distribution — Ensure activities are stateless and serializable