Agent Framework workflow routing in C# with Claude
Summarize with AI
The assistants open a new chat that asks for a short summary of this post.
The idea: a route written as a condition in C# gives the same answer for the same value every time and can be tested without a model. Let a model make the decision, as a typed value, and let code decide where it goes.
The workflow in the first workflow post was a straight line: every request went through the planner and then the reviewer. A household’s dinner assistant gets more than one kind of request, though. “Something quick for tonight” needs one dinner, “Dinners for Monday, Wednesday and Saturday” needs a plan, and “Which wine goes with lasagne?” isn’t a dinner request at all. This post builds a workflow that sorts them: a model classifies the request into a C# enum, and a switch sends it down one of three branches, with a default for anything the cases don’t cover.
Versions: Microsoft Agent Framework 1.24.0, .NET 10.
Code: samples/agent-framework/09-workflow-routing, a runnable version of this post with its tests.
Who this is for: C# developers who have built a workflow, as in Planner, then reviewer: two agents in one workflow, and asked an agent for a typed result, as in Agent Framework structured output in C# with Claude.
Note
The code runs on Claude Haiku 5.5, but it depends only on IChatClient: the first post shows the swap to OpenAI or Ollama.
The output limit of 4,096 tokens is sized for Haiku, which counts its reasoning as output.
The graph
Every box between input and output is an executor, a step of the workflow. Three of them call a model: classify decides what kind of request it is, quick suggests one dinner, and planner plans several. decline is a fixed sentence in plain C#.
Add the packages
A console app with three packages:
dotnet new console -n KitchenRoutingcd KitchenRoutingdotnet add package Microsoft.Agents.AI --version 1.24.0dotnet add package Microsoft.Agents.AI.Anthropic --version 1.24.0dotnet add package Microsoft.Agents.AI.Workflows --version 1.24.0Messages are records
The steps pass records to each other, in Messages.cs:
enum RequestKind { QuickDinner, WeekPlan, SomethingElse }
sealed record Triage(RequestKind Kind);
sealed record DinnerRequest(string Text, RequestKind Kind);
static class PlanText{ public static string Lines(DinnerPlan plan) => string.Join("\n", plan.Dinners.Select(d => $"{d.Day}: {d.Dish}, {d.Minutes} min " + $"({string.Join(", ", d.MainIngredients)})"));}Triage is what the classifier answers. A DinnerRequest keeps the household’s own words next to the decision, so the branch that answers works from the request and not from a summary. DinnerPlan and Dinner are the records from the structured output post, unchanged, in DinnerPlan.cs, and PlanText.Lines writes a plan as one line per dinner.
A typed decision at the front
An agent’s answer is text, and a condition like “the answer contains the word plan” breaks the first time the model phrases it differently. So the first step asks for a Triage with RunAsync<T> and passes a DinnerRequest on. The executors go in Executors.cs:
using Microsoft.Agents.AI;using Microsoft.Agents.AI.Workflows;using Microsoft.Extensions.AI;
sealed class Classify(AIAgent classifier) : Executor<string, DinnerRequest>("classify"){ public override async ValueTask<DinnerRequest> HandleAsync( string request, IWorkflowContext context, CancellationToken cancellationToken = default) { AgentResponse<Triage> response = await classifier.RunAsync<Triage>( request, cancellationToken: cancellationToken); return new DinnerRequest(request, response.CompleteResult().Kind); }}Executor<TInput, TOutput> is an executor with one handler. The string in the constructor is the executor’s ID. The value HandleAsync returns is sent along the executor’s outgoing edges; nothing else has to happen. If the executor is also registered as a source of output, the same value becomes a WorkflowOutputEvent.
The structured output post read Result only after checking FinishReason. Every executor that calls a model does the same, through two small extension methods:
static class CompleteAnswers{ public static T CompleteResult<T>(this AgentResponse<T> response) => response.FinishReason == ChatFinishReason.Stop ? response.Result : throw new InvalidOperationException( $"The model stopped early: {response.FinishReason}.");
public static string CompleteText(this AgentResponse response) => response.FinishReason == ChatFinishReason.Stop ? response.Text : throw new InvalidOperationException( $"The model stopped early: {response.FinishReason}.");}The exception ends up as an ExecutorFailedEvent and a WorkflowErrorEvent, as in the first workflow post.
The two branches that answer are one model call each. The quick branch returns the cook’s text, the plan branch a typed plan written out as lines:
sealed class QuickDinner(AIAgent cook) : Executor<DinnerRequest, string>("quick"){ public override async ValueTask<string> HandleAsync( DinnerRequest request, IWorkflowContext context, CancellationToken cancellationToken = default) { AgentResponse response = await cook.RunAsync( request.Text, cancellationToken: cancellationToken); return response.CompleteText(); }}
sealed class Planner(AIAgent planner) : Executor<DinnerRequest, string>("planner"){ public override async ValueTask<string> HandleAsync( DinnerRequest request, IWorkflowContext context, CancellationToken cancellationToken = default) { AgentResponse<DinnerPlan> response = await planner.RunAsync<DinnerPlan>( request.Text, cancellationToken: cancellationToken); return PlanText.Lines(response.CompleteResult()); }}These executors call their agents with RunAsync, so none of them needs the turn token from the first workflow post. That token is for agents placed in the graph directly.
The whole graph in one method
The graph goes in DinnerWorkflow.cs, so the sample’s tests can build the same graph with stubbed chat clients in place of Anthropic:
using Microsoft.Agents.AI;using Microsoft.Agents.AI.Workflows;
static class DinnerWorkflow{ public static Workflow Build(AIAgent classifier, AIAgent cook, AIAgent planner) { var classify = new Classify(classifier); var quick = new QuickDinner(cook); var plan = new Planner(planner); Func<DinnerRequest, string> declineRequest = _ => "I only plan dinners. Ask me for tonight's dinner or for a few days."; ExecutorBinding decline = declineRequest.BindAsExecutor("decline");
return new WorkflowBuilder(classify) .AddSwitch(classify, route => route .AddCase<DinnerRequest>( r => r?.Kind == RequestKind.QuickDinner, quick) .AddCase<DinnerRequest>( r => r?.Kind == RequestKind.WeekPlan, plan) .WithDefault(decline)) .WithOutputFrom(quick, plan, decline) .Build(); }}An executor doesn’t have to be a class. BindAsExecutor turns a Func<TInput, TOutput> into an executor with the given ID, so the decline branch is a lambda. WithOutputFrom names the three executors whose results are the workflow’s output.
A switch with a default
AddSwitch gets the source executor and an Action<SwitchBuilder> that configures the cases. Each AddCase<T> takes a predicate and one or more targets. The cases are tested in the order they were added, and the message goes only to the targets of the first case that matches. WithDefault names where a message goes when no case matches.
The predicate is a Func<T?, bool>: when the message isn’t a T, it gets default, which is null for a record like DinnerRequest, so the conditions use r?.Kind. Because the predicates are ordinary C#, the same request always takes the same branch, and a test can check a branch without a model.
The default does more than handle SomethingElse. Leave out WithDefault (and decline from WithOutputFrom), and a request the classifier labels SomethingElse goes nowhere: the run classifies it and ends, with no output, no ExecutorFailedEvent and no WorkflowErrorEvent, because nothing failed. Leave decline in WithOutputFrom, and Build catches the mistake instead: Workflow cannot be built because there are unreachable executors: decline. The same happens to every value someone adds to RequestKind later. With a default, the household at least gets an answer. Here it is a fixed sentence, so the decline branch needs no model.
Conditional edges, and messages that go nowhere
A switch is a convenience. Underneath, it is a fan-out edge with a function that picks the targets. The other way to branch is a condition on an ordinary edge, AddEdge<T>(source, target, condition: ...). Conditional edges don’t know about each other: every edge whose condition holds gets the message, so two conditions that both match send it to both targets, and conditions that all fail drop it without a word. A switch gives first-match-wins and a default; conditional edges give neither.
The third way is that fan-out edge itself, AddFanOutEdge<T>(source, targets, selector). The selector is a Func<T?, int, IEnumerable<int>>: it gets the message (or default when it isn’t a T) and the number of targets, and returns the positions in targets that get the message. It can pick one target, several or none, so one request can go to two branches at once. An empty result drops the message without an error, like conditions that all fail.
One more way to lose a message without an error: an edge delivers a message only if the target has a handler for its type. The builder doesn’t check the types across an edge, so a target that expects a DinnerRequest and gets a string is skipped in silence, with no output and no error event. The sample’s tests cover that case too.
Run three requests
Program.cs creates the chat client and the three agents, builds the workflow once and runs it for three requests:
using Anthropic;using Anthropic.Core;using Microsoft.Agents.AI;using Microsoft.Agents.AI.Workflows;using Microsoft.Extensions.AI;
var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("Set ANTHROPIC_API_KEY.");
IChatClient chatClient = new AnthropicClient(new ClientOptions { ApiKey = apiKey }) .AsIChatClient("claude-haiku-5-5", 4096);
AIAgent classifier = chatClient.AsAIAgent( name: "classifier", instructions: """ You sort requests to a household's dinner planner. QuickDinner: one dinner for tonight. WeekPlan: dinners for one or more named days. SomethingElse: anything else. """);
AIAgent cook = chatClient.AsAIAgent( name: "cook", instructions: """ You suggest one dinner for tonight that takes under 30 minutes. One line: the dish and its main ingredients. Nothing else. """);
AIAgent planner = chatClient.AsAIAgent( name: "planner", instructions: """ You plan dinners for one household. Plan one dinner for each day in the request. """);
Workflow workflow = DinnerWorkflow.Build(classifier, cook, planner);
string[] requests =[ "Something quick for tonight. We have eggs and spinach.", "Dinners for Monday, Wednesday and Saturday. We have salmon.", "Which wine goes with lasagne?",];The runner works in rounds called supersteps: in each one, every executor that has a message waiting handles it, and what those executors send is delivered in the next. The loop prints one line per superstep with the executors that ran in it, then the output:
foreach (string request in requests){ Console.WriteLine($"\n> {request}");
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, request);
await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { switch (evt) { case SuperStepStartedEvent step: Console.Write($"\n[step {step.StepNumber}]"); break; case ExecutorInvokedEvent invoked: Console.Write($" {invoked.ExecutorId}"); break; case WorkflowOutputEvent output: Console.WriteLine($"\n{output.Data}"); break; case ExecutorFailedEvent failed: Console.Error.WriteLine( $"\n{failed.ExecutorId} failed: {failed.Data}"); break; case WorkflowErrorEvent error: Console.Error.WriteLine( $"\nWorkflow error: {error.Exception}"); break; } }}SuperStepStartedEvent marks the start of a superstep. ExecutorInvokedEvent is raised each time an executor gets a message. A run:
> Something quick for tonight. We have eggs and spinach.[step 0] classify[step 1] quickSpinach and cheese omelette: wilt the spinach, then fold it into beaten eggs with a little cheese if you have it.> Dinners for Monday, Wednesday and Saturday. We have salmon.[step 0] classify[step 1] plannerMonday: Pan-seared salmon with lemon and green beans, 25 min (salmon, green bean, lemon, garlic)Wednesday: Salmon rice bowls with cucumber and sesame, 30 min (salmon, rice, cucumber, sesame seed, soy sauce)Saturday: Baked salmon with roasted potatoes and dill, 45 min (salmon, potato, dill, olive oil)> Which wine goes with lasagne?[step 0] classify[step 1] declineI only plan dinners. Ask me for tonight's dinner or for a few days.Each request took a different branch, and the third never reached a model after the classifier. The run made five model calls: three classifications, one quick dinner and one plan.
The plan is valid and typed, and it puts salmon on all three days. The planner doesn’t know the household’s rule of fish at most once a week, and nothing in this workflow checks it. Agent Framework fan-out, fan-in and loops in C# with Claude checks the plan against the household’s rules and sends it back to the planner, for at most three rounds.
Try it
- Remove
.WithDefault(decline)anddeclinefromWithOutputFrom, and run again. The wine question is classified and the run ends with no output and no error. - Give both
AddCasecalls the same predicate. Only the first case added gets the message. - Ask “Something quick for Friday?” and see which branch it takes. The classifier’s instructions are where that boundary is drawn: one named day counts as a
WeekPlan, so the planner can answer it. - Add a value such as
ShoppingtoRequestKindand describe it in the classifier’s instructions, without adding a case. A shopping question then goes to the default.