Back to homepage

Niels Kootstra

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

default input classify quick planner 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:

Messages are records

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

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:

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:

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:

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:

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:

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:

SuperStepStartedEvent marks the start of a superstep. ExecutorInvokedEvent is raised each time an executor gets a message. A run:

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) 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.