# Agent Framework workflow routing in C# with Claude

> Let a model classify each request into a C# enum, then route it with switch-case edges and a default branch.

Published: 29 Oct 2026
Series: Microsoft Agent Framework in C#, part 9
URL: https://kootstra.io/writing/agent-framework-workflow-routing-csharp/

**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](https://kootstra.io/writing/agent-framework-workflow-planner-reviewer/) 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](https://github.com/nkootstra/samples/tree/main/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](https://kootstra.io/writing/agent-framework-workflow-planner-reviewer/), and asked an agent for a typed result, as in [Agent Framework structured output in C# with Claude](https://kootstra.io/writing/structured-output-ai-agent-csharp/).

> [!NOTE]
> The code runs on Claude Haiku 5.5, but it depends only on `IChatClient`: [the first post](https://kootstra.io/writing/microsoft-agent-framework-first-csharp-agent/#swap-the-provider-by-changing-one-statement) 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

```mermaid title="The dinner workflow: classify, then one of three branches"
flowchart TD
  input([input]) --> classify
  classify --> quick
  classify --> planner
  classify -->|default| decline
  quick --> output([output])
  planner --> output
  decline --> output
```

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:

```bash
dotnet new console -n KitchenRouting
cd KitchenRouting
dotnet add package Microsoft.Agents.AI --version 1.24.0
dotnet add package Microsoft.Agents.AI.Anthropic --version 1.24.0
dotnet add package Microsoft.Agents.AI.Workflows --version 1.24.0
```

## Messages are records

The steps pass records to each other, in `Messages.cs`:

```csharp
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](https://kootstra.io/writing/structured-output-ai-agent-csharp/), 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`:

```csharp
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](https://kootstra.io/writing/structured-output-ai-agent-csharp/) read `Result` only after checking `FinishReason`. Every executor that calls a model does the same, through two small extension methods:

```csharp
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](https://kootstra.io/writing/agent-framework-workflow-planner-reviewer/).

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:

```csharp
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:

```csharp
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 // [!code highlight]
                .AddCase<DinnerRequest>(
                    r => r?.Kind == RequestKind.QuickDinner, quick)
                .AddCase<DinnerRequest>(
                    r => r?.Kind == RequestKind.WeekPlan, plan)
                .WithDefault(decline)) // [!code highlight]
            .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:

```csharp
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:

```csharp
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: // [!code highlight]
                Console.Write($"\n[step {step.StepNumber}]");
                break;
            case ExecutorInvokedEvent invoked: // [!code highlight]
                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:

```text

> Something quick for tonight. We have eggs and spinach.

[step 0] classify
[step 1] quick
Spinach 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] planner
Monday: 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] decline
I 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](https://kootstra.io/writing/agent-framework-fan-out-fan-in-csharp/) 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)` and `decline` from `WithOutputFrom`, and run again. The wine question is classified and the run ends with no output and no error.
- Give both `AddCase` calls 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 `Shopping` to `RequestKind` and describe it in the classifier's instructions, without adding a case. A shopping question then goes to the default.
