Chapter 16
Week 5.1: Google ADK and A2A
Welcome to Week 5 - Agent Frameworks
Day 1: Google ADK (and a quick look at A2A)
This week is a fast tour of today's agent frameworks. The big idea is that once you know one of them well, you basically know them all. Every framework, we build an agent the same five steps, so the pattern starts to feel familiar fast:
- Create the agent - give it a model and a system prompt.
- Run it - send a message, get a reply.
- Add tools - plain typed functions the agent can call.
- Add MCP - connect a tool server someone else wrote, wired the same way every time.
- Put it in a loop with a goal - hand it an objective and let it work, step after step, until the job is done.
Steps 1 and 2 are still just an LLM call. Tools and MCP give the agent things it can do. Step 5 is where it becomes an agent: the framework runs the loop on its own, choosing a tool, reading the result, choosing again, until the goal is met. That loop is the thing to watch all week.
We open with Google ADK, the framework we spend the most time on. It has the most polished developer experience of the bunch, a visual trace you can watch live, and first-class support for both MCP and A2A.
Our running project for the whole week is a small SQLite todo board. A worker picks one task off the board, does it with its agent loop, writes the result, and marks the task done. You build and run the whole worker right here, then see it again in ADK's visual UI. On Day 5 the same board grows up to coordinate a whole team of agents.
Setup
Two things this day needs, both already in place from earlier weeks:
- Node, for
npx(the filesystem MCP server runs over it). Check withnode --version. GOOGLE_API_KEYin the repo-root.env. You set this up in the CrewAI week, so it is already there.
Google ADK is part of the repo's environment, so a normal uv sync from the repo root installs everything. Open this notebook in Cursor and pick the repo's default Python 3.12.12 kernel, the same one you use every week, then run the cells top to bottom.
The notebook starts the filesystem MCP server for you at step 4, over npx. Warm it once now so that first run is fast, then stop it with Ctrl-C as soon as it prints that it is running:
npx -y @modelcontextprotocol/server-filesystem .import os
from dotenv import load_dotenv
from google.adk.agents import LlmAgent
from google.adk.runners import InMemoryRunner
from quiet import silence
silence() # turn down some noisy library logging so the agent's trace is easy to read
os.environ.setdefault("GOOGLE_GENAI_USE_VERTEXAI", "FALSE")
load_dotenv(override=True)Step 1: Create the agent
In ADK an agent is an LlmAgent: a model plus an instruction, which is its system prompt. That is the whole object, no wrapper class and no registration step. First the setup; GOOGLE_API_KEY comes from the repo-root .env.
MODEL = "gemini-3.1-flash-lite"
agent = LlmAgent(
model=MODEL,
name="assistant",
instruction="You are a concise, friendly assistant. Reply in a single short sentence.",
)Step 2: Run it
InMemoryRunner(...).run_debug(..., verbose=True) runs the agent and prints each step, so you can watch what it does. With no tools yet there is nothing to loop over, so the agent just replies. This is still only an LLM call.
result = await InMemoryRunner(agent=agent).run_debug("Say hello in Spanish.", verbose=True)Our project this week: a SQLite todo board
The worker coordinates through a tiny SQLite board: one file, one table, no server to run. board.py is a handful of small functions. A worker is handed one goal; to reach it, it writes its own step todos under that goal, ticks each one off as it goes, and closes the goal at the end. Under the hood the board is just a list of dicts (a goal has parent_id of None, a step points at its goal):
import board
board.reset_board()
board.add_goal("Read notes.txt, translate its contents into natural Spanish, and write the Spanish to spanish.txt.")
board.list_todos()show_board() prints that same data nicely, in the rich style from Week 1: each goal with its steps indented beneath, done tasks struck through in green and the one in progress in yellow. There are no steps yet; the worker writes its own when it plans.
board.show_board()Step 3: Add tools
A tool in ADK is just a typed Python function with a docstring. The docstring becomes the description the model reads, the type hints become the argument schema, and you hand the function to the agent in its tools=[...] list.
We write three small board tools: show_todos to read the board, plan_steps to break a goal into steps, and complete_task to mark a todo done. Here we give a quick agent just two of them and ask what is on the board; watch it decide, on its own, to call show_todos before it answers. That decide, call, read, answer is the agent loop starting to turn. All three tools come together in step 5.
def show_todos() -> list[dict]:
"""List every todo on the board. A goal has parent_id None; a step has parent_id set to its goal's id."""
return board.list_todos()
def plan_steps(goal_id: int, steps: list[str]) -> dict:
"""Break a goal into an ordered checklist of steps on the board. Pass the goal's id and a short list of step descriptions."""
return {"goal_id": goal_id, "step_ids": [board.add_step(goal_id, step) for step in steps]}
def complete_task(task_id: int, result: str) -> dict:
"""Mark a todo (a step or the goal) with this id as done and record a short result summary."""
board.complete_todo(task_id, result)
return {"task_id": task_id, "status": "done"}board_agent = LlmAgent(
model=MODEL,
name="board_agent",
instruction="You help manage a shared todo board.",
tools=[show_todos, complete_task],
)result = await InMemoryRunner(agent=board_agent).run_debug("What is on the board right now, and what is its status?", verbose=True)A tool can reach the real world too
The board tools talk to a little database. A tool can just as easily reach outside the machine. Here is one that sends a push notification to your phone through Pushover. It is the same send_push_notification used elsewhere in the course, and it is still just a typed function with a docstring; ADK reads those to learn how to call it.
import requests
def send_push_notification(message: str) -> str:
"""Send a short push notification to the user's phone to report a result or that a job is done."""
payload = {
"token": os.getenv("PUSHOVER_TOKEN"),
"user": os.getenv("PUSHOVER_USER"),
"message": message,
"sound": "climb"
}
response = requests.post("https://api.pushover.net/1/messages.json", data=payload)
return f"Push notification sent (status {response.status_code})."notifier = LlmAgent(
model=MODEL,
name="notifier",
instruction="You notify the user. When asked, use your tool to send a push notification.",
tools=[send_push_notification],
)
result = await InMemoryRunner(agent=notifier).run_debug("Send a push notification that says hello from Google ADK.", verbose=True)Step 4: Add MCP
MCP is just more tools: ones you did not write, connected over a small protocol. We give the agent the filesystem reference server (the same Node server every framework uses this week), scoped to a single workspace folder so it can only touch files in there. In ADK that is one McpToolset added to the same tools=[...] list.
ADK starts the server for you as a small Node process over npx. One detail worth knowing: we pass errlog=subprocess.DEVNULL. That quiets the server's startup logging, and it is also what lets the server run from a Jupyter kernel on Windows, where the kernel's stderr has no real file descriptor for the server to write to. On Mac and Linux it changes nothing.
import subprocess
from pathlib import Path
from google.adk.tools.mcp_tool import McpToolset, StdioConnectionParams
from mcp import StdioServerParameters
workspace = Path("task_worker/workspace").resolve() # the only folder the agent may touch
filesystem = McpToolset(
connection_params=StdioConnectionParams(
server_params=StdioServerParameters(
command="npx",
args=["-y", "@modelcontextprotocol/server-filesystem", str(workspace)],
cwd=str(workspace), # start the server in the workspace so relative file names resolve there
),
timeout=60
),
errlog=subprocess.DEVNULL,
)file_agent = LlmAgent(
model=MODEL,
name="file_agent",
instruction="You can read and write files in your workspace. Use your tools to do what is asked.",
tools=[filesystem],
)result = await InMemoryRunner(agent=file_agent).run_debug("Read notes.txt and summarize it in one short sentence.", verbose=True)Step 5: Put it in a loop with a goal
Now the payoff. Give one agent all three board tools and the filesystem server, hand it the goal, and let it run. It plans its own steps on the board, works them with its file tools, ticks each one off, and closes the goal when the work is done. That is the agent loop running on its own: read, plan, act, check off, repeat.
This time we drive the loop with run_async rather than run_debug, stepping through its events ourselves. We print each tool call as the agent makes it, and re-draw the board whenever a step is planned or ticked off, so you watch the plan appear and then strike through one line at a time.
from google.genai import types
INSTRUCTIONS = """
You are a careful worker with a shared todo board and a set of file tools.
Take the pending goal and see it through. Begin by laying out a short plan: the handful of concrete steps the work itself breaks down into, added to the board under the goal. Then carry them out with your file tools, marking each step done as you finish it. Once the steps are all done, close the goal. Your files live in the single folder your tools are allowed to use.
"""
worker = LlmAgent(
model=MODEL,
name="task_worker",
instruction=INSTRUCTIONS,
tools=[show_todos, plan_steps, complete_task, filesystem, send_push_notification],
)
board.reset_board()
goal_id = board.add_goal("Read notes.txt, translate its contents into natural Spanish, and write the Spanish to spanish.txt and send a push notification with a summary.")
board.claim_todo(goal_id)
# Drive the loop ourselves with run_async instead of run_debug, so we can watch it:
# print each tool call, and re-draw the board whenever a step is planned or ticked off.
runner = InMemoryRunner(agent=worker)
session = await runner.session_service.create_session(app_name=runner.app_name, user_id="ed")
async for event in runner.run_async(
user_id="ed",
session_id=session.id,
new_message=types.UserContent("Please work the pending goal on the board."),
):
for call in event.get_function_calls():
print(f"tool: {call.name}({call.args})")
if any(r.name in ("plan_steps", "complete_task") for r in event.get_function_responses()):
board.show_board()
if event.is_final_response():
print(event.content.parts[0].text)Which Runner, and which run method?
Worth a moment, because you meet both in every ADK project.
The Runner. We used InMemoryRunner: a Runner whose session, artifact and memory services live in memory. Ideal for learning and experiments, since nothing is saved and it all resets when the kernel stops. The plain Runner is what a real app uses. You give it the same agent plus real services, say a database-backed session service, so conversations persist across runs and across users.
The run method. Steps 2 to 4 used run_debug: a convenience that creates a session, sends your message, prints the trace, and returns the list of events. ADK's own docs call it debugging and experimentation only. Step 5 stepped up to run_async, the real entry point, where you own the session and iterate the event stream yourself. That is what let us print each tool call and re-draw the board as the loop ran:
from google.genai import types
runner = InMemoryRunner(agent=agent)
session = await runner.session_service.create_session(app_name=runner.app_name, user_id="ed")
message = types.UserContent("Say hi in French.")
async for event in runner.run_async(user_id="ed", session_id=session.id, new_message=message):
print(event.author, event.content)run_debug hides all of that so you can focus on the agent; run_async hands it back when you want the control, as in step 5. There is also run, a plain synchronous version of run_async for code that is not async, and run_live, an experimental streaming mode for real-time audio and video.
The same worker, as an ADK project
You just built the whole worker here in the notebook. The task_worker/ folder is that exact thing packaged as a module: an __init__.py and an agent.py holding the same board tools, the same filesystem server, and the same instruction. Packaging it as a folder is what lets ADK's tooling load it. An ADK agent is just a folder like this, and you can scaffold a fresh one from scratch with adk create <name>.
You get two things for free once it is a module. First, run it as a plain script, which seeds a goal and runs the loop exactly as above. Open a terminal in this day's folder and run:
uv run worker.pySecond, and the reason ADK is the nicest developer experience of the week, watch it in the live visual trace:
uv run worker.py --seed-only # put a fresh goal on the board
uv run adk web # then open the link it printsPick task_worker from the dropdown (it also lists a2a_demo, which is for the A2A section below, so ignore it here) and type "please work the pending goal on the board". Every tool call and MCP call lights up in the timeline as the loop runs, the same loop you just watched here, now with a UI.
A quick look at A2A
A2A (Agent2Agent) is a standard for letting separate agents discover and call each other over HTTP. ADK supports it in two calls: to_a2a exposes an agent as a service, and RemoteA2aAgent calls a remote one as if it were a local sub-agent. The a2a_demo/ folder has a small example: a translator agent exposed as a service, and a local concierge that delegates translation to it.
First, if an adk web from the previous section is still running, stop it with Ctrl-C. This demo launches its own adk web from inside a2a_demo, and adk web only lists the agents in the folder you launch it from. Launching it there is what puts spanish_concierge in the dropdown; launched from the day root you would see task_worker instead.
Use two terminals, both inside the a2a_demo folder:
cd a2a_demo
# Terminal 1: expose the translator as an A2A service on port 8001
uv run uvicorn server:a2a_app --host localhost --port 8001
# Terminal 2: fetch the translator's agent card, then drive the concierge
curl http://localhost:8001/.well-known/agent-card.json
uv run adk web # pick spanish_concierge, then ask it to translate something into SpanishHow does the concierge find the translator? We told it where to look. In spanish_concierge/agent.py the RemoteA2aAgent is built with the translator's card URL, http://localhost:8001/.well-known/agent-card.json. There is no broadcast or auto-scan; an A2A client is simply pointed at a known card URL. The curl above fetches that very card, which is how the concierge learns the agent's name, what it can do, and where to reach it before sending a single request. The concierge then delegates translation over A2A, and the Spanish comes back from the other process.
A2A is a real Linux Foundation standard and the discovery model is clean, but the hype runs well ahead of real adoption, and MCP is what carries day-to-day agent work right now. A2A does not come back in the Day 5 project.
