Remembering the conversation with agent sessions
Summarize with AI
The assistants open a new chat that asks for a short summary of this post.
The idea: a session doesn’t make the model remember; it sends the earlier turns again with every request.
Ask an agent what to cook with rice, eggs and an onion, then ask “And tomorrow?”, and it has no idea what you are talking about: the model keeps nothing between requests. In Microsoft Agent Framework, what sends the earlier turns back is an AgentSession. With one session per conversation, a console kitchen assistant follows the conversation and keeps it across a restart as a JSON file. A web endpoint loads a stored conversation only for the person who owns it.
Who this is for: C# developers who have built a first agent, as in Microsoft Agent Framework: your first C# agent.
Versions: Microsoft Agent Framework 1.24.0, .NET 10.
Code: samples/agent-framework/02-sessions, a runnable version of this post with its tests.
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.
Set up one agent on Claude Haiku
A console app with the Microsoft.Agents.AI and Microsoft.Agents.AI.Anthropic packages, and one agent on Claude Haiku 5.5:
using System.Text.Json;using Anthropic;using Anthropic.Core;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;
var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? throw new InvalidOperationException("Set ANTHROPIC_API_KEY.");
AIAgent agent = new AnthropicClient(new ClientOptions { ApiKey = apiKey }) .AsIChatClient("claude-haiku-5-5", 1024) .AsAIAgent( name: "kitchen-assistant", instructions: "You help one household decide what to cook. " + "Answer in at most three sentences.");Without a session, every call is the first
Console.WriteLine(await agent.RunAsync( "What can I cook tonight with rice, eggs and an onion?"));Console.WriteLine(await agent.RunAsync("And tomorrow?"));Make egg fried rice: sauté the diced onion in a little oil, add cold cooked rice and stir-fry until it's hot and starting to crisp, then push it aside, scramble two or three beaten eggs in the pan, and mix everything together with a splash of soy sauce or salt. If you have no cooked rice on hand, cook the rice first and let it cool for a bit, since day-old rice fries up better than fresh.I don't have a record of what you've eaten or what's planned for today, so I'm not sure what "tomorrow" follows from. Could you tell me what ingredients you have, how much time you'll have to cook, and whether anyone has dietary needs? With that, I can suggest a dinner for tomorrow.The second call sends the model the instructions and the words “And tomorrow?”, nothing else. Whatever it answers, it is not a follow-up to the rice and eggs, because the rice and eggs are not in the request.
RunAsync does not fall back on a shared default conversation when the session argument is left out. ChatClientAgent, the agent type AsAIAgent returns, creates a fresh session for that one call and drops it afterwards. That suits single-shot work such as classifying or summarizing a text. It breaks anything where the person gives information in pieces. A demo where one message happens to contain everything will not show the problem.
One session per conversation
CreateSessionAsync returns an empty session. Passing the same session to every call in a conversation is what connects the turns:
AgentSession session = await agent.CreateSessionAsync();
Console.WriteLine(await agent.RunAsync( "What can I cook tonight with rice, eggs and an onion?"));Console.WriteLine(await agent.RunAsync("And tomorrow?")); "What can I cook tonight with rice, eggs and an onion?", session));Console.WriteLine(await agent.RunAsync("And tomorrow?", session));Make egg fried rice: sauté the diced onion in a little oil, add cold cooked rice and stir-fry until it's hot and starting to crisp, then push it aside, scramble the beaten eggs in the pan, and mix everything together. Season with salt and soy sauce if you have it, or pepper if you don't.Tomorrow, try a Spanish-style tortilla: sauté the onion until soft, pour in beaten eggs mixed with some cooked rice, and cook gently over low heat until set, then flip or finish under the grill. Serve it in wedges with salt and pepper.On the second call the agent sends the model the instructions, the first question, the first answer and the new question. The model can build on tonight’s dinner because the dinner is in front of it again, not because it remembered.
The fix in a real application is mechanical: create the session when the conversation starts, keep it with the conversation, and pass it to every call.
The session holds the messages, not the instructions
With this agent, the session holds the messages themselves. A history provider is the component that stores a conversation’s messages and hands them back on the next run. Unless it is given another one, ChatClientAgent uses InMemoryChatHistoryProvider, which stores each run’s request and response messages inside the session. TryGetInMemoryChatHistory reads them back:
if (session.TryGetInMemoryChatHistory(out List<ChatMessage>? history)){ foreach (ChatMessage message in history) { Console.WriteLine($"{message.Role}: {message.Text}"); }}user: What can I cook tonight with rice, eggs and an onion?assistant: Make egg fried rice: sauté the diced onion in a little oil, add cold cooked rice and stir-fry until it's hot and starting to crisp, then push it aside, scramble the beaten eggs in the pan, and mix everything together. Season with salt and soy sauce if you have it, or pepper if you don't.user: And tomorrow?assistant: Tomorrow, try a Spanish-style tortilla: sauté the onion until soft, pour in beaten eggs mixed with some cooked rice, and cook gently over low heat until set, then flip or finish under the grill. Serve it in wedges with salt and pepper.The list has the user’s questions and the assistant’s answers, in order. The instructions are not in it. The agent adds them to every request from its own options, so changing the instructions changes the next turn of every stored conversation.
With a service that stores conversations itself, such as the OpenAI Responses API, the session holds only a conversation ID (ChatClientAgentSession.ConversationId) instead of the messages. To continue a conversation the service already has, ChatClientAgent.CreateSessionAsync(conversationId) creates a session around that ID.
Every turn resends the whole history
The history is not a summary. On each run the history provider hands the agent the full list, and the agent sends all of it to the model ahead of the new message. The token counts on each response show it:
string[] questions =[ "I also have a leek. Does that change anything?", "Which of those keeps best for lunch the next day?",];
foreach (string question in questions){ AgentResponse response = await agent.RunAsync(question, session); Console.WriteLine( $"{response.Usage?.InputTokenCount} input tokens: {question}");}272 input tokens: I also have a leek. Does that change anything?440 input tokens: Which of those keeps best for lunch the next day?The input token count climbs with every turn, because each request carries every earlier question and answer. Input tokens are billed, so a long conversation costs more per turn than a short one. Trimming or summarizing old history to keep that cost down is a separate problem. The session on its own keeps everything.
Save the session as JSON and restore it
A console app that exits loses its sessions, and so does a web server that restarts or runs on more than one machine. The agent turns a session into JSON and back:
string path = Path.Combine(AppContext.BaseDirectory, "kitchen-session.json");
JsonElement saved = await agent.SerializeSessionAsync(session);await File.WriteAllTextAsync(path, saved.GetRawText());
// On the next start of the program:AgentSession restored = await agent.DeserializeSessionAsync( JsonElement.Parse(await File.ReadAllTextAsync(path)));
Console.WriteLine(await agent.RunAsync( "Remind me what I had in the cupboard.", restored));You mentioned rice, eggs, an onion, and a leek. I also suggested salt, pepper, oil, and soy sauce, but you didn't say you had those.The saved kitchen-session.json, formatted, with the long encrypted protectedData values cut to [...]:
{ "stateBag": { "InMemoryChatHistoryProvider": { "messages": [ { "role": "user", "contents": [ { "$type": "text", "text": "What can I cook tonight with rice, eggs and an onion?" } ] }, { "authorName": "kitchen-assistant", "createdAt": "2026-10-08T11:50:05.995734+00:00", "role": "assistant", "contents": [ { "$type": "text", "text": "Make egg fried rice: sauté the diced onion in a little oil, add cold cooked rice and stir-fry until it's hot and starting to crisp, then push it aside, scramble the beaten eggs in the pan, and mix everything together. Season with salt and soy sauce if you have it, or pepper if you don't." } ], "messageId": "msg_011Cg25YcgmsNM64bjR3cMTu" }, { "role": "user", "contents": [ { "$type": "text", "text": "And tomorrow?" } ] }, { "authorName": "kitchen-assistant", "createdAt": "2026-10-08T11:50:07.235805+00:00", "role": "assistant", "contents": [ { "$type": "text", "text": "Tomorrow, try a Spanish-style tortilla: sauté the onion until soft, pour in beaten eggs mixed with some cooked rice, and cook gently over low heat until set, then flip or finish under the grill. Serve it in wedges with salt and pepper." } ], "messageId": "msg_011Cg25YidiN6AdHE7A57ZR6" }, { "role": "user", "contents": [ { "$type": "text", "text": "I also have a leek. Does that change anything?" } ] }, { "authorName": "kitchen-assistant", "createdAt": "2026-10-08T11:50:08.857062+00:00", "role": "assistant", "contents": [ { "$type": "reasoning", "text": "", "protectedData": "[...]" }, { "$type": "text", "text": "Yes, a leek works well in both dishes: slice the white and pale green parts and sauté them like the onion, which gives a milder, slightly sweeter flavor. You could also use the leek in the fried rice alongside the onion, or use it in place of the onion in tomorrow's tortilla." } ], "messageId": "msg_011Cg25Yop1Fg1SSoVEnh5CA" }, { "role": "user", "contents": [ { "$type": "text", "text": "Which of those keeps best for lunch the next day?" } ] }, { "authorName": "kitchen-assistant", "createdAt": "2026-10-08T11:50:10.557502+00:00", "role": "assistant", "contents": [ { "$type": "reasoning", "text": "", "protectedData": "[...]" }, { "$type": "text", "text": "The tortilla keeps better. It's good cold or at room temperature and holds its shape in a lunchbox. Fried rice is fine the next day too, but it can dry out, and rice should be cooled quickly and reheated until steaming hot." } ], "messageId": "msg_011Cg25Yvt9nBJYErcCqCuCG" } ] } }}Serialization belongs to the agent, not the session. SerializeSessionAsync and DeserializeSessionAsync are methods on AIAgent, and both are asynchronous: one returns a JsonElement, the other takes one.
The agent knows which providers wrote state into the session and how to read it back. Restore a session with an agent configured the same way. DeserializeSessionAsync does not check where the JSON came from: JSON written by a different kind of agent, or by an agent with other providers, loads without an error and the history is simply missing. What it refuses is JSON that is not an object, with an ArgumentException. A live session object of the wrong type is refused too: SerializeSessionAsync and RunAsync on a ChatClientAgent throw InvalidOperationException, saying that only sessions of type ChatClientAgentSession can be serialized or used by this agent.
The path is built from AppContext.BaseDirectory. A bare "kitchen-session.json" resolves against the current directory. That directory differs between dotnet run, the IDE and a deployed service, so the file can end up somewhere you don’t look.
Saving after every turn costs a write per turn, and a crash then loses at most the turn in progress. Saving only on exit loses everything since the last save when the process dies instead of exiting.
Open the file and the conversation is there in plain JSON. The stateBag object is the session’s dictionary of per-session state, and everything in it is saved with the session. It has an InMemoryChatHistoryProvider entry holding a messages list with every question and every answer. In this run the last two answers also carry a reasoning entry, an encrypted protectedData blob from the model, which is saved and sent back with the rest. The conversation itself makes the file personal data in most real applications. The API docs for SerializeSessionAsync call for access controls and encryption at rest wherever serialized sessions are stored. A “delete my data” request that clears the user table but not the session store is not finished.
Saving the whole session is not the only way to keep the history. A different ChatHistoryProvider, set through ChatClientAgentOptions.ChatHistoryProvider, writes the messages to a database itself, and the session’s state then holds only the key to find them. Agent Framework has one for Cosmos DB (Microsoft.Agents.AI.CosmosNoSql, preview) and one for Valkey (Microsoft.Agents.AI.Valkey, alpha).
Never load a session because the client named it
A web chat tells the server which conversation a message belongs to, usually with an ID the browser sends along. If the server loads whatever session that ID points to, anyone who changes the ID gets another household’s conversation loaded as context. A random ID makes guessing harder and does not fix the design.
Store each session with its owner and check the owner before loading. Agent Framework 1.24.0 does have a session store type, AgentSessionStore, but it is marked [Experimental], and the version that scopes sessions to a key such as a user ID is in the preview Microsoft.Agents.AI.Hosting package. The store below is application code, not a framework type:
using System.Text.Json;using Microsoft.Agents.AI;
public sealed record StoredSession(string OwnerId, JsonElement State);
public interface ISessionStore{ Task<StoredSession?> FindAsync(string sessionId); Task SaveAsync(string sessionId, StoredSession session);}
public static class KitchenChat{ // Starts a conversation for the signed-in user. The server picks the ID; // the client sends it back with every later message. public static async Task<string> StartAsync( AIAgent agent, ISessionStore store, string userId) { AgentSession session = await agent.CreateSessionAsync(); JsonElement state = await agent.SerializeSessionAsync(session); string sessionId = Guid.NewGuid().ToString("n"); await store.SaveAsync(sessionId, new StoredSession(userId, state)); return sessionId; }
// userId comes from the sign-in, // sessionId and message from the request. public static async Task<string?> ReplyAsync( AIAgent agent, ISessionStore store, string userId, string sessionId, string message) { StoredSession? stored = await store.FindAsync(sessionId); if (stored is null || stored.OwnerId != userId) { return null; // the endpoint answers 404 Not Found }
AgentSession session = await agent.DeserializeSessionAsync(stored.State); AgentResponse response = await agent.RunAsync(message, session);
JsonElement state = await agent.SerializeSessionAsync(session); await store.SaveAsync(sessionId, stored with { State = state }); return response.Text; }}A new conversation starts with StartAsync, which stores an empty session under an ID the server picks and records who owns it; every later message goes through ReplyAsync with that ID. The user ID comes from authentication, never from the request body. Answering 404 for someone else’s session, rather than 403, avoids confirming that the ID exists.
A stored session is untrusted input
The owner check protects reads. The store also needs protection against writes. The agent treats a restored history as its own. The API docs for DeserializeSessionAsync warn that a compromised store could change message roles to raise their trust, or add content that steers the model. So someone who edits a saved session can make the assistant appear to have said things it never said, and the next turn builds on that. A saved session is personal data when it is written, and untrusted input when it is read back.
Try it
- Run five turns in one session and print
InputTokenCountafter each. The number only goes up. - Save the session, open the JSON file and find your own messages in it. Then delete the file and ask a follow-up question: the agent has lost the thread.