# Agent Framework workflow checkpoints in C# with Claude

> Pause an Agent Framework workflow for a person's approval, save a checkpoint to disk, and resume in a new process.

Published: 3 Nov 2026
Series: Microsoft Agent Framework in C#, part 11
URL: https://kootstra.io/writing/agent-framework-workflow-checkpoints-csharp/

**The idea:** a workflow that waits for a person should not have to stay in memory while it waits. A checkpoint holds the open question and the data the next step needs, and a new process carries on from there.

A household's weekly shop is a good job for an agent and a bad one to finish without a person. The agent can turn the week's dinners into a basket, but someone should look at it before it is ordered, and that someone may answer an hour later from their phone. Here a workflow writes the basket, stops at a request for approval, saves itself to disk and exits. Running the app again loads the saved state and asks the question again. The person can approve, reject, or ask for a change, which sends the basket back to the agent and asks again, for at most three baskets. The basket reaches the last step through the workflow's shared state, and each step reports its progress with an event of its own.

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

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

**Who this is for:** C# developers who have built a workflow with a loop, as in [Agent Framework fan-out, fan-in and loops in C# with Claude](https://kootstra.io/writing/agent-framework-fan-out-fan-in-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.

## Set up the shopper

A console app with the same four packages as in [the parallel checks post](https://kootstra.io/writing/agent-framework-fan-out-fan-in-csharp/), the source generator included, because the step that writes the basket handles two kinds of message:

```bash
dotnet new console -n WeeklyShop
cd WeeklyShop
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 chat client talks to Claude Haiku 5.5 with a limit of 4,096 output tokens, which leaves room for the model's reasoning and the list:

```csharp title="Program.cs"
using Anthropic;
using Anthropic.Core;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
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);
```

One agent writes the basket, one item per line:

```csharp title="Program.cs"
AIAgent shopper = chatClient.AsAIAgent(
    name: "shopper",
    instructions: """
        You write the weekly shopping basket for one household.
        One item per line with its quantity, for example
        "500 g chicken thighs". Leave out what the household
        already has. Nothing else.
        """);
```

## The basket and the answer are plain records

What travels through the workflow are two records. `Basket` holds the items and turns the agent's text into a list. `BasketDecision` holds the person's answer, read from the console: `y` approves, `n` or nothing rejects, and any other text is a change the person wants:

```csharp title="Basket.cs"
public sealed record Basket(IReadOnlyList<string> Items, int Round)
{
    public static Basket Parse(string text, int round) =>
        new([.. text
            .Split('\n')
            .Select(line => line.Trim().TrimStart('-', '*', '•').Trim())
            .Where(line => line.Length > 0)], round);
}

public sealed record BasketDecision(bool Approved, string? Change, int Round)
{
    public static BasketDecision FromAnswer(string? answer, int round) =>
        answer?.Trim() switch
        {
            "y" or "Y" => new(true, null, round),
            null or "" or "n" or "N" => new(false, null, round),
            string change => new(false, change, round),
        };
}
```

Both carry a round: which basket this is, and which basket the answer is about. As in [the parallel checks post](https://kootstra.io/writing/agent-framework-fan-out-fan-in-csharp/), the counter that ends the loop travels in the messages, because an edge's condition sees only the message.

## An executor that writes and revises the basket

In the first workflow, the agents were steps of the graph themselves and needed a turn token to start. Here the agent sits inside an executor of its own, as the steps in [the routing post](https://kootstra.io/writing/agent-framework-workflow-routing-csharp/) did, with the fixed ID `write-basket`. That ID matters once the workflow is saved to disk, as the section on resuming shows. It has two handlers, like the planner in [the parallel checks post](https://kootstra.io/writing/agent-framework-fan-out-fan-in-csharp/): one writes the first basket from the request, the other revises the basket when the person asks for a change.

```csharp title="WriteBasketExecutor.cs"
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;

