In the previous post, we built an end-to-end single-agent application using ADK and AG-UI. In this post, we evolve that design into a production-ready multi-agent architecture with three coordinated agents:
- Weather Agent: retrieves and interprets real-time meteorological data.
- Maps Agent: performs geospatial reasoning, including location lookup, coordinate resolution, and map-aware contextual queries.
- Atlas Agent: supervises the workflow by delegating tasks to the right specialist, sequencing execution, and synthesizing results into a single, user-ready response.
To power this collaboration, we leverage MCP (Model Context Protocol) to distribute tool-based function execution across agents and A2A (Agent-to-Agent Protocol) as a standardized inter-agent communication layer that ensures consistent, seamless coordination among peers. We further extend the AG-UI (Agent-User Interaction Protocol) integration introduced in the previous tutorial to support these capabilities end-to-end, enabling all three protocols to operate cohesively while preserving each agent’s ability to drive dynamic interface behavior through a unified interaction model. The following figure illustrates the overall architecture of this multi-agent system.
Limitations of Single-Agent Architectures
While single-agent architectures can effectively support many workflows, they encounter fundamental limitations as applications grow in complexity and scope. One of the most prominent challenges is context overload. As tasks become more interdependent and require longer, richer instruction sequences, the amount of information a single agent must process quickly exceeds what is practical or reliable. Recent studies (https://arxiv.org/abs/2307.03172, https://github.com/Arize-ai/LLMTest_NeedleInAHaystack)) show that large language models often exhibit positional bias, placing disproportionate attention on the beginning and end of long prompts while overlooking information in the middle, where critical constraints and task dependencies are commonly specified. This limitation reduces reasoning consistency and degrades performance as task complexity increases.

Image taken from https://arize.com/blog-course/the-needle-in-a-haystack-test-evaluating-the-performance-of-llm-rag-systems/
Distributed Reasoning Through Multi-Agent Systems
A multi-agent system helps address these shortcomings by distributing reasoning across specialized agents, each responsible for a well-defined subset of tasks. Rather than burdening a single agent with managing both the global application state and task handling logic, responsibilities are decomposed across agents with distinct roles, independent context windows, and dedicated toolsets. In doing so, multi-agent systems provide a principled design pattern for enforcing separation of concerns, where reasoning, decision-making, and tool interaction are explicitly isolated across agents rather than entangled within a monolithic control loop. This decomposition reduces cognitive load, improves reasoning reliability, and enables more scalable and maintainable system designs, particularly as task complexity and contextual requirements grow (https://arxiv.org/abs/2305.14325).
When Multi-Agent Architectures Become Necessary
These orchestration needs become especially pronounced in problem settings that exceed the limits of single-agent reasoning and are inherently multidisciplinary. In such scenarios, effective problem solving depends on integrating multiple forms of expertise, heterogeneous reasoning strategies, and specialized tools, each addressing a distinct aspect of the task. This makes coordinated interaction among specialized agents a core system requirement rather than an implementation choice.
Problems that motivate multi-agent architectures typically exhibit four defining characteristics: planning , where solutions require coordinated, multi-step reasoning with explicit dependency management; diverse expertise , where multiple forms of domain-specific reasoning must be applied in concert; extensive context , where relevant information spans large, heterogeneous inputs beyond the reliable capacity of a single agent; and adaptive solutions , where execution paths must evolve dynamically in response to intermediate results, partial failures, or changing constraints. (https://multiagentbook.com/).
Taken together, these properties make multi-agent systems a modular, interpretable, and scalable paradigm for building advanced AI applications. In the following sections, we demonstrate how to transition from a single-agent design to a coordinated multi-agent framework using ADK for agent definition, MCP for tool-based function calling, and A2A for structured agent-to-agent communication.
Building the Multi-Agent System
To evolve our application from a single-agent design into a fully collaborative system, we decompose the main agent’s reasoning into three coordinated specialist agents. The weather_agent is responsible for handling environmental and meteorological queries; the maps_agent focuses on geospatial reasoning and place-based information retrieval; and the atlas_agent integrates and synthesizes information across domains, enabling higher-level reasoning and informed decision-making through cross-agent coordination.
Maps Agent
The Maps Agent is responsible for all geospatial reasoning within the multi-agent architecture. It handles user queries related to location identification, geographic coordinate resolution, and the generation of contextual and semantic descriptions of places. To support these capabilities, we equip the agent with two MCP-integrated tools that combine the Google Maps API with the Gemini API, leveraging Google Maps grounding to (i) resolve precise geographic coordinates and (ii) generate semantically rich, map-aware descriptions of locations. This integration enables the agent to ground generative outputs in authoritative, up-to-date geospatial data while preserving semantic expressiveness.
The MCP server exposes the following tools to the Maps Agent:
- get_place_location
Resolves the geographic coordinates (latitude and longitude) for a given place name using the Google Maps Geocoding API.
- The tool queries Google Maps directly.
- If successful, it returns a structured JSON object containing the latitude and longitude.
- If the place cannot be resolved, a descriptive error message is returned.
2. get_place_details
Provides a semantically rich, Gemini-generated description of a place. —
- First, the get_place_location tool is invoked to obtain the location coordinates.
- It then passes the user’s prompt and the resolved coordinates to the Gemini model, with Google Maps enabled, allowing Gemini to retrieve place-specific context, points of interest, and localized descriptions. —
- The result is a high-level narrative or analytic summary generated by Gemini, grounded in the Maps tool’s retrieved information.
Internally, these tools work together to bridge two modalities: Google Maps supplies precise geospatial data. Gemini interprets that data and produces natural-language, context-aware responses.
Here is the implementation of the maps agent and MCP server:
git clone https://github.com/haruiz/atlas_app # clone repo
cd ./atlas_app # jump into the code folder
git checkout single_agent # switch to the single_agent branch
mkdir -p mcp/maps_server # create folder
touch mcp/maps_server/{server.py,tools.py, __init__.py} # create python files
mcp/maps_server/tools.py
import os
from typing import Any, Dict, Union
import googlemaps
from dotenv import load_dotenv, find_dotenv
from google import genai
from google.genai import types
# Load environment variables
load_dotenv(find_dotenv())
gmaps_client = googlemaps.Client(key=os.getenv("GOOGLE_MAPS_API_KEY"))
genai_client = genai.Client(http_options=types.HttpOptions(api_version="v1"))
# ---------------------------------------------------------------------------
# Utility: Get Geolocation
# ---------------------------------------------------------------------------
def get_place_location(place_name: str) -> Dict[str, Union[str, Dict[str, float]]]:
"""
Get geographic coordinates from Google Maps API given a place name.
"""
try:
geocode_result = gmaps_client.geocode(place_name)
if not geocode_result:
return {
"status": "error",
"message": f"Could not find coordinates for: {place_name}"
}
location = geocode_result[0]["geometry"]["location"]
return {
"status": "success",
"result": {"latitude": location["lat"], "longitude": location["lng"]}
}
except Exception as e:
return {"status": "error", "message": str(e)}
# ---------------------------------------------------------------------------
# Utility: Get Place Details using Gemini + Maps Tool
# ---------------------------------------------------------------------------
def get_place_details(place_name: str, query_prompt: str) -> Dict[str, str]:
"""
Get place details using Gemini’s Google Maps Tool.
"""
try:
# Get coordinates first
location_result = get_place_location(place_name)
if location_result["status"] == "error":
raise Exception(location_result["message"])
coords = location_result["result"]
latitude = coords["latitude"]
longitude = coords["longitude"]
# Call Gemini with Maps tool
response = genai_client.models.generate_content(
model="gemini-2.5-flash",
contents=query_prompt,
config=types.GenerateContentConfig(
tools=[
types.Tool(
google_maps=types.GoogleMaps(enable_widget=False)
)
],
tool_config=types.ToolConfig(
retrieval_config=types.RetrievalConfig(
lat_lng=types.LatLng(
latitude=latitude,
longitude=longitude,
),
language_code="en_US",
)
),
),
)
return {
"status": "success",
"result": response.text
}
except Exception as e:
return {
"status": "error",
"message": f"Failed to get place details: {str(e)}"
}
mcp/maps_server/server.py
from typing import Any
from mcp.server.fastmcp import FastMCP
from tools import get_place_location, get_place_details
# Create an MCP server
mcp = FastMCP("geoloc_server", port=8080)
@mcp.tool(name="get_place_location", description="Get coordinates from an address using Google Maps API.")
async def get_place_location_tool(place_name: str) -> dict[str, Any]:
"""
Get coordinates from an address using Google Maps API.
:param place_name:
:return:
"""
return get_place_location(place_name)
@mcp.tool(name="get_place_details", description="Get place details using Google Maps Tool in Gemini.")
async def get_place_details_tool(place_name: str, query_prompt: str) -> dict[str, Any]:
"""
Get place details using Google Maps Tool in Gemini.
:param query_prompt: User query prompt for place details
:param place_name: Name of the place to get details for
:return:
"""
return get_place_details(place_name, query_prompt)
if __name__ == " __main__":
mcp.run(transport="streamable-http")
Weather Agent
The Weather Agent is responsible for handling all user requests related to real-time atmospheric conditions and environmental insights. It provides structured meteorological information — such as temperature, humidity, wind speed, apparent temperature, and qualitative weather conditions — based on geospatial coordinates. To support these capabilities, the agent leverages MCP-accessible tools backed by the Open‑Meteo API for authoritative weather data retrieval and the Gemini API for reasoning, interpretation, and contextual synthesis. This combination enables the Weather Agent to deliver accurate, location-aware environmental insights while maintaining coherent, user-friendly responses.
The MCP server exposes the following tool to the Weather Agent:
- get_weather
Retrieves current weather conditions for a specific latitude and longitude by querying the Open-Meteo API. The tool processes raw atmospheric measurements (temperature, humidity, wind speed, gust levels, and weather code) and maps weather codes to human-readable conditions using a predefined lookup table. The output is returned as a structured JSON object containing both the raw values and their semantic interpretation.
Internally, the tool workflow includes:
- Fetching meteorological variables via an asynchronous HTTP request.
- Translating numerical weather codes into descriptive categories (e.g., Clear sky, Moderate rain, Fog).
- Packaging the processed data into a standardized result format compatible with MCP.
Below is the code for the weather agent and MCP server.
mkdir -p mcp/weather_server # create folder
touch mcp/weather_server/{server.py,tools.py, __init__.py} # create python files
mcp/weather_server/tools.py
import httpx
from typing import Dict, Any, Optional
# ---------------------------------------------------------------------------
# Weather Code Mapping (constant)
# ---------------------------------------------------------------------------
WEATHER_CODE_MAP: Dict[int, str] = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Fog",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
61: "Light rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Light snow",
73: "Moderate snow",
75: "Heavy snow",
80: "Rain showers",
95: "Thunderstorm",
99: "Thunderstorm with hail",
}
def get_weather_condition(code: Optional[int]) -> str:
"""
Convert a weather code into a human-readable description.
Args:
code: Weather condition code.
Returns:
A descriptive string representing the weather condition.
"""
return WEATHER_CODE_MAP.get(code, "Unknown")
# ---------------------------------------------------------------------------
# Weather Fetching
# ---------------------------------------------------------------------------
async def get_weather(latitude: float, longitude: float) -> Dict[str, Any]:
"""
Fetch current weather conditions for a given latitude and longitude.
Args:
latitude: Latitude coordinate.
longitude: Longitude coordinate.
Returns:
A dictionary containing:
- status: "success" or "error"
- result: weather fields (if success)
- message: error message (if error)
"""
url = (
"https://api.open-meteo.com/v1/forecast?"
f"latitude={latitude}&longitude={longitude}"
"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,"
"wind_speed_10m,wind_gusts_10m,weather_code"
)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
current = data.get("current")
if not current:
return {"status": "error", "message": "Weather API returned no 'current' data."}
result = {
"temperature": current.get("temperature_2m"),
"feelsLike": current.get("apparent_temperature"),
"humidity": current.get("relative_humidity_2m"),
"windSpeed": current.get("wind_speed_10m"),
"windGust": current.get("wind_gusts_10m"),
"conditions": get_weather_condition(current.get("weather_code")),
}
return {"status": "success", "result": result}
except httpx.HTTPError as e:
return {"status": "error", "message": f"HTTP error: {str(e)}"}
except Exception as e:
return {"status": "error", "message": str(e)}
# ---------------------------------------------------------------------------
# Example Execution
# ---------------------------------------------------------------------------
if __name__ == " __main__":
import asyncio
# Example: New York City coordinates
nyc_lat, nyc_lon = 40.7128, -74.0060
weather = asyncio.run(get_weather(nyc_lat, nyc_lon))
print("Weather:", weather)
mcp/weather_server/server.py
from typing import Any
from mcp.server.fastmcp import FastMCP
from tools import get_weather
# Create an MCP server
mcp = FastMCP("weather_server", port=8081, json_response=True)
@mcp.tool(name="get_weather", description="Get weather information for a given location.")
async def get_weather_tool(latitude: float, longitude: float) -> dict:
"""
Get the weather for a given location.
:param latitude: Latitude of the location.
:param longitude: Longitude of the location.
:return:
"""
return await get_weather(latitude, longitude)
if __name__ == " __main__":
mcp.run(transport="streamable-http")
The instructions defining the behavior of both the maps and weather agents are available in the GitHub repository accompanying this blog post, in the multiple_agent branch.
GitHub - haruiz/atlas_app at multiple_agent
Atlas Agent
In the single-agent example, the Atlas Agent was responsible for managing all reasoning and decision-making within a single control loop. In this tutorial, however, the Atlas Agent transitions into a pure orchestration role, delegating domain-specific reasoning to specialized agents and focusing on coordination rather than execution. This section highlights the key changes required to evolve the Atlas Agent from a monolithic reasoning component into a multi-agent orchestrator.
Implemented using ADK(Agent Development Kit), the Atlas Agent supports A2A-based task delegation, optional debugging callbacks, and memory preloading. Its instruction set defines the end-to-end execution flow, including location resolution, weather data requests, sequencing of agent and tool calls, and the integration of multi-agent outputs into clear, user-friendly summaries.
Next, we implement this orchestration logic in code.
pycharm ./agents/atlas_agent/agent.py
agents/atlas_agent/agent.py
from typing import Optional, Dict, Any
from a2a.utils import AGENT_CARD_WELL_KNOWN_PATH
from google.adk.agents.callback_context import CallbackContext
from google.adk.agents.llm_agent import Agent
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
from google.adk.models import LlmRequest, LlmResponse
from google.adk.tools import BaseTool, ToolContext, AgentTool
from google.adk.tools.preload_memory_tool import PreloadMemoryTool
from dotenv import load_dotenv, find_dotenv
# Load environment variables
load_dotenv(find_dotenv())
# ======================================================================
# CALLBACKS
# ======================================================================
def before_model(
callback_context: CallbackContext,
llm_request: LlmRequest,
) -> Optional[LlmResponse]:
"""
Executes before the model handles a request.
Allows inspection of inputs, debugging, or intercepting execution.
Return:
- LlmResponse to skip model execution
- None to allow normal execution
"""
agent_name = callback_context.agent_name
print(f"[Orchestrator Callback] before_model for agent: {agent_name}")
last_user_message = ""
if llm_request.contents and llm_request.contents[-1].role == "user":
parts = llm_request.contents[-1].parts
if parts:
last_user_message = parts[0].text
print(f"[Orchestrator Callback] User message: {last_user_message}")
print("[Orchestrator Callback] Tools available to model:")
for tool in llm_request.config.tools or []:
print(f" - {tool.name}")
return None
def before_tool(
tool: BaseTool, args: Dict[str, Any], tool_context: ToolContext
) -> Optional[Dict]:
"""
Executes before any tool call.
Allows modification of arguments or skipping execution.
"""
print(
f"[Orchestrator Callback] before_tool: '{tool.name}' "
f"(agent: {tool_context.agent_name})"
)
print(f"[Orchestrator Callback] Tool arguments: {args}")
return None
# ======================================================================
# REMOTE AGENTS (enable when servers are available to test a2a locally)
# ======================================================================
# maps_agent = RemoteA2aAgent(
# name="maps_agent",
# description="Provides geocoding, place lookup, and mapping utilities.",
# agent_card=f"http://127.0.0.1:8001{AGENT_CARD_WELL_KNOWN_PATH}",
# )
#
# weather_agent = RemoteA2aAgent(
# name="weather_agent",
# description="Retrieves real-time weather conditions for a given coordinate.",
# agent_card=f"http://127.0.0.1:8002{AGENT_CARD_WELL_KNOWN_PATH}",
# )
# ======================================================================
# ORCHESTRATOR AGENT
# ======================================================================
root_agent = Agent(
name="atlas_agent",
model="gemini-2.5-pro",
# Uncomment for debugging
# before_tool_callback=before_tool,
# before_model_callback=before_model,
tools=[
PreloadMemoryTool(),
# When remote agents are active:
# AgentTool(agent=maps_agent),
# AgentTool(agent=weather_agent),
],
instruction="""
You are the Orchestrator Agent. Your role is to coordinate two specialized agents:
- maps_agent: Resolves place names, coordinates, and map-related queries.
- weather_agent: Retrieves weather information using geographic coordinates.
Your responsibility is to determine when each agent should be called, how their outputs should be used, and how to produce a final integrated response for the user.
----------------------------------------------------------------------
Workflow for Weather Queries
----------------------------------------------------------------------
When the user requests weather information for a location:
1. Call maps_agent
Input: the place name or location text.
Output: latitude and longitude.
2. Call weather_agent
Input: coordinates returned by maps_agent.
Output: structured weather information.
3. Combine both results into a clear, user-facing response.
----------------------------------------------------------------------
Workflow for Location or Mapping Queries
----------------------------------------------------------------------
If the user asks only for location information, directions, or coordinates:
1. Call maps_agent directly.
2. Return the result to the user.
----------------------------------------------------------------------
Execution Rules
----------------------------------------------------------------------
- Execute agent calls in sequential order.
- Do not call multiple agents at the same time.
- Always wait for the response of the previous agent before making a new call.
- Pass intermediate outputs to the next agent when required.
- Do not fabricate or assume agent outputs; always rely on actual tool responses.
- Provide a final, coherent response after completing the workflow.
----------------------------------------------------------------------
""",
)
MCP, A2A, and AG-UI Integration
Previously, we demonstrated how MCP standardizes interactions between models, agents, and tools, enabling a clear separation between the tools layer and agent implementations. This architectural decoupling improves scalability and maintainability while ensuring the tools layer remains fully language-agnostic, allowing tools to be implemented in any programming language and executed independently of the agent’s runtime environment.
Building on this foundation, we extend the architecture by transforming our ADK agents into remote agents using the Agent-to-Agent (A2A) protocol. A2A enables agents to communicate across network boundaries and operate as independently deployable services, significantly enhancing modularity, fault isolation, and scalability in distributed environments.
Beyond communication, A2A also brings structure. It standardizes how agent capabilities are defined and exposed, how requests are routed, and how messages and responses flow between agents. As a result, agents can collaborate as loosely coupled services that are easy to reason about, evolve, and operate.
The payoff is significant: agents can now be developed, scaled, updated, and even recovered independently, dramatically improving fault tolerance, system resilience, and long-term maintainability in real-world multi-agent systems.
Exposing ADK Agents via A2A
To begin, we need to convert our existing ADK agents into A2A-compliant endpoints so they can communicate with one another. This is the core step behind the question:
I have an agent — how do I expose it so that other agents can use it via A2A?
This capability is essential for building multi-agent systems where agents collaborate, delegate tasks, and exchange information programmatically. ADK offers two primary methods for exposing an agent through A2A:
- Using to_a2a(root_agent)
This is the simplest path when you want to convert an existing agent into an A2A endpoint. It gives you full control over deployment — e.g., exposing the agent via uvicorn rather than relying on adk deploy api_server. The to_a2a() function also auto-generates the agent card based on your code.
- Creating and hosting your own agent.json card via adk api_server — a2a
This approach integrates seamlessly with adk web for debugging and testing. It also allows you to host multiple agents from a single parent directory, each exposed automatically as long as an agent.json file is present. The tradeoff is that you must author the agent card manually (see the A2A Python tutorial for details).
Approach Used in This Tutorial
For this tutorial, we will use the to_a2a() function, as it provides the most direct and automated path for exposing an ADK agent as an A2A endpoint while generating the agent card behind the scenes. If you’re interested in the alternative adk api_server approach, refer to the full ADK documentation for additional guidance.
Let’s create and copy the following file to the weather_agent and maps_agent folders:
agents/maps_agent/a2a_server.py
from google.adk.a2a.utils.agent_to_a2a import to_a2a
from agent import root_agent
# Make your agent A2A-compatible
a2a_app = to_a2a(root_agent, port=8001)
if __name__ == ' __main__':
import uvicorn
uvicorn.run("a2a_server:a2a_app", host="127.0.0.1", port=8001, reload=True, workers=1)
agents/weather_agent/a2a_server.py
from google.adk.a2a.utils.agent_to_a2a import to_a2a
from agent import root_agent
# Make your agent A2A-compatible
a2a_app = to_a2a(root_agent, port=8002)
if __name__ == ' __main__':
import uvicorn
uvicorn.run("a2a_server:a2a_app", host="127.0.0.1",port=8002, reload=True, workers=1)
Now that we have our MCP servers in place and our A2A-compliant ADK agents running, the next step is to update the main application entry point. Specifically, we will modify the backend so the Atlas Agent is wrapped as an AG-UI–compatible agent, building directly on the previous example.
At this point, it is important to highlight an intentional design choice: during the implementation of the atlas_agent, we deliberately did not enable the remote agents, nor did we register them as sub-agents within the orchestrator’s definition. This is because, in this architecture, A2A task delegation and inter-agent messaging are managed at the frontend layer, within the Next.js application. The Next.js frontend acts as the coordination surface for user requests originating from the AG-UI interface, controlling how those requests are dispatched, tracked, and observed across the multi-agent system.
To support this flow, we need to update two key components:
- The backend main.py file, where the agent is exposed in an AG-UI–compatible form
- The frontend route.ts file in our Next.js application, which handles incoming user requests and streams agent updates to the UI
The core reason for these changes is that AG-UI must be able to intercept messages produced by the A2A-based multi-agent architecture. By doing so, AG-UI can observe agent interactions in real time and stream structured updates back to the React frontend, enabling a responsive and transparent user experience.
In the next sections, we’ll walk through the required updates on both the backend and frontend to make this integration work end-to-end.
First, let’s update the backend entry point: main.py. This is where we’ll wrap the Atlas Agent as an AG-UI–compatible agent and expose the endpoint that the frontend will connect to.
main.py
"""
Orchestrator Agent - Coordinates between Weather and Maps agents.
Communicates using the AG-UI Protocol for UI integration, and delegates tasks
to specialized A2A agents through ADK middleware.
"""
from __future__ import annotations
# Load environment variables (API keys, configs, etc.)
from dotenv import load_dotenv
load_dotenv()
import uvicorn
from fastapi import FastAPI
# ADK wrapper that exposes agents through the AG-UI Protocol
from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint
from agents.atlas_agent.agent import root_agent as orchestrator_agent
# -------------------------------------------------------------------
# Wrap the LlmAgent using ADKAgent
# This adds:
# - session handling
# - stateful memory
# - tool routing
# - AG-UI Protocol support
# -------------------------------------------------------------------
adk_orchestrator_agent = ADKAgent(
adk_agent=orchestrator_agent, # The LLM agent defined above
app_name="orchestrator_app", # ID for the AG-UI front-end
user_id="demo_user", # Session tracking user ID
session_timeout_seconds=3600, # Auto-expire session after 1 hour
use_in_memory_services=True # Enables in-memory memory + tool services
)
# -------------------------------------------------------------------
# FastAPI application setup
# Adds the ADK Orchestrator as an API endpoint using AG-UI Protocol
# -------------------------------------------------------------------
app = FastAPI(title="A2A Orchestrator (ADK + AG-UI Protocol)")
# Inject the orchestrator endpoint at "/"
add_adk_fastapi_endpoint(app, adk_orchestrator_agent, path="/")
# -------------------------------------------------------------------
# Run the development server (only when executing `python main.py`)
# Uvicorn reloads on changes and ensures ADK's MCP tools run properly
# -------------------------------------------------------------------
if __name__ == ' __main__':
uvicorn.run(
"main:app",
host="localhost",
port=8000,
reload=True, # Auto-reload server on code changes (dev mode)
workers=1 # ADK/MCP tools require single-worker execution
)
Next, we’ll move to the frontend and update ui/app/api/copilotkit/route.ts. This route will act as the bridge between the AG-UI interface and our remote multi-agent backend, handling requests and streaming agent updates back to the UI.
ui/app/api/copilotkit/route.ts
import { A2AMiddlewareAgent } from "@ag-ui/a2a-middleware"
import {CopilotRuntime, copilotRuntimeNextJSAppRouterEndpoint, ExperimentalEmptyAdapter} from "@copilotkit/runtime";
import {AbstractAgent, HttpAgent} from "@ag-ui/client";
import {NextRequest} from "next/server";
export async function POST(request: NextRequest) {
// These first two are the urls to the a2a agents
const weatherAgentUrl = process.env.WEATHER_AGENT_URL || "http://localhost:8002";
const mapsAgentUrl = process.env.MAPS_AGENT_URL || "http://localhost:8001";
const orchestratorUrl = process.env.ORCHESTRATOR_URL || "http://localhost:8000";
// the orchestrator agent we pass to the middleware needs to be an instance of a derivative of an ag-ui `AbstractAgent`
// In this case, we have access to the agent via url, so we can gain an instance using the `HttpAgent` class
const orchestrationAgent: AbstractAgent = new HttpAgent({
url: orchestratorUrl,
});
// A2A Middleware: Wraps orchestrator and injects send_message_to_a2a_agent tool
// This allows orchestrator to communicate with A2A agents transparently
const a2aMiddlewareAgent = new A2AMiddlewareAgent({
description:
"An orchestrator agent that coordinates two specialized agents: Weather Agent and Maps Agent build on the ADK framework.",
// We pass the urls to the a2a agents, the middleware will handle the connections
agentUrls: [
weatherAgentUrl,
mapsAgentUrl,
],
// Pass the agent instance
orchestrationAgent
});
// CopilotKit runtime connects frontend to agent system
// CopilotKit runtime connects frontend to agent system
const runtime = new CopilotRuntime({
agents: {
a2a_chat: a2aMiddlewareAgent, // Must match agent prop in <CopilotKit agent="a2a_chat">
},
});
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter: new ExperimentalEmptyAdapter(),
endpoint: "/api/copilotkit",
});
return handleRequest(request);
}
The most important piece of this frontend/Nextjs App integration is the A2A middleware layer, implemented via A2AMiddlewareAgent. Conceptually, it sits between your AG-UI/CopilotKit runtime and your remote A2A agents, turning a distributed set of services into a single, seamless “agent” the UI can talk to.
In this implementation, the middleware wraps the orchestrator, which is exposed as an AG UI AbstractAgent via HttpAgent, and registers the URLs of the specialized remote agents for Maps and Weather. From there, it injects a standardized tool interface, most notably a send_message_to_a2a_agent capability, allowing the orchestrator(atlas_agent) to delegate work to remote agents without having to manage low-level networking, request routing, or message formatting directly.
In other words, the orchestrator continues to focus on decision-making and sequencing, while the middleware handles the heavy lifting required for remote collaboration:
- Agent connectivity: manages connections to multiple A2A agent endpoints via their URLs
- Request routing: forwards orchestrator calls to the appropriate specialized agent
- Message exchange + coordination: standardizes how messages are sent and responses are returned
This is why, in the code, the UI only needs to register a single runtime agent:
agents: {
a2a_chat: a2aMiddlewareAgent,
}
We finally need to update our page.tsx file so the UI can properly render A2A interactions. The complete implementations of the MessageToA2A and MessageFromA2A components are available in the GitHub repository under: atlas_app, multiple_agent branch:
atlas_app/ui/components/a2a at multiple_agent · haruiz/atlas_app
ui/app/page.tsx
"use client";
import React, {useEffect} from "react";
import "@copilotkit/react-ui/styles.css";
// import "./style.css";
// CopilotKit core
import {useCopilotAction, useCopilotChat,} from "@copilotkit/react-core";
import {CopilotChat} from "@copilotkit/react-ui";
import {MessageToA2A} from "@/components/a2a/MessageToA2A";
import {MessageFromA2A} from "@/components/a2a/MessageFromA2A";
const Chat = () => {
const {visibleMessages} = useCopilotChat();
useCopilotAction({
name: "send_message_to_a2a_agent",
description: "Sends a message to an A2A agent",
available: "frontend",
parameters: [
{
name: "agentName",
type: "string",
description: "The name of the A2A agent to send the message to",
},
{
name: "task",
type: "string",
description: "The message to send to the A2A agent",
},
],
render: (actionRenderProps) => {
console.log(actionRenderProps);
return (
<>
<MessageToA2A {...actionRenderProps} />
<MessageFromA2A {...actionRenderProps} />
</>
);
},
});
useEffect(() => {
const extractDataFromMessages = () => {
for (const message of visibleMessages) {
const msg = message;
if (msg.type === "ResultMessage" && msg.actionName === "send_message_to_a2a_agent") {
try {
let parsed;
const result = msg.result;
if (typeof result === "string") {
try {
parsed = JSON.parse(result);
}
catch {
continue;
}
} else if (typeof result === "object") {
parsed = result;
}
console.log("Sending message to A2A agent: ", parsed);
} catch (e) {
console.error("Failed to extract data from message:", e);
}
}
}
};
extractDataFromMessages();
}, [visibleMessages]);
/* --------------------------------------------------------------------------------------------
* MAIN UI RENDERING
* ------------------------------------------------------------------------------------------*/
return (
<div
className="flex justify-center items-center h-full w-full"
data-testid="background-container"
>
<div className="h-full w-full md:w-8/10 md:h-8/10 rounded-lg">
<CopilotChat
onSubmitMessage={(message) => {
console.log("User submitted message:", message);
}}
suggestions={[{
title: "What is the weather in San Francisco?",
message: "What is the weather in San Francisco?"
}]}
className="h-full rounded-2xl max-w-6xl mx-auto"
labels={{initial: "Hi, I'm an agent. Want to chat?"}}
/>
</div>
<span className="absolute bottom-4 right-4 text-xs text-gray-400">
Num Messages: {visibleMessages.length}
</span>
</div>
);
};
/* ------------------------------------------------------------------------------------------------
* HOME PAGE WRAPPER
* ----------------------------------------------------------------------------------------------*/
const HomePage = () => {
return (
<main className="h-screen w-screen">
<Chat/>
</main>
);
};
export default HomePage;
Before wrapping up, here is a short video demo of the system in action, showing how user requests flow through the AG-UI frontend, are routed via the A2A middleware, and coordinated across the Maps, Weather, and Atlas agents in real time.
You now have a complete end-to-end multi-agent system that is modular, remotely deployable, and easy to extend. MCP keeps your tools cleanly separated and language-agnostic, A2A turns each ADK agent into a network-accessible service that can collaborate with peers, and AG-UI makes those interactions visible and interactive in the frontend. The result is an architecture where you can add new specialist agents, swap tool implementations, or scale components independently without rewriting your application.
From here, try adding a third agent, such as traffic, air quality, or travel planning, and update the A2A middleware instructions to route requests dynamically. As your agent ecosystem grows, you will start to see the real payoff of this approach: clearer separation of concerns, better reliability under complexity, and a user experience that remains transparent even as the system becomes increasingly distributed.
I hope this tutorial proves useful as you explore and build your own multi-agent systems. Whether you are experimenting with new agent interactions or designing production-ready architectures, I hope the patterns and examples shared here help accelerate your work. Thanks for following along, and I look forward to seeing you in the next post.
Note: The full, fully functional implementation of the application is available in the GitHub repository under the multiple_agent branch. To run the complete app end-to-end, use the provided run_app.sh script. This script automatically dispatches all required components—including the MCP servers, the A2A agents, the backend API, and the frontend—so the entire system can be launched with a single command.
Acknowledgements
This work was supported by Google ML Developer Programs and the Google Developers Program , which provided Google Cloud credits and high-quality technical resources #AISprint


Top comments (0)