Published on

Google's A2A Protocol — How AI Agents Talk to Each Other in Production

Authors

Introduction

Google''s Agent-to-Agent (A2A) protocol enables AI agents to discover, communicate, and coordinate with each other in production systems. Unlike function calling or webhooks, A2A provides a standardised way for agents to declare capabilities, accept tasks, and report progress. This post covers the A2A specification, how agent cards work, and building production-ready multi-agent systems.

What is the A2A Protocol?

A2A enables autonomous agents to register their capabilities and accept work from other agents. The protocol defines:

Agent Cards: JSON manifests declaring an agent''s capabilities, inputs, outputs, and endpoint Task Lifecycle: Submitted → Working → Completed → success/failure Webhooks: Push notifications when task status changes Discovery: Service registry for finding available agents

This decouples agents from orchestrators. An agent doesn''t care who sends it tasks—only that they conform to the schema defined in its agent card.

Agent Cards: Declaring Capabilities

An agent card is a JSON schema that describes what an agent does:

{
  "name": "email-summariser",
  "version": "1.0.0",
  "description": "Summarises email conversations",
  "capabilities": [
    {
      "id": "summarise_email_thread",
      "name": "Summarise Email Thread",
      "description": "Analyse an email conversation and produce a summary"
    }
  ],
  "input_schema": {
    "type": "object",
    "properties": {
      "email_thread_id": {
        "type": "string",
        "description": "ID of the email thread"
      },
      "max_length": {
        "type": "integer",
        "description": "Maximum summary length in words"
      }
    },
    "required": ["email_thread_id"]
  },
  "output_schema": {
    "type": "object",
    "properties": {
      "summary": {
        "type": "string",
        "description": "Summary of the email thread"
      },
      "key_decisions": {
        "type": "array",
        "items": { "type": "string" }
      }
    }
  },
  "endpoint": "https://api.internal.com/agents/email-summariser",
  "timeout_seconds": 300,
  "retry_policy": {
    "max_retries": 3,
    "backoff_multiplier": 2
  }
}

Task Lifecycle and Webhooks

A2A tasks progress through well-defined states:

interface A2ATask {
  task_id: string;
  agent_id: string;
  capability_id: string;
  status: 'submitted' | 'working' | 'completed';
  input: Record<string, unknown>;
  output?: Record<string, unknown>;
  error?: string;
  created_at: string;
  started_at?: string;
  completed_at?: string;
  webhook_url?: string;
}

// Webhook payload when task completes
interface TaskCompletionWebhook {
  task_id: string;
  status: 'success' | 'failure';
  output?: Record<string, unknown>;
  error?: string;
  completed_at: string;
}

The orchestrator receives webhooks instead of polling. This is critical for production scalability—no agent needs to know about the orchestrator''s state.

Building an A2A-Compatible Agent Endpoint in Express

import express, { Express, Request, Response } from 'express';
import crypto from 'crypto';

interface TaskRequest {
  task_id: string;
  input: { email_thread_id: string; max_length?: number };
  webhook_url?: string;
}

const app: Express = express();
app.use(express.json());

// Store tasks in memory (use persistent DB in production)
const tasks = new Map<string, TaskRequest>();

// Health check
app.get('/health', (req: Request, res: Response) => {
  res.json({ status: 'healthy', agent: 'email-summariser' });
});

// Get agent card
app.get('/agent-card', (req: Request, res: Response) => {
  res.json({
    name: 'email-summariser',
    version: '1.0.0',
    description: 'Summarises email conversations',
    input_schema: {
      type: 'object',
      properties: {
        email_thread_id: { type: 'string' },
        max_length: { type: 'integer' },
      },
      required: ['email_thread_id'],
    },
    output_schema: {
      type: 'object',
      properties: {
        summary: { type: 'string' },
        key_decisions: { type: 'array', items: { type: 'string' } },
      },
    },
    endpoint: 'https://api.internal.com/agents/email-summariser',
    timeout_seconds: 300,
  });
});

// Accept task
app.post('/tasks', async (req: Request, res: Response) => {
  const { task_id, input, webhook_url } = req.body as TaskRequest;

  // Validate input
  if (!input.email_thread_id) {
    return res.status(400).json({ error: 'email_thread_id is required' });
  }

  // Acknowledge task receipt
  tasks.set(task_id, { task_id, input, webhook_url });
  res.status(202).json({ task_id, status: 'submitted' });

  // Process asynchronously
  setImmediate(() => processTask(task_id, input, webhook_url));
});

async function processTask(
  taskId: string,
  input: { email_thread_id: string; max_length?: number },
  webhookUrl?: string
) {
  try {
    // Simulate fetching and summarising emails
    const emailThread = await fetchEmailThread(input.email_thread_id);
    const summary = await summariseEmails(emailThread, input.max_length);

    const output = {
      summary: summary.text,
      key_decisions: summary.decisions,
    };

    if (webhookUrl) {
      await notifyCompletion(webhookUrl, taskId, 'success', output);
    }
  } catch (error) {
    if (webhookUrl) {
      await notifyCompletion(
        webhookUrl,
        taskId,
        'failure',
        undefined,
        (error as Error).message
      );
    }
  }
}