public sealed partial class WriteBasketExecutor(AIAgent shopper)
    : Executor("write-basket")
{
    [MessageHandler]
    ValueTask<Basket> WriteAsync(
        string request,
        IWorkflowContext context,
        CancellationToken cancellationToken) =>
        RunShopperAsync(request, round: 1, context, cancellationToken);

    [MessageHandler]
    async ValueTask<Basket> ReviseAsync(
        BasketDecision decision,
        IWorkflowContext context,
        CancellationToken cancellationToken)
    {
        Basket current = await context.ReadStateAsync<Basket>(
            "basket", scopeName: "shop", cancellationToken)
            ?? throw new InvalidOperationException("No basket in state.");

        string prompt = $"""
            Your last basket:
            {string.Join("\n", current.Items)}

            The household asks for this change:
            {decision.Change}

            Return the whole basket.
            """;
        return await RunShopperAsync(
            prompt, current.Round + 1, context, cancellationToken);
    }

    async ValueTask<Basket> RunShopperAsync(
        string prompt,
        int round,
        IWorkflowContext context,
        CancellationToken cancellationToken)
    {
        AgentResponse response = await shopper.RunAsync(
            prompt, cancellationToken: cancellationToken);
        if (response.FinishReason != ChatFinishReason.Stop)
        {
            throw new InvalidOperationException(
                $"The shopper stopped with {response.FinishReason}.");
        }

        Basket basket = Basket.Parse(response.Text, round);

        await context.QueueStateUpdateAsync( // [!code highlight]
            "basket", basket, scopeName: "shop", cancellationToken); // [!code highlight]
        await context.AddEventAsync(
            new ShopProgressEvent(
                round == 1 ? "basket written" : "basket revised",
                $"{basket.Items.Count} items, "
                    + $"{response.Usage?.OutputTokenCount} output tokens"),
            cancellationToken);
        return basket;
    }
}
```

Both handlers return the basket, so it goes along the executor's edge without a `SendMessageAsync`. The revision prompt carries the last basket and the change, so the agent needs no session to remember the first basket; the last basket comes from the workflow's state, which the section on state explains.

A basket cut off at the output limit is not a basket, so the executor throws when the finish reason is anything but `Stop`. The workflow reports that as an `ExecutorFailedEvent`, followed by a `WorkflowErrorEvent`, and no approval is requested. The other two calls, to `QueueStateUpdateAsync` and `AddEventAsync`, come up in the sections on state and events.

## A request port asks the person

A `RequestPort` is a step that sends its input out of the workflow as a request and passes the answer on to the next step. `RequestPort.Create` takes the type it sends out, the type it expects back and an ID:

```csharp title="ShopWorkflow.cs"
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;

public static class ShopWorkflow
{
    public const int MaxRounds = 3;

    public static Workflow Build(AIAgent shopper)
    {
        var writeBasket = new WriteBasketExecutor(shopper);
        RequestPort<Basket, BasketDecision> approveBasket = // [!code highlight]
            RequestPort.Create<Basket, BasketDecision>("approve-basket"); // [!code highlight]
        var placeOrder = new PlaceOrderExecutor();

        return new WorkflowBuilder(writeBasket)
            .AddEdge(writeBasket, approveBasket)
            .AddEdge<BasketDecision>(approveBasket, writeBasket,
                condition: d => d is { Change: not null, Round: < MaxRounds })
            .AddEdge<BasketDecision>(approveBasket, placeOrder,
                condition: d => d is { Change: null } or { Round: >= MaxRounds })
            .WithOutputFrom(placeOrder)
            .Build();
    }
}
```

`Program.cs` builds it with `Workflow workflow = ShopWorkflow.Build(shopper);`, and the sample's tests build the same graph with a stubbed chat client. The answer goes one of two ways:

```mermaid title="The workflow: write-basket, approve-basket, then back to write-basket for a change or on to place-order"
flowchart TD
  input([input]) --> writeBasket[write-basket]
  writeBasket --> approveBasket[approve-basket]
  approveBasket --> answer{answer}
  answer -->|"change, round < 3"| writeBasket
  answer -->|otherwise| placeOrder[place-order]
  placeOrder --> output([output])
