Chapter 18: A2A Protocol - Agent-to-Agent Communication
This chapter explores how Pierre implements the Agent-to-Agent (A2A) protocol for secure inter-agent communication. You’ll learn about the A2A protocol architecture, Ed25519 signatures, agent capability discovery, and JSON-RPC-based messaging between AI agents.
A2A Protocol Overview
A2A (Agent-to-Agent) protocol enables AI agents to communicate and collaborate:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Agent A │ │ Pierre │ │ Agent B │
│ (Claude) │ │ A2A Server │ │ (Other AI) │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
│ 1. Get Agent Card │ │
├────────────────────────────────►│ │
│ (discover capabilities) │ │
│ │ │
│ 2. Register A2A Client │ │
│ (with Ed25519 public key) │ │
├────────────────────────────────►│ │
│ │ │
│ 3. Initialize session │ │
│ (negotiate protocol version) │ │
├────────────────────────────────►│ │
│ │ │
│ 4. Send message │ │
│ (with Ed25519 signature) │ │
├────────────────────────────────►│ │
│ │ 5. Forward message │
│ ├────────────────────────────────►│
│ │ │
│ 6. Stream response │ │
│◄────────────────────────────────┤ │
A2A use cases:
- Multi-agent workflows: Claude orchestrates Pierre for fitness analysis
- Task delegation: Long-running analytics tasks with progress updates
- Capability discovery: Agents learn what other agents can do
- Secure messaging: Ed25519 signatures prevent message tampering
JSON-RPC 2.0 Foundation
A2A protocol uses JSON-RPC 2.0 for all communication:
Source: src/a2a/protocol.rs:23-28
#![allow(unused)]
fn main() {
// Phase 2: Type aliases pointing to unified JSON-RPC foundation
/// A2A protocol request (JSON-RPC 2.0 request)
pub type A2ARequest = crate::jsonrpc::JsonRpcRequest;
/// A2A protocol response (JSON-RPC 2.0 response)
pub type A2AResponse = crate::jsonrpc::JsonRpcResponse;
}
Design choice: A2A reuses the same JSON-RPC infrastructure as MCP (Chapter 9), ensuring consistency and reducing code duplication.
A2A Error Types
A2A defines protocol-specific errors mapped to JSON-RPC error codes:
Source: src/a2a/protocol.rs:31-69
#![allow(unused)]
fn main() {
/// A2A Protocol Error types
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
pub enum A2AError {
/// Invalid request parameters or format
#[error("Invalid request: {0}")]
InvalidRequest(String),
/// Authentication failed
#[error("Authentication failed: {0}")]
AuthenticationFailed(String),
/// Client not registered
#[error("Client not registered: {0}")]
ClientNotRegistered(String),
/// Database operation failed
#[error("Database error: {0}")]
DatabaseError(String),
/// Client has been deactivated
#[error("Client deactivated: {0}")]
ClientDeactivated(String),
/// Rate limit exceeded
#[error("Rate limit exceeded: {0}")]
RateLimitExceeded(String),
/// Session expired or invalid
#[error("Session expired: {0}")]
SessionExpired(String),
/// Insufficient permissions
#[error("Insufficient permissions: {0}")]
InsufficientPermissions(String),
// ... more error types
}
}
Error code mapping:
Source: src/a2a/protocol.rs:76-95
#![allow(unused)]
fn main() {
impl From<A2AError> for A2AErrorResponse {
fn from(error: A2AError) -> Self {
let (code, message) = match error {
A2AError::InvalidRequest(msg) => (-32602, format!("Invalid params: {msg}")),
A2AError::AuthenticationFailed(msg) => {
(-32001, format!("Authentication failed: {msg}"))
}
A2AError::ClientNotRegistered(msg) => (-32003, format!("Client not registered: {msg}")),
A2AError::RateLimitExceeded(msg) => (-32005, format!("Rate limit exceeded: {msg}")),
A2AError::SessionExpired(msg) => (-32006, format!("Session expired: {msg}")),
A2AError::InsufficientPermissions(msg) => {
(-32008, format!("Insufficient permissions: {msg}"))
}
// ... more error mappings
};
Self {
code,
message,
data: None,
}
}
}
}
Error code ranges:
-32600to-32699: JSON-RPC reserved codes-32000to-32099: Server-defined errors-32001to-32010: A2A-specific error codes
A2A Client Structure
A2A clients have identities, public keys, and capabilities:
Source: src/a2a/auth.rs:34-68
#![allow(unused)]
fn main() {
/// A2A Client registration information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct A2AClient {
/// Unique client identifier
pub id: String,
/// User ID for session tracking and consistency
pub user_id: uuid::Uuid,
/// Human-readable client name
pub name: String,
/// Description of the client application
pub description: String,
/// Public key for signature verification
pub public_key: String,
/// List of capabilities this client can access
pub capabilities: Vec<String>,
/// Allowed OAuth redirect URIs
pub redirect_uris: Vec<String>,
/// Whether this client is active
pub is_active: bool,
/// When this client was created
pub created_at: chrono::DateTime<chrono::Utc>,
/// List of permissions granted to this client
#[serde(default = "default_permissions")]
pub permissions: Vec<String>,
/// Maximum requests allowed per window
#[serde(default = "default_rate_limit_requests")]
pub rate_limit_requests: u32,
/// Rate limit window duration in seconds
#[serde(default = "default_rate_limit_window")]
pub rate_limit_window_seconds: u32,
}
}
Key fields:
public_key: Ed25519 public key for signature verificationpermissions: Granted access (e.g.,read_activities,write_goals)rate_limit_requests: Max requests per time windowis_active: Admin can deactivate misbehaving clients
A2A Initialization Flow
Agents initialize sessions with protocol negotiation:
Source: src/a2a/protocol.rs:105-123
#![allow(unused)]
fn main() {
/// A2A Initialize Request structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct A2AInitializeRequest {
/// A2A protocol version
#[serde(rename = "protocolVersion")]
pub protocol_version: String,
/// Client information
#[serde(rename = "clientInfo")]
pub client_info: A2AClientInfo,
/// Client capabilities
pub capabilities: Vec<String>,
/// Optional OAuth application credentials provided by the client
#[serde(
rename = "oauthCredentials",
default,
skip_serializing_if = "Option::is_none"
)]
pub oauth_credentials: Option<HashMap<String, crate::mcp::schema::OAuthAppCredentials>>,
}
}
Initialization request (JSON):
{
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2025-11-15",
"clientInfo": {
"name": "Claude Agent",
"version": "1.0.0"
},
"capabilities": [
"message/send",
"message/stream",
"tasks/create"
]
},
"id": 1
}
Initialization response:
Source: src/a2a/protocol.rs:162-187
#![allow(unused)]
fn main() {
impl A2AInitializeResponse {
/// Create a new A2A initialize response with server information
#[must_use]
pub fn new(protocol_version: String, server_name: String, server_version: String) -> Self {
Self {
protocol_version,
server_info: A2AServerInfo {
name: server_name,
version: server_version,
description: Some(
"AI-powered fitness data analysis and insights platform".to_owned(),
),
},
capabilities: vec![
"message/send".to_owned(),
"message/stream".to_owned(),
"tasks/create".to_owned(),
"tasks/get".to_owned(),
"tasks/cancel".to_owned(),
"tasks/pushNotificationConfig/set".to_owned(),
"tools/list".to_owned(),
"tools/call".to_owned(),
],
}
}
}
}
Capability negotiation: Server returns intersection of client-requested and server-supported capabilities.
A2A Message Structure
Messages support text, structured data, and file attachments:
Source: src/a2a/protocol.rs:189-227
#![allow(unused)]
fn main() {
/// A2A Message structure for agent communication
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct A2AMessage {
/// Unique message identifier
pub id: String,
/// Message content parts (text, data, or files)
pub parts: Vec<MessagePart>,
/// Optional metadata key-value pairs
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, Value>>,
}
/// A2A Message Part types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum MessagePart {
/// Plain text message content
#[serde(rename = "text")]
Text {
/// Text content
content: String,
},
/// Structured data content (JSON)
#[serde(rename = "data")]
Data {
/// Data content as JSON value
content: Value,
},
/// File attachment content
#[serde(rename = "file")]
File {
/// File name
name: String,
/// MIME type of the file
mime_type: String,
/// File content (base64 encoded)
content: String,
},
}
}
Example message (JSON):
{
"id": "msg_abc123",
"parts": [
{
"type": "text",
"content": "Analyzing your recent running activities..."
},
{
"type": "data",
"content": {
"activities_analyzed": 10,
"average_pace": "5:30/km",
"trend": "improving"
}
}
],
"metadata": {
"agent": "Pierre",
"timestamp": "2025-11-15T10:00:00Z"
}
}
A2A Authentication
A2A supports API key authentication with rate limiting:
Source: src/a2a/auth.rs:95-113
#![allow(unused)]
fn main() {
/// Authenticate an A2A request using API key
///
/// # Errors
///
/// Returns an error if:
/// - The API key format is invalid
/// - Authentication fails
/// - Rate limits are exceeded
pub async fn authenticate_api_key(&self, api_key: &str) -> AppResult<AuthResult> {
// Check if it's an A2A-specific API key (with a2a_ prefix)
if api_key.starts_with("a2a_") {
return self.authenticate_a2a_key(api_key).await;
}
// Use standard API key authentication through MCP middleware
let middleware = &self.resources.auth_middleware;
middleware.authenticate_request(Some(api_key)).await
}
}
A2A-specific authentication:
Source: src/a2a/auth.rs:116-181
#![allow(unused)]
fn main() {
/// Authenticate A2A-specific API key with rate limiting
async fn authenticate_a2a_key(&self, api_key: &str) -> AppResult<AuthResult> {
// Extract key components (similar to API key validation)
if !api_key.starts_with("a2a_") || api_key.len() < 16 {
return Err(AppError::auth_invalid("Invalid A2A API key format").into());
}
let middleware = &self.resources.auth_middleware;
// First authenticate using regular API key system
let mut auth_result = middleware.authenticate_request(Some(api_key)).await?;
// Add A2A-specific rate limiting
if let AuthMethod::ApiKey { key_id, tier: _ } = &auth_result.auth_method {
// Find A2A client associated with this API key
if let Some(client) = self.get_a2a_client_by_api_key(key_id).await? {
let client_manager = &*self.resources.a2a_client_manager;
// Check A2A-specific rate limits
let rate_limit_status = client_manager
.get_client_rate_limit_status(&client.id)
.await?;
if rate_limit_status.is_rate_limited {
return Err(ProviderError::RateLimitExceeded {
provider: "A2A Client Authentication".to_owned(),
retry_after_secs: /* calculate from reset_at */,
limit_type: format!(
"A2A client rate limit exceeded. Limit: {}, Reset at: {}",
rate_limit_status.limit.unwrap_or(0),
rate_limit_status.reset_at.map_or_else(|| "unknown".into(), |dt| dt.to_rfc3339())
),
}
.into());
}
// Update auth method to indicate A2A authentication
auth_result.auth_method = AuthMethod::ApiKey {
key_id: key_id.clone(),
tier: format!("A2A-{}", rate_limit_status.tier.display_name()),
};
}
}
Ok(auth_result)
}
}
Rate limiting flow:
- Validate API key format: Must start with
a2a_and have minimum length - Standard authentication: Use existing API key middleware
- Lookup A2A client: Find client associated with API key
- Check rate limits: Enforce A2A-specific rate limits
- Return auth result: Include rate limit status in response
Agent Capability Discovery
Agents advertise capabilities through agent cards:
Source: src/a2a/agent_card.rs:16-34
#![allow(unused)]
fn main() {
/// A2A Agent Card for Pierre
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentCard {
/// Agent name ("Pierre Fitness AI")
pub name: String,
/// Human-readable description of the agent's capabilities
pub description: String,
/// Agent version number
pub version: String,
/// List of high-level capabilities (e.g., "fitness-data-analysis")
pub capabilities: Vec<String>,
/// Authentication methods supported
pub authentication: AuthenticationInfo,
/// Available tools/endpoints with schemas
pub tools: Vec<ToolDefinition>,
/// Optional additional metadata
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, Value>>,
}
}
Agent card example (Pierre):
Source: src/a2a/agent_card.rs:98-135
#![allow(unused)]
fn main() {
impl AgentCard {
/// Create a new Agent Card for Pierre
#[must_use]
pub fn new() -> Self {
Self {
name: "Pierre Fitness AI".into(),
description: "AI-powered fitness data analysis and insights platform providing comprehensive activity analysis, performance tracking, and intelligent recommendations for athletes and fitness enthusiasts.".into(),
version: "1.0.0".into(),
capabilities: vec![
"fitness-data-analysis".into(),
"activity-intelligence".into(),
"goal-management".into(),
"performance-prediction".into(),
"training-analytics".into(),
"provider-integration".into(),
],
authentication: AuthenticationInfo {
schemes: vec!["api-key".into(), "oauth2".into()],
oauth2: Some(OAuth2Info {
authorization_url: "https://pierre.ai/oauth/authorize".into(),
token_url: "https://pierre.ai/oauth/token".into(),
scopes: vec![
"fitness:read".into(),
"analytics:read".into(),
"goals:read".into(),
"goals:write".into(),
],
}),
api_key: Some(ApiKeyInfo {
header_name: "Authorization".into(),
prefix: Some("Bearer".into()),
registration_url: "https://pierre.ai/api/keys/request".into(),
}),
},
tools: Self::create_tool_definitions(),
metadata: Some(Self::create_metadata()),
}
}
}
}
Tool definition in agent card:
Source: src/a2a/agent_card.rs:140-200
#![allow(unused)]
fn main() {
ToolDefinition {
name: "get_activities".into(),
description: "Retrieve user fitness activities from connected providers".to_owned(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"limit": {
"type": "number",
"description": "Number of activities to retrieve (max 100)",
"minimum": 1,
"maximum": 100,
"default": 10
},
"before": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 date to get activities before"
},
"provider": {
"type": "string",
"enum": ["strava", "fitbit"],
"description": "Specific provider to query (optional)"
}
}
}),
output_schema: serde_json::json!({
"type": "object",
"properties": {
"activities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"sport_type": {"type": "string"},
"start_date": {"type": "string", "format": "date-time"},
"duration_seconds": {"type": "number"},
"distance_meters": {"type": "number"},
"elevation_gain": {"type": "number"}
}
}
},
"total_count": {"type": "number"}
}
}),
examples: Some(vec![ToolExample {
description: "Get recent activities".into(),
input: serde_json::json!({"limit": 5}),
output: serde_json::json!({/* example output */}),
}]),
}
}
Agent card benefits:
- Discoverability: Agents learn what Pierre can do without documentation
- JSON Schema: Input/output schemas enable automatic validation
- Examples: Sample usage helps agents understand tool behavior
- Authentication: Agents know how to authenticate (OAuth2, API keys)
Ed25519 Signatures
A2A uses Ed25519 for message authentication:
Ed25519 key generation (conceptual from src/a2a/client.rs:226):
#![allow(unused)]
fn main() {
// Generate Ed25519 keypair for the client
let signing_key = ed25519_dalek::SigningKey::generate(&mut OsRng);
let public_key = signing_key.verifying_key();
// Store public key in A2A client record
A2AClient {
public_key: base64::encode(public_key.to_bytes()),
key_type: "ed25519".into(),
// ... other fields
}
}
Why Ed25519:
- Fast: Much faster than RSA for both signing and verification
- Small keys: 32-byte public keys (vs 256+ bytes for RSA)
- Secure: 128-bit security level, resistant to timing attacks
- Deterministic: Same message always produces same signature (unlike ECDSA)
Signature verification (conceptual):
#![allow(unused)]
fn main() {
fn verify_signature(
message: &[u8],
signature: &[u8],
public_key_base64: &str,
) -> Result<(), A2AError> {
let public_key_bytes = base64::decode(public_key_base64)?;
let public_key = VerifyingKey::from_bytes(&public_key_bytes)?;
let signature = Signature::from_bytes(signature.try_into()?);
public_key
.verify(message, &signature)
.map_err(|_| A2AError::AuthenticationFailed("Invalid signature".into()))
}
}
A2A Tasks
A2A supports long-running tasks with progress tracking:
Source: src/a2a/protocol.rs:229-250 (conceptual)
#![allow(unused)]
fn main() {
/// A2A Task structure for long-running operations
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct A2ATask {
/// Unique task identifier
pub id: String,
/// Current status of the task
pub status: TaskStatus,
/// When the task was created
pub created_at: chrono::DateTime<chrono::Utc>,
/// When the task completed (if finished)
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
/// Task result data (if completed successfully)
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
/// Error message (if failed)
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// Client ID that created this task
pub client_id: String,
/// Type of task being performed
pub task_type: String,
}
}
Task lifecycle:
Created → Running → Completed
↘ Failed
↘ Cancelled
Task notifications: Server pushes progress updates via Server-Sent Events (SSE).
Key Takeaways
-
JSON-RPC foundation: A2A reuses the same JSON-RPC infrastructure as MCP.
-
Agent cards: Self-describing capabilities enable dynamic discovery without documentation.
-
Ed25519 signatures: Fast, secure public key authentication for agent messages.
-
Structured messages: Support text, JSON data, and base64-encoded file attachments.
-
Rate limiting: A2A clients have separate rate limits from regular API keys.
-
API key prefix: A2A API keys use
a2a_prefix to distinguish from standard API keys. -
Protocol negotiation: Clients and servers negotiate supported capabilities during initialization.
-
Long-running tasks: Async operations return task IDs with progress tracking.
-
Error codes: A2A-specific error codes in -32001 to -32010 range.
-
Tool schemas: JSON Schema for input/output enables automatic validation and client generation.
-
Multi-part messages: Single message can contain multiple content parts (text + data + files).
-
Permission model: A2A clients have granular permissions (read_activities, write_goals, etc.).
End of Part V: OAuth, A2A & Providers
You’ve completed the OAuth and provider integration section. You now understand:
- OAuth 2.0 server implementation (Chapter 15)
- OAuth 2.0 client for fitness providers (Chapter 16)
- Provider data models and rate limiting (Chapter 17)
- A2A protocol for agent communication (Chapter 18)
Next Chapter: Chapter 19: Comprehensive Tools Guide - Begin Part VI by learning about all 45+ MCP tools Pierre provides for fitness data analysis, how to use them with natural language prompts, and tool categorization.