Skip to main content

Sessions in the GitHub Copilot SDK: What they are and how to manage them

In the previous post we got a working .NET app talking to the Copilot agent runtime. We created a CopilotSession, sent messages through it, and saw how multi-turn conversations just work — the agent remembered what you said three messages ago without you having to manage that state yourself.

That "just works" quality is deliberate, and it's worth understanding what's actually happening underneath. Sessions are the stateful core of the Copilot SDK. How you create, configure, scope, and dispose them determines whether your application is resilient, scalable, and cost-efficient — or fragile and leaky.

This post goes deep on sessions: what they are, how their lifecycle works, how to persist them across restarts, and the patterns that hold up in production.

The mental model: Client vs. Session

Before getting into lifecycle specifics, it's worth being precise about the two core classes and what each one owns.

CopilotClient is infrastructure. It manages the connection to the Copilot CLI process running locally — process lifecycle, network communication, authentication. It has no concept of what you're talking about. Create one per application and treat it as a singleton.

CopilotSession is context. It holds the full conversation history, tool execution state, and planning context for a specific interaction. It knows what was said, in what order, with what results. Multiple independent sessions can run simultaneously through the same client connection.

The separation matters: the client handles "how do I talk to Copilot at all", while the session handles "what are we talking about". You set up the client once, then create and dispose sessions as needed.

// Client: singleton, created once
await using var client = new CopilotClient();
await client.StartAsync();

// Session: created per conversation, disposed when done
await using var session = await client.CreateSessionAsync(new SessionConfig
{
    Model = "gpt-4.1",
    OnPermissionRequest = PermissionHandler.ApproveAll
});

Session lifecycle

A session moves through four observable states during its lifetime:

  • Created — the session instance exists but no request has been sent yet. This is where you configure hooks and event handlers before any interaction begins.
  • Active — a request is in flight. The agent is processing, potentially invoking tools, streaming tokens. The session is busy.
  • Idle — the agent has finished responding. The session.idle event fires at this point. The session is holding its context in memory and ready for the next prompt.
  • Disposed — the session has been shut down. Resources are released. Context is gone (unless you've enabled persistence — more on that below).

The SDK has a built-in 30-minute idle timeout. Sessions that sit inactive longer than this are automatically cleaned up. If you're building long-running workflows, plan for this: either keep sessions warm, persist and resume them, or design your workflow so individual sessions don't need to outlive the timeout.

await using var session = await client.CreateSessionAsync(new SessionConfig
{
    Model = "gpt-4.1",
    OnPermissionRequest = PermissionHandler.ApproveAll
});

// Wire up lifecycle events before your first send
session.On<SessionIdleEvent>(evt =>
{
    Console.WriteLine($"Agent finished. Idle for {evt.Data.IdleDurationMs}ms");
});

session.On<AssistantMessageEvent>(evt =>
{
    Console.WriteLine($"Full response: {evt.Data.Content}");
});

await session.SendAndWaitAsync(new MessageOptions { Prompt = "What changed in HEAD?" });

Events

The SDK surfaces everything through an event model. You subscribe to events using session.On<T>(), and each event type tells you something specific about what the agent is doing.

The core events you'll work with:

Event When it fires
AssistantMessageEvent A complete response has been generate
AssistantMessageDeltaEvent A streaming token has arrived
ToolExecutionStartEvent The agent is about to call a tool
ToolExecutionCompleteEvent A tool call has finished
SessionIdleEvent The agent has finished its current turn
SessionErrorEvent Something went wrong :-)
SessionCompactionStartEvent The context window is being trimmed
SessionCompactionCompleteEvent Context compaction has finishe

The session.On() overload that takes a base event works for catch-all logging:

// Log every event — useful during development
var unsubscribe = session.On(evt =>
    Console.WriteLine($"[{evt.Type}] {evt.Data}"));

// Typed handlers for production use
session.On<ToolExecutionStartEvent>(evt =>
    logger.LogInformation("Tool called: {Tool}", evt.Data.ToolName));

session.On<SessionErrorEvent>(evt =>
    logger.LogError("Session error: {Error}", evt.Data.Message));

// Dispose the subscription when you no longer need it
unsubscribe.Dispose();

Session scoping patterns

The right session scope depends on what your application is doing. Three patterns cover most cases.

Session-per-conversation

One session for each user conversation, created at the start, disposed when the conversation ends. This is the most common pattern and the cleanest to reason about.

public class ConversationService
{
    private readonly CopilotClient _client;

    public ConversationService(CopilotClient client) => _client = client;

    public async Task RunConversationAsync(string userId)
    {
        await using var session = await _client.CreateSessionAsync(new SessionConfig
        {
            Model = "gpt-4.1",
    OnPermissionRequest = PermissionHandler.ApproveAll,
            SessionId = $"{userId}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"
        });

        // Full conversation runs through this session
        while (true)
        {
            var input = Console.ReadLine();
            if (input == "exit") break;

            var response = await session.SendAndWaitAsync(
                new MessageOptions { Prompt = input });

            Console.WriteLine(response?.Data.Content);
        }
        // Session is disposed here — context is released
    }
}