```

The diamond is not a step of its own: it stands for the two conditional edges that leave the port. A change on the first or second basket goes back to `write-basket`, and the revised basket reaches the same port again. Anything else goes on to `place-order`: an approval, a rejection, or a change asked for on the third basket. The two conditions don't overlap and together cover every answer, as with the loop in the parallel checks post. A household that keeps asking for changes costs three baskets of tokens, and the loop stops.

When a `Basket` reaches the port, the workflow raises a `RequestInfoEvent`. Its `Request` property is an `ExternalRequest`, and `TryGetDataAs<Basket>` reads the basket from it. The code watching the run answers with `SendResponseAsync`, and `CreateResponse` ties the answer to that one request. It also checks the type: an answer that is not a `BasketDecision` throws an `InvalidOperationException` instead of reaching the next step. The port then sends the `BasketDecision` along whichever of its two edges accepts it.

`WithOutputFrom(placeOrder)` makes what the last step yields arrive as a `WorkflowOutputEvent`, as in [the routing post](https://kootstra.io/writing/agent-framework-workflow-routing-csharp/).

[The approval post](https://kootstra.io/writing/human-in-the-loop-tool-approval-csharp/) asked a person too, with `ApprovalRequiredAIFunction`. That fits an action the model chooses: the question comes up only when the model calls the wrapped tool, and the answer goes back on the agent's session. A `RequestPort` fits a step the process requires: every basket stops at the port, whatever the model wrote, and the answer goes back to the workflow.

## Save every step to disk

So far nothing would survive the process ending. A checkpoint manager changes that. Passed to the run, it saves the workflow's state at the end of every superstep, the rounds a workflow runs in, as [the routing post](https://kootstra.io/writing/agent-framework-workflow-routing-csharp/) introduced them. `CheckpointManager.CreateJson` writes JSON to a store, and `FileSystemJsonCheckpointStore` keeps that store in a folder:

```csharp title="Program.cs"
const string SessionId = "weekly-shop";
var folder = new DirectoryInfo(
    Path.Combine(AppContext.BaseDirectory, "checkpoints"));
var store = new FileSystemJsonCheckpointStore(folder);
CheckpointManager checkpoints = CheckpointManager.CreateJson(store);

CheckpointInfo? waiting =
    await checkpoints.GetLatestCheckpointAsync(SessionId); // [!code highlight]
