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.
Choosing the right pattern
Section titled “Choosing the right pattern”Fleans gives you three ways to back a <bpmn:serviceTask>:
| Need | Pattern |
|---|---|
.NET worker, typed parameter editor, listed in GET /custom-tasks | ExternalTaskHandler (this guide) |
| Worker is non-.NET (Python, Node, Go), same features | gRPC protocol |
| Queue or message bus of your own between engine and worker | External completion |
Prerequisites
Section titled “Prerequisites”- 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 -dat least once. - .NET 10 SDK on your workstation.
- Docker daemon running locally.
dotnet publish /t:PublishContainerpushes the image straight to the daemon — no Dockerfile, no remote registry needed for the tutorial.
What you’ll build
Section titled “What you’ll build”A trivial “say hello” task. The workflow author writes:
<!-- 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.
Step 1 — Create the worker project
Section titled “Step 1 — Create the worker project”dotnet new worker -o my-worker -n MyWorker -f net10.0cd my-workerrm Worker.csdotnet add package Fleans.ExternalTasks.Sdk --version 0.11.0Pin 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.
Step 2 — Write the handler
Section titled “Step 2 — Write the handler”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.ActivityInstanceIdstays the same across deliveries, so use it as the idempotency key for external side effects.HelloWorldHandlerhas none, so a re-run is harmless. - Honour the
CancellationTokenby passing it to your I/O. It fires when the engine stops waiting, the connection drops, or the host stops. A resultingOperationCanceledExceptionis not a failure: the task is delivered again. - The result crosses the wire as JSON, so values must be JSON-serializable. The
__responsekey is what output mappings read by convention; put whatever shape you like under it. contextalso carriesWorkflowInstanceId,ActivityId,TaskTypeandBusinessId, the instance’s business id, ornullwhen 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".
Step 3 — Declare the parameter schema
Section titled “Step 3 — Declare the parameter schema”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).
Step 4 — Wire DI
Section titled “Step 4 — Wire DI”Replace Program.cs:
using Fleans.ExternalTasks.Sdk;using MyWorker;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddFleansExternalTaskWorker(); // binds Fleans:ExternalTaskWorkerbuilder.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.
Step 5 — Run
Section titled “Step 5 — Run”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):
Fleans__ExternalTaskWorker__EngineUrl="http://localhost:8081" dotnet runThe worker runs as a container on the same Docker network as the engine bundle:
# (a) Engine first — the bundle creates the fleans_default network on this first up.cd <unzipped-engine-compose-bundle>echo "COMPOSE_PROJECT_NAME=fleans" >> .env # ensures the network is named fleans_defaultdocker compose up -d
# (b) Build the worker image, tagged "my-worker:latest" per the <ContainerRepository> in your csproj.cd ../my-workerdotnet publish /t:PublishContainer -c Release
# (c) Apply the override file below. Compose merges docker-compose.yaml + docker-compose.override.yaml.cd <unzipped-engine-compose-bundle>docker compose up -ddotnet publish /t:PublishContainer emits an image for your host architecture. For multi-arch production images, pass -r linux-x64 or -r linux-arm64 explicitly — see .NET SDK container support docs.
docker-compose.override.yaml — drop this file next to the engine bundle’s docker-compose.yaml:
services: my-worker: image: my-worker:latest networks: - fleans_default environment: Fleans__ExternalTaskWorker__EngineUrl: "http://fleans-core:8080"
networks: fleans_default: external: true name: fleans_defaultOrder matters. Step (a) creates the fleans_default network. The override declares it as external: true, so running step (c) first fails with network fleans_default declared as external, but could not be found.
Once the worker is running, hit the catalog API to confirm:
curl http://localhost:8081/custom-tasksExpect 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:
curl -X POST http://localhost:8081/Definitions/deploy \ -H "Content-Type: application/json" \ -d "{\"BpmnXml\": $(jq -Rs . < /path/to/your.bpmn)}"Start an instance:
curl -X POST http://localhost:8081/Execution/start \ -H "Content-Type: application/json" \ -d '{"WorkflowId":"<your-process-id>","Variables":{"customerName":"alice"}}'Inspect the result:
curl http://localhost:8081/Instances/<instance-id>/stateThe 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 runstdout
Troubleshooting
Section titled “Troubleshooting”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.
- No worker of that type was connected.
GET /custom-tasksreturns no entry for"hello", and the engine logsNo connected worker accepted(EventId 14502). - 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.
- 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:
- 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 usexmlns:zeebe="http://camunda.org/schema/zeebe/1.0"instead — that’s also accepted via Fleans’s back-compat probe order.) - The
targetattribute isn’t a valid identifier (^[a-zA-Z_][a-zA-Z0-9_]*$). Rename or fix the typo.
Where to next
Section titled “Where to next”- Custom Tasks — parameter types, mapping grammar, failure semantics, catalog and liveness.
- External Workers — the protocol, delivery contract, deferred tasks and wildcards.
- Hosting workers — connection lifecycle and scaling.
Fleans.Plugins.RestCalleron nuget.org — a real-world handler, maintained in fleans-plugins (see its guide).- File a feature request if you hit a limitation that isn’t on the list above.