What is the A2A Protocol?
The Agent-to-Agent (A2A) Protocol is an open standard introduced by Google in April 2025 that enables AI agents to communicate and collaborate seamlessly. It’s designed to create an interoperable ecosystem where different AI systems can discover each other’s capabilities and work together effectively.
I recently implemented A2A Protocol on this blog to make my content accessible to AI agents. This guide will walk you through the entire process, from understanding the protocol to having a fully functional implementation that agents can discover and use.
Try It Before You Build It
Before diving into implementation, I encourage you to experience A2A in action. I’ve created a comprehensive Jupyter notebook that demonstrates A2A Protocol interaction.
The notebook allows you to:
- Test against live A2A implementations (including this blog)
- Understand the discovery process
- See real request/response patterns
- Benchmark performance
- Validate your own implementation
You can run it locally or in Google Colab to experiment with A2A endpoints.
Core A2A Architecture
The A2A Protocol follows a client-server model with three main components:
- Discovery: Agents find your service via an agent card at
/.well-known/agent.json
- Authentication: Secure access using API keys, OAuth 2.0, or OpenID Connect
- Communication: JSON-RPC 2.0 messages over HTTPS
Step 1: Create Your Agent Card
The agent card is the entry point for AI agents to discover your service. Create this file at /.well-known/agent.json
:
{
"name": "Colin McNamara's Blog",
"description": "Tech blog focusing on AI, networking, and software development with A2A Protocol support",
"version": "1.0.0",
"protocolVersion": "0.1.0",
"capabilities": {
"jsonrpc": {
"endpoint": "/api/a2a/service",
"batchRequests": false,
"extensions": []
}
},
"serviceEndpoints": {
"blog.list_posts": "/api/a2a/blog/list",
"blog.get_post": "/api/a2a/blog/get",
"blog.search_posts": "/api/a2a/blog/search",
"blog.get_metadata": "/api/a2a/blog/metadata",
"blog.get_author_info": "/api/a2a/blog/author"
},
"skills": [
{
"name": "blog",
"description": "Access and search blog content",
"methods": [
{
"name": "list_posts",
"description": "List blog posts with pagination support",
"parameters": {
"limit": "Number of posts to return (default: 10)",
"offset": "Number of posts to skip (default: 0)"
}
},
{
"name": "get_post",
"description": "Get a specific blog post by ID",
"parameters": {
"id": "The post ID (slug)"
}
},
{
"name": "search_posts",
"description": "Search blog posts by query",
"parameters": {
"query": "Search query string",
"limit": "Maximum results to return"
}
}
]
}
],
"rateLimit": {
"requestsPerMinute": 60,
"requestsPerHour": 1000
},
"authenticationSchemes": []
}
Step 2: Implement the Service Endpoints
I recommend using a multi-endpoint architecture where each method has its own endpoint. This approach:
- Avoids caching issues on edge networks
- Provides better observability
- Allows independent scaling
- Simplifies debugging
List Posts Endpoint (/api/a2a/blog/list.ts
)
import type { APIContext } from 'astro';
import { getCollection } from 'astro:content';
export async function POST({ request }: APIContext) {
try {
const { params = {}, id } = await request.json();
const limit = params.limit || 10;
const offset = params.offset || 0;
// Get all posts from Astro content collection
const allPosts = await getCollection('posts');
// Sort by date (newest first) and paginate
const sortedPosts = allPosts
.sort((a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime())
.slice(offset, offset + limit);
// Format posts for A2A response
const posts = sortedPosts.map(post => ({
id: post.id,
title: post.data.title,
summary: post.data.description,
date: post.data.date.toISOString(),
url: `https://colinmcnamara.com/posts/${post.id}/`,
author: {
name: post.data.author,
email: "[email protected]"
},
tags: post.data.tags || [],
_a2a: {
contentHash: "simplified-endpoint",
lastModified: post.data.date.toISOString(),
license: {
type: "CC BY 4.0",
url: "https://creativecommons.org/licenses/by/4.0/"
}
}
}));
return new Response(JSON.stringify({
jsonrpc: "2.0",
result: {
posts,
pagination: {
total: allPosts.length,
limit,
offset,
hasMore: offset + limit < allPosts.length
}
},
id: id || 1
}), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-A2A-Version': '0.1.0'
}
});
} catch (error) {
return new Response(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal error",
data: error instanceof Error ? error.message : "Unknown error"
},
id: id || 1
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
Search Posts Endpoint (/api/a2a/blog/search.ts
)
export async function POST({ request }: APIContext) {
try {
const { params = {}, id } = await request.json();
const query = params.query?.toLowerCase() || '';
const limit = params.limit || 10;
if (!query) {
return new Response(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32602,
message: "Invalid params",
data: "Missing required parameter: query"
},
id: id || 1
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const allPosts = await getCollection('posts');
// Search in title, description, and tags
const matchingPosts = allPosts.filter(post =>
post.data.title.toLowerCase().includes(query) ||
post.data.description.toLowerCase().includes(query) ||
post.data.tags?.some(tag => tag.toLowerCase().includes(query)) ||
post.body.toLowerCase().includes(query)
);
// Sort by relevance (simple scoring based on title matches)
const scoredPosts = matchingPosts.map(post => ({
post,
score: (
(post.data.title.toLowerCase().includes(query) ? 3 : 0) +
(post.data.description.toLowerCase().includes(query) ? 2 : 0) +
(post.data.tags?.some(tag => tag.toLowerCase().includes(query)) ? 1 : 0)
)
}));
const posts = scoredPosts
.sort((a, b) => b.score - a.score ||
new Date(b.post.data.date).getTime() - new Date(a.post.data.date).getTime())
.slice(0, limit)
.map(({ post }) => ({
id: post.id,
title: post.data.title,
summary: post.data.description,
date: post.data.date.toISOString(),
url: `https://colinmcnamara.com/posts/${post.id}/`,
tags: post.data.tags || [],
_a2a: {
relevanceScore: scoredPosts.find(s => s.post.id === post.id)?.score || 0
}
}));
return new Response(JSON.stringify({
jsonrpc: "2.0",
result: {
posts,
query,
totalMatches: matchingPosts.length
},
id: id || 1
}), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
});
} catch (error) {
return new Response(JSON.stringify({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal error",
data: error instanceof Error ? error.message : "Unknown error"
},
id: id || 1
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
Step 3: Add Discovery Headers
Help agents discover your A2A support by adding these headers to your homepage:
// middleware.ts
export async function onRequest(context, next) {
const response = await next();
// Add A2A discovery headers on homepage
if (context.url.pathname === '/') {
response.headers.set('X-A2A-Agent-Card', '/.well-known/agent.json');
response.headers.set('Link', '</.well-known/agent.json>; rel="a2a-agent-card"');
}
return response;
}
Step 4: Test Your Implementation
Use the A2A Quick Start notebook to test your implementation:
import requests
import json
# Your domain
base_url = "https://yourdomain.com"
# 1. Test agent card discovery
agent_card_url = f"{base_url}/.well-known/agent.json"
response = requests.get(agent_card_url)
agent_card = response.json()
print(f"✅ Found A2A service: {agent_card['name']}")
print(f" Protocol version: {agent_card['protocolVersion']}")
# 2. Test listing posts
list_endpoint = agent_card['serviceEndpoints']['blog.list_posts']
response = requests.post(
f"{base_url}{list_endpoint}",
json={
"jsonrpc": "2.0",
"method": "blog.list_posts",
"params": {"limit": 5},
"id": 1
}
)
result = response.json()
print(f"\n✅ Listed {len(result['result']['posts'])} posts")
# 3. Test search
search_endpoint = agent_card['serviceEndpoints']['blog.search_posts']
response = requests.post(
f"{base_url}{search_endpoint}",
json={
"jsonrpc": "2.0",
"method": "blog.search_posts",
"params": {"query": "AI", "limit": 3},
"id": 2
}
)
result = response.json()
print(f"\n✅ Found {len(result['result']['posts'])} posts matching 'AI'")
Implementation Best Practices
1. Error Handling
Always return proper JSON-RPC error responses:
// Standard error codes
const ERROR_CODES = {
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL_ERROR: -32603
};
2. Input Validation
Use a schema validation library like Zod:
import { z } from 'zod';
const ListPostsSchema = z.object({
limit: z.number().min(1).max(100).default(10),
offset: z.number().min(0).default(0)
});
// In your endpoint
const params = ListPostsSchema.parse(request.params || {});
3. Rate Limiting
Implement rate limiting to prevent abuse:
const rateLimit = new Map();
export function checkRateLimit(clientId: string): boolean {
const now = Date.now();
const windowStart = now - 60000; // 1 minute window
const requests = rateLimit.get(clientId) || [];
const recentRequests = requests.filter(time => time > windowStart);
if (recentRequests.length >= 60) {
return false; // Rate limit exceeded
}
recentRequests.push(now);
rateLimit.set(clientId, recentRequests);
return true;
}
4. Caching Strategy
For static content, implement ETag-based caching:
import crypto from 'crypto';
function generateETag(content: any): string {
return crypto
.createHash('md5')
.update(JSON.stringify(content))
.digest('hex');
}
// In your response
const etag = generateETag(result);
response.headers.set('ETag', `"${etag}"`);
Testing with Live Endpoints
You can test your A2A client against this blog’s live endpoints:
- Agent Card: https://colinmcnamara.com/.well-known/agent.json
- List Posts: https://colinmcnamara.com/api/a2a/blog/list
- Search: https://colinmcnamara.com/api/a2a/blog/search
- Get Post: https://colinmcnamara.com/api/a2a/blog/get
- Metadata: https://colinmcnamara.com/api/a2a/blog/metadata
- Author Info: https://colinmcnamara.com/api/a2a/blog/author
Common Implementation Challenges
Edge Network Caching
If deploying to Vercel or similar platforms, be aware that edge caching can cause issues with POST requests. Solutions:
- Use multi-endpoint architecture (recommended)
- Disable caching with
Cache-Control: no-store
- Add cache-busting parameters if needed
CORS Configuration
Enable CORS for browser-based agents:
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type');
Content Freshness
Ensure agents get fresh content by:
- Including
lastModified
timestamps - Implementing proper cache headers
- Using ETags for conditional requests
Extending Your Implementation
Once basic functionality works, consider adding:
- Authentication: Implement API keys for premium content
- Webhooks: Notify agents of new content
- Batch Operations: Support multiple requests in one call
- Streaming: Use Server-Sent Events for real-time updates
- Custom Extensions: Add domain-specific methods
Join the A2A Ecosystem
By implementing A2A Protocol, you’re making your content accessible to a growing ecosystem of AI agents. This enables:
- Automated content discovery
- Cross-site content aggregation
- AI-powered research tools
- Agent-based workflows
Resources and References
- A2A Protocol Official Documentation - Comprehensive protocol specification
- A2A Quick Start Notebook - Interactive testing tool
- Live Agent Card Example - See a working implementation
Get Help
If you run into issues:
- Test against the live endpoints on this blog
- Use the notebook to debug your implementation
- Check the error responses for specific issues
- Validate your JSON-RPC formatting
Happy building! Welcome to the agent-enabled web. 🤖