```

A checkpoint is identified by a `CheckpointInfo`, a session ID and a checkpoint ID. `GetLatestCheckpointAsync` returns the newest checkpoint of a session, or `null` when there is none. That is how the app knows which run this is: no checkpoint means the week's shop hasn't started yet. The folder is built from `AppContext.BaseDirectory` for the reason [the sessions post](https://kootstra.io/writing/agent-framework-sessions-csharp/) gives for its session file: a bare `"checkpoints"` would land in whatever directory the app was started from.

The folder holds an `index.jsonl` with one line per checkpoint and one JSON file per checkpoint. The store opens the index exclusively, so two processes can't use the same folder at once; the second gets `The store at '...' is already in use by another process.`

The files hold the household's basket in plain JSON, so in most real applications they are personal data, like [a saved session](https://kootstra.io/writing/agent-framework-sessions-csharp/). They are also untrusted when read back: change an item in them between the two runs, and the resumed run shows the changed basket for approval.

## The first run stops at the request

Without a checkpoint, the app starts the workflow with the week's dinners and passes the checkpoint manager and the session ID:

```csharp title="Program.cs"
if (waiting is null)
{
    await using StreamingRun run =
        await InProcessExecution.RunStreamingAsync(
            workflow,
            """
            Dinners this week: chicken stir-fry on Monday, lentil
            soup on Wednesday, homemade pizza on Saturday. We have
            rice, onions and olive oil.
            """,
            checkpoints,
            SessionId);

    await WatchAsync(run, canAnswer: false);
    if (await run.GetStatusAsync() == RunStatus.PendingRequests)
    {
        Console.WriteLine("Waiting for approval. Run the app again to answer.");
    }
    else
    {
        Environment.ExitCode = 1;
    }
    store.Dispose();
}
```

`WatchAsync`, a local function at the end of `Program.cs`, reads the events. The first run passes `canAnswer: false` because nobody is there to answer. The answer gets the round of the basket it answers, which is what the edge conditions read:

```csharp title="Program.cs"
static async Task WatchAsync(StreamingRun run, bool canAnswer)
{
    await foreach (WorkflowEvent evt in
        run.WatchStreamAsync(blockOnPendingRequest: canAnswer)) // [!code highlight]
    {
        switch (evt)
        {
            case ShopProgressEvent progress:
                Console.WriteLine($"[{progress.Step}] {progress.Detail}");
                break;
            case RequestInfoEvent request
                when request.Request.TryGetDataAs(out Basket? basket):
                Console.WriteLine(
                    $"Basket {basket.Round} of {ShopWorkflow.MaxRounds}:");
                foreach (string item in basket.Items)
                {
                    Console.WriteLine($"  {item}");
                }
                if (canAnswer)
                {
                    Console.Write("Approve this basket? (y, n or a change) ");
                    BasketDecision decision = BasketDecision.FromAnswer(
                        Console.ReadLine(), basket.Round);
                    await run.SendResponseAsync(
                        request.Request.CreateResponse(decision));
                }
                break;
            case WorkflowOutputEvent output:
                Console.WriteLine(output.Data);
                break;
            case ExecutorFailedEvent failed:
                Console.Error.WriteLine(
                    $"{failed.ExecutorId} failed: {failed.Data}");
                break;
            case WorkflowErrorEvent error:
                Console.Error.WriteLine(
                    $"Workflow error: {error.Exception}");
                break;
        }
    }
}
```

`blockOnPendingRequest` decides what the event stream does when the workflow has nothing left to do but wait for an answer. With `true`, which is what `WatchStreamAsync()` without arguments uses, the stream pauses there and goes on once a response is sent. With `false`, the stream ends at that point. The first run needs `false`: with `true` and no answer coming, the loop would wait forever and the process would never exit.

The stream also ends when a step fails, so the end of the loop alone doesn't mean a question is waiting. `GetStatusAsync` tells the two apart: `PendingRequests` when the run stopped at the port, and something else, normally `Ended`, when it failed. The app says it is waiting only in the first case. A failed first run, with an invalid key for example, prints the two error events, exits with code 1 and leaves no checkpoint, so the next run starts the week again.

```bash
dotnet run
```

```text
[basket written] 17 items, 514 output tokens
Basket 1 of 3:
  500 g chicken breast
  2 bell peppers
  1 head broccoli
  3 carrots
  1 bulb garlic
  1 small piece fresh ginger
  100 ml soy sauce
  2 celery sticks
  250 g red lentils
  400 g tinned chopped tomatoes
  1 litre vegetable stock
  2 vegetable stock cubes
  2 pizza bases or 500 g pizza dough
  200 g tomato passata
  250 g mozzarella
  150 g mushrooms
  100 g pepperoni
