Agent-to-Agent Protocol (A2A)
12 minute read
What is the Agent-to-Agent (A2A) Protocol?
The Agent2Agent (A2A) Protocol is an open standard developed by Google (now under the Linux Foundation) for enabling communication and interoperability between independent, potentially opaque AI agent systems. While MCP focuses on connecting AI to tools, A2A focuses on connecting AI agents to each other.
In an ecosystem where agents might be built using different frameworks, languages, or by different vendors, A2A provides a common language and interaction model that enables agents to:
- Discover each other’s capabilities
- Negotiate interaction modalities (text, forms, media)
- Securely collaborate on long-running tasks
- Operate without exposing their internal state, memory, or tools
Key Insight: A2A treats agents as opaque peers - they collaborate based on declared capabilities and exchanged information, without needing to share their internal thoughts, plans, or tool implementations.
A2A vs MCP: Complementary Protocols
A2A and MCP serve different purposes in the AI ecosystem:
| Aspect | MCP (Model Context Protocol) | A2A (Agent-to-Agent Protocol) |
|---|---|---|
| Focus | Connecting AI to tools | Connecting agents to agents |
| Relationship | AI → Tool (master/slave) | Agent ↔ Agent (peer-to-peer) |
| Visibility | Full tool access | Opaque execution |
| State | Tool state visible | Agent state hidden |
| Use Case | Function calling, data access | Agent collaboration, delegation |
How They Work Together
┌──────────────────────────────────────────────────────────────────┐
│ Client Agent │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ MCP Client │ │ A2A Client │ │ AI Model │ │
│ └──────┬──────┘ └──────┬──────┘ └─────────────┘ │
└─────────┼───────────────────┼────────────────────────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ MCP Server │ │ Remote Agent │ ◄── Uses MCP internally
│ (Database) │ │ (A2A Server)│ for its own tools
└──────────────┘ └──────────────┘
An A2A Client agent might request an A2A Server agent to perform a complex task. The Server agent, in turn, might use MCP internally to interact with databases, APIs, or other tools.
Core A2A Concepts
Agent Card
The Agent Card is a JSON metadata document published by an A2A Server describing:
- Identity and provider information
- Available capabilities and skills
- Supported input/output formats
- Authentication requirements
- Service endpoints
{
"name": "Research Assistant Agent",
"description": "AI agent for academic research and fact-checking",
"version": "1.0.0",
"supportedInterfaces": [
{
"url": "https://research/-agent.example.com/a2a/v1",
"protocolBinding": "HTTP+JSON",
"protocolVersion": "1.0"
}
],
"provider": {
"organization": "Example Research Inc.",
"url": "https://example/-research.com"
},
"capabilities": {
"streaming": true,
"pushNotifications": true
},
"defaultInputModes": ["text/plain", "application/json"],
"defaultOutputModes": ["text/plain", "application/json"],
"skills": [
{
"id": "academic-search",
"name": "Academic Paper Search",
"description": "Searches academic databases for relevant papers",
"tags": ["research", "academic", "papers"],
"examples": [
"Find peer-reviewed articles on climate change",
"Search for recent papers on machine learning"
]
},
{
"id": "fact-check",
"name": "Fact Checker",
"description": "Verifies claims against trusted sources",
"tags": ["verification", "facts", "sources"]
}
],
"securitySchemes": {
"bearer": {
"type": "http",
"scheme": "bearer"
}
}
}
Task
A Task is the fundamental unit of work in A2A. Tasks have:
- Unique ID - Server-generated identifier
- Context ID - Groups related tasks and messages
- Status - Current state with timestamp
- History - Message exchange history
- Artifacts - Output data produced by the task
Task Lifecycle
┌───────────────────────────────────────┐
│ Task States │
└───────────────────────────────────────┘
┌──────────┐ ┌──────────┐ ┌──────────────────┐
│ Submitted│────►│ Working │────►│ Completed │ (terminal)
└──────────┘ └────┬─────┘ └──────────────────┘
│
├──────────►┌──────────────────┐
│ │ Failed │ (terminal)
│ └──────────────────┘
│
├──────────►┌──────────────────┐
│ │ Canceled │ (terminal)
│ └──────────────────┘
│
├──────────►┌──────────────────┐
│ │ Rejected │ (terminal)
│ └──────────────────┘
│
├──────────►┌──────────────────┐
│ │ Input Required │ (interrupted)
│ └────────┬─────────┘
│ │
│◄───────────────────┘ (user provides input)
│
└──────────►┌──────────────────┐
│ Auth Required │ (interrupted)
└──────────────────┘
Messages and Parts
Messages are communication units between client and server:
{
"messageId": "msg-uuid-123",
"role": "ROLE_USER",
"parts": [
{
"text": "Analyze this document and summarize key findings"
},
{
"url": "https://storage.example.com/document.pdf",
"mediaType": "application/pdf",
"filename": "research-paper.pdf"
}
],
"contextId": "context-uuid-456"
}
Parts can contain:
- Text - Plain text content
- Raw bytes - Binary data (base64 encoded)
- URL - Reference to external file
- Structured data - JSON objects
Artifacts
Artifacts are task outputs containing one or more parts:
{
"artifactId": "artifact-uuid-789",
"name": "Research Summary",
"description": "Summary of analyzed research papers",
"parts": [
{
"text": "## Key Findings\n\n1. Climate change...",
"mediaType": "text/markdown"
},
{
"data": {
"paperCount": 42,
"topTopics": ["climate", "sustainability"],
"confidence": 0.95
},
"mediaType": "application/json"
}
]
}
A2A Protocol Bindings
A2A supports multiple transport protocols:
| Binding | Transport | Use Case |
|---|---|---|
| JSON-RPC | HTTP(S) + SSE | Standard web integration |
| gRPC | HTTP/2 + Protobuf | High-performance, streaming |
| HTTP+JSON/REST | HTTP(S) | RESTful integration |
JSON-RPC Methods
| Operation | Method | Description |
|---|---|---|
| Send Message | SendMessage | Initiate or continue a task |
| Stream Message | SendStreamingMessage | Real-time streaming updates |
| Get Task | GetTask | Retrieve task status |
| List Tasks | ListTasks | List tasks with filtering |
| Cancel Task | CancelTask | Request task cancellation |
| Subscribe | SubscribeToTask | Subscribe to task updates |
HTTP+JSON/REST Endpoints
| Operation | Method | Endpoint |
|---|---|---|
| Send Message | POST | /message:send |
| Stream Message | POST | /message:stream |
| Get Task | GET | /tasks/{id} |
| List Tasks | GET | /tasks |
| Cancel Task | POST | /tasks/{id}:cancel |
| Subscribe | POST | /tasks/{id}:subscribe |
| Agent Card | GET | /.well-known/agent-card.json |
A2A in .NET
The official A2A .NET SDK is available via NuGet:
dotnet add package A2A
A2A Server Implementation
using A2A;
using A2A.Server;
using System.Text.Json;
/// <summary>
/// A2A agent server for document analysis.
/// </summary>
public sealed class DocumentAnalysisAgent : IA2AAgent
{
private readonly IDocumentAnalyzer _analyzer;
private readonly ILogger<DocumentAnalysisAgent> _logger;
/// <summary>
/// Initializes the document analysis agent.
/// </summary>
public DocumentAnalysisAgent(
IDocumentAnalyzer analyzer,
ILogger<DocumentAnalysisAgent> logger)
{
_analyzer = analyzer;
_logger = logger;
}
/// <inheritdoc/>
public AgentCard GetAgentCard()
{
return new AgentCard
{
Name = "Document Analysis Agent",
Description = "Analyzes documents and extracts insights",
Version = "1.0.0",
SupportedInterfaces =
[
new AgentInterface
{
Url = "https://doc/-agent.example.com/a2a",
ProtocolBinding = "HTTP+JSON",
ProtocolVersion = "1.0"
}
],
Provider = new AgentProvider
{
Organization = "Example Corp",
Url = "https://example.com/"
},
Capabilities = new AgentCapabilities
{
Streaming = true,
PushNotifications = true
},
DefaultInputModes = ["text/plain", "application/pdf"],
DefaultOutputModes = ["application/json", "text/markdown"],
Skills =
[
new AgentSkill
{
Id = "summarize",
Name = "Document Summarization",
Description = "Summarizes documents into key points",
Tags = ["summary", "analysis", "documents"],
Examples = ["Summarize this PDF document"]
},
new AgentSkill
{
Id = "extract-entities",
Name = "Entity Extraction",
Description = "Extracts named entities from text",
Tags = ["entities", "NER", "extraction"]
}
]
};
}
/// <inheritdoc/>
public async Task<SendMessageResponse> SendMessageAsync(
SendMessageRequest request,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Received message: {MessageId}", request.Message.MessageId);
// Create a new task
A2ATask task = new()
{
Id = Guid.NewGuid().ToString(),
ContextId = request.Message.ContextId ?? Guid.NewGuid().ToString(),
Status = new TaskStatus
{
State = TaskState.Working,
Timestamp = DateTimeOffset.UtcNow
}
};
// Process the message asynchronously
_ = ProcessMessageAsync(task, request.Message, cancellationToken);
return new SendMessageResponse { Task = task };
}
private async Task ProcessMessageAsync(
A2ATask task,
Message message,
CancellationToken cancellationToken)
{
try
{
// Extract text content from parts
string textContent = string.Join("\n",
message.Parts
.Where(p => !string.IsNullOrEmpty(p.Text))
.Select(p => p.Text));
// Analyze the document
DocumentAnalysisResult result = await _analyzer.AnalyzeAsync(
textContent, cancellationToken);
// Create artifact with results
Artifact artifact = new()
{
ArtifactId = Guid.NewGuid().ToString(),
Name = "Analysis Results",
Description = "Document analysis output",
Parts =
[
new Part
{
Text = result.Summary,
MediaType = "text/markdown"
},
new Part
{
Data = JsonSerializer.SerializeToElement(new
{
result.WordCount,
result.SentimentScore,
result.Entities,
result.Topics
}),
MediaType = "application/json"
}
]
};
// Update task to completed
task.Status = new TaskStatus
{
State = TaskState.Completed,
Timestamp = DateTimeOffset.UtcNow
};
task.Artifacts = [artifact];
}
catch (Exception ex)
{
_logger.LogError(ex, "Task {TaskId} failed", task.Id);
task.Status = new TaskStatus
{
State = TaskState.Failed,
Message = new Message
{
Role = Role.Agent,
Parts = [new Part { Text = $"Analysis failed: {ex.Message}" }]
},
Timestamp = DateTimeOffset.UtcNow
};
}
}
}
/// <summary>
/// Result of document analysis.
/// </summary>
public sealed record DocumentAnalysisResult(
string Summary,
int WordCount,
double SentimentScore,
IReadOnlyList<string> Entities,
IReadOnlyList<string> Topics);
A2A Client Implementation
using A2A;
using A2A.Client;
using System.Net.Http.Headers;
/// <summary>
/// Client for communicating with A2A agents.
/// </summary>
public sealed class A2AAgentClient : IDisposable
{
private readonly HttpClient _httpClient;
private readonly ILogger<A2AAgentClient> _logger;
/// <summary>
/// Initializes the A2A client.
/// </summary>
public A2AAgentClient(
HttpClient httpClient,
ILogger<A2AAgentClient> logger)
{
_httpClient = httpClient;
_logger = logger;
}
/// <summary>
/// Discovers an agent's capabilities by fetching its Agent Card.
/// </summary>
public async Task<AgentCard> DiscoverAgentAsync(
Uri agentBaseUri,
CancellationToken cancellationToken = default)
{
Uri wellKnownUri = new(agentBaseUri, "/.well-known/agent-card.json");
_logger.LogInformation("Discovering agent at {Uri}", wellKnownUri);
HttpResponseMessage response = await _httpClient.GetAsync(
wellKnownUri, cancellationToken);
response.EnsureSuccessStatusCode();
AgentCard? card = await response.Content.ReadFromJsonAsync<AgentCard>(
cancellationToken: cancellationToken);
return card ?? throw new InvalidOperationException("Invalid Agent Card");
}
/// <summary>
/// Sends a message to an A2A agent and receives a task.
/// </summary>
public async Task<A2ATask> SendMessageAsync(
Uri agentEndpoint,
string messageText,
string? contextId = null,
CancellationToken cancellationToken = default)
{
SendMessageRequest request = new()
{
Message = new Message
{
MessageId = Guid.NewGuid().ToString(),
Role = Role.User,
ContextId = contextId,
Parts = [new Part { Text = messageText }]
},
Configuration = new SendMessageConfiguration
{
AcceptedOutputModes = ["application/json", "text/plain"],
Blocking = false
}
};
_logger.LogInformation("Sending message to {Endpoint}", agentEndpoint);
HttpResponseMessage response = await _httpClient.PostAsJsonAsync(
new Uri(agentEndpoint, "/message:send"),
request,
cancellationToken);
response.EnsureSuccessStatusCode();
SendMessageResponse? result = await response.Content
.ReadFromJsonAsync<SendMessageResponse>(cancellationToken: cancellationToken);
return result?.Task ?? throw new InvalidOperationException("No task returned");
}
/// <summary>
/// Polls for task completion.
/// </summary>
public async Task<A2ATask> WaitForTaskCompletionAsync(
Uri agentEndpoint,
string taskId,
TimeSpan pollInterval,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
using CancellationTokenSource timeoutCts = new(timeout);
using CancellationTokenSource linkedCts = CancellationTokenSource
.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
while (!linkedCts.Token.IsCancellationRequested)
{
A2ATask task = await GetTaskAsync(agentEndpoint, taskId, linkedCts.Token);
if (IsTerminalState(task.Status.State))
{
return task;
}
_logger.LogDebug("Task {TaskId} is {State}, polling...", taskId, task.Status.State);
await Task.Delay(pollInterval, linkedCts.Token);
}
throw new TimeoutException($"Task {taskId} did not complete within {timeout}");
}
/// <summary>
/// Gets the current state of a task.
/// </summary>
public async Task<A2ATask> GetTaskAsync(
Uri agentEndpoint,
string taskId,
CancellationToken cancellationToken = default)
{
Uri taskUri = new(agentEndpoint, $"/tasks/{taskId}");
HttpResponseMessage response = await _httpClient.GetAsync(
taskUri, cancellationToken);
response.EnsureSuccessStatusCode();
A2ATask? task = await response.Content.ReadFromJsonAsync<A2ATask>(
cancellationToken: cancellationToken);
return task ?? throw new InvalidOperationException("Task not found");
}
/// <summary>
/// Streams task updates using Server-Sent Events.
/// </summary>
public async IAsyncEnumerable<StreamResponse> StreamMessageAsync(
Uri agentEndpoint,
string messageText,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
SendMessageRequest request = new()
{
Message = new Message
{
MessageId = Guid.NewGuid().ToString(),
Role = Role.User,
Parts = [new Part { Text = messageText }]
}
};
using HttpRequestMessage httpRequest = new(HttpMethod.Post,
new Uri(agentEndpoint, "/message:stream"));
httpRequest.Content = JsonContent.Create(request);
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
using HttpResponseMessage response = await _httpClient.SendAsync(
httpRequest,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using StreamReader reader = new(stream);
while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested)
{
string? line = await reader.ReadLineAsync(cancellationToken);
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: "))
{
continue;
}
string json = line[6..]; // Remove "data: " prefix
StreamResponse? streamResponse = JsonSerializer.Deserialize<StreamResponse>(json);
if (streamResponse is not null)
{
yield return streamResponse;
}
}
}
private static bool IsTerminalState(TaskState state) => state switch
{
TaskState.Completed => true,
TaskState.Failed => true,
TaskState.Canceled => true,
TaskState.Rejected => true,
_ => false
};
/// <inheritdoc/>
public void Dispose()
{
_httpClient.Dispose();
}
}
Multi-Turn Conversation Example
using A2A;
using A2A.Client;
/// <summary>
/// Demonstrates multi-turn A2A conversation.
/// </summary>
public sealed class MultiTurnConversationExample
{
private readonly A2AAgentClient _client;
public MultiTurnConversationExample(A2AAgentClient client)
{
_client = client;
}
/// <summary>
/// Runs a multi-turn conversation with an A2A agent.
/// </summary>
public async Task RunConversationAsync(
Uri agentEndpoint,
CancellationToken cancellationToken = default)
{
// Discover the agent first
AgentCard card = await _client.DiscoverAgentAsync(agentEndpoint, cancellationToken);
Console.WriteLine($"Connected to: {card.Name}");
Console.WriteLine($"Skills: {string.Join(", ", card.Skills.Select(s => s.Name))}");
string? contextId = null;
string? taskId = null;
while (true)
{
Console.Write("\nYou: ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("quit", StringComparison.OrdinalIgnoreCase))
{
break;
}
// Send message with context for multi-turn
SendMessageRequest request = new()
{
Message = new Message
{
MessageId = Guid.NewGuid().ToString(),
Role = Role.User,
ContextId = contextId,
TaskId = taskId,
Parts = [new Part { Text = input }]
},
Configuration = new SendMessageConfiguration
{
Blocking = true // Wait for completion
}
};
// Use streaming for real-time response
Console.Write("Agent: ");
await foreach (StreamResponse response in _client.StreamMessageAsync(
agentEndpoint, input, cancellationToken))
{
if (response.Task is not null)
{
contextId = response.Task.ContextId;
taskId = response.Task.Id;
}
if (response.ArtifactUpdate?.Artifact.Parts is { } parts)
{
foreach (Part part in parts)
{
if (!string.IsNullOrEmpty(part.Text))
{
Console.Write(part.Text);
}
}
}
if (response.StatusUpdate?.Status.State == TaskState.InputRequired)
{
Console.WriteLine("\n[Agent needs more input]");
}
}
Console.WriteLine();
}
}
}
Hosting A2A Server in ASP.NET Core
using A2A;
using A2A.Server;
using A2A.Server.AspNetCore;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Register A2A agent services
builder.Services.AddSingleton<IDocumentAnalyzer, DocumentAnalyzer>();
builder.Services.AddSingleton<IA2AAgent, DocumentAnalysisAgent>();
// Add A2A server infrastructure
builder.Services.AddA2AServer();
// Configure authentication
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
});
builder.Services.AddAuthorization();
WebApplication app = builder.Build();
// Serve Agent Card at well-known location
app.MapGet("/.well-known/agent-card.json", (IA2AAgent agent) =>
Results.Json(agent.GetAgentCard()));
// Map A2A endpoints
app.MapA2AEndpoints()
.RequireAuthorization();
app.Run();
Push Notifications
A2A supports webhook-based push notifications for long-running tasks:
using A2A;
/// <summary>
/// Configures push notifications for task updates.
/// </summary>
PushNotificationConfig config = new()
{
Url = "https://my/-app.example.com/webhooks/a2a",
Token = "secure-webhook-token",
Authentication = new AuthenticationInfo
{
Schemes = ["Bearer"]
}
};
SendMessageRequest request = new()
{
Message = new Message
{
MessageId = Guid.NewGuid().ToString(),
Role = Role.User,
Parts = [new Part { Text = "Analyze this large dataset..." }]
},
Configuration = new SendMessageConfiguration
{
PushNotificationConfig = config,
Blocking = false // Don't wait, use webhook
}
};
Webhook Handler
using A2A;
/// <summary>
/// ASP.NET Core controller for A2A webhook notifications.
/// </summary>
[ApiController]
[Route("webhooks/a2a")]
public sealed class A2AWebhookController : ControllerBase
{
private readonly ILogger<A2AWebhookController> _logger;
public A2AWebhookController(ILogger<A2AWebhookController> logger)
{
_logger = logger;
}
/// <summary>
/// Receives A2A push notifications.
/// </summary>
[HttpPost]
public async Task<IActionResult> ReceiveNotificationAsync(
[FromBody] StreamResponse notification)
{
if (notification.StatusUpdate is { } statusUpdate)
{
_logger.LogInformation(
"Task {TaskId} status: {State}",
statusUpdate.TaskId,
statusUpdate.Status.State);
if (statusUpdate.Status.State == TaskState.Completed)
{
// Handle completion
await HandleTaskCompletedAsync(statusUpdate.TaskId);
}
}
if (notification.ArtifactUpdate is { } artifactUpdate)
{
_logger.LogInformation(
"Task {TaskId} artifact: {ArtifactId}",
artifactUpdate.TaskId,
artifactUpdate.Artifact.ArtifactId);
// Process the artifact
await ProcessArtifactAsync(artifactUpdate.Artifact);
}
return Ok();
}
private Task HandleTaskCompletedAsync(string taskId)
{
// Implementation
return Task.CompletedTask;
}
private Task ProcessArtifactAsync(Artifact artifact)
{
// Implementation
return Task.CompletedTask;
}
}
Error Handling
A2A defines specific error types:
| Error | HTTP | Description |
|---|---|---|
TaskNotFoundError | 404 | Task doesn’t exist or isn’t accessible |
TaskNotCancelableError | 409 | Task already in terminal state |
ContentTypeNotSupportedError | 415 | Media type not supported |
UnsupportedOperationError | 400 | Operation not supported by agent |
VersionNotSupportedError | 400 | Protocol version not supported |
using A2A;
using System.Net;
/// <summary>
/// Handles A2A-specific errors.
/// </summary>
public async Task<A2ATask> SafeGetTaskAsync(
A2AAgentClient client,
Uri endpoint,
string taskId,
CancellationToken cancellationToken)
{
try
{
return await client.GetTaskAsync(endpoint, taskId, cancellationToken);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
throw new A2AException("TaskNotFoundError",
$"Task {taskId} not found or not accessible", ex);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.BadRequest)
{
throw new A2AException("UnsupportedOperationError",
"Operation not supported by agent", ex);
}
}
/// <summary>
/// A2A-specific exception.
/// </summary>
public sealed class A2AException : Exception
{
public string ErrorType { get; }
public A2AException(string errorType, string message, Exception? inner = null)
: base(message, inner)
{
ErrorType = errorType;
}
}
A2A Protocol Comparison
- Peer-to-peer agent communication
- Agent opacity preserved
- Multi-turn conversations
- Streaming and push notifications
- Enterprise-ready security
- Multiple protocol bindings
- More complex than direct API calls
- Requires A2A-compatible agents
- Still evolving (v1.0 RC)
- Limited .NET ecosystem currently
When to Use A2A vs REST vs MCP
| Scenario | Recommended Protocol |
|---|---|
| AI agent needs to call a database | MCP |
| AI agent delegates to another AI agent | A2A |
| Mobile app calls backend API | REST |
| AI agent uses a search tool | MCP |
| Multiple AI agents collaborate on a task | A2A |
| Standard CRUD operations | REST |
| AI agent orchestrates other agents | A2A |