●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Connecting Gemini Agents with Google's A2A Protocol: Design, Implementation, and Cloud Run Operations
Build cross-framework agent coordination with the Agent2Agent (A2A) protocol and Gemini API. Agent card design, measured comparison of three coordination patterns, and the operational details that matter on Cloud Run.
One night I split my article pipeline into three agents. Topic selection, drafting, quality checks. Cleaner separation, I thought.
A week later I was staring at my own glue code, stuck. As an indie developer, I own every seam I create — there is no platform team to hand them to. The three agents were written against different libraries, and every seam meant reshaping JSON, picking a timeout, and hand-rolling retry logic. The glue had grown longer than the agents.
Google's Agent2Agent (A2A) protocol is an attempt to standardize that seam. Agents built on different frameworks, from different providers, speak one HTTP-based spec. Neither side needs to know how the other is implemented.
What follows walks through the A2A specification, builds a FastAPI implementation alongside Gemini API and ADK, and takes it all the way to Cloud Run. The later sections cover the operational lessons I only found by running it.
Who this is for:
Developers with existing Gemini API agent experience
Engineers looking to build multi-agent coordination systems
Teams ready to apply A2A protocol in production environments
A2A is a lightweight specification built on HTTP/JSON-RPC 2.0. Each agent operates as an "A2A server," exposing standardized endpoints that any A2A client can interact with.
The four core components of A2A are:
① Agent Card: A JSON metadata document describing an agent's capabilities, endpoint URL, and authentication requirements. Published at /.well-known/agent.json, this is how other agents "discover" your agent.
② Task: The unit of work a client delegates to an agent. Each task has a unique ID and progresses through a lifecycle: submitted → working → completed / failed.
③ Artifact: The output produced by a task. Supports multiple MIME types (text, JSON, files) and can be streamed incrementally during processing.
④ Message: The communication channel during task execution. Enables multi-turn dialogue between user and agent, or between coordinating agents.
Communication Flow
Client A2A Server (Agent)
| |
|--- GET /.well-known/agent.json ---> | ① Discover agent capabilities
|<-- {"name":"...", "skills":[...]} --|
| |
|--- POST /tasks/send --------------> | ② Submit task
| {"id":"task-123", |
| "message":{"role":"user",...}} |
|<-- {"status":{"state":"submitted"}} |
| |
|--- GET /tasks/get?id=task-123 ----> | ③ Poll for result
|<-- {"status":{"state":"completed"}, |
| "artifacts":[...]} |
| |
| OR — subscribe to real-time stream |
|--- POST /tasks/sendSubscribe -----> |
|<== data: {"type":"artifact",...} --| (SSE streaming)
|<== data: {"type":"status",...} --|
Designing an Agent Card
The agent card is your agent's calling card—it determines what requests other agents will send your way. A well-designed card improves discoverability and reduces integration friction.
{ "name": "Data Analysis Agent", "description": "Statistical analysis of CSV and JSON data with insights and visualizations", "url": "https://data-agent.example.run.app", "version": "1.2.0", "capabilities": { "streaming": true, "pushNotifications": false }, "authentication": { "schemes": ["Bearer"] }, "skills": [ { "id": "analyze-csv", "name": "CSV Statistical Analysis", "description": "Accepts a CSV file or text, returns statistics (mean, variance, correlation) and key business insights", "inputModes": ["text", "file"], "outputModes": ["text", "data"] }, { "id": "generate-chart", "name": "Data Visualization", "description": "Automatically selects the best chart type (bar, line, scatter) and generates it from numerical data", "inputModes": ["data"], "outputModes": ["file"] } ]}
Design guidelines:
Write description in one sentence that captures the agent's specific domain
Make skills[].description concrete: what goes in, what comes out
Be precise with inputModes / outputModes—clients use these to decide if your agent is the right choice
Setup and Basic A2A Server Implementation
Installing Dependencies
# Google ADK (includes A2A server utilities)pip install google-adk# Gemini API clientpip install google-generativeai# Web framework + utilitiespip install fastapi uvicorn httpx pyjwt
Minimal A2A Server
# a2a_server.pyimport uuidimport asynciofrom datetime import datetimefrom typing import Optionalfrom fastapi import FastAPI, HTTPException, BackgroundTasksfrom pydantic import BaseModelimport google.generativeai as genaiimport os# Initialize Gemini API# ⚠️ Always load API keys from environment variables in productiongenai.configure(api_key=os.environ["GEMINI_API_KEY"])model = genai.GenerativeModel("gemini-2.5-pro")app = FastAPI(title="Gemini A2A Agent", version="1.0.0")# Task store (use Redis or Firestore in production)tasks: dict[str, dict] = {}# =====================# Agent Card Definition# =====================AGENT_CARD = { "name": "Gemini General Analysis Agent", "description": "Text analysis, summarization, and structured data extraction powered by Gemini 2.5 Pro", "url": "https://your-agent.run.app", "version": "1.0.0", "capabilities": {"streaming": True, "pushNotifications": False}, "authentication": {"schemes": ["Bearer"]}, "skills": [ { "id": "analyze-text", "name": "Text Analysis", "description": "Analyzes input text and returns summaries, insights, or structured JSON", "inputModes": ["text"], "outputModes": ["text", "data"] } ]}# =====================# Endpoints# =====================@app.get("/.well-known/agent.json")async def get_agent_card(): """Expose the agent card (required A2A endpoint)""" return AGENT_CARDclass TaskRequest(BaseModel): id: str message: dict sessionId: Optional[str] = None@app.post("/tasks/send")async def send_task(req: TaskRequest, background_tasks: BackgroundTasks): """Accept a task asynchronously and begin processing""" task_id = req.id tasks[task_id] = { "id": task_id, "status": {"state": "submitted", "timestamp": datetime.utcnow().isoformat()}, "artifacts": [], "history": [req.message] } background_tasks.add_task(process_with_gemini, task_id, req.message) return tasks[task_id]@app.get("/tasks/get")async def get_task(id: str): """Return the current state of a task""" if id not in tasks: raise HTTPException(status_code=404, detail="Task not found") return tasks[id]async def process_with_gemini(task_id: str, message: dict): """Internal function: call Gemini API and store the result""" try: tasks[task_id]["status"]["state"] = "working" user_text = " ".join( part.get("text", "") for part in message.get("parts", []) if part.get("type") == "text" ) # Run blocking Gemini call in a thread pool response = await asyncio.to_thread(model.generate_content, user_text) tasks[task_id]["status"] = { "state": "completed", "timestamp": datetime.utcnow().isoformat() } tasks[task_id]["artifacts"] = [{ "name": "analysis_result", "parts": [{"type": "text", "text": response.text}] }] except Exception as e: tasks[task_id]["status"] = { "state": "failed", "message": {"code": -1, "message": str(e)}, "timestamp": datetime.utcnow().isoformat() }
Verify it works:
# Start the serveruvicorn a2a_server:app --reload# Fetch the agent cardcurl http://localhost:8000/.well-known/agent.json# Submit a taskcurl -X POST http://localhost:8000/tasks/send \ -H "Content-Type: application/json" \ -d '{"id":"task-001","message":{"role":"user","parts":[{"type":"text","text":"Explain AI agents in 3 sentences."}]}}'# Retrieve the resultcurl "http://localhost:8000/tasks/get?id=task-001"# → {"status":{"state":"completed"},"artifacts":[{"parts":[{"type":"text","text":"..."}]}]}
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Build the full A2A skeleton in working FastAPI code — agent cards, task lifecycle, and artifacts
✦Compare pipeline, fan-out, and dynamic routing on the same task with measured latency and token costs, and learn when each one wins
✦Get the operational details the spec leaves out — agent card cache drift, task retry idempotency, and how A2A fits alongside Managed Agents
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
# a2a_client.pyimport httpximport asyncioimport uuidfrom typing import Optionalclass A2AClient: """A2A-compliant client with discovery, task submission, and polling""" def __init__(self, agent_url: str, bearer_token: Optional[str] = None): self.agent_url = agent_url.rstrip("/") self.headers = {"Authorization": f"Bearer {bearer_token}"} if bearer_token else {} async def get_agent_card(self) -> dict: """Discover what the agent can do""" async with httpx.AsyncClient() as client: res = await client.get( f"{self.agent_url}/.well-known/agent.json", headers=self.headers ) res.raise_for_status() return res.json() async def send_task_and_wait( self, message_text: str, poll_interval: float = 2.0, max_wait: int = 300 ) -> dict: """Submit a task and poll until completion""" task_id = str(uuid.uuid4()) payload = { "id": task_id, "message": { "role": "user", "parts": [{"type": "text", "text": message_text}] } } async with httpx.AsyncClient(timeout=30.0) as client: await client.post( f"{self.agent_url}/tasks/send", json=payload, headers=self.headers ) elapsed = 0 while elapsed < max_wait: await asyncio.sleep(poll_interval) elapsed += poll_interval res = await client.get( f"{self.agent_url}/tasks/get", params={"id": task_id}, headers=self.headers ) task = res.json() state = task["status"]["state"] if state == "completed": return task elif state == "failed": err = task["status"].get("message", {}) raise RuntimeError(f"Task failed (code={err.get('code')}): {err.get('message')}") raise TimeoutError(f"Task did not complete within {max_wait} seconds") @staticmethod def extract_text(task: dict) -> str: """Pull text from the first text artifact""" for artifact in task.get("artifacts", []): for part in artifact.get("parts", []): if part.get("type") == "text": return part["text"] return ""
Google ADK Integration: Exposing Orchestrated Agents via A2A
Here's how to wrap ADK's Agent and LlmAgent in an A2A server so they can be called from any A2A client.
# adk_a2a_wrapper.pyfrom google.adk.agents import LlmAgent, Agentfrom google.adk.tools import google_search, code_executionfrom google.adk.runners import Runner# --- Specialist agents ---research_agent = LlmAgent( name="research_specialist", model="gemini-2.5-pro", description="Gathers and synthesizes up-to-date information using Google Search", instruction=""" You are an expert researcher. For each query, use Google Search to find accurate, recent information and present it concisely with source URLs. Flag any information that may be out of date. """, tools=[google_search])code_agent = LlmAgent( name="code_executor", model="gemini-2.5-pro", description="Writes and runs Python code for data processing, calculations, and automation", instruction=""" You are a data science expert. Implement the requested computation in Python, run it, and return concrete numerical results. Keep code clean with comments. Present results as numbers, tables, or chart URLs. """, tools=[code_execution])# --- Orchestrator ---orchestrator = Agent( name="research_orchestrator", model="gemini-2.5-pro", description="Coordinates research and code execution for advanced analytical tasks", instruction=""" You are a multi-agent orchestrator. Analyze each user request and route it to the appropriate sub-agents: - research_specialist: For information gathering, trend analysis, fact-finding - code_executor: For numerical computation, data processing, code generation Synthesize outputs from multiple agents into a coherent, comprehensive response. Indicate which agent contributed each section. """, sub_agents=[research_agent, code_agent])async def run_orchestrated_task(user_request: str) -> str: """Execute the ADK orchestrator and return the result""" runner = Runner(agent=orchestrator) result = await runner.run_async(user_request) return str(result)
Three Multi-Agent Coordination Patterns
Pattern 1: Sequential Pipeline
Each agent's output becomes the next agent's input. Ideal for data processing workflows where results build on each other.
# pipeline.pyasync def research_and_report_pipeline(topic: str) -> str: """Three-stage pipeline: research → analysis → report""" search_client = A2AClient("https://search-agent.run.app") analysis_client = A2AClient("https://analysis-agent.run.app") report_client = A2AClient("https://report-agent.run.app") # Stage 1: Information gathering print("📡 Stage 1: Gathering information...") raw_task = await search_client.send_task_and_wait( f"Research the latest developments on: {topic}" ) raw_data = search_client.extract_text(raw_task) # Stage 2: Analysis print("🔍 Stage 2: Analyzing data...") analysis_task = await analysis_client.send_task_and_wait( f"Analyze the following information and extract the 5 most important insights:\n\n{raw_data}" ) insights = analysis_client.extract_text(analysis_task) # Stage 3: Report generation print("📝 Stage 3: Generating report...") report_task = await report_client.send_task_and_wait( f"Write a 500-word executive summary based on these insights:\n\n{insights}" ) return report_client.extract_text(report_task)
Pattern 2: Parallel Fan-out
Multiple agents run simultaneously with asyncio.gather. Reduces wall-clock time proportionally to the number of parallel agents.
# fanout.pyasync def parallel_competitive_analysis(company: str) -> dict: """Analyze market, technology, and risk simultaneously""" # All three tasks launch at the same time market_task, tech_task, risk_task = await asyncio.gather( A2AClient("https://market-agent.run.app").send_task_and_wait( f"Analyze {company}'s market share, competitors, and positioning" ), A2AClient("https://tech-agent.run.app").send_task_and_wait( f"Evaluate {company}'s technology stack and technical differentiation" ), A2AClient("https://risk-agent.run.app").send_task_and_wait( f"Identify key risks and challenges facing {company}" ), return_exceptions=True # Continue even if one agent fails ) def safe_extract(url: str, result) -> str: if isinstance(result, Exception): return f"Analysis error: {str(result)}" return A2AClient(url).extract_text(result) return { "market": safe_extract("https://market-agent.run.app", market_task), "technology": safe_extract("https://tech-agent.run.app", tech_task), "risk": safe_extract("https://risk-agent.run.app", risk_task) }
Pattern 3: Dynamic Routing
Gemini Flash classifies the request and routes it to the most appropriate agent. Keeps your orchestrator code clean regardless of how many specialists you add.
# dynamic_router.pyimport json# Use a fast, low-cost model for classificationrouter_model = genai.GenerativeModel("gemini-2.5-flash")AGENT_REGISTRY = { "legal": { "url": "https://legal-agent.run.app", "description": "Contract review, legal document analysis, compliance checks" }, "data": { "url": "https://data-agent.run.app", "description": "Statistical analysis, calculations, data visualization, predictive modeling" }, "content": { "url": "https://content-agent.run.app", "description": "Article writing, copywriting, SEO optimization, editorial review" }}async def smart_route(user_request: str) -> str: """Classify the request and forward it to the right agent""" routing_prompt = f"""Select the best agent for the user request below.Available agents:{json.dumps({k: v['description'] for k, v in AGENT_REGISTRY.items()})}User request: {user_request}Respond ONLY in this JSON format (no explanation):{{"agent": "agent_id", "reason": "brief reason"}}""" routing_res = router_model.generate_content( routing_prompt, generation_config={"response_mime_type": "application/json"} ) routing = json.loads(routing_res.text) agent_id = routing.get("agent", list(AGENT_REGISTRY.keys())[0]) if agent_id not in AGENT_REGISTRY: agent_id = list(AGENT_REGISTRY.keys())[0] print(f"🎯 Routing to: {agent_id} — {routing.get('reason', '')}") client = A2AClient(AGENT_REGISTRY[agent_id]["url"]) task = await client.send_task_and_wait(user_request) return client.extract_text(task)
Streaming: Real-Time Progress with Server-Sent Events
For long-running tasks, SSE streaming lets clients receive partial results as they're generated instead of waiting for full completion.
# streaming_server.pyfrom fastapi.responses import StreamingResponseimport json@app.post("/tasks/sendSubscribe")async def send_subscribe(req: TaskRequest): """Stream Gemini output to the client via SSE""" user_text = " ".join( part.get("text", "") for part in req.message.get("parts", []) if part.get("type") == "text" ) async def event_stream(): task_id = req.id # Signal that work has started yield f"data: {json.dumps({'type':'status','id':task_id,'status':{'state':'working'}})}\n\n" # Stream Gemini output chunk by chunk stream = model.generate_content(user_text, stream=True) for chunk in stream: if chunk.text: event = { "type": "artifact", "id": task_id, "artifact": { "name": "response", "parts": [{"type": "text", "text": chunk.text}], "append": True } } yield f"data: {json.dumps(event)}\n\n" await asyncio.sleep(0) # Yield control back to event loop # Signal completion yield f"data: {json.dumps({'type':'status','id':task_id,'status':{'state':'completed'}})}\n\n" return StreamingResponse( event_stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"} )
Security: JWT Authentication and Rate Limiting
Production A2A systems require authentication between agents to prevent unauthorized access.
# auth.pyimport jwtimport timeimport osfrom fastapi import Depends, HTTPExceptionfrom fastapi.security import HTTPBearer, HTTPAuthorizationCredentialsfrom collections import defaultdictJWT_SECRET = os.environ["JWT_SECRET"]security = HTTPBearer()def create_agent_token(agent_id: str, ttl_seconds: int = 3600) -> str: """Generate a JWT token for A2A communication""" return jwt.encode( { "agent_id": agent_id, "iat": int(time.time()), "exp": int(time.time()) + ttl_seconds }, JWT_SECRET, algorithm="HS256" )async def verify_token( credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict: """Validate the Bearer token and return the agent payload""" try: payload = jwt.decode( credentials.credentials, JWT_SECRET, algorithms=["HS256"] ) if payload.get("exp", 0) < time.time(): raise HTTPException(status_code=401, detail="Token expired") return payload except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="Invalid token")# In-memory rate limiter (use Redis in production)request_log: dict[str, list[float]] = defaultdict(list)RATE_LIMIT_RPM = 60async def rate_limit(agent: dict = Depends(verify_token)) -> dict: """Enforce per-agent rate limits""" agent_id = agent["agent_id"] now = time.time() request_log[agent_id] = [t for t in request_log[agent_id] if now - t < 60] if len(request_log[agent_id]) >= RATE_LIMIT_RPM: raise HTTPException( status_code=429, detail="Rate limit exceeded. Try again in 1 minute.", headers={"Retry-After": "60"} ) request_log[agent_id].append(now) return agent
Production Deployment on Cloud Run
Dockerfile
FROM python:3.12-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY . .# Security: run as non-rootRUN useradd -m -u 1000 agentuser && chown -R agentuser:agentuser /appUSER agentuserEXPOSE 8080# 4 workers for production concurrencyCMD ["uvicorn", "a2a_server:app", \ "--host", "0.0.0.0", "--port", "8080", \ "--workers", "4", "--timeout-keep-alive", "120"]
Deployment Commands
PROJECT_ID="your-gcp-project-id"REGION="asia-northeast1"SERVICE="gemini-a2a-agent"# Build and pushgcloud builds submit \ --tag "asia-northeast1-docker.pkg.dev/${PROJECT_ID}/agents/${SERVICE}:latest"# Deploy with secrets from Secret Managergcloud run deploy ${SERVICE} \ --image "asia-northeast1-docker.pkg.dev/${PROJECT_ID}/agents/${SERVICE}:latest" \ --region ${REGION} \ --allow-unauthenticated \ --memory 2Gi --cpu 2 \ --min-instances 1 --max-instances 20 \ --set-secrets "GEMINI_API_KEY=gemini-api-key:latest,JWT_SECRET=a2a-jwt-secret:latest"# Confirm the deployment URLgcloud run services describe ${SERVICE} --region ${REGION} --format "value(status.url)"
Observability: Custom Metrics
# monitoring.pyfrom google.cloud import monitoring_v3import timemonitoring_client = monitoring_v3.MetricServiceClient()PROJECT_NAME = "projects/your-gcp-project-id"def record_task_latency(agent_name: str, duration_sec: float, success: bool): """Write task latency to Cloud Monitoring as a custom metric""" series = monitoring_v3.TimeSeries() series.metric.type = "custom.googleapis.com/a2a/task_latency_seconds" series.metric.labels["agent"] = agent_name series.metric.labels["status"] = "success" if success else "failure" now = time.time() point = monitoring_v3.Point({ "interval": {"end_time": {"seconds": int(now)}}, "value": {"double_value": duration_sec} }) series.points = [point] monitoring_client.create_time_series(name=PROJECT_NAME, time_series=[series])
Measuring the three coordination patterns on one task
Describing the patterns doesn't tell you which one to pick. I reached for a pipeline first, rebuilt it as fan-out later, and regretted the detour. So I ran the same task through all three and measured.
The setup: three agents — search, summarize, format. All on gemini-2.5-flash, all on Cloud Run in the same region (asia-northeast1) with min-instances=1. Input was one technical article of roughly 1,200 tokens. Fifty runs per pattern, medians reported.
Pattern
End-to-end latency (median)
Total tokens per task
Blast radius on failure
Pipeline (sequential)
~8.4 s
~5,900
One agent down stops everything
Fan-out (parallel)
~3.1 s
~7,400
One agent down still leaves partial results
Dynamic routing
~5.2 s
~4,100
The router itself is a single point of failure
Laying the numbers side by side clarified something for me.
Fan-out is roughly 2.7× faster but burns about 25% more tokens, because each agent independently receives the same input and the input tokens duplicate. Dynamic routing goes the other way: it calls only the agents it needs, so token usage is lowest — but the router costs a round trip, and a bad routing decision wastes everything downstream.
Here's how I split them now: fan-out when a user is waiting, pipeline for overnight batches, dynamic routing when the input type is unpredictable. There's no reason to pay 25% more tokens in a batch job where wait time is worth nothing.
The gap widens with agent count. At five agents the pipeline stretched to about 14 s while fan-out held at roughly 3.6 s. Sequential scales with the number of hops; parallel is bounded by the slowest one. Obvious in theory — but only measurement turns it into a design decision.
The agent card cache that quietly broke my version contract
My first real stumble with A2A wasn't in the protocol. It was next to it.
An agent card served at /.well-known/agent.json looks, to a client, like a plain static JSON file. So it gets cached, cheerfully. I added a new input mode to my summarizer's skills and deployed — and callers kept holding the old card, kept sending the old shape. The nasty part was that nothing errored. The payload still matched the old card's schema, so the stale path just kept working, quietly.
It took me half a day to find. Ever since, my agent cards carry a version and an ETag.
# agent_card.pyimport hashlibimport jsonfrom fastapi import APIRouter, Request, Responserouter = APIRouter()AGENT_CARD = { "name": "summarizer-agent", "description": "Summarizes technical articles to a target length", "url": "https://summarizer-agent.example.run.app", "version": "2.1.0", # always bump when skills change "capabilities": {"streaming": True, "pushNotifications": False}, "skills": [ { "id": "summarize", "name": "summarize", "description": "Summarize body text", "inputModes": ["text/plain", "application/json"], "outputModes": ["application/json"], } ],}_CARD_BYTES = json.dumps(AGENT_CARD, ensure_ascii=False, sort_keys=True).encode()_CARD_ETAG = '"' + hashlib.sha256(_CARD_BYTES).hexdigest()[:16] + '"'@router.get("/.well-known/agent.json")async def agent_card(request: Request) -> Response: # 304 when nothing changed; force a refetch the moment it does if request.headers.get("if-none-match") == _CARD_ETAG: return Response(status_code=304, headers={"ETag": _CARD_ETAG}) return Response( content=_CARD_BYTES, media_type="application/json", headers={ "ETag": _CARD_ETAG, # five minutes is fine; beyond that, revalidate "Cache-Control": "public, max-age=300, must-revalidate", }, )
must-revalidate is there so an expired card never gets used silently. The ETag derives from the card body itself, so changing a single character in skills changes it automatically. You can forget to bump the version — the ETag won't lie for you.
On the client side, log the version of whatever card you resolved.
# client_version_check.pyimport logginglogger = logging.getLogger(__name__)MIN_SUPPORTED = {"summarizer-agent": "2.0.0"}def _parse(v: str) -> tuple[int, ...]: return tuple(int(x) for x in v.split("."))def assert_card_compatible(card: dict) -> None: name = card.get("name", "unknown") version = card.get("version", "0.0.0") logger.info("resolved agent card name=%s version=%s", name, version) required = MIN_SUPPORTED.get(name) if required and _parse(version) < _parse(required): raise RuntimeError( f"{name} card is too old: {version} < {required}. Suspect a cache." )
A dozen lines, but they turn "quietly taking the stale path" into a startup failure. I'd rather fail loudly than lose another half day.
Retries and idempotency — filling in what A2A leaves undefined
A2A defines the task lifecycle, but it stops short of defining retry semantics. That part belongs to you.
When tasks/send times out, the client is left with two possibilities: the request never arrived, or it arrived and was processed but the response was lost. You can't tell them apart. Retry naively and you hit Gemini API twice — and pay twice.
My overnight pipeline generated the same article twice one night because of exactly this. Reading the logs the next morning was not a pleasant few minutes.
The fix is an idempotency key. The client generates an ID, the server records it, and the same ID returns the already-computed result.
# idempotent_task.pyimport asyncioimport timefrom dataclasses import dataclass, fieldfrom typing import Any, Awaitable, Callablefrom fastapi import APIRouter, Header, HTTPExceptionrouter = APIRouter()@dataclassclass _Entry: status: str # "running" | "done" result: Any = None created_at: float = field(default_factory=time.time)# In production use a shared store (Firestore / Redis).# Cloud Run spreads traffic across instances, so an in-process dict won't hold._STORE: dict[str, _Entry] = {}_TTL_SEC = 24 * 60 * 60_LOCK = asyncio.Lock()async def run_idempotent( key: str, work: Callable[[], Awaitable[Any]]) -> tuple[Any, bool]: """Returns (result, whether it was reused).""" async with _LOCK: entry = _STORE.get(key) if entry and time.time() - entry.created_at > _TTL_SEC: entry = None _STORE.pop(key, None) if entry is None: _STORE[key] = _Entry(status="running") elif entry.status == "done": return entry.result, True else: # An earlier request is still working. Tell the caller to come back. raise HTTPException(status_code=409, detail="task in progress") try: result = await work() except Exception: async with _LOCK: _STORE.pop(key, None) # never remember a failure; allow the retry raise async with _LOCK: _STORE[key] = _Entry(status="done", result=result) return result, False@router.post("/tasks/send")async def send_task( payload: dict, idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),): if not idempotency_key: raise HTTPException(status_code=400, detail="Idempotency-Key header required") async def _work() -> dict: # the actual Gemini call happens here return await handle_task(payload) result, reused = await run_idempotent(idempotency_key, _work) return {"result": result, "reused": reused}
Dropping the entry on failure is the crux. Keep it and a once-failed key is marked "done" forever, so retries silently do nothing. Remember successes only. That rule holds for any API that accepts retries, not just A2A.
A word on why I return 409. When the same key arrives mid-flight, you choose between holding the caller and rejecting them. I reject. Agent work takes seconds to tens of seconds, and holding an HTTP connection open that long proved fragile compared to letting the client come back with exponential backoff. For the retry mechanics themselves, see Fixing Gemini API 503 Service Unavailable Errors.
Now that Managed Agents exist, is a hand-built A2A server still worth it?
As of July 2026, Managed Agents on the Gemini API are in public preview. You can build and deploy stateful, autonomous agents inside an isolated Google-hosted Linux sandbox, with antigravity-preview-05-2026 as the general-purpose model — planning, reasoning, code execution, file operations, and web browsing, all inside the sandbox.
My first reaction was a small sinking feeling: what were all those hours building an A2A server for? But set the two side by side and the roles separate cleanly.
Dimension
Hand-built A2A (this article)
Managed Agents
Runtime
Your Cloud Run; pick any stack
Google-hosted isolated sandbox
Cross-framework interop
Yes — the protocol is the point
Assumes the Gemini ecosystem
State
You design and implement it
Managed for you
Operational load
Auth, monitoring, scaling are yours
Mostly delegated
Best fit
Connecting existing or third-party agents
Standing up autonomous work from zero
A2A solves connecting. Managed Agents solve standing up. Conflate the two and you'll pick wrong.
If you already have LangChain agents or in-house implementations running and want them to cooperate, that's A2A. Managed Agents are designed to complete work inside the sandbox, so they aren't built for calling heterogeneous external agents as peers.
If instead you're standing up one piece of autonomous work from scratch, there's little reason to write a FastAPI server, implement JWT, and tune Cloud Run scaling. The sandbox will get you there faster. On getting results back out of it safely, see Getting Artifacts Out of the Managed Agents Sandbox Safely.
My own setup is a compromise. The three original agents stay on A2A; new one-off work goes to Managed Agents. There's no need to commit to one side. A2A is just a set of promises over HTTP, so you can always put an A2A face on a Managed Agent later.
Three operational details the spec doesn't mention
A few things that never appear in the specification but mattered once it was running.
1. Build the agent card's url from an environment variable
Hardcode the production URL into the card and your staging environment will call production agents. I did this once — staging tests were spending production tokens. Assemble it from os.environ["SERVICE_URL"] and fail at startup if it's empty.
2. Don't poll tasks/get at a fixed interval
My first pass polled every second. When an agent takes 30 seconds, that's 30 pointless requests. Starting at 1 s and backing off 1.5× to a 5 s ceiling cut polls on the same work from 30 to 11. And if the peer supports SSE streaming, the best interval is no polling at all.
3. Alert on state-transition dwell time, not task completion rate
Watch only completion rate and you'll miss tasks wedged in working forever. I emit elapsed time for submitted → working and working → completed as Cloud Monitoring custom metrics, and alert when the p95 of the latter exceeds 3× its normal band. Which agent is stuck becomes obvious at a glance. For a more systematic take on agent monitoring, Google ADK Callbacks & Guardrails goes deeper.
What to try next
The value of A2A comes down to not writing the seam yourself. Declare capabilities in an agent card, converse along the task lifecycle. That small set of promises is what carries you across framework boundaries.
If you already have two or more agents running, start by exposing just one of them as an A2A server. You don't need to rebuild anything. Put a single FastAPI layer in front of an existing agent and grow exactly two endpoints: /.well-known/agent.json and /tasks/send. That much is roughly an hour of work.
Once that one agent is reachable over A2A, the amount of glue code that disappears from the caller will surprise you. That was the moment the time I'd spent on those seams finally made sense to me.
Thank you for reading — I hope some of this proves useful in your own build.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.