Waiting for approval. Run the app again to answer.
```

The process has exited, and the run left two checkpoints: one after `write-basket` finished, and one after the port raised its request. The second is the latest. Besides the graph itself, it holds the open request with the whole basket in it, and the shared state.

## The second run resumes from the checkpoint

With a checkpoint in the folder, the app takes the other branch. `ResumeStreamingAsync` builds a run from the checkpoint instead of from an input:

```csharp title="Program.cs"
else
{
    await using StreamingRun run =
        await InProcessExecution.ResumeStreamingAsync( // [!code highlight]
            workflow, waiting, checkpoints); // [!code highlight]

    await WatchAsync(run, canAnswer: true);
    store.Dispose();
    folder.Delete(recursive: true);
}
```

When the checkpoint has an open request, the resumed run raises its `RequestInfoEvent` again, with the same request ID as in the first process. The same `WatchAsync` handles it, this time with `canAnswer: true`, so it asks the question and sends the answer. Once the run is done, the app deletes the folder, so the next run starts a new week. Here the household is away on Saturday:

```bash
dotnet run
```

```text
Basket 1 of 3:
  500 g chicken breast
  2 bell peppers
  [...]
  150 g mushrooms
  100 g pepperoni
Approve this basket? (y, n or a change) We are away on Saturday, no pizza
[basket revised] 13 items, 471 output tokens
Basket 2 of 3:
  500 g chicken breast
  2 bell peppers
  1 head broccoli
  3 carrots
  1 bulb garlic
  1 small piece fresh ginger
  100 ml soy sauce
  2 celery sticks
  250 g red lentils
  400 g tinned chopped tomatoes
  1 litre vegetable stock
  2 vegetable stock cubes
  150 g mushrooms
Approve this basket? (y, n or a change) y
[order placed] 13 items
Ordered 13 items.
```

The first basket came from the checkpoint, so `write-basket` didn't run again for it. The change made the one model call of this process: the revised basket dropped the pizza bases, the passata, the mozzarella and the pepperoni, and kept the mushrooms, which the change didn't mention. The revised basket reached the same port as a new request, and the same process asked again, because its stream waits for answers.

The run is saved after every superstep here too. Had the second process ended while "Basket 2 of 3" was waiting, the latest checkpoint would hold that request, and a third run would show basket 2 without a model call. The sample ran it that way as well, and the third process ordered the revised basket.

## A checkpoint fits only the same graph

A checkpoint records the graph it was taken from: the executors by ID and type, the edges and the ports. The second process builds the workflow again from the same code, and the resume checks that the two match. If they don't, for example because `place-order` was renamed to `order` between the two runs, `ResumeStreamingAsync` throws an `InvalidDataException`: `The specified checkpoint is not compatible with the workflow associated with this runner.`

This is why `write-basket` has a fixed ID. An agent placed straight into a graph, as in the first workflow, gets an executor ID made of its name and its `Id`, and that `Id` is a new GUID every time the agent is created unless you set one. A new process would build a graph with different IDs, and the checkpoint would no longer fit. Agent Framework's own checkpoint sample says the same: a rebuilt agent needs the same `Id` (and, if it has one, the same `Name`) as the one that made the checkpoint.

The store wrote `Basket` and `BasketDecision` to JSON, and read the basket back, without any setup. `CreateJson` also takes an optional `JsonSerializerOptions`, and its documentation says to pass one when messages or state use your own types. This app didn't need it.

## Shared state carries the basket past the port

The port passes on only the person's answer, a `BasketDecision`. The steps after it also need the basket: `place-order` to order it, and `write-basket` to revise it. Instead of making the answer carry the basket along, `write-basket` puts every basket it writes in the workflow's state with `QueueStateUpdateAsync`, and both read it back with `ReadStateAsync`. `PlaceOrderExecutor` declares its output with `[YieldsOutput(typeof(string))]` and hands it to the workflow with `YieldOutputAsync`; with the generator package referenced, such a class has to be `partial`, or the build stops with `error MAFGENWF008`:

```csharp title="PlaceOrderExecutor.cs"
using Microsoft.Agents.AI.Workflows;

