Skip to content
GitHub

Writing custom tasks

This tutorial goes from an empty repo to a working <bpmn:serviceTask type="hello"> running against a Fleans engine deployment you did not build from source. End-to-end takes about 15 minutes the first time. You never git clone the engine: your worker consumes Fleans.ExternalTasks.Sdk from nuget.org and connects to the API over gRPC. It has no Orleans dependency and does not join the cluster.

Fleans gives you three ways to back a <bpmn:serviceTask>:

NeedPattern
.NET worker, typed parameter editor, listed in GET /custom-tasksExternalTaskHandler (this guide)
Worker is non-.NET (Python, Node, Go), same featuresgRPC protocol
Queue or message bus of your own between engine and workerExternal completion
  • A running Fleans engine — follow Self-host with Docker Compose first. The rest of this tutorial assumes the bundle is unzipped and you’ve done docker compose up -d at least once.
  • .NET 10 SDK on your workstation.
  • Docker daemon running locally. dotnet publish /t:PublishContainer pushes the image straight to the daemon — no Dockerfile, no remote registry needed for the tutorial.

A trivial “say hello” task. The workflow author writes:

At runtime the engine hands your task to a connected worker over gRPC, where the handler you write here runs; the worker reports the outcome back. Open interactive diagram ↗
<!-- Requires xmlns:fleans="https://fleans.io/schema/bpmn/1.0" on <bpmn:definitions> -->
<bpmn:serviceTask id="greet" type="hello">
<bpmn:extensionElements>
<fleans:taskDefinition type="hello" />
<fleans:ioMapping>
<fleans:input source="=customerName" target="name" />
<fleans:input source="true" target="excitement" />
<fleans:output source="=__response.text" target="greeting" />
</fleans:ioMapping>
</bpmn:extensionElements>
</bpmn:serviceTask>

Your handler reads name (String) and excitement (Boolean), and returns { "text": "Hello, alice!!" }. The output mapping pulls text out and writes it to the workflow variable greeting.

Terminal window
dotnet new worker -o my-worker -n MyWorker -f net10.0
cd my-worker
rm Worker.cs
dotnet add package Fleans.ExternalTasks.Sdk --version 0.11.0

Pin rule. Use the same <X.Y.Z> as the engine images you pulled when you start. Workers talk to the engine over a versioned protocol, so you don’t have to rebuild them with every engine release.

To build a container image directly, add one line to MyWorker.csproj’s <PropertyGroup>:

<ContainerRepository>my-worker</ContainerRepository>

For a repo with this already wired, including health checks and telemetry, start from the fleans-custom-worker-example template instead. See Hosting workers.

Add HelloWorldHandler.cs:

using System.Dynamic;
using Fleans.ExternalTasks.Sdk;
namespace MyWorker;
public sealed partial class HelloWorldHandler(ILogger<HelloWorldHandler> logger) : ExternalTaskHandler
{
public override Task<IDictionary<string, object?>> ExecuteAsync(
IDictionary<string, object?> resolvedInputs,
ExpandoObject variables,
ExternalTaskContext context,
CancellationToken cancellationToken)
{
var name = resolvedInputs.TryGetValue("name", out var n) ? n?.ToString() : "world";
var excited = resolvedInputs.TryGetValue("excitement", out var e)
&& string.Equals(e?.ToString(), "true", StringComparison.OrdinalIgnoreCase);
var text = excited ? $"Hello, {name}!!" : $"Hello, {name}.";
LogGreeted(name ?? "(null)", excited);
return Task.FromResult<IDictionary<string, object?>>(new Dictionary<string, object?>
{
["__response"] = new Dictionary<string, object?> { ["text"] = text },
});
}
[LoggerMessage(Level = LogLevel.Information, Message = "HelloWorld greeted {Name} (excited={Excited})")]
private partial void LogGreeted(string name, bool excited);
}

Things worth noticing:

  • Your handler must be idempotent. Delivery is at-least-once: if a worker disconnects, crashes or runs past the execution timeout, the task is delivered again, possibly to another worker. context.ActivityInstanceId stays the same across deliveries, so use it as the idempotency key for external side effects. HelloWorldHandler has none, so a re-run is harmless.
  • Honour the CancellationToken by passing it to your I/O. It fires when the engine stops waiting, the connection drops, or the host stops. A resulting OperationCanceledException is not a failure: the task is delivered again.
  • The result crosses the wire as JSON, so values must be JSON-serializable. The __response key is what output mappings read by convention; put whatever shape you like under it.
  • context also carries WorkflowInstanceId, ActivityId, TaskType and BusinessId, the instance’s business id, or null when it was started without one.
  • A new handler instance per task. It is resolved from a fresh DI scope, so keep per-task state in locals, and shared clients (an HttpClient, a connection pool) in DI as singletons.
  • Throw ExternalTaskFailedException(string code, string message) to fail with a BPMN error code that a boundary error event can catch. Any other exception fails with code "500".

Add HelloWorldSchema.cs:

using Fleans.ExternalTasks.Sdk;
namespace MyWorker;
public static class HelloWorldSchema
{
public static readonly ExternalTaskParameterSchema Default = new(
[
new ExternalTaskParameter(
Name: "name",
DisplayName: "Recipient name",
Type: ExternalTaskParameterType.String,
Description: "Greeting target; defaults to \"world\".",
DefaultValue: "world"),
new ExternalTaskParameter(
Name: "excitement",
DisplayName: "Add exclamation",
Type: ExternalTaskParameterType.Boolean,
Description: "If true, two exclamation points; if false, a period.",
DefaultValue: "false"),
]);
}