Session-per-task

For background workflows or batch operations, create a session for each discrete task rather than each user. This gives each task its own isolated context and makes failures easy to scope.

public async Task ProcessTaskAsync(WorkItem task)
{
    await using var session = await _client.CreateSessionAsync(new SessionConfig
    {
        Model = "gpt-4.1",
    OnPermissionRequest = PermissionHandler.ApproveAll,
        SessionId = $"task-{task.Id}",
        Instructions = $"You are processing a {task.Type} task. Context: {task.Description}"
    });

    var result = await session.SendAndWaitAsync(new MessageOptions{
        Prompt = task.Prompt
    });

    await _store.SaveResultAsync(task.Id, result?.Data.Content);
}

Long-running sessions

Some workflows genuinely benefit from a session that spans many interactions over time — an interactive coding agent that remembers the files it read earlier, for example. This works, but requires you to think about context accumulation. Every turn adds to the session's context window. Eventually the SDK will trigger compaction (you'll see SessionCompactionStart / SessionCompactionComplete events), which trims history automatically. Listen for these events if the completeness of history matters to your use case.

Session persistence: surviving restarts

By default, session state lives in memory and disappears when the session ends. For most use cases that's fine. But if your application restarts, deploys new code, or migrates between hosts, in-memory sessions vanish — and users lose their context.

The SDK supports durable session persistence by letting you provide your own sessionId. When you do, state is saved to disk at ~/.copilot/session-state/{sessionId}/:

~/.copilot/session-state/
└── alice-code-review-1714900000/
    ├── checkpoints/
    │   ├── 001.json   # State after first turn
    │   ├── 002.json   # State after second turn
    │   └── ...
    ├── plan.md        # Agent's planning state
    └── files/         # Artifacts the agent created

An example:



Creating a resumable session:

// Use a meaningful, stable ID that encodes ownership and purpose
var sessionId = $"{userId}-{taskType}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";

var session = await client.CreateSessionAsync(new SessionConfig
{
    SessionId = sessionId,
    Model = "gpt-4.1"
,
   OnPermissionRequest = PermissionHandler.ApproveAll});

// Persist the sessionId somewhere — database, cache, user preferences
await _store.SaveSessionIdAsync(userId, sessionId);

Resuming after a restart:

var sessionId = await _store.GetSessionIdAsync(userId);

// Resume picks up exactly where the conversation left off
var session = await client.ResumeSessionAsync(sessionId);

// You can change configuration on resume — useful for model upgrades
var session = await client.ResumeSessionAsync(sessionId, new SessionConfig
{
    Model = "gpt-4.1",
    OnPermissionRequest = PermissionHandler.ApproveAll,     // Switch models between sessions
    Tools = [myUpdatedTool] // Add new tools to an existing conversation
});

The onSessionStart lifecycle hook fires on both new and resumed sessions, and tells you which case you're in via input.Source:

var session = await client.CreateSessionAsync(new SessionConfig
{
    SessionId = sessionId,
    Model = "gpt-4.1",
    OnPermissionRequest = PermissionHandler.ApproveAll,
    Hooks = new SessionHooks
    {
        OnSessionStart = async (input, invocation) =>
        {
            if (input.Source == "resume")
            {
                // Inject context about what's changed since the session was paused
                var summary = await _store.GetUpdatesSinceAsync(invocation.SessionId, input.Timestamp);
                return new SessionStartHookOutput
                {
                    AdditionalContext = $"Session resumed. Updates since last session:\n{summary}"
                };
            }
            return null;
        }
    }
});

Managing multiple concurrent sessions

If you're building a multi-user application — a web API, an internal tool with multiple operators, anything serving more than one person at a time — you'll need to manage session concurrency deliberately.

A single CLI server can handle many concurrent sessions. The key constraint is memory: each active session holds its conversation history in memory, and long-running sessions accumulate context. Without a ceiling, a busy application will eventually exhaust available memory.

Here's a straightforward SessionManager that enforces a concurrency limit:

public class SessionManager : IAsyncDisposable
{
    private readonly CopilotClient _client;
    private readonly Dictionary<string, CopilotSession> _active = new();
    private readonly int _maxConcurrent;
    private readonly SemaphoreSlim _lock = new(1, 1);

    public SessionManager(CopilotClient client, int maxConcurrent = 50)
    {
        _client = client;
        _maxConcurrent = maxConcurrent;
    }

