Getting Started with the GitHub Copilot SDK — Part 2: Sessions, Events & Multi-Turn Conversations

This is Part 2 of the series. In Part 1 we got the client alive and authenticated.
Now we actually use it. The companion code lives on GitHub at
egarim/GettingStartedWithGithubCopilotSDK
— each numbered folder runs on its own.
This time we go to the heart of the SDK: the session.
What a session is
A CopilotSession is one conversation with the model. The key word is stateful —
the model remembers everything said earlier in the same session. That's what makes
multi-turn chat possible.
A session is a conversation with memory. The client is just the door.
You create one off the client:
var session = await client.CreateSessionAsync(new SessionConfig { Model = "gpt-4o" });
Console.WriteLine(quot;Session created: {session.SessionId}");
var messages = await session.GetMessagesAsync();
Console.WriteLine(quot;Initial messages: {messages.Count}"); // -> 1 (the system message)
await session.DisposeAsync();
Note the SessionId — you'll want that later for resume. And note it already has 1
message the moment it's born: the default system prompt. Use await using so it disposes
itself.
Multi-turn — the model remembers
This is the payoff. Send one message, then a second that references the first:
await using var session = await client.CreateSessionAsync();
var a1 = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 10 + 15?" });
Console.WriteLine(quot;A1: {a1?.Data.Content}"); // -> 25
var a2 = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Now double that result." });
Console.WriteLine(quot;A2: {a2?.Data.Content}"); // -> 50
"Now double that result" only makes sense because the session kept the context. You don't
resend history — the session holds it for you.
SendAsync vs SendAndWaitAsync
There are two ways to send, and the difference matters:
SendAndWaitAsyncblocks until the model's turn is fully done — until the session goes
idle. By the time it returns, the reply is ready.SendAsyncis fire-and-forget. Control comes back immediately, before the model
finishes.
var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 3+3?" });
Console.WriteLine(quot;Reply: {response?.Data.Content}"); // -> 6, already available
For most code, reach for
SendAndWaitAsync. It's simpler and you can't trip over a
half-finished response.
SendAsync earns its keep when you want to drive things off events instead.
The event stream
Every session emits events. Subscribe with session.On(...) and you get total visibility
into a turn's lifecycle:
var receivedEvents = new List<string>();
var idleTcs = new TaskCompletionSource<bool>();
var sub = session.On(evt =>
{
receivedEvents.Add(evt.GetType().Name);
if (evt is SessionIdleEvent) idleTcs.TrySetResult(true);
});
await session.SendAsync(new MessageOptions { Prompt = "What is 100 + 200?" });
await idleTcs.Task.WaitAsync(TimeSpan.FromMinutes(1));
sub.Dispose(); // unsubscribe
The handlers you'll meet most:
AssistantMessageEvent— a complete message landed.AssistantMessageDeltaEvent— one streaming token (whenStreaming = true).SessionIdleEvent— the turn is over. This is your "we're done" signal.SessionErrorEvent/SessionResumeEvent— error during a turn / session resumed.
The pattern above — fire SendAsync, then await a TaskCompletionSource that you complete
on SessionIdleEvent — is the manual version of what SendAndWaitAsync does for you.
Streaming, token by token
Flip Streaming = true and listen for deltas to get the ChatGPT-style typewriter effect:
await using var session = await client.CreateSessionAsync(new SessionConfig { Streaming = true });
var buffer = new StringBuilder();
var idleTcs = new TaskCompletionSource<bool>();
session.On(evt =>
{
switch (evt)
{
case AssistantMessageDeltaEvent delta:
Console.Write(delta.Data.DeltaContent); // prints token by token
buffer.Append(delta.Data.DeltaContent);
break;
case SessionIdleEvent:
idleTcs.TrySetResult(true);
break;
}
});
await session.SendAsync(new MessageOptions { Prompt = "Tell me a very short joke." });
await idleTcs.Task.WaitAsync(TimeSpan.FromMinutes(1));
Same building blocks — SendAsync + wait-for-idle — but now you render each
DeltaContent the instant it arrives. Wrap that loop around Console.ReadLine() and you
have a working interactive chatbot in about a dozen lines.
Shaping the model with system messages
You can override the default system prompt two ways via SystemMessageConfig:
// Append: your instruction is added AFTER Copilot's default prompt
SystemMessage = new SystemMessageConfig
{
Mode = SystemMessageMode.Append,
Content = "End each response with the phrase 'Have a nice day!'"
}
// Replace: your content COMPLETELY overrides the default prompt
SystemMessage = new SystemMessageConfig
{
Mode = SystemMessageMode.Replace,
Content = "You are an assistant called Testy McTestface. Reply succinctly."
}
Append keeps Copilot being Copilot, just with your extra rule bolted on. Replace
wipes the slate — ask the second session its name and it answers "Testy McTestface," not
"GitHub Copilot."
Resuming a conversation
Conversations outlive a single run. Hang on to the SessionId and reconnect with
ResumeSessionAsync:
var session1 = await client.CreateSessionAsync();
var sessionId = session1.SessionId;
await session1.SendAndWaitAsync(new MessageOptions { Prompt = "Remember this number: 42" });
var session2 = await client.ResumeSessionAsync(sessionId);
var a2 = await session2.SendAndWaitAsync(
new MessageOptions { Prompt = "What number did I ask you to remember?" });
Console.WriteLine(quot;Reply: {a2?.Data.Content}"); // -> 42
The resumed session remembers the 42. One thing to handle in production:
Resuming a session ID that doesn't exist throws
IOException. Wrap it.
try
{
await client.ResumeSessionAsync("non-existent-session-id");
}
catch (IOException ex)
{
Console.WriteLine(quot;Expected error: {ex.Message}");
}
That's the case a returning user with an expired ID will hit. Catch it instead of crashing.
Takeaways
- A
CopilotSessionis a stateful conversation — it remembers prior turns, so multi-turn
chat is free. SendAndWaitAsyncblocks until idle and is the safe default;SendAsyncis
fire-and-forget and pairs with the event stream.- Subscribe with
session.On(...); treatSessionIdleEventas "turn complete." - Set
Streaming = trueand readAssistantMessageDeltaEvent.Data.DeltaContentfor
token-by-token output. SystemMessageConfigcustomizes behavior — Append adds, Replace overrides.ResumeSessionAsync(sessionId)continues a past conversation; an unknown ID throws
IOException.
Sessions give the model memory. Next we give it hands. In
Part 3 — Custom Tools (AIFunction)
we let the model call our own C# functions and act on the world.
Want to follow along? Grab the .NET 10 SDK and GitHub Copilot access, then
dotnet run --project 02.SessionDemofrom the
course repo.