Agent Framework workflow checkpoints 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 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, 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.
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.
Set up the shopper
A console app with the same four packages as in the parallel checks post, the source generator included, because the step that writes the basket handles two kinds of message:
dotnet new console -n WeeklyShopcd WeeklyShopdotnet 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.0dotnet add package Microsoft.Agents.AI.Workflows.Generators --version 1.24.0The 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:
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:
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:
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, 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 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: one writes the first basket from the request, the other revises the basket when the person asks for a change.
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( "basket", basket, scopeName: "shop", cancellationToken); 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:
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 = RequestPort.Create<Basket, BasketDecision>("approve-basket"); 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:
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.
The approval post 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 introduced them. CheckpointManager.CreateJson writes JSON to a store, and FileSystemJsonCheckpointStore keeps that store in a folder:
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);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 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. 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:
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:
static async Task WatchAsync(StreamingRun run, bool canAnswer){ await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: canAnswer)) { 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.
dotnet run[basket written] 17 items, 514 output tokensBasket 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 pepperoniWaiting 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:
else{ await using StreamingRun run = await InProcessExecution.ResumeStreamingAsync( workflow, waiting, checkpoints);
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:
dotnet runBasket 1 of 3: 500 g chicken breast 2 bell peppers [...] 150 g mushrooms 100 g pepperoniApprove this basket? (y, n or a change) We are away on Saturday, no pizza[basket revised] 13 items, 471 output tokensBasket 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 mushroomsApprove this basket? (y, n or a change) y[order placed] 13 itemsOrdered 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:
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>( "basket", scopeName: "shop", cancellationToken) ?? 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:
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-ordertoorderand run it again. Read the exception fromResumeStreamingAsync. - Remove
scopeName: "shop"from theReadStateAsynccall inPlaceOrderExecutor, resume a waiting run and approve it:place-orderfails withNo basket in state. - Set
MaxRoundsto 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: canAnswertotrueand start a new week. The basket appears, and the app waits for an answer that the first run can’t give.