[YieldsOutput(typeof(string))]
public sealed partial class PlaceOrderExecutor()
    : Executor<BasketDecision>("place-order")
{
    public override async ValueTask HandleAsync(
        BasketDecision decision,
        IWorkflowContext context,
        CancellationToken cancellationToken = default)
    {
        Basket basket = await context.ReadStateAsync<Basket>( // [!code highlight]
            "basket", scopeName: "shop", cancellationToken) // [!code highlight]
            ?? throw new InvalidOperationException("No basket in state.");

        if (!decision.Approved)
        {
            string reason = decision.Change is null
                ? "rejected"
                : $"still a change after basket {decision.Round}: {decision.Change}";
            await context.AddEventAsync(
                new ShopProgressEvent("order skipped", reason),
                cancellationToken);
            await context.YieldOutputAsync(
                $"No order placed: {reason}", cancellationToken);
            return;
        }

        await context.AddEventAsync(
            new ShopProgressEvent(
                "order placed", $"{basket.Items.Count} items"),
            cancellationToken);
        await context.YieldOutputAsync(
            $"Ordered {basket.Items.Count} items.", cancellationToken);
    }
}
```

State is saved with every checkpoint, which is how the basket survived the first process. Two details decide whether the read finds anything.

**The scope name.** Both calls pass `scopeName: "shop"`. A state key lives in a scope, and the framework's `ScopeId` compares two scopes by name when both have one and by executor ID when neither has. So an executor that names no scope gets one only it can see, and two executors share a value only through a named scope. Reading a key that isn't in the scope returns `null` rather than throwing, so the `?? throw` turns a missing basket into an error that says what is missing.

**The timing.** An update is queued. The executor that wrote it sees the new value on its next read, and every other executor sees it from the next superstep on. `place-order` and the revising `write-basket` both run supersteps after the basket was written, so it is there. Two steps running side by side in the same superstep cannot pass data to each other this way.

Edges are still the first choice for data between steps, because the graph then shows where the data goes. State is for data a later step needs that the steps in between have no reason to carry, like the basket behind the port.

## A custom event for progress

The built-in events describe the runtime: a superstep started, an executor was invoked, a request was raised. The `[basket written]` and `[order placed]` lines come from an event in the shop's own terms. Any class that derives from `WorkflowEvent` can be raised from an executor with `AddEventAsync`:

```csharp title="ShopProgressEvent.cs"
using Microsoft.Agents.AI.Workflows;

public sealed class ShopProgressEvent(string step, string detail)
    : WorkflowEvent(detail)
{
    public string Step { get; } = step;
    public string Detail { get; } = detail;
}
```

The base class keeps whatever is passed to its constructor in `Data`, typed as `object`. The typed `Step` and `Detail` properties save every consumer a cast. The events reach the same stream as the built-in ones, so `WatchAsync` picks them out with one more `case`. A dashboard could do the same, or count tokens from the `basket written` detail.

## What a checkpoint doesn't hold

A checkpoint holds what the workflow knows about: the graph, the queued messages, the open requests and the state. It doesn't hold the fields of an executor object, or anything else kept in memory outside the workflow. An executor with fields of its own has to write them to state when a checkpoint is taken, by overriding `OnCheckpointingAsync`, and read them back in `OnCheckpointRestoredAsync`. Every executor here keeps its data in messages or state, so a new process loses nothing. An executor that kept the basket in a field would have no basket in the new process.

## Try it

- Run the app once, then change the ID of `place-order` to `order` and run it again. Read the exception from `ResumeStreamingAsync`.
- Remove `scopeName: "shop"` from the `ReadStateAsync` call in `PlaceOrderExecutor`, resume a waiting run and approve it: `place-order` fails with `No basket in state.`
- Set `MaxRounds` to 1 and ask for a change. There is no second basket: the run ends with "No order placed: still a change after basket 1: ..." and the change you typed.
- Change `blockOnPendingRequest: canAnswer` to `true` and start a new week. The basket appears, and the app waits for an answer that the first run can't give.
