# Agent Framework fan-out, fan-in and loops in C# with Claude

> Run three checks on a dinner plan in parallel, join them at a fan-in barrier, and cap the revise loop at three rounds.

Published: 2 Nov 2026
Series: Microsoft Agent Framework in C#, part 10
URL: https://kootstra.io/writing/agent-framework-fan-out-fan-in-csharp/

**The idea:** checks that don't depend on each other can run at the same time, and a loop that asks a model to fix its own work needs a way out that doesn't depend on the model. A counter in the message is that way out.

The planner in [the routing post](https://kootstra.io/writing/agent-framework-workflow-routing-csharp/) returned a valid, typed plan with salmon on all three days. The household eats fish at most once a week, but nothing checked. This post builds the checking half of the dinner workflow: the planner's plan goes to three rule checks at once, a barrier waits for all three and combines their findings into one review, and a plan with problems goes back to the planner, for at most three rounds.

**Versions:** Microsoft Agent Framework 1.24.0, .NET 10.

**Code:** [samples/agent-framework/10-workflow-parallel](https://github.com/nkootstra/samples/tree/main/agent-framework/10-workflow-parallel), a runnable version of this post with its tests.

**Who this is for:** C# developers who have routed a workflow on a typed decision, as in [Agent Framework workflow routing in C# with Claude](https://kootstra.io/writing/agent-framework-workflow-routing-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="Plan, three parallel checks, combine, and a loop back to the planner"
flowchart TD
  input([input]) --> planner
  planner --> peanuts
  planner --> fish
  planner --> weekdayTime[weekday-time]
  peanuts --> combine
  fish --> combine
  weekdayTime --> combine
  combine -->|"problems, round < 3"| planner
  combine -->|otherwise| result
  result --> output([output])
```

Two of the steps call a model: `planner`, and `peanuts`, which asks a model whether a dish hides peanuts. The fish rule and the weekday time rule are plain C#, and so are the steps that combine the checks and write the result. In the full dinner assistant this graph sits behind the routing post's `WeekPlan` branch; here it starts at the planner, so the input is the request itself.

Three things are new compared with the routing post: an executor that handles two kinds of message, a fan-out with a barrier, and an edge that points backwards.

The first brings some machinery. A step with one handler, like the routing post's `Executor<TInput, TOutput>`, needs nothing extra. A step that handles more than one message type, or sends a message it doesn't return, needs a source generator package and the `partial` keyword: from attributes on its methods, the generator writes the code that hands each message to the handler for its type, much like a `switch` on the message's type. The planner and the combine step are such steps. The ideas of the post, three checks at once and a loop with a way out, are in [Three checks at once](#three-checks-at-once) and [A loop that stops after three rounds](#a-loop-that-stops-after-three-rounds), and the code in between can be skimmed on a first read. If every check were plain C#, a `for` loop around `HouseholdRules` would do the same job; the graph pays off when several checks wait on a model at once.

## Add the packages

A console app with four packages:

```bash
dotnet new console -n KitchenChecks
cd KitchenChecks
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
dotnet add package Microsoft.Agents.AI.Workflows.Generators --version 1.24.0
```

The fourth is a source generator. The planner and the combine step below each handle more than one message or send a message they don't return, and for such an executor the generator writes the routing code from attributes on its methods. `Microsoft.Agents.AI.Workflows` doesn't bring it along. Without it, those classes don't compile: `error CS0534: 'Planner' does not implement inherited abstract member 'Executor.ConfigureProtocol(ProtocolBuilder)'`.

## Messages carry the round

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

```csharp
sealed record PlanDraft(string Request, DinnerPlan Plan, int Round);

sealed record RuleCheck(string Rule, List<string> Problems, PlanDraft Draft);

sealed record Review(PlanDraft Draft, List<string> Problems)
{
    public bool Passed => Problems.Count == 0;
}

sealed record PeanutFindings(List<PeanutFinding> Findings);

sealed record PeanutFinding(DayOfWeek Day, string Reason);
```

A `PlanDraft` carries the household's request, the plan and its round number, which is what ends the loop later. Each check answers with a `RuleCheck`, and the combine step turns three of them into one `Review`. `DinnerPlan`, `Dinner` and `PlanText.Lines` are the same as in [the routing post](https://kootstra.io/writing/agent-framework-workflow-routing-csharp/), and so is the `CompleteResult` extension that fails the run when the model stops early.

## An executor with two handlers

The planner gets two kinds of message: the request, for the first plan, and a `Review` from the combine step when a plan has to be fixed. An executor with more than one handler derives from `Executor`, is `partial`, and marks each handler with `[MessageHandler]`:

```csharp
sealed partial class Planner(AIAgent planner) : Executor("planner")
{
    [MessageHandler]
    async ValueTask<PlanDraft> PlanAsync(
        string request,
        IWorkflowContext context,
        CancellationToken cancellationToken)
    {
        AgentResponse<DinnerPlan> response = await planner.RunAsync<DinnerPlan>(
            request, cancellationToken: cancellationToken);
        return new PlanDraft(request, response.CompleteResult(), Round: 1);
    }

    [MessageHandler]
    async ValueTask<PlanDraft> ReviseAsync(
        Review review,
        IWorkflowContext context,
        CancellationToken cancellationToken)
    {
        string prompt = $"""
            {review.Draft.Request}

            Your last plan:
            {PlanText.Lines(review.Draft.Plan)}

            Fix these problems and return the whole plan:
            {string.Join("\n", review.Problems)}
            """;
        AgentResponse<DinnerPlan> response = await planner.RunAsync<DinnerPlan>(
            prompt, cancellationToken: cancellationToken);
        return review.Draft with
        {
            Plan = response.CompleteResult(),
            Round = review.Draft.Round + 1,
        };
    }
}
```

The generator reads the first parameter of each handler and routes messages of that type to it. Without `partial`, it stops the build with `error MAFGENWF003: Class 'Planner' contains [MessageHandler] methods but is not declared as partial`. A handler's return value is sent along the edges, like the return value of `HandleAsync` in the routing post. Each handler call is one agent run without a session: the revision prompt carries the request, the last plan and the problems, so the planner needs nothing from an earlier call.

## Three checks at once

`AddFanOutEdge(plan, [peanuts, fish, weekdayTime])` sends every `PlanDraft` the planner returns to all three checks. Two of them are the household's rules from [the structured output post](https://kootstra.io/writing/structured-output-ai-agent-csharp/), split in two, in `HouseholdRules.cs`:

```csharp
static class HouseholdRules
{
    static readonly string[] Fish =
        ["salmon", "cod", "tuna", "haddock", "mackerel", "trout", "fish"];

    public static RuleCheck CheckFish(PlanDraft draft)
    {
        List<DayOfWeek> fishDays = draft.Plan.Dinners
            .Where(d => d.MainIngredients.Any(i => Fish.Any(f =>
                i.Contains(f, StringComparison.OrdinalIgnoreCase))))
            .Select(d => d.Day)
            .ToList();

        List<string> problems = fishDays.Count > 1
            ? [$"Fish on {string.Join(", ", fishDays[..^1])} " +
                $"and {fishDays[^1]}: at most once a week."]
            : [];
        return new RuleCheck("fish", problems, draft);
    }

    public static RuleCheck CheckWeekdayTime(PlanDraft draft)
    {
        List<string> problems = draft.Plan.Dinners
            .Where(d => d.Minutes < 1 || (d.Minutes >= 40
                && d.Day is not (DayOfWeek.Saturday or DayOfWeek.Sunday)))
            .Select(d => d.Minutes < 1
                ? $"{d.Day}: takes {d.Minutes} minutes."
                : $"{d.Day}: takes {d.Minutes} minutes, " +
                    "weekday dinners must take under 40.")
            .ToList();
        return new RuleCheck("weekday-time", problems, draft);
    }
}
```

`CheckWeekdayTime` also keeps the structured output post's check for minutes below 1, on any day: the `[Range(1, 240)]` on `Minutes` only describes the schema, and nothing on the way from the model's JSON to this check enforces it.

The peanut rule is the third. The code check in the structured output post only saw the main ingredients the model listed, so peanuts in a satay sauce would get through. Recognising a sauce that usually contains peanuts is a judgement, so this check asks a model, with a typed answer:

```csharp
sealed class PeanutCheck(AIAgent checker)
    : Executor<PlanDraft, RuleCheck>("peanuts")
{
    public override async ValueTask<RuleCheck> HandleAsync(
        PlanDraft draft,
        IWorkflowContext context,
        CancellationToken cancellationToken = default)
    {
        AgentResponse<PeanutFindings> response =
            await checker.RunAsync<PeanutFindings>(
                PlanText.Lines(draft.Plan), cancellationToken: cancellationToken);
        List<string> problems = response.CompleteResult().Findings
            .Select(f => $"{f.Day}: may contain peanuts ({f.Reason}).")
            .ToList();
        return new RuleCheck("peanuts", problems, draft);
    }
}
```

The routing post showed the supersteps a run is made of: in each one, every executor that has a message waiting gets it, and the messages those executors send are delivered in the next. The in-process runner starts the executors of one superstep together and waits for all of them, so the three checks run at the same time and the superstep takes as long as the slowest. Here that is the peanut check, the only one that calls a model; the two code checks don't wait for anything, so running them alongside it saves almost nothing. The shape pays off when several branches each wait for a model.

The cost is load: every branch that calls a model is a request in flight at the same time, so a wide fan-out reaches a provider's rate limit sooner.

## One verdict from three checks

`AddFanInBarrierEdge([peanuts, fish, weekdayTime], combine)` holds the checks' messages until each of the three has sent at least one. It then delivers them all to `combine` in the next superstep and starts waiting again, which is what makes the barrier usable in a loop. The combine step gets three `RuleCheck` messages, one handler call each, and has to answer once:

```csharp
[SendsMessage(typeof(Review))]
sealed partial class CombineChecks() : Executor("combine")
{
    readonly List<RuleCheck> _checks = [];

    [MessageHandler]
    void Collect(RuleCheck check, IWorkflowContext context) => _checks.Add(check);

    protected override async ValueTask OnMessageDeliveryFinishedAsync( // [!code highlight]
        IWorkflowContext context,
        CancellationToken cancellationToken = default)
    {
        Review review = new(
            _checks[0].Draft,
            [.. _checks.OrderBy(c => c.Rule, StringComparer.Ordinal)
                .SelectMany(c => c.Problems)]);
        _checks.Clear();
        await context.SendMessageAsync(review, cancellationToken: cancellationToken);
    }
}
```

`OnMessageDeliveryFinishedAsync` runs once in each superstep in which the executor receives messages, after its handlers have handled all of them. It also runs when a handler throws, and then the messages after that one are not handled. The handler collects, the hook combines and clears the list for the next round. The checks arrive in whatever order they finished, so the problems are sorted by rule to keep the review the same from run to run.

A value sent with `SendMessageAsync` has to be declared. `Executor<TInput, TOutput>` and a handler's return type declare their own; this executor sends a `Review` that is no handler's return value, so it says so with `[SendsMessage(typeof(Review))]`. Without the attribute, the send throws `Executor 'combine' cannot send messages of type 'Review'.` It is reported as a `WorkflowErrorEvent` only, without an `ExecutorFailedEvent`, because it is thrown from the hook after the handlers have finished. With the generator package referenced, a class that uses `[SendsMessage]` or `[YieldsOutput]` without any `[MessageHandler]` method has to be `partial` too, or the build stops with `error MAFGENWF008`. `CombineChecks` has a handler, so leaving out `partial` there gives `MAFGENWF003`, as with the planner.

A barrier has a failure of its own. If one check throws, it sends nothing, so the barrier never opens: the run reports the failed executor and ends without output. In the sample's tests, a peanut check whose model stops at the output token limit leaves `combine` without a single call.

## A loop that stops after three rounds

Edges can point backwards. The whole graph, in `DinnerWorkflow.cs`:

```csharp
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;

static class DinnerWorkflow
{
    public const int MaxRounds = 3;

    public static Workflow Build(AIAgent planner, AIAgent peanutChecker)
    {
        var plan = new Planner(planner);
        var peanuts = new PeanutCheck(peanutChecker);
        Func<PlanDraft, RuleCheck> checkFish = HouseholdRules.CheckFish;
        ExecutorBinding fish = checkFish.BindAsExecutor("fish");
        Func<PlanDraft, RuleCheck> checkTime = HouseholdRules.CheckWeekdayTime;
        ExecutorBinding weekdayTime = checkTime.BindAsExecutor("weekday-time");
        var combine = new CombineChecks();
        var result = new PlanResult();

        return new WorkflowBuilder(plan)
            .AddFanOutEdge(plan, [peanuts, fish, weekdayTime])
            .AddFanInBarrierEdge([peanuts, fish, weekdayTime], combine)
            .AddEdge<Review>(combine, plan, // [!code highlight]
                condition: r => r is { Passed: false, Draft.Round: < MaxRounds })
            .AddEdge<Review>(combine, result, // [!code highlight]
                condition: r => r is { Passed: true } or { Draft.Round: >= MaxRounds })
            .WithOutputFrom(result)
            .Build();
    }
}
```

The two code checks are method groups turned into executors with `BindAsExecutor`, as the decline branch was in the routing post. The combine step's `Review` has two edges with conditions. A review with problems goes back to the planner until the plan has had three rounds. A passing plan, or the third failing one, goes to the result. The two conditions don't overlap and together cover every review, because conditional edges, unlike a switch, have no first match and no default. The way out of the loop depends only on a counter in the message, not on the model ever producing a plan that passes, so a planner that keeps breaking a rule costs three rounds of tokens and stops.

The result step writes the plan and the verdict:

```csharp
sealed class PlanResult() : Executor<Review, string>("result")
{
    public override ValueTask<string> HandleAsync(
        Review review,
        IWorkflowContext context,
        CancellationToken cancellationToken = default)
    {
        string plan = PlanText.Lines(review.Draft.Plan);
        string verdict = review.Passed
            ? $"Keeps every rule (round {review.Draft.Round})."
            : $"Still wrong after round {review.Draft.Round}:\n"
                + string.Join("\n", review.Problems.Select(p => $"- {p}"));
        return ValueTask.FromResult($"{plan}\n{verdict}");
    }
}
```

What to do with a plan that still breaks a rule after three rounds is a decision for the household. Here it is shown with its problems, not hidden. An app that shows plans to people should treat that verdict as a block, not a warning: a plan that failed the peanut rule must not look approved. [Agent Framework workflow checkpoints in C# with Claude](https://kootstra.io/writing/agent-framework-workflow-checkpoints-csharp/) puts a person in the workflow: it stops at a request, saves itself to disk and goes on when the answer comes.

## Run it

`Program.cs` creates the chat client and the two agents, and runs the workflow for one request. The planner doesn't know the household's rules, as in [the structured output post](https://kootstra.io/writing/structured-output-ai-agent-csharp/): the checks do.

```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 planner = chatClient.AsAIAgent(
    name: "planner",
    instructions: """
        You plan dinners for one household. Plan one dinner
        for each day in the request.
        """);

AIAgent peanutChecker = chatClient.AsAIAgent(
    name: "peanut-checker",
    instructions: """
        You check a dinner plan for peanuts, including hidden
        ones such as satay sauce or peanut oil. List each day
        whose dinner probably contains peanuts, with the reason
        in a few words. Return an empty list if none does.
        """);

Workflow workflow = DinnerWorkflow.Build(planner, peanutChecker);

string request = "Dinners for Monday, Wednesday and Saturday. We have salmon.";
Console.WriteLine($"> {request}");

await using StreamingRun run =
    await InProcessExecution.RunStreamingAsync(workflow, request);
```

The event loop is the routing post's, with one addition: when an executor receives a `Review`, it prints the review's problems, so a plan that goes back to the planner shows why.

```csharp
case ExecutorInvokedEvent invoked:
    Console.Write($" {invoked.ExecutorId}");
    if (invoked.Data is Review review) // [!code highlight]
    {
        foreach (string problem in review.Problems)
        {
            Console.Write($"\n  - {problem}");
        }
    }
    break;
```

A run:

```text
> Dinners for Monday, Wednesday and Saturday. We have salmon.

[step 0] planner
[step 1] fish weekday-time peanuts
[step 2] combine combine combine
[step 3] planner
  - Fish on Monday, Wednesday and Saturday: at most once a week.
[step 4] fish weekday-time peanuts
[step 5] combine combine combine
[step 6] result
Monday: Pan-seared salmon with lemon green beans and rice, 30 min (salmon, green beans, rice, lemon)
Wednesday: Chicken teriyaki bowls with cucumber and sesame, 25 min (chicken, rice, cucumber, sesame seeds, soy sauce)
Saturday: Chickpea and spinach curry with rice, 40 min (chickpeas, spinach, tomatoes, coconut milk, rice)
Keeps every rule (round 2).
```

In every round the three checks ran in one superstep, and the barrier delivered their three messages to `combine` together in the next. Read `combine combine combine` as one superstep in which `combine` handled three messages; it sent one review. The first plan had salmon on all three days, the fish check sent it back, and the second plan kept every rule. That took four model calls, a plan and a peanut check per round. The sample ran it three times, and all three went this way: two rounds, the fish rule the only problem, nothing flagged by the peanut check. A plan that breaks a rule in every round would take three rounds and six calls, and end with "Still wrong after round 3:" and the remaining problems.

## One workflow, one run at a time

A `Workflow` can be run by one runner at a time. Running the same `Workflow` again works once the `StreamingRun` of the previous run is disposed; starting a second run while the first is still open throws `Cannot use a Workflow that is already owned by another runner or parent workflow.`, as [the first workflow post](https://kootstra.io/writing/agent-framework-workflow-planner-reviewer/) showed.

`InProcessExecution.Concurrent` is an execution environment that allows several runs of one workflow at once, but only if every executor can be shared between runs. An agent placed in the graph directly can. An executor instance, like `new CombineChecks()`, can only if it was created with `declareCrossRunShareable: true`, and the same goes for `BindAsExecutor` with `threadsafe: true`. This workflow has neither, and for good reason: `CombineChecks` keeps a list between its handler and its hook, and two runs would fill the same list. `Concurrent` refuses it with `Workflow must only consist of cross-run share-capable or factory-created executors.`, followed by the IDs of the executors that can't be shared. To serve several households at once with this graph, build one `Workflow` per run.

## Try it

- Set `MaxRounds` to 1 and run again. The plan now goes to the result after its first round, and the fish problem is listed under "Still wrong" instead of going back to the planner.
- Ask for "Dinners for Monday to Friday. We love satay." and see whether the peanut check catches what the code check in [the structured output post](https://kootstra.io/writing/structured-output-ai-agent-csharp/) missed.
- Remove `[SendsMessage(typeof(Review))]` from `CombineChecks` and run again. The run ends with a `WorkflowErrorEvent` after the first round of checks.
