Building a Conversational Work IQ Agent in C#: The A2A SDK, Multi-Turn, and Streaming
In Part 1 we proved the concept: register an Entra app, POST a JSON-RPC envelope to the Work IQ gateway, and get back a grounded, permission-trimmed answer with citations. About forty lines of code, no RAG pipeline, no permission logic.
It worked — but it isn’t an agent. It was one synchronous question, with me hand-building the JSON-RPC envelope and hand-parsing the response, and the user staring at nothing until the whole answer was computed server-side. Three things are missing before this is something you’d actually ship:
- The envelope and parsing should be the SDK’s job, not mine. Hand-rolling JSON-RPC is fine to see once; it’s a liability to maintain.
- The agent has no memory. Ask a follow-up like “tell me more about the 2 PM call” and Part 1’s code has no idea what call you mean — every request starts from zero.
- There’s no streaming. Copilot-quality answers take a few seconds to assemble. Making the user wait in silence for all of it is a bad experience when you could stream tokens as they arrive.
In this part we fix all three with the A2A .NET SDK: we’ll rebuild the call cleanly, add multi-turn conversations with contextId, and then upgrade to streaming, including the genuinely interesting bit, which is how Work IQ chunks its answer over the wire.
The A2A SDK
A2A is an open protocol, and there’s a first-party .NET SDK for it on NuGet. It’s what Microsoft’s own Work IQ samples use, and it gives you typed Message, Task, and Artifact objects, the JSON-RPC plumbing, and — the reason we’re here — clean streaming primitives.
One thing that doesn’t change from Part 1: you still bring your own authenticated HttpClient. The SDK handles the protocol; you handle the token and the A2A-Version header, then hand the client over. That’s actually a nice separation — your MSAL setup stays exactly as it was.
Set up the project:
dotnet new console -n Part2.ConversationalAgent
cd Part2.ConversationalAgent
dotnet add package A2A --prerelease
dotnet add package Microsoft.Identity.ClientThe A2A package is still prerelease at the time of writing (1.0.0-preview2), hence –prerelease. Here’s the .csproj so you can pin to exactly what I’m using:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A" Version="1.0.0-preview2" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.84.2" />
</ItemGroup>
</Project>A heads-up: because preview packages are fussy: if your environment has a custom NuGet feed configured, restore can fail to find A2A. Add a nuget.config next to the project that points at nuget.org and you’re set. Microsoft’s own sample ships one for exactly this reason.
Step 1: The same call, but with the SDK
Let’s rebuild Part 1’s one-shot question, this time with the SDK. Notice how much disappears, no manual envelope, no JsonDocument spelunking:
using System.Net.Http.Headers;
using System.Text.Json;
using A2A;
using Microsoft.Identity.Client;
const string ClientId = "<your-app-client-id>";
const string TenantId = "<your-tenant-id>";
const string Scope = "api://workiq.svc.cloud.microsoft/.default";
const string Endpoint = "https://workiq.svc.cloud.microsoft/a2a/";
// --- Auth: identical to Part 1 ---
var app = PublicClientApplicationBuilder
.Create(ClientId)
.WithAuthority($"https://login.microsoftonline.com/{TenantId}")
.WithDefaultRedirectUri()
.Build();
var auth = await app.AcquireTokenInteractive(new[] { Scope }).ExecuteAsync();
// --- Bring your own authed HttpClient, then hand it to the SDK ---
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", auth.AccessToken);
// Still required — opts in to the A2A v1.0 wire format (see Part 1).
http.DefaultRequestHeaders.TryAddWithoutValidation("A2A-Version", "1.0");
var client = new A2AClient(new Uri(Endpoint), http);
// --- Build a typed message and send it ---
var message = new Message
{
Role = Role.User,
MessageId = Guid.NewGuid().ToString(),
Parts = [Part.FromText("What meetings do I have today?")],
Metadata = new Dictionary<string, JsonElement>
{
["Location"] = JsonSerializer.SerializeToElement(new
{
timeZoneOffset = (int)TimeZoneInfo.Local.BaseUtcOffset.TotalMinutes,
timeZone = TimeZoneInfo.Local.Id
})
}
};
var response = await client.SendMessageAsync(new SendMessageRequest { Message = message });
// The answer text lives in the task's artifacts.
if (response.PayloadCase == SendMessageResponseCase.Task)
{
var task = response.Task!;
var text = string.Join("", task.Artifacts!
.SelectMany(a => a.Parts)
.Where(p => p.ContentCase == PartContentCase.Text)
.Select(p => p.Text));
Console.WriteLine(text);
}The shape to internalize here, because it carries into everything below:
- A SendMessageResponse has a PayloadCase, usually
Taskfor Work IQ. (The other case, Message, shows up in simpler direct replies.) - The answer is in Task.Artifacts[].Parts, not in the task’s status. The status message carries progress and metadata — status text, citations — not the final text. Mixing those two up is the single most common parsing mistake, so it’s worth saying twice: artifacts hold the answer, status holds the commentary.
Step 2: Give it a memory
Here’s the part that turns a query tool into a conversation. Every Work IQ response carries a contextId. Pass it back on your next message and Work IQ treats the exchange as one continuous thread — so “tell me more about the 2 PM call” resolves against what it just told you.
That’s the whole mechanic: capture the contextId, send it on the next turn. Let’s wrap the call in a REPL that does exactly that:
string? contextId = null;
Console.WriteLine("Ask Work IQ something ('quit' to exit).\n");
while (true)
{
Console.Write("You > ");
var input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) ||
input.Equals("quit", StringComparison.OrdinalIgnoreCase))
break;
var message = new Message
{
Role = Role.User,
MessageId = Guid.NewGuid().ToString(),
ContextId = contextId, // null on turn 1, reused after
Parts = [Part.FromText(input)],
Metadata = new Dictionary<string, JsonElement>
{
["Location"] = JsonSerializer.SerializeToElement(new
{
timeZoneOffset = (int)TimeZoneInfo.Local.BaseUtcOffset.TotalMinutes,
timeZone = TimeZoneInfo.Local.Id
})
}
};
var response = await client.SendMessageAsync(new SendMessageRequest { Message = message });
var task = response.Task!;
// Capture the contextId so the *next* turn continues this thread.
contextId = task.ContextId ?? contextId;
var text = string.Join("", task.Artifacts!
.SelectMany(a => a.Parts)
.Where(p => p.ContentCase == PartContentCase.Text)
.Select(p => p.Text));
Console.WriteLine(text + "\n");
}Run it and try a two-turn exchange:
You > What meetings do I have today?
Today you have: 9 AM standup, 11 AM review with Dana, 2 PM customer call.
You > Who's organizing the 2 PM one, and what should I read before it?
The 2 PM customer call is organized by Priya Nair. Two threads are worth
reading first: the "Contoso renewal" email chain from Tuesday and the draft
SOW in the shared "Contoso" folder.The second answer only makes sense because Work IQ remembered the first. No history array, no re-sending the prior turns — contextId does it, server-side. If you’ve read my Foundry memory posts, notice the contrast: there I wired up long-term memory myself; here the conversation state is just part of the protocol.
Step 3: Stream the response
Synchronous calls are fine for a script. For anything a person waits on, you want streaming — partly so tokens appear as they’re generated, and partly because Work IQ streams progress updates (the “looking through your calendar…” status lines) which make the wait feel like progress instead of a hang. Microsoft’s own sample code calls these intermediate messages chain-of-thought; I’ll stick to “progress” or “status” updates here, since that’s all we can observe from the outside.
Streaming swaps SendMessageAsync for SendStreamingMessageAsync, which returns an IAsyncEnumerable of events. Each event has a PayloadCase, and there are four you’ll see:
| PayloadCase | What it is |
| Task | The initial “I’ve accepted this” event. Informational — grab the contextId from it. |
| StatusUpdate | Progress / status lines, and the terminal state. The final one is where citation metadata currently arrives (see below). |
| ArtifactUpdate | The answer itself, streamed in chunks. |
| Message | A direct message reply (rare in this flow). |
The one subtlety worth slowing down for is how ArtifactUpdate chunks arrive. Each one has an Append flag:
- Append = true: delta semantics. The chunk is the new tail; append it to what you’ve got.
- Append = false: replace semantics. The chunk is the full artifact so far. Work IQ’s streaming sends each replacement as a strict extension of the previous one, so the right move is to print only the new suffix rather than re-rendering the whole thing.
Handle both and you get clean, flicker-free streaming. Here’s the loop:
using System.Text;
static async Task<string?> StreamAnswer(A2AClient client, Message message)
{
string? contextId = null;
var buffers = new Dictionary<string, StringBuilder>();
Dictionary<string, JsonElement>? finalMetadata = null;
await foreach (var evt in client.SendStreamingMessageAsync(
new SendMessageRequest { Message = message }))
{
switch (evt.PayloadCase)
{
case StreamResponseCase.Task:
contextId = evt.Task!.ContextId;
break;
case StreamResponseCase.StatusUpdate:
var status = evt.StatusUpdate!;
if (status.Status.Message is { } m)
{
contextId = m.ContextId ?? contextId;
finalMetadata = m.Metadata; // where citations currently arrive (terminal event)
var thought = JoinText(m.Parts);
if (!string.IsNullOrEmpty(thought))
WriteDim($"\n [{thought}]"); // progress / status line
}
break;
case StreamResponseCase.ArtifactUpdate:
var au = evt.ArtifactUpdate!;
var id = au.Artifact.ArtifactId;
if (!buffers.TryGetValue(id, out var sb))
buffers[id] = sb = new StringBuilder();
var chunk = JoinText(au.Artifact.Parts);
if (au.Append)
{
sb.Append(chunk);
Console.Write(chunk); // print the delta
}
else
{
// Replace: each chunk is a prefix-extension — print only the new suffix.
var old = sb.ToString();
sb.Clear();
sb.Append(chunk);
Console.Write(chunk.StartsWith(old, StringComparison.Ordinal)
? chunk[old.Length..]
: chunk);
}
break;
}
}
if (finalMetadata is not null) PrintCitations(finalMetadata);
return contextId;
static string JoinText(IEnumerable<Part> parts) =>
string.Join("", parts
.Where(p => p.ContentCase == PartContentCase.Text)
.Select(p => p.Text));
}Drop that into the REPL by replacing the body of the loop with:
Console.Write("Agent > ");
contextId = await StreamAnswer(client, message);
Console.WriteLine("\n");Now the experience is what you’d want: a dim status line or two while Work IQ assembles context, then the answer typing itself out in real time, then the sources.
Step 4: Citations (and a note on where they live)
The citation handling is the same idea as Part 1, but now we’re reading from the metadata we captured off the terminal StatusUpdate:
static void PrintCitations(Dictionary<string, JsonElement> metadata)
{
if (!metadata.TryGetValue("attributions", out var attrs) ||
attrs.ValueKind != JsonValueKind.Array)
return;
Console.WriteLine("\n\nSources:");
foreach (var a in attrs.EnumerateArray())
{
var name = a.TryGetProperty("providerDisplayName", out var n) ? n.GetString() : "(source)";
var url = a.TryGetProperty("seeMoreWebUrl", out var u) ? u.GetString() : "";
Console.WriteLine($" • {name} {url}");
}
}
static void WriteDim(string s)
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine(s);
Console.ResetColor();
}One forward-looking caveat, and I’m flagging it because it’ll save you a debugging session later: as of now, citations ride along in Status.Message.Metadata under attributions. I’m not just going off my own testing here — Microsoft’s upstream Work IQ samples say so in a code comment: the attributions are “still in Status.Message.Metadata until DataPart migration ships.” Once that migration lands, you’ll read them off a structured DataPart instead of fishing them out of metadata. The shape of what you get (provider, URL, type) shouldn’t change; where you read it from will. So if a future SDK bump makes this come back null, that migration is your most likely culprit — check the artifact parts.
Chat vs Context (a quick aside)
Everything we’ve done lives in Work IQ’s Chat domain: you ask, Work IQ reasons, you get a synthesized, cited answer. There’s a sibling worth knowing about — Context — which returns the raw grounding Work IQ would have used, packaged for your agent to reason over itself, rather than a finished answer. Chat is “let Copilot answer this”; Context is “give my agent the relevant material and let it decide.” For a lot of agent designs you’ll mix them: Context to gather, your own model to synthesize. I’ll come back to this when it earns its own demo.
Where this is going
We now have a real conversational agent: typed SDK calls, memory across turns via contextId, streaming output with visible progress updates, and citations — all still permission-trimmed to the signed-in user, still with zero retrieval code on my side.
In Part 3 we leave A2A behind and pick up the Work IQ MCP — the redesigned remote server that collapses hundreds of Microsoft 365 operations into about ten generic tool verbs (fetch, create, update, and friends), with a getSchema call that lets an agent discover how data is shaped at runtime. That’s a different and, in some ways, more powerful way to give an agent access to M365 — and it’s the on-ramp if you’re wiring Work IQ into Copilot Studio or a Foundry agent rather than writing the client yourself.
Code for this part is in Part2.ConversationalAgent in the series repo. Next up: Work IQ as MCP tools.