diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 24b596509e..a9112fcb15 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -243,6 +243,9 @@
+
+
+
@@ -288,7 +291,7 @@
-
+
diff --git a/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs b/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs
new file mode 100644
index 0000000000..3a21dd8d28
--- /dev/null
+++ b/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs
@@ -0,0 +1,72 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+///
+/// The registry of agents used in the workflow.
+///
+/// The to use as the agent backend.
+internal sealed class AgentRegistry(IChatClient chatClient)
+{
+ internal const string IntakeAgentName = "Assistant";
+ public AIAgent IntakeAgent { get; } = chatClient.AsAIAgent(
+ instructions:
+ """
+ You receive a user request and are responsible for routing to the correct initial expert agent.
+ """,
+ IntakeAgentName
+ );
+
+ internal const string LiquidityAnalysisAgentName = "Liquidity Analysis";
+ public AIAgent LiquidityAnalysisAgent { get; } = chatClient.AsAIAgent(
+ instructions:
+ """
+ You are responsible for Liquidity Analysis.
+ """,
+ LiquidityAnalysisAgentName
+ );
+
+ internal const string TaxAnalysisAgentName = "Tax Analysis";
+ public AIAgent TaxAnalysisAgent { get; } = chatClient.AsAIAgent(
+ instructions:
+ """
+ You are responsible for Tax Analysis.
+ """,
+ TaxAnalysisAgentName
+ );
+
+ internal const string ForeignExchangeAgentName = "Foreign Exchange Analysis";
+ public AIAgent ForeignExchangeAgent { get; } = chatClient.AsAIAgent(
+ instructions:
+ """
+ You are responsible for Foreign Exchange Analysis.
+ """,
+ ForeignExchangeAgentName
+ );
+
+ internal const string EquityAgentName = "Equity Analysis";
+ public AIAgent EquityAgent { get; } = chatClient.AsAIAgent(
+ instructions:
+ """
+ You are responsible for Equity Analysis.
+ """,
+ EquityAgentName
+ );
+
+ public IEnumerable Experts => [this.LiquidityAnalysisAgent, this.TaxAnalysisAgent, this.ForeignExchangeAgent, this.EquityAgent];
+
+ public HashSet All
+ {
+ get
+ {
+ if (field == null)
+ {
+ field = [this.IntakeAgent, .. this.Experts];
+ }
+
+ return field;
+ }
+ }
+}
diff --git a/dotnet/samples/03-workflows/Orchestration/Handoff/Handoff.csproj b/dotnet/samples/03-workflows/Orchestration/Handoff/Handoff.csproj
new file mode 100644
index 0000000000..5fe709e505
--- /dev/null
+++ b/dotnet/samples/03-workflows/Orchestration/Handoff/Handoff.csproj
@@ -0,0 +1,29 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+
+ MAAIW001
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/03-workflows/Orchestration/Handoff/Handoff.sln b/dotnet/samples/03-workflows/Orchestration/Handoff/Handoff.sln
new file mode 100644
index 0000000000..4bd42ea64e
--- /dev/null
+++ b/dotnet/samples/03-workflows/Orchestration/Handoff/Handoff.sln
@@ -0,0 +1,24 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.5.2.0
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Handoff", "Handoff.csproj", "{2BF9227D-128C-52CD-C43E-E51BD268AB65}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {2BF9227D-128C-52CD-C43E-E51BD268AB65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2BF9227D-128C-52CD-C43E-E51BD268AB65}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2BF9227D-128C-52CD-C43E-E51BD268AB65}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2BF9227D-128C-52CD-C43E-E51BD268AB65}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {CF541D9A-AD24-4553-A5A6-2F9FFEDD6C56}
+ EndGlobalSection
+EndGlobal
diff --git a/dotnet/samples/03-workflows/Orchestration/Handoff/Program.cs b/dotnet/samples/03-workflows/Orchestration/Handoff/Program.cs
new file mode 100644
index 0000000000..6990e049ff
--- /dev/null
+++ b/dotnet/samples/03-workflows/Orchestration/Handoff/Program.cs
@@ -0,0 +1,125 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Azure.AI.Projects;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
+ ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
+string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
+
+// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
+// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
+// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
+AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential());
+
+IChatClient chatClient = projectClient.ProjectOpenAIClient
+ .GetChatClient(deploymentName)
+ .AsIChatClient();
+
+Workflow workflow = CreateWorkflow(chatClient);
+
+await RunWorkflowAsync(workflow).ConfigureAwait(false);
+
+static Workflow CreateWorkflow(IChatClient chatClient)
+{
+ AgentRegistry agents = new(chatClient);
+
+ HandoffWorkflowBuilder handoffBuilder = AgentWorkflowBuilder.CreateHandoffBuilderWith(agents.IntakeAgent);
+
+ // Add a handoff to each of the experts from every agent in the registry (experts + Intake)
+ foreach (AIAgent expert in agents.Experts)
+ {
+ handoffBuilder.WithHandoffs(agents.All.Except([expert]), expert);
+ }
+
+ // Let agents request more user information and return to the asking agent (rather than going back to the intake agent)
+ handoffBuilder.EnableReturnToPrevious();
+
+ return handoffBuilder.Build();
+}
+
+static async Task RunWorkflowAsync(Workflow workflow)
+{
+ using CancellationTokenSource cts = CreateConsoleCancelKeySource();
+ await using StreamingRun run = await InProcessExecution.OpenStreamingAsync(workflow, cancellationToken: cts.Token)
+ .ConfigureAwait(false);
+
+ bool hadError = false;
+ do
+ {
+ Console.Write("> ");
+ string userInput = Console.ReadLine() ?? string.Empty;
+
+ if (userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
+ {
+ break;
+ }
+
+ await run.TrySendMessageAsync(userInput);
+ string? speakingAgent = null;
+ await foreach (WorkflowEvent evt in run.WatchStreamAsync(cts.Token).WithCancellation(cts.Token))
+ {
+ switch (evt)
+ {
+ case AgentResponseUpdateEvent update:
+ {
+ if (speakingAgent == null || speakingAgent != update.Update.AuthorName)
+ {
+ speakingAgent = update.Update.AuthorName;
+ Console.Write($"\n{speakingAgent}: ");
+ }
+
+ Console.Write(update.Update.Text);
+ break;
+ }
+
+ case WorkflowErrorEvent workflowError:
+ {
+ Console.ForegroundColor = ConsoleColor.Red;
+
+ if (workflowError.Exception != null)
+ {
+ Console.WriteLine($"\nWorkflow error: {workflowError.Exception}");
+ }
+ else
+ {
+ Console.WriteLine("\nUnknown workflow error occurred.");
+ }
+
+ Console.ResetColor();
+
+ hadError = true;
+ break;
+ }
+
+ case WorkflowWarningEvent workflowWarning when workflowWarning.Data is string message:
+ {
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine(message);
+ Console.ResetColor();
+ break;
+ }
+ }
+ }
+ } while (!hadError);
+}
+
+static CancellationTokenSource CreateConsoleCancelKeySource()
+{
+ CancellationTokenSource cts = new();
+
+ // Normally, support a way to detach events, but in this case this is a termination signal, so cleanup will happen
+ // as part of application shutdown.
+ Console.CancelKeyPress += (s, args) =>
+ {
+ cts.Cancel();
+
+ // We handle cleanup + termination ourselves
+ args.Cancel = true;
+ };
+
+ return cts;
+}