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:
mkdir OutWit.Controller.MyMath
cd OutWit.Controller.MyMath
dotnet new classlib -n OutWit.Controller.MyMathAdd the required NuGet packages:
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 MemoryPackYour .csproj should look something like this:
<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:
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:
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:
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 referencespool— The variable pool where you resolve parameter valuesactivityStatus— 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:
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:
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:
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.SdkCreate MyMathTests.cs:
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:
dotnet testIf everything is wired correctly, all tests pass.
Debugging Tips
Use Trace for Logging
Add Trace statements to see what's happening:
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:
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:
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:
[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.csprojWhat'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