Skip to content
GitHub

External Workers (gRPC)

Every custom task runs on an external worker, in the spirit of Zeebe’s job workers and Camunda 7’s external tasks: a process that connects to the Fleans API over gRPC, receives the tasks of the types it implements, and reports each outcome. A worker joins no Orleans cluster. It needs only the API’s URL, so it can be written in any language with a gRPC client and deployed on its own schedule.

service task "charge-card" reached
│
▼
Fleans engine ── TaskAssignment ──▶ worker (Subscribe: one server stream per process)
▲ │
└──────── CompleteTask / FailTask ┘ (unary calls, quoting the task token)
Workers (gRPC)REST completion only
Worker languageany gRPC client; .NET SDKany
How the worker finds workFleans streams it to the workeryour own channel
Typed parameters in the editoryes, announced by the workerno
Listed in the custom-task catalogyes, while connectedno

For a .NET tutorial, see Writing custom tasks.

There is nothing to turn on. The gRPC service is served by Fleans.Api on its HTTP endpoint, as plain gRPC (HTTP/2) and as gRPC-Web (HTTP/1.1, which passes any proxy). When the API has Authentication:Authority set, workers send a bearer token like any other API client. The timeouts are in the configuration reference.

A service task with a type (or zeebe:taskDefinition) goes to the workers of that type. The worker receives the task’s input mappings, already evaluated, and the variables it declared, none by default:

<bpmn:serviceTask id="charge" name="Charge card">
<bpmn:extensionElements>
<zeebe:taskDefinition type="charge-card" />
<zeebe:ioMapping>
<zeebe:input source="=order.total" target="amount" />
<zeebe:input source="=order.customerId" target="customerId" />
<zeebe:output source="=paymentRef" target="paymentRef" />
</zeebe:ioMapping>
</bpmn:extensionElements>
</bpmn:serviceTask>

Output mappings apply to the result the worker sends back, as for any task. A task with no output mappings merges the whole result into the workflow scope.

Reference the Fleans.ExternalTasks.Sdk package in a plain generic host:

using System.Dynamic;
using Fleans.ExternalTasks.Sdk;
public sealed class ChargeHandler(IPaymentGateway payments) : ExternalTaskHandler
{
public override async Task<IDictionary<string, object?>> ExecuteAsync(
IDictionary<string, object?> resolvedInputs,
ExpandoObject variables,
ExternalTaskContext context,
CancellationToken cancellationToken)
{
var amount = Convert.ToDecimal(resolvedInputs["amount"]);
// ActivityInstanceId stays the same when a task is delivered again: the idempotency key.
var charge = await payments.ChargeAsync(amount, idempotencyKey: context.ActivityInstanceId, cancellationToken);
if (charge.Declined)
throw new ExternalTaskFailedException("PAYMENT_DECLINED", charge.Reason); // BPMN error code
return new Dictionary<string, object?> { ["paymentRef"] = charge.Reference };
}
}
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<IPaymentGateway, PaymentGateway>();
builder.Services.AddFleansExternalTaskWorker(); // binds Fleans:ExternalTaskWorker
builder.Services.AddExternalTaskHandler<ChargeHandler>("charge-card", displayName: "Charge card");
builder.Build().Run();
Terminal window
Fleans__ExternalTaskWorker__EngineUrl=http://fleans-api:8080 dotnet run

The handler is resolved from a fresh DI scope for every task. Pass a parameterSchema to AddExternalTaskHandler to give the editor typed inputs. category, visibility and allowedProcessKeys are described in Categories and private plugins; over the protocol they are the category, visibility and allowed_process_keys fields of TaskTypeRegistration. A public task that declares allowed processes is rejected.

A handler receives no workflow variables unless it asks for them, so large variables don’t cross the wire with every task. Declare what the code needs when you register it:

builder.Services.AddExternalTaskHandler<ChargeHandler>("charge-card"); // none: `variables` is empty
builder.Services.AddExternalTaskHandler<ChargeHandler>("charge-card",
variables: ExternalTaskVariables.Names("orderId", "amount")); // these top-level variables
builder.Services.AddExternalTaskHandler<ChargeHandler>("charge-card",
variables: ExternalTaskVariables.All); // everything visible to the task

Names match exactly, and a name no visible variable has is left out. Input mappings always arrive in resolvedInputs, whatever the handler declares: the process author writes those, and the handler author declares what the code reads, so editing a BPMN file can’t change what a handler receives.