The worker announces the schema when it subscribes, and the management UI’s BPMN editor renders typed widgets from it. The five primitive types are String, Integer, Boolean, Expression, MultilineString. Repeat-allowed parameters use List or Map with an ItemType (see Custom Tasks).

Replace Program.cs:

using Fleans.ExternalTasks.Sdk;
using MyWorker;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddFleansExternalTaskWorker(); // binds Fleans:ExternalTaskWorker
builder.Services.AddExternalTaskHandler<HelloWorldHandler>(
taskType: "hello",
displayName: "Say hello",
parameterSchema: HelloWorldSchema.Default);
builder.Build().Run();

AddExternalTaskHandler also takes an optional category, which groups the task in the editor’s picker, and visibility plus allowedProcessKeys, which restrict it to specific processes. See Categories and private plugins.

HelloWorldHandler reads only resolvedInputs, so it declares no variables, and its variables parameter is empty. A handler that reads workflow variables declares them with variables: ExternalTaskVariables.Names("orderId") or ExternalTaskVariables.All. See Choose which variables a worker receives.

The worker’s only required setting is the API’s URL. Pick the path that matches how you want to iterate.

dotnet run on your workstation, pointed at the API’s published port (8081 in the compose bundle):

Terminal window
Fleans__ExternalTaskWorker__EngineUrl="http://localhost:8081" dotnet run

Once the worker is running, hit the catalog API to confirm:

Terminal window
curl http://localhost:8081/custom-tasks

Expect a JSON entry with taskType: "hello", displayName: "Say hello", and siloNames listing your worker’s name ({machine}-{pid} by default). Or open http://localhost:8080/admin/custom-tasks for the same data as a table.

URLs use plain HTTP because the compose bundle does not terminate TLS — front the compose stack with a reverse proxy that terminates TLS for production deploys, or see Self-host with Helm for the Kubernetes deployment pattern with Ingress and Cert-Manager.

Step 6 — Author the BPMN with the editor

Section titled “Step 6 — Author the BPMN with the editor”

Open http://localhost:8080/editor. Drag a Service Task onto the canvas and click it.

In the right-hand properties panel, find the “Plugin (custom task)” dropdown and pick Say hello (hello). The editor seeds the defaults — your task now has <fleans:input source="world" target="name"/> and <fleans:input source="false" target="excitement"/> written under <fleans:ioMapping>.

Type =customerName into the name field. The help text under each primitive widget reminds you the field accepts either a literal value or =variableName. Toggle excitement on with the checkbox.

In the panel’s Input/Output mapping section, click Add output and enter source =__response.text, target greeting — the editor writes <fleans:output source="=__response.text" target="greeting"/> into the same <fleans:ioMapping> (see BPMN Editor).

Save the diagram. The BPMN XML now contains the snippet from the What you’ll build section above.

Step 7 — Run a workflow that uses the task

Section titled “Step 7 — Run a workflow that uses the task”

Deploy the BPMN:

Terminal window
curl -X POST http://localhost:8081/Definitions/deploy \
-H "Content-Type: application/json" \
-d "{\"BpmnXml\": $(jq -Rs . < /path/to/your.bpmn)}"

Start an instance:

Terminal window
curl -X POST http://localhost:8081/Execution/start \
-H "Content-Type: application/json" \
-d '{"WorkflowId":"<your-process-id>","Variables":{"customerName":"alice"}}'

Inspect the result:

Terminal window
curl http://localhost:8081/Instances/<instance-id>/state

The variable projection includes greeting: "Hello, alice!!". Tail your worker’s log to see the HelloWorld greeted alice (excited=True) entry your [LoggerMessage] writes:

  • Docker tab: docker compose logs -f my-worker
  • Local-dev tab: the dotnet run stdout

The task type doesn’t appear in the catalog.

Check the worker’s log. It retries on a failed connection, so a wrong EngineUrl shows up as repeated connection errors, not a crash. A 401 means the API has authentication on: set Fleans__ExternalTaskWorker__AccessToken. A registration the engine refuses (for example a public task with allowed processes) is logged and retried.

<serviceTask type="hello"> deploys but the activity stays active.

  1. No worker of that type was connected. GET /custom-tasks returns no entry for "hello", and the engine logs No connected worker accepted (EventId 14502).
  2. The worker connected too late. A task waits only for the stream delivery window (6 minutes by default); after that the engine stops offering it, and settling it takes a REST call.
  3. The handler is still running or ignoring its token. Check the worker’s log; after ExecutionTimeout (5 minutes by default) the task is delivered again.

The handler ran twice.

That is the at-least-once contract, not a bug: see Step 2. Key side effects by context.ActivityInstanceId.

A failure isn’t caught by my boundary event.

Boundary error events match ExternalTaskFailedException.Code by string equality. Any other exception has code "500".

<fleans:input> not parsed at deploy time.

Two possible causes:

  1. The BPMN file is missing xmlns:fleans="https://fleans.io/schema/bpmn/1.0" on <bpmn:definitions>. Add the namespace declaration. (Files exported from Camunda’s modeler may use xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" instead — that’s also accepted via Fleans’s back-compat probe order.)
  2. The target attribute isn’t a valid identifier (^[a-zA-Z_][a-zA-Z0-9_]*$). Rename or fix the typo.