diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 3be35c1cda0..9e47424cd98 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -79,6 +79,7 @@
+
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting/09_SwitchRouting.csproj b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting/09_SwitchRouting.csproj
new file mode 100644
index 00000000000..5db719ec651
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting/09_SwitchRouting.csproj
@@ -0,0 +1,28 @@
+
+
+ net10.0
+ Exe
+ enable
+ enable
+ SwitchRouting
+ SwitchRouting
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting/Executors.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting/Executors.cs
new file mode 100644
index 00000000000..484a397a512
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting/Executors.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI.Workflows;
+
+namespace SwitchRouting;
+
+internal sealed record Expense(string Id, decimal Amount);
+
+internal sealed class ExpenseParser() : Executor("ExpenseParser")
+{
+ public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
+ {
+ // The input is the expense amount (e.g., "50" or "4500"). A real workflow would
+ // look the expense up from a store; here we just attach a generated id.
+ decimal amount = decimal.TryParse(message, out decimal parsed) ? parsed : 0m;
+ return ValueTask.FromResult(new Expense($"EXP-{Guid.NewGuid().ToString()[..4]}", amount));
+ }
+}
+
+internal sealed class AutoApprove() : Executor("AutoApprove")
+{
+ public override ValueTask HandleAsync(Expense message, IWorkflowContext context, CancellationToken cancellationToken = default)
+ => ValueTask.FromResult($"Expense {message.Id} for {message.Amount:C} was auto-approved.");
+}
+
+internal sealed class ManagerApproval() : Executor("ManagerApproval")
+{
+ public override ValueTask HandleAsync(Expense message, IWorkflowContext context, CancellationToken cancellationToken = default)
+ => ValueTask.FromResult($"Expense {message.Id} for {message.Amount:C} was routed to a manager for approval.");
+}
+
+internal sealed class DirectorApproval() : Executor("DirectorApproval")
+{
+ public override ValueTask HandleAsync(Expense message, IWorkflowContext context, CancellationToken cancellationToken = default)
+ => ValueTask.FromResult($"Expense {message.Id} for {message.Amount:C} was routed to a director for approval.");
+}
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting/Program.cs b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting/Program.cs
new file mode 100644
index 00000000000..a115a1d93de
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting/Program.cs
@@ -0,0 +1,103 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates multi-way routing in a workflow using AddSwitch.
+// An expense is routed to a different approval path based on its amount:
+// - Amount < 100 -> AutoApprove
+// - Amount < 1000 -> ManagerApproval
+// - Otherwise -> DirectorApproval (default)
+//
+// Unlike AddEdge(..., condition:), which adds an independent boolean condition per edge,
+// AddSwitch evaluates its cases in order and routes to the FIRST matching branch (or the
+// default when none match), making it a natural fit for multi-way routing.
+
+using Microsoft.Agents.AI.DurableTask;
+using Microsoft.Agents.AI.DurableTask.Workflows;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.DurableTask.Client.AzureManaged;
+using Microsoft.DurableTask.Worker.AzureManaged;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using SwitchRouting;
+
+string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
+ ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
+
+// Create executor instances
+ExpenseParser expenseParser = new();
+AutoApprove autoApprove = new();
+ManagerApproval managerApproval = new();
+DirectorApproval directorApproval = new();
+
+// Build a workflow that switches on the parsed expense amount. Cases are evaluated in order;
+// the first matching case wins, and WithDefault handles everything else.
+WorkflowBuilder builder = new(expenseParser);
+builder.AddSwitch(expenseParser, switchBuilder =>
+ switchBuilder
+ .AddCase(expense => expense!.Amount < 100m, autoApprove)
+ .AddCase(expense => expense!.Amount < 1000m, managerApproval)
+ .WithDefault(directorApproval));
+
+Workflow approveExpense = builder.WithName("ApproveExpense").Build();
+
+IHost host = Host.CreateDefaultBuilder(args)
+.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
+.ConfigureServices(services =>
+{
+ services.ConfigureDurableWorkflows(
+ workflowOptions => workflowOptions.AddWorkflow(approveExpense),
+ workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
+ clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
+})
+.Build();
+
+await host.StartAsync();
+
+IWorkflowClient workflowClient = host.Services.GetRequiredService();
+
+Console.WriteLine("Enter an expense amount (or 'exit'):");
+Console.WriteLine("Tip: try 50, 450, and 5000 to see each branch.\n");
+
+while (true)
+{
+ Console.Write("> ");
+ string? input = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
+ {
+ break;
+ }
+
+ try
+ {
+ await StartNewWorkflowAsync(input, approveExpense, workflowClient);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error: {ex.Message}");
+ }
+
+ Console.WriteLine();
+}
+
+await host.StopAsync();
+
+// Start a new workflow and wait for completion
+static async Task StartNewWorkflowAsync(string amount, Workflow workflow, IWorkflowClient client)
+{
+ Console.WriteLine($"Starting workflow for expense amount '{amount}'...");
+
+ // Cast to IAwaitableWorkflowRun to access WaitForCompletionAsync
+ IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, amount);
+ Console.WriteLine($"Run ID: {run.RunId}");
+
+ try
+ {
+ Console.WriteLine("Waiting for workflow to complete...");
+ string? result = await run.WaitForCompletionAsync();
+ Console.WriteLine($"Workflow completed. {result}");
+ }
+ catch (InvalidOperationException ex)
+ {
+ Console.WriteLine($"Failed: {ex.Message}");
+ }
+}
diff --git a/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting/README.md b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting/README.md
new file mode 100644
index 00000000000..b3de9fbc686
--- /dev/null
+++ b/dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting/README.md
@@ -0,0 +1,83 @@
+# Switch Routing Workflow Sample
+
+This sample demonstrates how to build a workflow with **multi-way routing** using `AddSwitch`. A switch evaluates a set of ordered cases against the output of an executor and routes execution to the **first matching** branch — or to a **default** branch when no case matches.
+
+> **Related sample:** [`03_ConditionalEdges`](../03_ConditionalEdges/README.md) solves a similar branching problem with a different API — `AddEdge(..., condition:)` for per-edge boolean conditions. See [`AddSwitch` vs. conditional edges](#addswitch-vs-conditional-edges) below for when to use which.
+
+## Key Concepts Demonstrated
+
+- Building workflows with **multi-way routing** using `AddSwitch` and `AddCase` / `WithDefault`
+- Ordered, first-match-wins case evaluation
+- Falling back to a default branch when no case matches
+- Using `ConfigureDurableWorkflows` to register workflows with dependency injection
+
+## Overview
+
+The sample implements an expense approval workflow that routes each expense to a different approval path based on its amount:
+
+```
+ExpenseParser --[amount < 100]---> AutoApprove
+ |--[amount < 1000]--> ManagerApproval
+ +--[default]--------> DirectorApproval
+```
+
+| Executor | Description |
+|----------|-------------|
+| ExpenseParser | Parses the entered amount into an `Expense` |
+| AutoApprove | Auto-approves small expenses (`< 100`) |
+| ManagerApproval | Routes mid-range expenses (`< 1000`) to a manager |
+| DirectorApproval | Default branch for everything else (`>= 1000`) |
+
+## How `AddSwitch` Works
+
+`AddSwitch` configures a switch on the output of a source executor. Cases are evaluated **in order**, and the **first** matching case wins; `WithDefault` handles anything that matches no case:
+
+```csharp
+builder.AddSwitch(expenseParser, switchBuilder =>
+ switchBuilder
+ .AddCase(expense => expense!.Amount < 100m, autoApprove)
+ .AddCase(expense => expense!.Amount < 1000m, managerApproval)
+ .WithDefault(directorApproval));
+```
+
+Each case predicate receives the output of the source executor and returns a boolean.
+
+### `AddSwitch` vs. conditional edges
+
+The [`03_ConditionalEdges`](../03_ConditionalEdges/README.md) sample uses `AddEdge(..., condition:)`, where each edge carries its own **independent** boolean condition (an edge is traversed whenever its condition is true). `AddSwitch` instead models **mutually exclusive, first-match-wins** routing with a single default — a better fit when exactly one of several branches should run.
+
+## Environment Setup
+
+See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
+
+## Running the Sample
+
+```bash
+cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/09_SwitchRouting
+dotnet run --framework net10.0
+```
+
+### Sample Output
+
+```text
+Enter an expense amount (or 'exit'):
+Tip: try 50, 450, and 5000 to see each branch.
+
+> 50
+Starting workflow for expense amount '50'...
+Run ID: abc123...
+Waiting for workflow to complete...
+Workflow completed. Expense EXP-1a2b for $50.00 was auto-approved.
+
+> 450
+Starting workflow for expense amount '450'...
+Run ID: def456...
+Waiting for workflow to complete...
+Workflow completed. Expense EXP-3c4d for $450.00 was routed to a manager for approval.
+
+> 5000
+Starting workflow for expense amount '5000'...
+Run ID: ghi789...
+Waiting for workflow to complete...
+Workflow completed. Expense EXP-5e6f for $5,000.00 was routed to a director for approval.
+```
diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
index 2264d994280..2a79e882b1e 100644
--- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
+++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
@@ -5,6 +5,7 @@
- Fix issue with resuming checkpoint after package version upgrade ([#6670](https://github.com/microsoft/agent-framework/pull/6670))
- Bind MCP threadId to the current agent and guard cross-agent session dispatch ([#6531](https://github.com/microsoft/agent-framework/pull/6531))
- Added support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
+- Added support for `AddSwitch` and target-selecting fan-out edges in the durable workflow runner ([#6749](https://github.com/microsoft/agent-framework/pull/6749))
## v1.0.0-preview.260219.1
diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs
index 57ef010a2f9..2dd1e2add92 100644
--- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs
+++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Logs.cs
@@ -227,4 +227,42 @@ public static partial void LogWaitingForExternalEvent(
public static partial void LogReceivedExternalEvent(
this ILogger logger,
string requestPortId);
+
+ [LoggerMessage(
+ EventId = 114,
+ Level = LogLevel.Debug,
+ Message = "Fan-out from {Source}: selector matched {SelectedCount} of {TotalCount} target(s)")]
+ public static partial void LogFanOutSelectorMatched(
+ this ILogger logger,
+ string source,
+ int selectedCount,
+ int totalCount);
+
+ [LoggerMessage(
+ EventId = 115,
+ Level = LogLevel.Warning,
+ Message = "Failed to evaluate fan-out selector for source {Source}, skipping")]
+ public static partial void LogFanOutSelectorEvaluationFailed(
+ this ILogger logger,
+ Exception ex,
+ string source);
+
+ [LoggerMessage(
+ EventId = 116,
+ Level = LogLevel.Debug,
+ Message = "Fan-out from {Source}: routing to {Count} target(s)")]
+ public static partial void LogFanOutRouting(
+ this ILogger logger,
+ string source,
+ int count);
+
+ [LoggerMessage(
+ EventId = 117,
+ Level = LogLevel.Warning,
+ Message = "Fan-out from {Source}: selector returned out-of-range index {Index} (target count {Count}), skipping")]
+ public static partial void LogFanOutSelectorIndexOutOfRange(
+ this ILogger logger,
+ string source,
+ int index,
+ int count);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableSerialization.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableSerialization.cs
index 245ec36fb83..11fbc894562 100644
--- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableSerialization.cs
+++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableSerialization.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
@@ -19,4 +20,27 @@ internal static class DurableSerialization
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
+
+ ///
+ /// Deserializes a workflow message's JSON to the source executor's output type so an edge condition or
+ /// fan-out selector can evaluate it as a strongly-typed value. Falls back to a generic object when the
+ /// type is unknown, and returns null for empty input.
+ ///
+ /// The serialized message.
+ /// The source executor's output type, or null if unknown.
+ /// The deserialized object, or null if the JSON is empty.
+ /// Thrown when the JSON is invalid or cannot be deserialized to the target type.
+ [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
+ [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
+ internal static object? DeserializeMessage(string json, Type? targetType)
+ {
+ if (string.IsNullOrEmpty(json))
+ {
+ return null;
+ }
+
+ return targetType is null
+ ? JsonSerializer.Deserialize