Over the protocol these are the all_variables and variable_names fields of TaskTypeRegistration; with neither set, variables_json is an empty object. A registration that sets both, or a blank name, is rejected. The declaration belongs to each worker, so workers of one type can differ during a rolling upgrade.

Key (Fleans:ExternalTaskWorker:…)Default
EngineUrl— (required)The Fleans API base URL.
WorkerName{machine}-{pid}Shown in the custom-task catalog; unique per process.
UseGrpcWebtruegRPC-Web over HTTP/1.1 works against any endpoint and proxy. Set false for native gRPC when the endpoint speaks HTTP/2.
AccessToken—Bearer token for an API with authentication on (or set AccessTokenProvider in code).
MaxConcurrentTasks32Tasks run at once; further assignments wait in the process.
ReconnectDelay5sPause before subscribing again after the connection drops.

Run as many worker processes as you like: each task goes to one connected worker of its type, picked at random.

Generate a client from external_task_worker.proto (package fleans.externaltasks.v1, also shipped in the Fleans.ExternalTasks.Sdk NuGet package under protos/). The loop is:

  1. Call Subscribe once per process, with a worker_name and the task types you implement. Keep the stream open: it carries a TaskAssignment per task, a TaskCancellation when the engine stops waiting for one, and a Heartbeat every few seconds.
  2. For each assignment, do the work, then call CompleteTask (with a JSON result_json) or FailTask (with a BPMN error code), quoting the assignment’s task_token. Or call DeferTask when the outcome comes later.
  3. When the stream drops, subscribe again after a pause.

At least once, so handlers must be idempotent. A task is delivered again — possibly to another worker — when its worker disconnects or crashes before reporting, or does not report within Fleans:ExternalTasks:ExecutionTimeout (5 minutes by default; the worker gets a cancellation first). Key your side effects on the activity instance id, which stays the same across deliveries; a report for a task that has already finished is ignored.

A task with no connected worker waits, for a limited time. The token stays on the service task and the task is offered again on each stream redelivery, so a worker that connects within the stream’s delivery window (Fleans__Streaming__MaxEventDeliveryTime, 6 minutes by default) picks it up. Past the window, the engine stops offering that task for good, without logging it. The activity stays active, and a worker that connects later does not get it: settle it with POST /Execution/complete-activity or fail-activity. If workers can be away longer, raise the window on every silo, keeping in mind that a retrying event holds up its stream queue while it waits. Put a timer boundary event on the task if the process must not wait.

Failures are BPMN errors. FailTask (in .NET, throwing ExternalTaskFailedException) fails the activity with that error code, catchable by an error boundary event or error event sub-process. Any other exception in a .NET handler fails it with code 500. A cancelled task is not a failure: it is not reported and is delivered again.

REST still works. A worker that took a task can also report it over POST /Execution/complete-activity or fail-activity with the task’s ActivityInstanceId — see the API reference.

When a task’s outcome arrives after the handler returns (a person, or an existing system that polls for work), call DeferTask, or in .NET return ExternalTaskHandler.Deferred:

await inbox.AddIfMissingAsync(context.ActivityInstanceId, resolvedInputs, cancellationToken);
return Deferred;

The engine stops waiting, and the activity stays active with no timeout until something calls POST /Execution/complete-activity or fail-activity with its ActivityInstanceId. Record the task idempotently first: a redelivery can still run the handler again. While holding it, GET /Execution/{workflowInstanceId}/activities/{activityInstanceId}/variables returns all the variables it sees, whatever the handler declared, or 404 once the activity is no longer active, for example after a boundary event interrupts it.

A worker can subscribe to <scheme>:*, for example AddExternalTaskHandler<T>("camunda:*"), to serve every type starting with <scheme>:. A worker claiming the exact type goes first. A wildcard is not listed in the custom-task catalog, because there is no single type for an author to pick.

EventIdLevelMeaning
5002DebugTask published to the external-task stream
14500 / 14501Information / DebugTask dispatched / assigned to a worker
14502WarningNo connected worker took the task; it is delivered again
14504 / 14505WarningThe worker disconnected / did not report within the execution timeout
14514InformationThe worker deferred the task; it waits for a REST report
14540 / 14541InformationA worker connected / disconnected
9326 / 9327InformationA worker’s task type entered / left the custom-task catalog

GET /custom-tasks/{taskType} lists the connected workers of a type under siloNames.