6 min read

A2A in Practice: Three Agents, One Orchestrator, Zero Shared State

A2A in Practice: Three Agents, One Orchestrator, Zero Shared State

Most agent systems today are single-framework monoliths. One LangGraph. One LlamaIndex. One custom loop. The moment you want two agents to talk to each other — and they weren't built by the same team with the same SDK — the seams show.

That's the problem the Agent2Agent (A2A) protocol solves. And it's the problem we stress-tested with a three-agent demo you can run in docker-compose up.

The repo: github.com/RELETIXAI/a2a_protocol


What A2A is, in one paragraph

A2A v0.3.0 is a specification for letting heterogeneous agents communicate over JSON-RPC 2.0 over HTTP. Every agent exposes an Agent Card at /.well-known/agent-card.json declaring its identity, skills, and capabilities. Any client — another agent, a web inspector, your application — fetches the card, then sends structured messages to message/send. The agents are opaque to each other. They share no state, no framework, no memory. They only share the protocol.

That last line is the whole point.


The demo, by the numbers

Three agents. Two frameworks. One orchestrator. Four containers.

Component Framework Port Role
agent1 LangGraph ReAct 8001 Sends emails via Gmail API
agent2 Google GenAI SDK 8002 Creates Google Sheets
agent3 LangGraph StateGraph 8003 Orchestrator — discovers and routes
inspector a2aprotocol/inspector 4000 Web UI to talk to agent3

Agent 1 and Agent 2 don't know each other exists. Agent 3 discovers them at runtime by polling for their Agent Cards, reads their declared skills, and uses structured LLM output to decide where a user request should go.

The user types: "Send an email to [email protected] saying hello." What happens:

  1. Inspector POSTs the message to agent3:8000.
  2. Agent 3 fans out GETs to agent1:8000/.well-known/agent-card.json and agent2:8000/.well-known/agent-card.json.
  3. Agent 3's router node passes the user input plus the discovered skill descriptions to Gemini, constrained to return a RouteDecision(agent_url, reasoning).
  4. Gemini returns agent_url=http://agent1:8000 with a one-line reason.
  5. Agent 3 opens an A2A client to Agent 1 and forwards the message.
  6. Agent 1's ReAct loop picks the send_email tool, hits Gmail API, returns a completion message.
  7. Agent 3 proxies the response back to the Inspector.

No shared memory. No framework coupling. No hardcoded routes. Gemini decides at runtime which agent can handle the request, based on text descriptions agents publish about themselves.


The 6 design decisions that made this work

1. Agent Cards as the contract

Every agent publishes a static JSON document describing what it can do. Not OpenAPI. Not GraphQL. A capability declaration in natural language, optimized for LLMs to read.

{
  "name": "Email Agent",
  "description": "Agent that sends emails via Gmail API.",
  "url": "http://agent1:8000",
  "capabilities": { "streaming": false },
  "skills": [
    {
      "id": "send_email",
      "name": "Send Email",
      "description": "Capable of sending emails to any address with a custom subject and body.",
      "tags": ["email", "communication"],
      "examples": ["Send an email to [email protected] saying hi"]
    }
  ]
}

The router's prompt concatenates these descriptions. The LLM doesn't pick from code — it picks from prose the agent itself wrote. That's the decoupling.

2. JSON-RPC, not REST

A2A uses JSON-RPC 2.0 on purpose. Every call is a structured { jsonrpc, id, method, params } object. Every response is a result or error. This matters because:

  • Method names (message/send, tasks/get, tasks/cancel) form a small, closed vocabulary.
  • Error codes are standardized (-32601 for unknown method, -32001 for task not found, etc.).
  • Clients can be generic. The A2A SDK ships with a ClientFactory that speaks the protocol against any compliant server.

You don't design endpoints. You implement the spec.

3. Framework-agnostic by construction

Agent 1 and Agent 3 use LangGraph. Agent 2 uses the raw Google GenAI SDK with manual function calling. They don't know about each other. They don't share a base class, a task store, or an LLM client.

The only shared surface is the A2A SDK's AgentExecutor abstract class:

class AgentExecutor(ABC):
    async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: ...
    async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: ...

Everything inside execute is the agent's private universe. That's why you can swap LangGraph for CrewAI, or Python for Go, and nothing downstream breaks.

4. Dynamic discovery beats a registry

We considered standing up a service registry. We didn't need one.