async function notifyCompletion(
  webhookUrl: string,
  taskId: string,
  status: 'success' | 'failure',
  output?: Record<string, unknown>,
  error?: string
) {
  try {
    await fetch(webhookUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        task_id: taskId,
        status,
        output,
        error,
        completed_at: new Date().toISOString(),
      }),
    });
  } catch (err) {
    console.error('Failed to notify webhook:', err);
  }
}

async function fetchEmailThread(threadId: string): Promise<string[]> {
  // Simulate fetching from database
  return [
    'Can we move the meeting to 3pm?',
    'Sure, that works for me',
    'Great, confirmed!',
  ];
}

async function summariseEmails(
  emails: string[],
  maxLength?: number
): Promise<{ text: string; decisions: string[] }> {
  // Simulate LLM-powered summarisation
  return {
    text: 'Team agreed to reschedule meeting to 3pm',
    decisions: ['Meeting rescheduled to 3pm'],
  };
}

app.listen(3000, () => {
  console.log('A2A agent listening on port 3000');
});

Discovery Service for Agent Registry

A central discovery service lets orchestrators find available agents:

import express from 'express';

interface RegisteredAgent {
  agent_id: string;
  name: string;
  endpoint: string;
  card_url: string;
  registered_at: string;
  status: 'healthy' | 'unhealthy';
}

const discoveryApp = express();
const agents = new Map<string, RegisteredAgent>();

// Register agent
discoveryApp.post('/register', (req, res) => {
  const { agent_id, name, endpoint, card_url } = req.body;

  agents.set(agent_id, {
    agent_id,
    name,
    endpoint,
    card_url,
    registered_at: new Date().toISOString(),
    status: 'healthy',
  });

  res.json({ success: true, agent_id });
});

// Discover agents by capability
discoveryApp.get('/agents', async (req, res) => {
  const { capability } = req.query as { capability?: string };

  // Fetch agent cards and filter by capability
  const matchingAgents = [];
  for (const [agentId, agent] of agents.entries()) {
    const response = await fetch(agent.card_url);
    const card = await response.json();

    if (!capability || card.capabilities.some((c: { id: string }) => c.id === capability)) {
      matchingAgents.push({ agent, card });
    }
  }

  res.json({ agents: matchingAgents });
});

// Health check
discoveryApp.post('/health-check/:agent_id', async (req, res) => {
  const agent = agents.get(req.params.agent_id);
  if (!agent) {
    return res.status(404).json({ error: 'Agent not found' });
  }

  try {
    const response = await fetch(`${agent.endpoint}/health`);
    if (response.ok) {
      agent.status = 'healthy';
      res.json({ agent_id: agent.agent_id, status: 'healthy' });
    } else {
      agent.status = 'unhealthy';
      res.status(503).json({ agent_id: agent.agent_id, status: 'unhealthy' });
    }
  } catch (error) {
    agent.status = 'unhealthy';
    res.status(503).json({ agent_id: agent.agent_id, status: 'unhealthy' });
  }
});

discoveryApp.listen(3001, () => {
  console.log('Discovery service listening on port 3001');
});

A2A vs MCP

Both enable agent communication but solve different problems:

MCP: Tools embedded in a server. A single model (Claude, Cursor) connects to multiple MCP servers. Synchronous tool calling. Best for single-client scenarios.

A2A: Agents register capabilities and accept tasks. Multiple orchestrators can submit work. Asynchronous task processing. Best for multi-agent systems with decoupled workflows.

In practice, use MCP within your system (Claude calls MCP tools), and A2A between independent agent teams (Team A''s orchestrator submits tasks to Team B''s specialist agents).

Streaming Task Updates with Server-Sent Events

For real-time task progress, use SSE:

app.get('/tasks/:task_id/stream', (req, res) => {
  const taskId = req.params.task_id;

  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
  });

  const updateInterval = setInterval(() => {
    const task = tasks.get(taskId);
    if (!task) {
      res.write('event: error\n');
      res.write('data: {"error": "Task not found"}\n\n');
      clearInterval(updateInterval);
      res.end();
      return;
    }

    res.write('event: task-update\n');
    res.write(`data: ${JSON.stringify(task)}\n\n`);

    // Stop streaming when complete
    if (task.status === 'completed') {
      clearInterval(updateInterval);
      res.end();
    }
  }, 1000);

  req.on('close', () => {
    clearInterval(updateInterval);
  });
});

Client subscribes to task progress:

const eventSource = new EventSource('/tasks/task-123/stream');
eventSource.addEventListener('task-update', (event) => {
  const task = JSON.parse(event.data);
  console.log('Task status:', task.status);
});

Checklist

  • Understand A2A agent cards and task lifecycle
  • Build an A2A-compatible agent endpoint
  • Implement agent discovery service
  • Add webhook notifications for task completion
  • Set up health checks and agent monitoring
  • Implement SSE for task progress streaming

Conclusion

A2A protocol standardises how independent AI agents communicate and collaborate. By defining agent capabilities in schemas and using asynchronous task-based workflows, A2A scales multi-agent systems beyond simple function calling. Start with a discovery service, register your first agents, and build a lightweight orchestrator that routes tasks by capability. As your agent fleet grows, A2A''s loose coupling and async patterns keep systems manageable.