A bare-minimum A2A client using only HttpClient and System.Text.Json — no A2A SDK. Shows exactly what goes over the wire when talking to a Work IQ agent.
This sample calls the agent endpoint directly — no agent card retrieval, no discovery, no capability negotiation. It assumes you already know the agent URL and sends JSON-RPC v1.0 messages to it.
Targets the Work IQ Gateway (https://workiq.svc.cloud.microsoft/a2a/).
Protocol version: This sample uses the A2A v1.0 JSON-RPC wire format (PROTOJSON conventions: SCREAMING_SNAKE_CASE enums, no
kinddiscriminators, named result wrappers). Work IQ also accepts v0.3 wire format via theA2A-Version: 0.3request header for callers that haven't migrated yet. The v1.0 spec also defines a REST binding (POST /v1/message:send); Work IQ may expose this in a future preview update.
Use this sample when you want to understand the A2A protocol at the HTTP level, or when you don't want to take a dependency on the A2A .NET SDK. For the SDK-based sample with agent-card handling, see ../a2a/.
a2a/ (SDK) |
a2a-raw/ (this sample) |
|
|---|---|---|
| Dependencies | A2A NuGet SDK + MSAL | MSAL only |
| Protocol handling | SDK manages JSON-RPC, SSE parsing, types | Raw HttpClient + JsonDocument |
| Lines of code | ~480 | ~280 |
| Recommended for | Any .NET integration with Work IQ | Reading the protocol on the wire; reference for porting to languages without an A2A SDK |
- Microsoft 365 Copilot license on your test user.
- An Entra app registration configured with the right permissions and redirect URIs. One-time task.
- If you're the tenant admin:
# Bash ../../scripts/admin-setup.sh # PowerShell ..\..\scripts\admin-setup.ps1
- Otherwise, hand
../../ADMIN_SETUP.mdto your admin. They'll give you an App ID and Tenant ID.
- If you're the tenant admin:
- .NET 8 SDK or later — download.
dotnet run -- --token WAM --appid <APP_ID> --tenant <TENANT_ID>Type a message, see a response, type quit to exit.
Add --stream to switch from SendMessage to SendStreamingMessage.
Without --agent-id, the sample POSTs to the Work IQ Gateway A2A endpoint (the gateway's default agent). To target a specific agent:
dotnet run -- --agent-id <AGENT_ID> --token WAM --appid <APP_ID> --tenant <TENANT_ID>The sample then does two raw HTTP calls — illustrating exactly what a non-.NET / no-SDK port would do:
-
Agent card fetch:
GET {endpoint}/{agent-id}/.well-known/agent-card.json Authorization: Bearer <token>Response is the standard A2A agent card JSON. The sample parses three fields:
url— where to POST messages for this agentname— friendly name (for logging)capabilities.streaming— whetherSendStreamingMessageis supported
-
Message post — same JSON-RPC shape as before, but POSTed to
agentCard.url(read from step 1) instead of the gateway A2A endpoint.
If --stream is set but the agent's card has capabilities.streaming = false, the sample falls back to SendMessage automatically and prints a note.
{
"name": "Researcher Agent",
"description": "...",
"url": "<agent-endpoint-url>",
"version": "1.0",
"capabilities": {
"streaming": true,
"pushNotifications": false
},
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": [...]
}This is the A2A AgentCard schema. Useful as a porting reference if you're implementing this in another language.
Use the WorkIQ CLI to list the agents available to your signed-in user. The list command is currently behind an experimental flag:
npm install -g @microsoft/workiq # or: dotnet tool install --global WorkIQ
workiq accept-eula
workiq config set experimental=true
workiq list-agentsYou can also copy the agent ID from the address bar in the Microsoft 365 Copilot Chat website — the segment after /chat/agent/. Treat the ID as an opaque string.
dotnet run -- --token eyJ0eXAi...macOS / Linux users: WAM is only available on Windows. Use
--token <JWT>with a pre-obtained token instead.
Connected to: https://workiq.svc.cloud.microsoft/a2a/
Mode: sync (SendMessage)
Type a message. 'quit' to exit.
You > What's on my schedule today?
Agent > Today you have:
- 9:00 AM — team standup
- 11:00 AM — review with Alice
- 2:00 PM — customer call
[200 OK]
request-id: a1b2c3d4-...
You > quit
| Flag | Description |
|---|---|
--token, -t |
WAM for Windows broker auth, or a pre-obtained JWT string. Required. |
--scope, -s |
Token scope for WAM. Default: api://workiq.svc.cloud.microsoft/.default |
--appid, -a |
Entra app client ID (required with WAM) |
--tenant, -T |
Tenant ID or domain. Required with WAM for single-tenant apps; defaults to common for multi-tenant. |
--account |
Account hint for WAM (e.g., user@contoso.com) |
--agent-id, -A |
Invoke a specific agent (fetches .well-known/agent-card.json and POSTs to agentCard.url). See How to find an agent ID above. |
--show-wire |
Pretty-print raw JSON-RPC request/response bodies and each streaming SSE data: event as it arrives. Useful for protocol debugging. |
--stream |
Use streaming mode (SendStreamingMessage via SSE) |
--all-headers |
Print every response header (default: only diagnostic ones) |
The server accepts A2A v1.0 JSON-RPC. All requests POST to the base URL with the method inside the JSON-RPC body.
{
"jsonrpc": "2.0",
"id": "<request-guid>",
"method": "SendMessage",
"params": {
"message": {
"role": "ROLE_USER",
"messageId": "<message-guid>",
"parts": [{ "text": "What meetings do I have today?" }],
"metadata": { "Location": { "timeZoneOffset": -480, "timeZone": "America/Los_Angeles" } }
}
}
}Response is a JSON-RPC envelope with result.task containing the agent's task and a contextId for multi-turn:
{
"jsonrpc": "2.0",
"id": "<request-guid>",
"result": {
"task": {
"id": "<task-id>",
"contextId": "ctx-1",
"status": { "state": "TASK_STATE_COMPLETED" },
"artifacts": [
{
"artifactId": "<artifact-id>",
"name": "Answer",
"parts": [{ "text": "Today you have 3 meetings: ..." }]
}
]
}
}
}Same JSON-RPC request with "method": "SendStreamingMessage". Response is text/event-stream (SSE) where each event is a JSON-RPC response carrying one of task, statusUpdate, artifactUpdate, or message:
data: {"jsonrpc":"2.0","id":"...","result":{"statusUpdate":{"taskId":"<t>","contextId":"ctx-1","status":{"state":"TASK_STATE_WORKING"}}}}
data: {"jsonrpc":"2.0","id":"...","result":{"artifactUpdate":{"taskId":"<t>","contextId":"ctx-1","artifact":{"artifactId":"<a>","parts":[{"text":"You"}]}}}}
data: {"jsonrpc":"2.0","id":"...","result":{"artifactUpdate":{"taskId":"<t>","contextId":"ctx-1","artifact":{"artifactId":"<a>","parts":[{"text":"You have 3 meetings..."}]}}}}
data: {"jsonrpc":"2.0","id":"...","result":{"statusUpdate":{"taskId":"<t>","contextId":"ctx-1","status":{"state":"TASK_STATE_COMPLETED"}}}}
The default mode is "Delta" — each artifactUpdate carries just the new tail with append: true. The sample concatenates append: true parts per artifactId to assemble the full answer.
- JSON-RPC envelope required — every request must include
jsonrpc,id,method,params. - POST to base URL — the method (
SendMessage,SendStreamingMessage) is inside the body, not in the URL path. - No
kinddiscriminators — parts are flat objects with field-presence ({"text": "..."}not{"kind": "text", "text": "..."}). - PROTOJSON enums — roles use
ROLE_USER/ROLE_AGENT; states useTASK_STATE_WORKING/TASK_STATE_COMPLETED/TASK_STATE_FAILED/ etc. - Named result wrappers — sync responses carry
result.taskorresult.message; streaming events useresult.statusUpdate,result.artifactUpdate,result.task, orresult.message. - Backward compatibility — set the
A2A-Version: 0.3request header to opt back into the v0.3 wire format.
| Package | Purpose |
|---|---|
Microsoft.Identity.Client |
MSAL token acquisition |
Microsoft.Identity.Client.Broker |
Windows WAM broker |
That's it — no A2A SDK, no JWT decoder.
| Symptom | Fix |
|---|---|
401 Unauthorized |
Token aud doesn't match the endpoint. The Work IQ Gateway needs api://workiq.svc.cloud.microsoft/.default. |
403 Forbidden without a scope message |
User is missing the Microsoft 365 Copilot license. |
See the root README for the full troubleshooting matrix.