Agent 3 discovers agents at runtime by walking known service names in the Docker network and calling /.well-known/agent-card.json with a 1-second timeout. Reachable agents are added to the routing pool. Unreachable ones are skipped without failing the request. The orchestrator also filters out its own card to prevent self-routing loops — one of those details that feels obvious in hindsight and eats an afternoon the first time.

In production you'd replace the service-name walk with DNS-SD, mDNS, or a real registry. The discovery pattern stays identical.

5. Structured LLM output for routing

The router node doesn't parse a free-form LLM response. It uses llm.with_structured_output(RouteDecision) to force Gemini into a pydantic schema:

class RouteDecision(BaseModel):
    agent_url: str       # must be a discovered URL, or "direct_response"
    reasoning: str

This is the boring decision that saves you at 2am. Free-text routing fails silently — the LLM returns an agent name that almost-matches, string comparison misses, the request goes nowhere. Structured output fails loudly and predictably.

6. InMemoryTaskStore for the demo, not for production

The A2A SDK exposes a TaskStore interface. We used InMemoryTaskStore because the demo restarts cleanly. Production deployments need Redis, Postgres, or an object store to survive restarts and scale horizontally. This is the single largest gap between the demo and a production A2A system. Plan for it.


Request flow, compressed

User → Inspector
     → POST agent3:8000 (A2A message/send)
       agent3: TaskState=working
       agent3: discover_agent_cards() → GET agent{1,2}:8000/.well-known/
       agent3: router_node() → Gemini → RouteDecision(agent_url=agent1)
       agent3: execute_route_node() → A2A ClientFactory.connect(agent1)
         → POST agent1:8000 (A2A message/send)
           agent1: TaskState=working
           agent1: LangGraph ReAct → send_email() → Gmail API
           agent1: TaskState=completed → Message
       agent3: TaskState=completed → Message(proxied)
User ← Inspector

Two A2A hops. Two independent task lifecycles. One user experience.


What this is not

We are publishing this because the A2A ecosystem is full of toy demos and short on honest accounting. So:

  • This is not multi-agent reasoning. Agent 3 does not debate with Agent 1. It routes and proxies. Multi-hop planning, critique loops, and role-play are out of scope.
  • This is not authenticated. The demo has no token auth on the A2A endpoints. In production you put mTLS or OAuth client credentials between agents. The generate_token.py script only handles Google OAuth for Gmail/Sheets, not inter-agent auth.
  • This is not streaming. capabilities.streaming: false on every Agent Card. We poll. SSE is part of the A2A spec (message/stream) and a natural next step.
  • This is not production-ready. InMemoryTaskStore, no retries, no circuit breakers, print("[DEBUG]") instead of structured logging. It's a protocol demonstration, not a deployment.

Name the gaps. Ship what's real.


The 8 engineering pillars this exercise locks in

  1. Protocol before framework. Pick the wire format first. Pick your agent SDK second.
  2. Self-describing agents. If your agent can't explain itself in JSON, another agent can't route to it.
  3. Structured routing. Free-text routing decisions are a liability. Use constrained output.
  4. Discovery, not configuration. Hardcoded agent URLs rot. /.well-known/ scales.
  5. Opacity. Inter-agent contracts are data, not shared objects. No base classes across agent boundaries.
  6. Loop guards at the router. The orchestrator must filter itself out of the candidate pool. Always.
  7. Task store is the persistence boundary. The moment you leave the demo, InMemoryTaskStore becomes your outage.
  8. Name what you don't have. Auth, streaming, retries, observability — list them explicitly so nobody assumes the demo covers them.

Run it yourself

git clone https://github.com/RELETIXAI/a2a_protocl.git
cd a2a_protocl
cp .env.example .env   # add GEMINI_API_KEY
# drop your Google OAuth credentials.json and token.json at root
docker-compose up -d --build

Then open http://localhost:4000, point the Inspector at http://agent3:8000, and try:

  • "Send an email to your-own-address saying Hello from A2A." → routes to Agent 1.
  • "Create a spreadsheet tracking my groceries." → routes to Agent 2.

Watch the docker logs agent3 -f stream to see the discovery, the RouteDecision, and the downstream call happen in real time.


Build with the community

  • Deploy the A2A demo: github.com/RELETIXAI/a2a_protocl
  • Add a new agent: fork langgraph_agent_template/, publish your Agent Card, wire it into docker-compose.yml. Agent 3 will discover it on next request.
  • Push back on the design: if you've implemented A2A with a different discovery, auth, or task-store strategy, send it to [email protected]. We'll publish the delta.

Explore the full library at ai.reletix.com

RELETIX AI — Depth over volume. Builders over followers.