Back to homepage

Niels Kootstra

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:

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:

One agent writes the basket, one item per line:

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:

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.

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:

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:

change, round < 3 otherwise input write-basket approve-basket answer place-order 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.

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:

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:

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:

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.

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:

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:

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:

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:

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.