A comprehensive .NET client library for Conductor workflow orchestration engine. Features a strongly-typed workflow builder DSL, task handlers, and quality-of-life additions for building robust workflow applications.
Note: This documentation has been AI generated and human reviewed.
AI Assistant Users: See SKILL.md for a condensed reference guide optimized for AI coding assistants. It provides quick-reference documentation for all task types, configuration options, and common patterns. This file follows the Agent Skills open standard for extending AI assistant capabilities.
- Installation
- Quick Start
- Core Concepts
- Task Types
- Configuration
- Pipeline Behaviors
- Health Checks
- Patterns Package
- Kafka Cancellation Notifier
- Toolkit CLI
- API Services
- Running the Examples
- General Notes
# API client for Conductor
dotnet add package ConductorSharp.Client
# Workflow engine with builder DSL, task handlers, and worker scheduling
dotnet add package ConductorSharp.Engine# Built-in tasks (WaitSeconds, ReadWorkflowTasks, C# Lambda Tasks)
dotnet add package ConductorSharp.Patterns
# Kafka-based task cancellation notifications
dotnet add package ConductorSharp.KafkaCancellationNotifier
# CLI tool for scaffolding task/workflow definitions
dotnet tool install --global ConductorSharp.ToolkitusingConductorSharp.Engine.Extensions;usingMicrosoft.Extensions.Hosting;varbuilder=Host.CreateApplicationBuilder(args);builder.Services.AddConductorSharp(baseUrl:"http://localhost:8080").AddExecutionManager(maxConcurrentWorkers:10,sleepInterval:500,longPollInterval:100,domain:null,typeof(Program).Assembly).AddPipelines(pipelines =>{pipelines.AddRequestResponseLogging();pipelines.AddValidation();});builder.Services.RegisterWorkflow<MyWorkflow>();varhost=builder.Build();awaithost.RunAsync();usingConductorSharp.Engine.Builders.Metadata;usingConductorSharp.Engine;publicclassPrepareEmailRequest:IRequest<PrepareEmailResponse>{publicstringCustomerName{get;set;}publicstringAddress{get;set;}}publicclassPrepareEmailResponse{publicstringEmailBody{get;set;}}[OriginalName("EMAIL_prepare")]publicclassPrepareEmailHandler:TaskRequestHandler<PrepareEmailRequest,PrepareEmailResponse>{publicoverrideasyncTask<PrepareEmailResponse>Handle(PrepareEmailRequestrequest,CancellationTokencancellationToken){varbody=$"Hello {request.CustomerName} at {request.Address}!";returnnewPrepareEmailResponse{EmailBody=body};}}usingConductorSharp.Engine.Builders;usingConductorSharp.Engine.Builders.Metadata;publicclassSendNotificationInput:WorkflowInput<SendNotificationOutput>{publicintCustomerId{get;set;}}publicclassSendNotificationOutput:WorkflowOutput{publicstringEmailBody{get;set;}}[OriginalName("NOTIFICATION_send")][WorkflowMetadata(OwnerEmail="team@example.com")]publicclassSendNotificationWorkflow:Workflow<SendNotificationWorkflow,SendNotificationInput,SendNotificationOutput>{publicSendNotificationWorkflow(WorkflowDefinitionBuilder<SendNotificationWorkflow,SendNotificationInput,SendNotificationOutput>builder):base(builder){}publicGetCustomerHandlerGetCustomer{get;set;}publicPrepareEmailHandlerPrepareEmail{get;set;}publicoverridevoidBuildDefinition(){_builder.AddTask(
wf =>wf.GetCustomer,
wf =>newGetCustomerRequest{CustomerId=wf.WorkflowInput.CustomerId});_builder.AddTask(
wf =>wf.PrepareEmail,
wf =>newPrepareEmailRequest{CustomerName=wf.GetCustomer.Output.Name,Address=wf.GetCustomer.Output.Address});_builder.SetOutput(wf =>newSendNotificationOutput{EmailBody=wf.PrepareEmail.Output.EmailBody});}}Workflows are defined by inheriting from Workflow<TWorkflow, TInput, TOutput>:
publicclassMyWorkflow:Workflow<MyWorkflow,MyWorkflowInput,MyWorkflowOutput>{publicMyWorkflow(WorkflowDefinitionBuilder<MyWorkflow,MyWorkflowInput,MyWorkflowOutput>builder):base(builder){}// Task properties - these become task references in the workflowpublicSomeTaskHandlerFirstTask{get;set;}publicAnotherTaskHandlerSecondTask{get;set;}publicoverridevoidBuildDefinition(){// Add tasks with strongly-typed input expressions_builder.AddTask(wf =>wf.FirstTask, wf =>newSomeTaskRequest{Input=wf.WorkflowInput.SomeValue});_builder.AddTask(wf =>wf.SecondTask, wf =>newAnotherTaskRequest{Input=wf.FirstTask.Output.Result});// Set workflow output_builder.SetOutput(wf =>newMyWorkflowOutput{Result=wf.SecondTask.Output.Value});}}[OriginalName("MY_TASK_name")]publicclassMyTaskHandler:TaskRequestHandler<MyTaskRequest,MyTaskResponse>{publicoverrideasyncTask<MyTaskResponse>Handle(MyTaskRequestrequest,CancellationTokencancellationToken){returnnewMyTaskResponse{/* ... */};}}// Workflow I/OpublicclassMyWorkflowInput:WorkflowInput<MyWorkflowOutput>{publicstringCustomerId{get;set;}}publicclassMyWorkflowOutput:WorkflowOutput{publicstringResult{get;set;}}// Task I/OpublicclassMyTaskRequest:IRequest<MyTaskResponse>{[Required]publicstringInputValue{get;set;}}publicclassMyTaskResponse{publicstringOutputValue{get;set;}}In Conductor, task inputs in workflows are specified using Conductor expressions with the format: ${SOURCE.input/output.JSONPath}. The SOURCE can be workflow or a task reference name in the workflow definition. input/output refers to the input of the workflow or output of the task. JSONPath is used to traverse the input/output object.
ConductorSharp generates these expressions automatically when writing workflows. Here's an example:
_builder.AddTask(
wf =>wf.PrepareEmail,
wf =>newPrepareEmailRequest{CustomerName=$"{wf.GetCustomer.Output.FirstName}{wf.GetCustomer.Output.LastName}",Address=wf.WorkflowInput.Address});This is converted to the following Conductor input parameters specification:
"inputParameters": {
"customer_name": "${get_customer.output.first_name} ${get_customer.output.last_name}",
"address": "${workflow.input.address}"
}When input/output parameters are of different types, casting can be used:
wf =>newPrepareEmailRequest{CustomerName=((FullName)wf.GetCustomer.Output.Name).FirstName,Address=(string)wf.GetCustomer.Output.Address}This translates to:
"inputParameters": {
"customer_name": "${get_customer.output.name.first_name}",
"address": "${get_customer.output.address}"
}Array initialization is supported. Arrays can be typed or dynamic:
wf =>new(){Integers=new[]{1,2,3},TestModelList=newList<ArrayTaskInput.TestModel>{newArrayTaskInput.TestModel{String=wf.Input.TestValue},newArrayTaskInput.TestModel{String="List2"}},Models=new[]{newArrayTaskInput.TestModel{String="Test1"},newArrayTaskInput.TestModel{String="Test2"}},Objects=newdynamic[]{new{AnonymousObjProp="Prop"},new{Test="Prop"}}}This translates to:
"inputParameters": {
"integers": [1, 2, 3],
"test_model_list": [
{
"string": "${workflow.input.test_value}"
},
{
"string": "List2"
}
],
"models": [
{
"string": "Test1"
},
{
"string": "Test2"
}
],
"objects": [
{
"anonymous_obj_prop": "Prop"
},
{
"test": "Prop"
}
]
}Object initialization is supported, including anonymous objects when initializing sub-properties:
wf =>new(){NestedObjects=newTestModel{Integer=1,String="test",Object=newTestModel{Integer=1,String="string",Object=new{NestedInput="1"}}}}This translates to:
"inputParameters": {
"nested_objects": {
"integer": 1,
"string": "test",
"object": {
"integer": 1,
"string": "string",
"object": {
"nested_input": "1"
}
}
}
}Dictionary indexing is supported. Indexing using an indexer on arbitrary types is currently not supported:
wf =>new(){CustomerName=wf.WorkflowInput.Dictionary["test"].CustomerName,Address=wf.WorkflowInput.DoubleDictionary["test"]["address"]}This translates to:
"inputParameters": {
"customer_name": "${workflow.input.dictionary['test'].customer_name}",
"address": "${workflow.input.double_dictionary['test']['address']}"
}You can embed the name of any workflow in task input specification using NamingUtil.NameOf<T>():
wf =>new(){Name=$"Workflow name: {NamingUtil.NameOf<StringInterpolation>()}",WfName=NamingUtil.NameOf<StringInterpolation>()}This translates to:
"inputParameters": {
"name": "Workflow name: TEST_StringInterpolation",
"wf_name": "TEST_StringInterpolation"
}Note: StringInterpolation has an attribute [OriginalName("TEST_StringInterpolation")] applied.
String concatenation is supported. You can concatenate strings with numbers, input/output parameters, and interpolation strings:
wf =>new(){Input=1+"Str_"+"2Str_"+wf.WorkflowInput.Input+$"My input: {wf.WorkflowInput.Input}"+NamingUtil.NameOf<StringAddition>()+1}This translates to:
"inputParameters": {
"input": "1Str_2Str_${workflow.input.input}My input: ${workflow.input.input}string_addition1"
}Note: StringAddition has an attribute [OriginalName("string_addition")] applied.
| Attribute | Target | Description |
|---|---|---|
[OriginalName("NAME")] | Class | Custom task/workflow name in Conductor |
[WorkflowMetadata(...)] | Class | Workflow metadata (OwnerEmail, OwnerApp, Description, FailureWorkflow) |
[Version(n)] | Class | Version number for sub-workflow references |
[TaskDomain("domain")] | Class | Assign task to specific domain |
Note: There is no task equivalent of the WorkflowMetadata attribute. The task metadata is configured when registering the task:
services.RegisterWorkerTask<MyTaskHandler>(options =>{options.OwnerEmail="team@example.com";options.Description="My task description";});_builder.AddTask(wf =>wf.MySimpleTask, wf =>newMySimpleTaskRequest{Input=wf.WorkflowInput.Value});publicSubWorkflowTaskModel<ChildWorkflowInput,ChildWorkflowOutput>ChildWorkflow{get;set;}_builder.AddTask(wf =>wf.ChildWorkflow, wf =>newChildWorkflowInput{CustomerId=wf.WorkflowInput.CustomerId});publicSwitchTaskModelSwitchTask{get;set;}publicTaskATaskInCaseA{get;set;}publicTaskBTaskInCaseB{get;set;}_builder.AddTask(
wf =>wf.SwitchTask,
wf =>newSwitchTaskInput{SwitchCaseValue=wf.WorkflowInput.Operation},newDecisionCases<MyWorkflow>{["caseA"]= builder =>builder.AddTask(wf =>wf.TaskInCaseA, wf =>newTaskARequest{}),["caseB"]= builder =>builder.AddTask(wf =>wf.TaskInCaseB, wf =>newTaskBRequest{}),DefaultCase= builder =>{/* default case tasks */}});publicDynamicTaskModel<ExpectedInput,ExpectedOutput>DynamicHandler{get;set;}_builder.AddTask(
wf =>wf.DynamicHandler,
wf =>newDynamicTaskInput<ExpectedInput,ExpectedOutput>{TaskInput=newExpectedInput{CustomerId=wf.WorkflowInput.CustomerId},TaskToExecute=wf.WorkflowInput.TaskName// Task name resolved at runtime});publicDynamicForkJoinTaskModelDynamicFork{get;set;}_builder.AddTask(
wf =>wf.DynamicFork,
wf =>newDynamicForkJoinInput{DynamicTasks=/* list of tasks */,DynamicTasksInput=/* corresponding inputs */});publicDoWhileTaskModelDoWhile{get;set;}publicCustomerGetHandlerGetCustomer{get;set;}_builder.AddTask(
wf =>wf.DoWhile,
wf =>newDoWhileInput{Value=wf.WorkflowInput.Loops},"$.do_while.iteration < $.value",// Loop condition
builder =>{builder.AddTask(wf =>wf.GetCustomer, wf =>newCustomerGetRequest{CustomerId="CUSTOMER-1"});});Note: ConductorSharp does not provide a strongly typed output for the DoWhile task, as can be seen from the implementation:
publicclassDoWhileTaskModel:TaskModel<DoWhileInput,NoOutput>{}publicclassLambdaInput:IRequest<LambdaOutput>{publicstringValue{get;set;}}publicclassLambdaOutput{publicstringSomething{get;set;}}publicLambdaTaskModel<LambdaInput,LambdaOutput>LambdaTask{get;set;}_builder.AddTask(
wf =>wf.LambdaTask,
wf =>newLambdaInput{Value=wf.WorkflowInput.Input},script:"return { something: $.Value.toUpperCase() }"// JavaScript expression);For context, in the above parameterized generic class LambdaTaskModel, the LambdaOutput instance is available as Output.Result.Something. This is less than ideal, but is the current way of things. Reasoning can be seen in the implementation:
publicabstractclassLambdaOutputModel<O>{publicOResult{get;set;}}publicabstractclassLambdaTaskModel<I,O>whereI:IRequest<O>{publicIInput{get;set;}publicLambdaOutputModel<O>Output{get;set;}}publicWaitTaskModelWaitTask{get;set;}_builder.AddTask(
wf =>wf.WaitTask,
wf =>newWaitTaskInput{Duration="1h"}// or Until = "2024-01-01T00:00:00Z");publicTerminateTaskModelTerminateTask{get;set;}_builder.AddTask(
wf =>wf.TerminateTask,
wf =>newTerminateTaskInput{TerminationStatus="COMPLETED",WorkflowOutput=new{Result="Done"}});publicHumanTaskModel<HumanTaskOutput>HumanTask{get;set;}_builder.AddTask(
wf =>wf.HumanTask,
wf =>newHumanTaskInput<HumanTaskOutput>{/* ... */});publicJsonJqTransformTaskModel<JqInput,JqOutput>TransformTask{get;set;}_builder.AddTask(
wf =>wf.TransformTask,
wf =>newJqInput{QueryExpression=".data | map(.name)",Data=wf.WorkflowInput.Items});For tasks not covered by the builder:
_builder.AddTasks(newWorkflowTask{Name="CUSTOM_task",TaskReferenceName="custom_ref",Type="CUSTOM",InputParameters=newDictionary<string,object>{["key"]="value"}});Mark tasks as optional (workflow continues on failure):
_builder.AddTask(wf =>wf.OptionalTask, wf =>newOptionalTaskRequest{}).AsOptional();services.AddConductorSharp(baseUrl:"http://localhost:8080").AddExecutionManager(maxConcurrentWorkers:10,// Max concurrent task executionssleepInterval:500,// Base polling interval (ms)longPollInterval:100,// Long poll timeout (ms)domain:"my-domain",// Optional worker domaintypeof(Program).Assembly// Assemblies containing handlers);services.AddConductorSharp(baseUrl:"http://primary-conductor:8080").AddAlternateClient(baseUrl:"http://secondary-conductor:8080",key:"Secondary",apiPath:"api",ignoreInvalidCertificate:false);// Usage with keyed servicespublicclassMyController(IWorkflowServiceprimaryService,[FromKeyedServices("Secondary")]IWorkflowServicesecondaryService){}// Default: Inverse exponential backoff.AddExecutionManager(...)// Constant interval polling.AddExecutionManager(...).UseConstantPollTimingStrategy()Register standalone tasks without workflow:
services.RegisterWorkerTask<MyTaskHandler>(options =>{options.OwnerEmail="team@example.com";options.Description="My task description";});Behaviors form a middleware pipeline for task execution (powered by MediatR):
.AddPipelines(pipelines =>{// Add custom behavior (runs first)pipelines.AddCustomBehavior(typeof(MyCustomBehavior<,>));// Built-in behaviorspipelines.AddExecutionTaskTracking();// Track task execution metricspipelines.AddContextLogging();// Add context to log scopespipelines.AddRequestResponseLogging();// Log requests/responsespipelines.AddValidation();// Validate using DataAnnotations})publicclassTimingBehavior<TRequest,TResponse>:IPipelineBehavior<TRequest,TResponse>{publicasyncTask<TResponse>Handle(TRequestrequest,RequestHandlerDelegate<TResponse>next,CancellationTokencancellationToken){varsw=Stopwatch.StartNew();varresponse=awaitnext();Console.WriteLine($"Execution took {sw.ElapsedMilliseconds}ms");returnresponse;}}// In Program.csbuilder.Services.AddHealthChecks().AddCheck<ConductorSharpHealthCheck>("conductor-worker");// Configure health service.AddExecutionManager(...).SetHealthCheckService<FileHealthService>()// or InMemoryHealthService| Service | Description |
|---|---|
InMemoryHealthService | In-memory health state (default) |
FileHealthService | Persists health to CONDUCTORSHARP_HEALTH.json file |
Access workflow/task metadata in handlers:
publicclassMyHandler:TaskRequestHandler<MyRequest,MyResponse>{privatereadonlyConductorSharpExecutionContext_context;publicMyHandler(ConductorSharpExecutionContextcontext){_context=context;}publicoverrideasyncTask<MyResponse>Handle(MyRequestrequest,CancellationTokencancellationToken){varworkflowId=_context.WorkflowId;vartaskId=_context.TaskId;varcorrelationId=_context.CorrelationId;// ...}}Additional built-in tasks and utilities:
.AddExecutionManager(...).AddConductorSharpPatterns()// Adds WaitSeconds, ReadWorkflowTasks.AddCSharpLambdaTasks()// Adds C# lambda task supportpublicWaitSecondsWaitTask{get;set;}_builder.AddTask(wf =>wf.WaitTask, wf =>newWaitSecondsRequest{Seconds=30});Read task data from another workflow:
publicReadWorkflowTasksReadTasks{get;set;}_builder.AddTask(
wf =>wf.ReadTasks,
wf =>newReadWorkflowTasksInput{WorkflowId=wf.WorkflowInput.TargetWorkflowId,TaskNames="task1,task2"// Comma-separated reference names});Execute C# code inline in workflows:
publicCSharpLambdaTaskModel<LambdaInput,LambdaOutput>InlineLambda{get;set;}_builder.AddTask(
wf =>wf.InlineLambda,
wf =>newLambdaInput{Value=wf.WorkflowInput.Input},
input =>newLambdaOutput{Result=input.Value.ToUpperInvariant()});The Signal Wait pattern allows workflows to pause execution until an external signal is received, without consuming worker threads. Useful for human approvals, external callbacks, or long-running async operations.
.AddExecutionManager(...).AddSignalWait<YourSignalStore>("MY_PREFIX")Important: It is recommended to implement your own ISignalStore with a persistent backend (database, Redis, etc.). The built-in InMemorySignalStore is provided for development and testing only - it uses a static dictionary that only works within a single process and loses all data on restart.
Important: The purpose of 'MY_PREFIX' is to allow multiple microservices running on the same Conductor instance to have different signal stores and not accidentally poll eachothers tasks.
publicclassOrderWorkflow:Workflow<OrderWorkflow,OrderInput,OrderOutput>{publicSignalWaitWaitForPayment{get;set;}publicoverridevoidBuildDefinition(){// ... previous tasks ..._builder.AddTask(
wf =>wf.WaitForPayment,
wf =>newSignalWaitInput{SignalKey=$"payment:{wf.WorkflowInput.OrderId}"});// ... tasks after signal received ...}}publicclassPaymentController:ControllerBase{privatereadonlyISignalService_signalService;[HttpPost("webhook")]publicasyncTask<IActionResult>PaymentReceived(PaymentNotificationnotification){await_signalService.SendSignalAsync($"payment:{notification.OrderId}",TaskResultStatus.COMPLETED,newDictionary<string,object>{["transactionId"]=notification.TransactionId});returnOk();}}varpending=await_signalStore.GetPendingWaitersAsync();The signal system consists of:
| Component | Description |
|---|---|
SignalWait | Sub-workflow that registers a waiter and enters a WAIT task |
RegisterWaiter | Task that registers the workflow in the signal store |
ISignalStore | Persistence abstraction - implement with your own backend |
ISignalService | Service for sending signals to waiting workflows |
SignalSweeperService | Background service that reconciles signals with WAIT tasks |
InMemorySignalStore | Development-only in-memory implementation |
Signals and waiters can arrive in any order - if a signal arrives before the workflow registers, the workflow will see it immediately and skip waiting.
The RegisterWaiter task is registered with specific settings:
- ConcurrentExecLimit = 1: Only one registration can execute at a time per worker, preventing race conditions when multiple workflows register simultaneously
- RetryCount = 10 with RetryDelaySeconds = 1: Provides resilience against transient failures (e.g., database connection issues)
These settings mean that if many workflows start waiting at the same time, registrations will be serialized, which may introduce slight delays but ensures consistency in the signal store.
Handle task cancellation via Kafka events:
.AddExecutionManager(...).AddKafkaCancellationNotifier(kafkaBootstrapServers:"localhost:9092",topicName:"conductor.status.task",groupId:"my-worker-group",createTopicOnStartup:true)appsettings.json:
{
"Conductor": {
"BaseUrl": "http://localhost:8080",
"MaxConcurrentWorkers": 10,
"SleepInterval": 500,
"LongPollInterval": 100,
"KafkaCancellationNotifier": {
"BootstrapServers": "localhost:9092",
"GroupId": "my-worker",
"TopicName": "conductor.status.task"
}
}
}Generate C# models from existing Conductor task/workflow definitions.
dotnet tool install --global ConductorSharp.Toolkit --version 4.0.1Create conductorsharp.yaml:
baseUrl: http://localhost:8080apiPath: apinamespace: MyApp.Generateddestination: ./Generated# Scaffold all tasks and workflows
dotnet-conductorsharp
# Use custom config file
dotnet-conductorsharp -f myconfig.yaml
# Filter by name
dotnet-conductorsharp -n CUSTOMER_get -n ORDER_create
# Filter by owner email
dotnet-conductorsharp -e team@example.com
# Filter by owner app
dotnet-conductorsharp -a my-application
# Skip tasks or workflows
dotnet-conductorsharp --no-tasks
dotnet-conductorsharp --no-workflows
# Preview without generating files
dotnet-conductorsharp --dry-run| Option | Description |
|---|---|
-f, --file | Configuration file path (default: conductorsharp.yaml) |
-n, --name | Filter by task/workflow name (can specify multiple) |
-a, --app | Filter by owner app |
-e, --email | Filter by owner email |
--no-tasks | Skip task scaffolding |
--no-workflows | Skip workflow scaffolding |
--dry-run | Preview what would be generated |
Inject these services to interact with Conductor programmatically:
| Service | Description |
|---|---|
IWorkflowService | Start, pause, resume, terminate workflows |
ITaskService | Update tasks, get logs, poll for tasks |
IMetadataService | Manage workflow/task definitions |
IAdminService | Admin operations, queue management |
IEventService | Event handlers |
IQueueAdminService | Queue administration |
IWorkflowBulkService | Bulk workflow operations |
IHealthService | Conductor server health |
IExternalPayloadService | External payload storage |
publicclassWorkflowController:ControllerBase{privatereadonlyIWorkflowService_workflowService;privatereadonlyIMetadataService_metadataService;publicWorkflowController(IWorkflowServiceworkflowService,IMetadataServicemetadataService){_workflowService=workflowService;_metadataService=metadataService;}[HttpPost("start")]publicasyncTask<string>StartWorkflow([FromBody]StartRequestrequest){returnawait_workflowService.StartAsync(newStartWorkflowRequest{Name="MY_workflow",Version=1,Input=newDictionary<string,object>{["customerId"]=request.CustomerId}});}[HttpGet("definitions")]publicasyncTask<ICollection<WorkflowDef>>GetDefinitions(){returnawait_metadataService.ListWorkflowsAsync();}}Clone and run Conductor:
git clone https://github.com/conductor-oss/conductor.git cd conductor docker-compose up -dConductor UI available at: http://localhost:5000 (may vary by version)
The solution includes three example projects:
| Project | Description |
|---|---|
ConductorSharp.Definitions | Console app with workflow definitions |
ConductorSharp.ApiEnabled | Web API with workflow execution endpoints |
ConductorSharp.NoApi | Console app with Kafka cancellation support |
# Run with Docker Compose
docker-compose up
# Or run individual projectscd examples/ConductorSharp.Definitions
dotnet runThe Conductor events are currently not supported by the library.
MIT License - see LICENSE for details.
Contributions are welcome! Please feel free to submit a Pull Request.