    public async Task<CopilotSession> GetOrCreateAsync(string sessionId, string model = "gpt-4.1")
    {
        await _lock.WaitAsync();
        try
        {
            if (_active.TryGetValue(sessionId, out var existing))
                return existing;

            if (_active.Count >= _maxConcurrent)
                await EvictOldestAsync();

            var session = await _client.CreateSessionAsync(new SessionConfig
            {
                SessionId = sessionId,
                Model = model
            });

            _active[sessionId] = session;
            return session;
        }
        finally
        {
            _lock.Release();
        }
    }

    private async Task EvictOldestAsync()
    {
        var oldest = _active.Keys.First();
        await _active[oldest].DisposeAsync();
        _active.Remove(oldest);
    }

    public async ValueTask DisposeAsync()
    {
        foreach (var session in _active.Values)
            await session.DisposeAsync();
    }
}

For production deployments, a few additional considerations:

  • Isolation. If you're building a multi-tenant SaaS product where data isolation matters, consider giving each user or tenant their own CLI server instance rather than sharing one. The official scaling docs cover this pattern in detail.
  • Shared storage for horizontal scale. If you're running multiple application instances behind a load balancer, session state needs to live on shared storage (a network file system or mounted volume) so any instance can resume any session. The state path (~/.copilot/session-state/) is configurable if you need to redirect it.
  • The 30-minute idle timeout. Plan for sessions being cleaned up automatically. Either design your workflows to complete within a session's natural lifetime, or use persistence and resume to span longer timescales.

Registering CopilotClient in ASP.NET Core

For web applications, register CopilotClient as a singleton and use a hosted service to manage its lifecycle cleanly:

// Program.cs
builder.Services.AddSingleton<CopilotClient>();
builder.Services.AddHostedService<CopilotClientHostedService>();
builder.Services.AddSingleton<SessionManager>();

// CopilotClientHostedService.cs
public class CopilotClientHostedService : IHostedService
{
    private readonly CopilotClient _client;

    public CopilotClientHostedService(CopilotClient client) => _client = client;

    public Task StartAsync(CancellationToken ct) => _client.StartAsync(ct);

    public Task StopAsync(CancellationToken ct) => _client.StopAsync(ct);
}

Then inject SessionManager into your controllers or minimal API handlers and create sessions per request or per user:

app.MapPost("/chat/{sessionId}", async (
    string sessionId,
    ChatRequest request,
    SessionManager sessions) =>
{
    var session = await sessions.GetOrCreateAsync(sessionId);

    var response = await session.SendAndWaitAsync(
        new PromptRequest { Prompt = request.Message });

    return Results.Ok(new { reply = response.Content });
});

What's next

You now have a solid understanding of how sessions work: their lifecycle, their event model, how to persist and resume them, and how to manage concurrency in a real application.

In the next post, we'll look at deployment — what deployment options are available, when to choose what and how to scale your setup.

Popular posts from this blog

Podman– Command execution failed with exit code 125

After updating WSL on one of the developer machines, Podman failed to work. When we took a look through Podman Desktop, we noticed that Podman had stopped running and returned the following error message: Error: Command execution failed with exit code 125 Here are the steps we tried to fix the issue: We started by running podman info to get some extra details on what could be wrong: >podman info OS: windows/amd64 provider: wsl version: 5.3.1 Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM Error: unable to connect to Podman socket: failed to connect: dial tcp 127.0.0.1:2655: connectex: No connection could be made because the target machine actively refused it. That makes sense as the podman VM was not running. Let’s check the VM: >podman machine list NAME         ...

Cache stampede: when our cache turned against us

While investigating some performance issues, we ran into an ASP.NET Core API that cached a fairly expensive aggregation query for 60 seconds. Under normal load, that was fine: one request rebuilds the cache, everyone else reads from it. Under peak load, dozens of requests would arrive in that same expiry window, all see a cache miss, and all fire the same expensive query in parallel. The database didn't like that. That was the moment when our caching layer stopped helping and started hurting. A burst of requests comes in at the same time, all miss the cache, and all go hammer the database or the downstream API at once. That's a cache stampede . The cache was supposed to protect our backend, and for a few hundred milliseconds it did the opposite. Why this happens IMemoryCache.GetOrCreate (and its async sibling) looks like it protects you, but it doesn't add any locking on its own. Look at the naive version: public async Task<Report> GetReportAsync(string key) ...

A complex system designed from scratch never works

A few years ago, I worked as an architect on a big mainframe rewrite. I still count it as one of my failures. Not because the technology was wrong, but because I couldn't convince the management team to simplify the approach. Years later, the organization is still struggling to get the new system up and running. I left the project at the time, because I couldn't put my name behind an approach that would take very long and cost a lot of money without a working system to show for it along the way. Gall’s Law That memory keeps coming back to me, because it's a textbook case of Gall's Law playing out in real life. Gall's Law , from John Gall's Systemantics , states it plainly: A complex system that works is invariably found to have evolved from a simple system that worked. A complex system designed from scratch never works, and it cannot be patched to make it work. You have to start over with a simple system that works. What does that mean in practice,...