Most agent tutorials show you the happy path. They give you a chat UI, a tool, and a one-shot demo. Then you try to build something real, and the demo collapses under a single missing concept: how state actually flows, how to test a non-deterministic system, what to do when a tool takes ten minutes instead of ten milliseconds, or how two agents from different teams find each other in production.
The 5-Day AI Agents course on Kaggle is the closest thing I have found to a structured curriculum that covers the full life-cycle of an agent system, not just the demo. Ten codelabs, five whitepapers, and a single coherent framework that ties it all together. I worked through every codelab end-to-end. This post is the tutorial I wish I had read first: what each lab is actually trying to teach, the actual code from the codelabs, how to think through the exercises, and the parts of the material that translate cleanly into a real production system.
The patterns in the course are not tied to one framework. The same ideas show up in LangGraph, CrewAI, AutoGen, and any custom agent runtime. I am using the course’s framework vocabulary here, but the lessons are not.
A map of the course
Five days, ten codelabs, one framework. The arc is the real arc of an agent system, in the order you actually build it:
| Day | Codelab | The question it answers |
|---|---|---|
| 1a | From prompt to action | What is an agent, and how does a model call a tool? |
| 1b | Multi-agent architectures | When do I split one agent into many, and how do I coordinate them? |
| 2a | Custom tools and code execution | How do I give an agent my business logic, and how do I make sure its math is right? |
| 2b | MCP and long-running tools | How do agents call other people’s tools, and what do I do when a tool takes 10 minutes? |
| 3a | Sessions | How does an agent remember what we said two turns ago, and how does that survive a restart? |
| 3b | Memory | How does an agent recall what we said last week? |
| 4a | Observability | How do I see what the agent actually did? |
| 4b | Evaluation | How do I prove the agent is correct, and how do I catch it when it isn’t? |
| 5a | Agent-to-agent protocol | How do two agents from different teams talk to each other? |
| 5b | Deployment | How do I turn the agent in my notebook into an HTTP service? |
The order matters. Each lab depends on the previous one. By the end of Day 5, you have an agent you can deploy, monitor, evaluate, and expose to other systems — the same set of capabilities a real production system needs.
What follows is a walkthrough of every lab, with the actual teaching code, the design lessons, and the parts that translate to a real system.
Day 1a — From prompt to action
What the lab teaches
An agent is not magic. It is a model plus a loop. The model reads a prompt and either returns text or returns a function call. The runtime executes the function, appends the result, and asks the model again. The loop continues until the model returns plain text.
That is the entire abstraction. The rest of the course is layers on top of it.
The first codelab makes this concrete with the simplest possible example: a single agent, a single tool (web search), and three questions. The point is not the search. The point is to show the mechanism — that an agent can be told “use this tool” and will do so, that the tool’s output is fed back into the next turn, and that the loop ends when the model has enough information to answer.
The code
The lab has you build the agent in three layers. First the imports and the model wrapper:
from google.adk.agents import Agent
from google.adk.models.google_llm import Gemini
from google.adk.runners import InMemoryRunner
from google.adk.tools import google_search
from google.genai import types
# Retry options handle transient errors (rate limits, 5xx responses)
retry_config = types.HttpRetryOptions(
attempts=5,
exp_base=7,
initial_delay=1,
http_status_codes=[429, 500, 503, 504],
)
The retry config is worth noting. LLM APIs fail. Rate limits hit, networks drop, services restart. A production agent has to survive those failures. The retry options here say “if you get a 429 or 5xx, wait initial_delay * exp_base^attempt seconds and try again, up to 5 times.” The exponential backoff (exp_base=7) is steeper than the usual 2 because the failures tend to be sustained overloads, not blips.
Next, the agent itself:
root_agent = Agent(
name="search_agent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="You are a helpful research assistant. Use the google_search tool to find current information.",
tools=[google_search],
)
Four fields matter here:
nameis the agent’s identifier. It shows up in logs, in traces, in the agent card if you ever expose this over A2A. Pick something descriptive.modelis the LLM. Theretry_optionsflow through to every model call the agent makes.instructionis the system prompt. The lab keeps it short. In practice, this is the single most important lever for shaping agent behavior — more on this in Day 2a.toolsis the list of tools the agent can call. In this lab it is just one —google_search, a built-in tool that returns search results.
Finally, the runner:
runner = InMemoryRunner(agent=root_agent)
response = await runner.run_debug("What is the latest version of the Agent Development Kit?")
InMemoryRunner is the simplest way to execute an agent. It creates an in-memory session, runs the agent loop until the model returns text, and streams the events back. run_debug is a helper that prints each event as it happens, so you can watch the model think, call the tool, get the result, and then answer.
How to think about it
When I started, I tried to reason about agents as a new kind of software. They are not. They are the same request/response software you have written a hundred times, with one twist: the request and the response are text, and the choice of which function to call is made by a model instead of by a switch statement.
Once you internalize that, the rest of the course is about three things:
- What the model can choose to do — the set of tools and sub-agents you give it
- What the model can see — the context you pass in: sessions, memory, prior messages
- How the loop terminates — when the model returns text instead of a function call
Everything in the next nine codelabs is a refinement of one of these three things.
Real-world lesson
In a real system, the loop is where the cost and the latency live. Every tool call is a network round-trip. Every model turn is a token charge. An agent that calls five tools in series is five times slower and five times more expensive than one that calls them in parallel. The Day 1b Parallel pattern is the first explicit treatment of this, but you should keep the cost/latency frame in mind from Day 1a.
A second real-world lesson: tool design is more important than prompt design. A model given a good tool with a good docstring will use it correctly. A model given a bad tool with a bad docstring will hallucinate the input and fail silently. The Day 2a codelab makes this explicit by walking through what a well-designed tool looks like.
Knowledge extracted
- An agent is a model in a loop, not a new abstraction
- The loop’s three levers are tools, context, and termination
- Cost and latency are dominated by the loop, not the model call
- Tool design is prompt design
Day 1b — Multi-agent architectures
What the lab teaches
A single agent can do a lot. A single agent doing everything — research, writing, editing, fact-checking — cannot. The prompt gets long, the model gets confused, and debugging becomes a nightmare because you cannot tell which part of the instruction failed.
The lab walks through four patterns for splitting work across multiple agents. The right one to pick depends on the structure of the work:
| Pattern | When to use it | Example |
|---|---|---|
| LLM coordinator | The work is unpredictable and the model should decide which specialist to call | Research + Summarize, where the order is not fixed |
| Sequential | The work has a fixed order, and each step builds on the previous | Outline → Write → Edit |
| Parallel | The work is independent and the speed gain from concurrency matters | Three parallel researchers, then one aggregator |
| Loop | The work needs iterative refinement, with a quality gate | Writer + Critic, repeat until approved |
The lab has you build all four. The implementations are short — twenty lines of code per pattern — but the decision tree is the real lesson.
Pattern 1: LLM coordinator
The coordinator is a root agent that holds other agents as tools. The model decides which one to call and in what order.
# Step 1: Define the specialists
research_agent = Agent(
name="ResearchAgent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""You are a specialized research agent. Your only job is to use the
google_search tool to find 2-3 pieces of relevant information on the given topic
and present the findings with citations.""",
tools=[google_search],
output_key="research_findings", # The result of this agent will be stored
# in the session state with this key.
)
summarizer_agent = Agent(
name="SummarizerAgent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
# The instruction is modified to request a bulleted list for a clear output format.
instruction="""Read the provided research findings: {research_findings}
Create a concise summary as a bulleted list with 3-5 key points.""",
output_key="final_summary",
)
# Step 2: Build the root coordinator that calls them as tools
root_agent = Agent(
name="ResearchCoordinator",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""You are a research coordinator. Your goal is to answer the user's query
by orchestrating a workflow.
1. First, you MUST call the `ResearchAgent` tool to find relevant information on the topic.
2. Next, after receiving the research findings, you MUST call the `SummarizerAgent` tool
to create a concise summary.
3. Finally, present the final summary clearly to the user as your response.""",
tools=[AgentTool(research_agent), AgentTool(summarizer_agent)],
)
runner = InMemoryRunner(agent=root_agent)
response = await runner.run_debug(
"What are the latest advancements in quantum computing and what do they mean for AI?"
)
The output_key mechanism is the part worth understanding. When research_agent finishes, its final response is automatically stored in session.state["research_findings"]. When summarizer_agent runs, its instruction includes the placeholder {research_findings}, and the framework substitutes the value from state before sending it to the model.
The two specialists never call each other directly. They communicate through session state. That is the entire plumbing.
Pattern 2: Sequential
The Sequential pattern is a fixed pipeline. Each agent runs in order, and the output of one becomes the input of the next:
outline_agent = Agent(
name="OutlineAgent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""Create a blog outline for the given topic with:
1. A catchy headline
2. An introduction hook
3. 3-5 main sections with 2-3 bullet points for each
4. A concluding thought""",
output_key="blog_outline",
)
writer_agent = Agent(
name="WriterAgent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""Following this outline strictly: {blog_outline}
Write a brief, 200 to 300-word blog post with an engaging and informative tone.""",
output_key="blog_draft",
)
editor_agent = Agent(
name="EditorAgent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""Edit this draft: {blog_draft}
Your task is to polish the text by fixing any grammatical errors, improving the
flow and sentence structure, and enhancing overall clarity.""",
output_key="final_blog",
)
# Sequential pipeline: outline -> write -> edit
root_agent = SequentialAgent(
name="BlogPipeline",
sub_agents=[outline_agent, writer_agent, editor_agent],
)
runner = InMemoryRunner(agent=root_agent)
response = await runner.run_debug(
"Write a blog post about the benefits of multi-agent systems for software developers"
)
Notice what SequentialAgent does. It is a workflow agent — a container that runs its sub-agents in the order listed. The model is not in the loop for control flow. The framework runs A, waits for A to finish, runs B, waits for B to finish, runs C. The output of A flows into the state, B’s instruction reads it, and the chain is deterministic.
Pattern 3: Parallel + Sequential
The Parallel pattern runs independent agents concurrently, then aggregates. The lab wires it together as a Parallel agent nested inside a Sequential one:
# Three independent researchers
tech_researcher = Agent(
name="TechResearcher",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""Research the latest AI/ML trends. Include 3 key developments,
the main companies involved, and the potential impact. Keep the report very concise (100 words).""",
tools=[google_search],
output_key="tech_research",
)
health_researcher = Agent(
name="HealthResearcher",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""Research recent medical breakthroughs. Include 3 significant advances,
their practical applications, and estimated timelines. Keep the report concise (100 words).""",
tools=[google_search],
output_key="health_research",
)
finance_researcher = Agent(
name="FinanceResearcher",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""Research current fintech trends. Include 3 key trends,
their market implications, and the future outlook. Keep the report concise (100 words).""",
tools=[google_search],
output_key="finance_research",
)
# The aggregator synthesizes all three findings
aggregator_agent = Agent(
name="AggregatorAgent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""Combine these three research findings into a single executive summary:
**Technology Trends:**
{tech_research}
**Health Breakthroughs:**
{health_research}
**Finance Innovations:**
{finance_research}
Your summary should highlight common themes, surprising connections, and the most
important key takeaways from all three reports. The final summary should be around 200 words.""",
output_key="executive_summary",
)
# Parallel researchers, then sequential aggregation
parallel_research_team = ParallelAgent(
name="ParallelResearchTeam",
sub_agents=[tech_researcher, health_researcher, finance_researcher],
)
root_agent = SequentialAgent(
name="ResearchSystem",
sub_agents=[parallel_research_team, aggregator_agent],
)
runner = InMemoryRunner(agent=root_agent)
response = await runner.run_debug(
"Run the daily executive briefing on Tech, Health, and Finance"
)
The ParallelAgent runs its sub-agents concurrently. The framework handles the synchronization — it waits for all three to finish before yielding control to the next stage. The speedup is real: three research calls in parallel is roughly 1/3 the latency of three in sequence. The aggregator runs after, and it sees all three output_key values in the session state.
Pattern 4: Loop
The Loop pattern is iterative refinement. A writer drafts, a critic reviews, a refiner either rewrites or exits the loop. The trick is that the LoopAgent itself does not know when to stop. You need an explicit exit signal:
# A function the agent can call to exit the loop
def exit_loop():
"""Call this function ONLY when the critique is 'APPROVED', indicating the
story is finished and no more changes are needed."""
return {"status": "approved", "message": "Story approved. Exiting refinement loop."}
initial_writer_agent = Agent(
name="InitialWriterAgent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""Based on the user's prompt, write the first draft of a short story
(around 100-150 words). Output only the story text, with no introduction or explanation.""",
output_key="current_story",
)
critic_agent = Agent(
name="CriticAgent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""You are a constructive story critic. Review the story provided below.
Story: {current_story}
Evaluate the story's plot, characters, and pacing.
- If the story is well-written and complete, you MUST respond with the exact phrase: "APPROVED"
- Otherwise, provide 2-3 specific, actionable suggestions for improvement.""",
output_key="critique",
)
refiner_agent = Agent(
name="RefinerAgent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""You are a story refiner. You have a story draft and critique.
Story Draft: {current_story}
Critique: {critique}
Your task is to analyze the critique.
- IF the critique is EXACTLY "APPROVED", you MUST call the `exit_loop` function and nothing else.
- OTHERWISE, rewrite the story draft to fully incorporate the feedback from the critique.""",
output_key="current_story", # Overwrites the story with the new, refined version
tools=[FunctionTool(exit_loop)],
)
# The loop runs critic -> refiner repeatedly, up to max_iterations
story_refinement_loop = LoopAgent(
name="StoryRefinementLoop",
sub_agents=[critic_agent, refiner_agent],
max_iterations=2, # Prevents infinite loops
)
# The overall pipeline: write first draft, then enter refinement loop
root_agent = SequentialAgent(
name="StoryPipeline",
sub_agents=[initial_writer_agent, story_refinement_loop],
)
runner = InMemoryRunner(agent=root_agent)
response = await runner.run_debug(
"Write a short story about a lighthouse keeper who discovers a mysterious, glowing map"
)
The control flow is subtle. The loop runs critic_agent then refiner_agent. The critic either says "APPROVED" or gives feedback. The refiner reads the critique: if it is "APPROVED", the refiner calls exit_loop and the loop ends; otherwise, the refiner rewrites the story, and the loop runs again with the new draft.
max_iterations=2 is the safety net. If the critic never says “APPROVED” exactly (or the model interprets the condition wrong), the loop still terminates after 2 iterations. The lab deliberately keeps the iterations low — you can see the loop work without burning tokens.
How to think about it
The temptation, when you see multi-agent systems, is to reach for the LLM coordinator pattern because it feels the most “AI-native.” Resist that. The coordinator is the weakest pattern because it relies on the model’s instruction-following to maintain order. If your prompt says “first call A, then call B, then call C,” a sufficiently complex model may decide to skip a step or call them in the wrong order. It is also the hardest to debug because the control flow is implicit in the prompt.
The structured patterns — Sequential, Parallel, Loop — make the control flow explicit. You can read the code and know exactly what runs, in what order, with what inputs. They are also easier to evaluate (Day 4b), because the expected trajectory is a literal sequence of agent names.
The LLM coordinator earns its place when the work is genuinely unpredictable. A customer-support triage agent that needs to choose between billing, technical, or general-specialist sub-agents based on the user’s message is a coordinator. A research pipeline that always runs the same three steps in the same order is a Sequential.
Real-world lesson
Most production systems I have seen are not one big coordinator. They are a few Sequential/Parallel patterns wired together, with a thin LLM coordinator at the top to do the routing. The coordinator delegates downward to a deterministic pipeline. The pipeline is what you test and debug.
The other real-world lesson is the output_key mechanism. When an agent finishes, its final response is stored in a session-state key. The next agent’s instruction can reference that key with a placeholder like {research_findings}. This is the plumbing of multi-agent systems: how data flows from one agent to the next without explicit function calls.
The pattern transfers. LangGraph calls it “state channels.” CrewAI calls it “task outputs.” Whatever the framework, the lesson is: agents communicate by writing to a shared scratchpad, not by calling each other directly.
Knowledge extracted
- The four patterns are a vocabulary, not a recipe. The right one depends on the work
- LLM coordinators are the most flexible pattern and the least reliable. Use them at the top, deterministic pipelines at the bottom
- Agents share data through a state scratchpad, not direct calls
- The pattern you pick determines what you can evaluate. Sequential/Parallel have literal trajectories. Coordinators do not
Day 2a — Custom tools and code execution
What the lab teaches
Two ideas at once, because they are inseparable in practice.
Idea 1: Custom tools. Built-in tools (like web search) are useful, but every business has logic that no built-in covers. The lab shows how to wrap any Python function as a tool, and what good tool design looks like: dictionary returns with explicit status keys, clear docstrings that the model uses to decide when to call, type hints that become the function schema, and structured error responses so the model can recover gracefully.
The example is a currency converter: get_fee_for_payment_method and get_exchange_rate, both returning {"status": "success", ...} or {"status": "error", "error_message": "..."}. The model calls both in parallel, gets the fee and the rate, then does the math.
Idea 2: Code execution. Models are bad at math. They are especially bad at multi-step math where the answer depends on the previous step’s output. The lab’s fix is to give the agent a code executor so it can write and run Python instead of computing in tokens. The currency agent becomes three layers: a custom-tools agent that gets the fee and rate, a calculation agent that writes Python to do the math, and a parent agent that delegates to the calculation agent as a sub-tool.
The code: a well-designed tool
The lab starts with the most important lesson of the day: what a good tool looks like.
def get_fee_for_payment_method(method: str) -> dict:
"""Looks up the transaction fee percentage for a given payment method.
This tool simulates looking up a company's internal fee structure based on
the name of the payment method provided by the user.
Args:
method: The name of the payment method. It should be descriptive,
e.g., "platinum credit card" or "bank transfer".
Returns:
Dictionary with status and fee information.
Success: {"status": "success", "fee_percentage": 0.02}
Error: {"status": "error", "error_message": "Payment method not found"}
"""
# This simulates looking up a company's internal fee structure.
fee_database = {
"platinum credit card": 0.02, # 2%
"gold debit card": 0.035, # 3.5%
"bank transfer": 0.01, # 1%
}
fee = fee_database.get(method.lower())
if fee is not None:
return {"status": "success", "fee_percentage": fee}
else:
return {
"status": "error",
"error_message": f"Payment method '{method}' not found",
}
Four rules are in play here, and the lab spells them out:
1. Dictionary returns with status keys. The model can read the response and branch. If you return a string or a bare number, the model has to guess whether the call succeeded. With {"status": "success", "fee_percentage": 0.02}, the model can chain to the next call deterministically. With {"status": "error", "error_message": "..."}, the model can ask the user for a different method instead of silently making up a fee.
2. Docstrings as instructions. The model does not have access to your code, only to the docstring. The docstring is the only place where you can tell the model when to call this tool, what arguments are valid, and what the return shape means. A bad docstring is a bug. A good docstring is a contract.
3. Type hints as schema. The framework reads the type hints and generates the JSON schema the model uses to construct the call. If your function takes method: str, the model knows to pass a string. If you leave it untyped, the model has to guess from the docstring alone, and it will guess wrong about a quarter of the time.
4. Structured errors. Errors are how the model learns to recover. A function that raises a Python exception will crash the agent. A function that returns {"status": "error", ...} lets the model decide what to do next — ask the user, try a different argument, give up gracefully. This is the difference between an agent that fails visibly and one that fails invisibly.
The second tool follows the same pattern:
def get_exchange_rate(base_currency: str, target_currency: str) -> dict:
"""Looks up and returns the exchange rate between two currencies.
Args:
base_currency: The ISO 4217 currency code of the currency you
are converting from (e.g., "USD").
target_currency: The ISO 4217 currency code of the currency you
are converting to (e.g., "EUR").
Returns:
Dictionary with status and rate.
Success: {"status": "success", "rate": 0.93}
Error: {"status": "error", "error_message": "Unsupported currency pair"}
"""
# Static data simulating a live exchange rate API
# In production, this would call something like: requests.get("api.exchangerates.com")
rate_database = {
"usd": {
"eur": 0.93,
"jpy": 157.50,
"inr": 83.58,
}
}
base = base_currency.lower()
target = target_currency.lower()
rate = rate_database.get(base, {}).get(target)
if rate is not None:
return {"status": "success", "rate": rate}
else:
return {
"status": "error",
"error_message": f"Unsupported currency pair: {base_currency}/{target_currency}",
}
The code: the agent that uses the tools
currency_agent = LlmAgent(
name="currency_agent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""You are a smart currency conversion assistant.
For currency conversion requests:
1. Use `get_fee_for_payment_method()` to find transaction fees
2. Use `get_exchange_rate()` to get currency conversion rates
3. Check the "status" field in each tool's response for errors
4. Calculate the final amount after fees based on the output from
`get_fee_for_payment_method` and `get_exchange_rate` methods and provide a clear breakdown.
5. First, state the final converted amount.
Then, explain how you got that result by showing the intermediate amounts.
Your explanation must include: the fee percentage and its value in the original currency,
the amount remaining after the fee, and the exchange rate used for the final conversion.
If any tool returns status "error", explain the issue to the user clearly.
""",
tools=[get_fee_for_payment_method, get_exchange_rate],
)
currency_runner = InMemoryRunner(agent=currency_agent)
_ = await currency_runner.run_debug(
"I want to convert 500 US Dollars to Euros using my Platinum Credit Card. How much will I receive?"
)
Two things to notice. The instruction explicitly tells the agent to check the status field — that is the tool design contract being reinforced in the prompt. And the tools list is just [get_fee_for_payment_method, get_exchange_rate]. No wrappers. The framework reads the docstrings and type hints and builds the tool spec automatically.
The code: code execution via a sub-agent
Models hallucinate arithmetic. The lab’s fix is to have the agent write Python to do the math, then run it. The cleanest way to do that is to make a sub-agent whose only job is to write Python, and use that sub-agent as a tool from the parent.
calculation_agent = LlmAgent(
name="CalculationAgent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""You are a specialized calculator that ONLY responds with Python code.
You are forbidden from providing any text, explanations, or conversational responses.
Your task is to take a request for a calculation and translate it into a single
block of Python code that calculates the answer.
RULES:
1. Your output MUST be ONLY a Python code block.
2. Do NOT write any text before or after the code block.
3. The Python code MUST calculate the result.
4. The Python code MUST print the final result to stdout.
5. You are PROHIBITED from performing the calculation yourself. Your only job is
to generate the code that will perform the calculation.
Failure to follow these rules will result in an error.""",
code_executor=BuiltInCodeExecutor(), # Gives the agent code execution capabilities
)
The instruction is unusually strict for a reason. A calculation agent that adds explanatory text breaks the parse. A calculation agent that does the math in tokens defeats the purpose. The rules are explicit, and the framework will reject output that does not conform.
The parent agent uses the calculation sub-agent as a tool:
enhanced_currency_agent = LlmAgent(
name="enhanced_currency_agent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
# Updated instruction
instruction="""You are a smart currency conversion assistant. You must strictly follow
these steps and use the available tools.
For any currency conversion request:
1. Get Transaction Fee: Use the get_fee_for_payment_method() tool to determine the fee.
2. Get Exchange Rate: Use the get_exchange_rate() tool to get the currency conversion rate.
3. Error Check: After each tool call, you must check the "status" field in the response.
If the status is "error", you must stop and clearly explain the issue to the user.
4. Calculate Final Amount (CRITICAL): You are strictly prohibited from performing any
arithmetic calculations yourself. You must use the calculation_agent tool to generate
Python code that calculates the final converted amount.
5. Provide Detailed Breakdown: In your summary, you must:
* State the final converted amount.
* Explain how the result was calculated, including:
* The fee percentage and the fee amount in the original currency.
* The amount remaining after deducting the fee.
* The exchange rate applied.
""",
tools=[
get_fee_for_payment_method,
get_exchange_rate,
AgentTool(agent=calculation_agent), # Using another agent as a tool!
],
)
enhanced_runner = InMemoryRunner(agent=enhanced_currency_agent)
response = await enhanced_runner.run_debug(
"Convert 1,250 USD to INR using a Bank Transfer. Show me the precise calculation."
)
What happens at runtime: the parent calls the fee and rate tools, gets the values, then calls the calculation_agent tool. The sub-agent receives the request, generates Python like:
amount = 1250
fee_rate = 0.01
exchange_rate = 83.58
fee_amount = amount * fee_rate
amount_after_fee = amount - fee_amount
final_amount = amount_after_fee * exchange_rate
print(f"{final_amount:.2f}")
The code executor runs it, captures stdout (104475.05), and returns that to the parent. The parent formats the final answer with the breakdown. The math was done by the Python interpreter, not by the model’s tokens. It is correct.
How to think about it
The tool-design half is the more important lesson, and the one most tutorials skip. The four rules in the lab are not arbitrary. They are the difference between a model that uses your tool correctly and a model that hallucinates the call.
The code-execution half is conceptually simpler but operationally important. Any time the model needs to do precise computation, you want it to generate code, not tokens. Currency conversion. Date math. Filtering a list. Statistical summaries. The framework gives the agent a sandboxed Python environment and the agent writes the code.
A subtle point about AgentTool vs sub-agents (the lab calls this out explicitly):
- AgentTool — Agent A calls Agent B as a tool. Agent B’s response goes back to Agent A. Agent A stays in control and continues the conversation. Use it for delegation.
- Sub-agents — Agent A transfers control completely to Agent B. Agent B takes over and handles all future user input. Agent A is out of the loop. Use it for handoff (like customer support tiers).
In the currency example, you want the parent to keep control — it called the calculation agent, got a number, and needs to keep going to format the final answer. AgentTool is the right choice. If the user had said “transfer me to a human,” you would want a sub-agent that takes over completely.
Real-world lesson
In a real system, the tool design rules from the lab are the same rules you would write for any public API. The model is an untrusted caller. It will pass the wrong types, it will pass strings when you expected enums, it will not read your error messages carefully. Treat your tool surface as a public API, because that is what it is.
The code-executor pattern is the one most teams under-use. There is a tendency to try to make the model do everything in tokens, then patch the arithmetic with regex post-processing. That is fragile. A code executor turns “calculate 990 × 157.5” into print(990 * 157.5), which never lies. Use it.
A second real-world lesson: the sub-agent-as-tool pattern is a powerful generalization. A sub-agent has its own prompt, its own tools, and its own model — and from the parent’s perspective, it is just a callable. You can build a hierarchy of agents that mirrors a hierarchy of skills: a research agent that delegates to a search agent, a writing agent that delegates to a style agent, a deployment agent that delegates to a test agent. Each level abstracts the one below.
Knowledge extracted
- Tool design is API design. Dictionary returns, docstrings, type hints, structured errors
- Code execution is the right answer for any precise computation
- AgentTool delegates. Sub-agents hand off. Pick based on who keeps control
- The four tool rules are not optional. They are how the model uses your tool correctly
Day 2b — MCP and long-running tools
What the lab teaches
Two more tool concepts, both essential for any non-toy system.
Concept 1: The Model Context Protocol (MCP). MCP is a standard for agents to call external tools. The protocol defines a handshake: the agent connects to an MCP server, the server publishes a list of tools with their schemas, and the agent calls them like any other tool. The lab has you connect to @modelcontextprotocol/server-everything, a toy server that ships with getTinyImage, add, printEnv, and a few others. The point is to show that the agent does not need to know how the tool is implemented. It calls getTinyImage and gets bytes back. Whether those bytes come from a local file, a network call, or a database is invisible.
Concept 2: Long-running operations with human approval. Some tools take a long time. Some tools are dangerous. The lab’s example is a shipping agent: orders of fewer than 5 containers are auto-approved, orders of 5 or more pause and ask for a human to click “Approve” before proceeding. The mechanism is a ToolContext-aware function and a request_confirmation call inside the tool. When the model calls the tool with too many containers, the tool returns {"status": "pending"} instead of {"status": "approved"}, and the agent runtime knows to halt the loop until the human acts.
The code: MCP integration
Connecting to an MCP server is a single object:
from google.adk.tools.mcp_tool import McpToolset
from mcp import StdioServerParameters
# MCP integration with the Everything Server
mcp_image_server = McpToolset(
connection_params=StdioServerParameters(
command="npx", # Run MCP server via npx
args=[
"-y", # Argument for npx to auto-confirm install
"@modelcontextprotocol/server-everything",
],
tool_filter=["getTinyImage"], # Only use this one tool
),
timeout=30,
)
Behind the scenes, this does six things:
- Server launch — runs
npx -y @modelcontextprotocol/server-everythingas a subprocess - Handshake — establishes stdio communication between the agent and the server
- Tool discovery — the server publishes its tool list; ADK reads the schemas
- Integration — the tools appear in the agent’s
tools=[]list automatically - Execution — when the model calls
getTinyImage(), ADK forwards to the server - Response — the result comes back the same way any other tool’s result does
The agent does not know it is talking to a subprocess over stdio. It just sees getTinyImage in its tool list.
Using it is a one-liner:
image_agent = LlmAgent(
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
name="image_agent",
instruction="Use the MCP Tool to generate images for user queries",
tools=[mcp_image_server],
)
runner = InMemoryRunner(agent=image_agent)
response = await runner.run_debug("Provide a sample tiny image", verbose=True)
# Decode and display the base64-encoded image the server returned
import base64
from IPython.display import display, Image as IPImage
for event in response:
if event.content and event.content.parts:
for part in event.content.parts:
if hasattr(part, "function_response") and part.function_response:
for item in part.function_response.response.get("content", []):
if item.get("type") == "image":
display(IPImage(data=base64.b64decode(item["data"])))
The tool_filter=["getTinyImage"] line is the part worth understanding. The everything server ships with multiple tools. Without the filter, the model would see all of them and might pick the wrong one. The filter says “only these tools are part of this agent.” It is a permission boundary as much as a UX detail.
The code: a long-running tool with human approval
The shipping tool demonstrates the pause-for-approval pattern. The function takes a ToolContext, which the framework injects automatically:
LARGE_ORDER_THRESHOLD = 5
def place_shipping_order(
num_containers: int, destination: str, tool_context: ToolContext
) -> dict:
"""Places a shipping order. Requires approval if ordering more than 5 containers.
Args:
num_containers: Number of containers to ship
destination: Shipping destination
Returns:
Dictionary with order status
"""
# SCENARIO 1: Small orders auto-approve
if num_containers <= LARGE_ORDER_THRESHOLD:
return {
"status": "approved",
"order_id": f"ORD-{num_containers}-AUTO",
"num_containers": num_containers,
"destination": destination,
"message": f"Order auto-approved: {num_containers} containers to {destination}",
}
# SCENARIO 2: First call for a large order — request human approval
if not tool_context.tool_confirmation:
tool_context.request_confirmation(
hint=f"⚠️ Large order: {num_containers} containers to {destination}. Do you want to approve?",
payload={"num_containers": num_containers, "destination": destination},
)
return { # This is sent to the Agent
"status": "pending",
"message": f"Order for {num_containers} containers requires approval",
}
# SCENARIO 3: Resumed call — handle the human's approval decision
if tool_context.tool_confirmation.confirmed:
return {
"status": "approved",
"order_id": f"ORD-{num_containers}-HUMAN",
"num_containers": num_containers,
"destination": destination,
"message": f"Order approved: {num_containers} containers to {destination}",
}
else:
return {
"status": "rejected",
"message": f"Order rejected: {num_containers} containers to {destination}",
}
The function is a state machine. The tool_context.tool_confirmation object is the runtime’s way of saying “I have not seen a human decision yet” (Scenario 2) vs “I have a human decision — read .confirmed” (Scenario 3). The framework persists the context across the pause-and-resume, so the function can be called twice for the same logical operation: once to request, once to complete.
The wiring on the agent side:
shipping_agent = LlmAgent(
name="shipping_agent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""You are a shipping coordinator. Place orders using the
place_shipping_order tool. Always report the order status to the user clearly.""",
tools=[FunctionTool(place_shipping_order)],
)
runner = InMemoryRunner(agent=shipping_agent)
# Small order — auto-approved on the first call
await runner.run_debug("Ship 3 containers to Singapore")
# Large order — first call returns "pending", second call after human approval completes it
await runner.run_debug("Ship 50 containers to Mars")
How to think about it
MCP is the more important of the two. It is the first time in the course where the protocol matters more than the framework. Whatever framework you use, MCP is the wire format that lets any agent talk to any tool server. If you write a tool today, the question is not “how do I expose it to framework X?” — it is “how do I expose it via MCP so that any agent, in any framework, can use it?”
The lab makes this concrete. The MCP server is started as a separate process (npx -y @modelcontextprotocol/server-everything). The agent connects to it over stdio. The tool call goes out, the bytes come back, the agent has no idea the server is a different process. From the agent’s perspective, MCP tools and custom-function tools are the same thing — entries in the same tools=[] list.
This is the same pattern as LSP (Language Server Protocol) in editors. A protocol that decouples clients from servers, so any client can talk to any server. MCP is doing for agent tools what LSP did for IDE features. If you build anything that needs to be reusable across teams, you build an MCP server, not a framework-specific tool.
The long-running part is operationally critical. The lab’s approval gate is the simplest possible example: a binary approve/reject. In a real system, you have several flavors of the same idea:
- Approval gates for destructive actions (delete, send, transfer)
- Async resume for long computations (the agent calls a tool that returns a job ID, polls later)
- Streaming output for tools that produce partial results (the agent gets bytes as they are available, not all at once)
All three are the same pattern: a tool that does not return its final answer in one call, and a runtime that knows how to wait, poll, or pause.
Real-world lesson
If you are building internal tools, MCP is the right interface. The cost of building an MCP server is comparable to building an HTTP API, and the value is that every agent in your company — present and future — can use it. Without MCP, you are writing N integrations for N agent frameworks. With MCP, you write one.
The long-running lesson is broader than approval gates. Every external system is slow or unreliable. The pattern of “call a tool, get a status, return to the user” is the only one that survives contact with real databases, real payment processors, real human-in-the-loop systems. If your agent can only handle synchronous tool calls that complete in 200ms, it will not survive first contact with production.
Knowledge extracted
- MCP is a wire protocol, not a framework feature. Build tool servers that speak MCP, and any agent can use them
- The
tool_filterargument is a permission boundary. Use it to keep agents from invoking tools you did not intend - Long-running operations need an explicit status, an explicit resume, and an explicit timeout
- Approval gates are the simplest example of human-in-the-loop. The same pattern handles every async or slow operation
Day 3a — Sessions
What the lab teaches
LLMs are stateless. Every call is independent. Without help, an agent that “remembers” your name is one that has your name in its prompt — and you have to keep putting it there.
The lab’s fix is the session, a container that holds a single conversation’s history, tool interactions, and a state scratchpad. The session is tied to one user and one agent, and it persists across model calls.
The lab has you build three layers of sessions:
- InMemorySessionService — fast, lost when the process dies. Good for testing.
- DatabaseSessionService — backed by SQLite (or Postgres, in production). Survives restarts.
- The session’s internal model — a session is
Events(the chronological log of what happened) plusState(a{key: value}scratchpad that any agent or tool can read and write).
The code: an in-memory stateful agent
The first agent in the lab is the simplest possible stateful chatbot:
from google.adk.agents import Agent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
APP_NAME = "default"
USER_ID = "default"
SESSION = "default"
# Step 1: Create the agent
root_agent = Agent(
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
name="text_chat_bot",
description="A text chatbot",
)
# Step 2: Set up session management
session_service = InMemorySessionService() # Stores conversations in RAM (temporary)
# Step 3: Create the Runner
runner = Runner(agent=root_agent, app_name=APP_NAME, session_service=session_service)
Three pieces. The agent has no special session awareness — it is just an LLM with a name. The session service is the storage layer. The runner is the orchestrator that ties them together and maintains the conversation history across calls.
The test:
# Both queries are part of the SAME session, so context is maintained
await run_session(
runner,
[
"Hi, I am Sam! What is the capital of United States?",
"Hello! What is my name?", # The agent should remember
],
"stateful-agentic-session",
)
Output:
### Session: stateful-agentic-session
User > Hi, I am Sam! What is the capital of United States?
gemini-2.5-flash-lite > Hi Sam! The capital of the United States is Washington, D.C.
User > Hello! What is my name?
gemini-2.5-flash-lite > Your name is Sam!
The agent remembered because the runner fed the previous turn’s transcript back into the new turn’s context. The session is what makes that automatic.
The code: persistence with a database
The InMemorySessionService is lost on restart. The lab upgrades to DatabaseSessionService to show the persistence story:
from google.adk.sessions import DatabaseSessionService
# SQLite-backed session service — survives restarts
session_service = DatabaseSessionService(
db_url="sqlite+aiosqlite:///my_agent_data.db"
)
runner = Runner(agent=chatbot_agent, app_name=APP_NAME, session_service=session_service)
The db_url takes a SQLAlchemy URL. In the lab it is SQLite; in production it is Postgres. The agent code does not change. The runner API does not change. Only the storage backend changes.
The lab demonstrates the persistence explicitly. The first run writes a name:
# First "session" — start of the conversation
session = await session_service.create_session(
app_name=APP_NAME, user_id=USER_ID, session_id="persistent-session"
)
await run_session(
runner,
["Hi, I am Sam! What is the capital of United States?"],
"persistent-session",
)
Then the lab has you restart the kernel (or, equivalently, create a new Runner instance) and verify that the agent still remembers:
# Brand-new runner instance, same database
session_service = DatabaseSessionService(db_url="sqlite+aiosqlite:///my_agent_data.db")
runner = Runner(agent=chatbot_agent, app_name=APP_NAME, session_service=session_service)
# Same session ID, same user — the conversation continues
await run_session(
runner,
["Hello! What is my name?"],
"persistent-session",
)
Output:
User > Hello! What is my name?
gemini-2.5-flash-lite > Your name is Sam!
Same agent, same user, same session ID. The conversation is still there because the SQLite file survived the “restart.” This is the smallest viable persistence story for an agent system.
How to think about it
The mental model that finally made sessions click for me is this: a session is a notebook and the runtime is a filing clerk. The notebook has a chronological log of every line written and a sticky-note section at the back for shared state. The filing clerk knows which notebook belongs to which user and which agent, and pulls the right one out when a new message arrives.
The State part is what makes multi-agent systems work. The lab uses it implicitly in Day 1b: agent A writes its output to output_key="research_findings", and agent B’s instruction references {research_findings}. Under the hood, agent A wrote to the session state and agent B read from it. They never called each other. They communicated through the notebook’s sticky-note section.
This is the right level of abstraction. In a real system, you want all inter-agent communication to go through session state, not through direct function calls, because:
- It is debuggable. You can read the state and see exactly what each agent saw.
- It is replayable. You can rewind the session, change one agent’s instruction, and rerun.
- It survives across agents. A different sub-agent can be dropped in without changing the data flow.
The persistence half is operationally important but conceptually simple. Pick the session service that matches your durability needs. In-memory for tests. SQLite for single-node dev. Postgres for production multi-node. The agent code does not change.
Real-world lesson
The single most common production bug in agent systems is “the agent forgot.” Usually this is a session-state bug, not a model bug. The session ID is not being passed correctly. The user ID is different between turns. The state key has a typo. The session expired.
The cure is to make sessions a first-class concept in your system, not a library detail. Every request gets a user_id and a session_id from the start, every agent writes to a known state schema, every tool reads from that schema. A debug-mode flag that dumps the session state to logs is worth more than a hundred log lines.
A second real-world lesson: session state and the model’s context window are not the same thing. A long conversation will exceed the context window, but the session state can keep going. Most frameworks handle this with “context compaction” — automatically summarizing older turns when the context gets too long. The lesson is: do not assume “remembering” means “every word is in the prompt.” Compaction is real, and your agent design has to account for it.
Knowledge extracted
- A session is a notebook: chronological events plus a shared state scratchpad
- Session state, not direct calls, is how agents communicate. It is debuggable, replayable, and survives across agents
- Pick the session service by durability need: in-memory, SQLite, Postgres
- “The agent forgot” is almost always a session bug, not a model bug
Day 3b — Memory
What the lab teaches
Sessions are per-conversation. Memory is across-conversation. The lab draws the line clearly:
- Session is short-term memory. It holds the current conversation.
- Memory is long-term knowledge. It holds facts the agent has learned about the user, the domain, the world.
The exercise: in conversation 1, the user says “my favorite color is blue-green.” In conversation 2 (a brand new session, possibly days later), the user asks “what’s my favorite color?” Without memory, the agent does not know. With memory, it does.
The lab shows two patterns for using memory:
- Reactive — the agent has a
load_memorytool and calls it when it needs context. - Proactive — the runtime automatically loads relevant memories into the context before each turn.
The code: ingest, store, retrieve
The flow is three steps. First, populate the session with a conversation:
# User tells the agent about their favorite color
await run_session(
runner,
"My favorite color is blue-green. Can you write a Haiku about it?",
"conversation-01", # Session ID
)
Then, after the session ends, transfer it to long-term memory:
# End the session and add it to memory
await memory_service.add_session_to_memory(
await session_service.get_session(app_name=APP_NAME, user_id=USER_ID, session_id="conversation-01")
)
The add_session_to_memory call is the bridge between the short-term world (sessions) and the long-term world (memory). In a managed service like Vertex AI Memory Bank, this is where intelligent extraction happens — the system pulls out facts like “user’s favorite color is blue-green” instead of storing the entire conversation. The lab’s in-memory implementation stores the raw text, but the interface is the same.
The retrieval part is the meat. The lab shows two ways an agent can pull from memory.
Reactive — the agent has a load_memory tool and chooses when to call it:
from google.adk.tools import load_memory
memory_enabled_agent = LlmAgent(
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
name="memory_enabled_agent",
instruction="""You are a helpful assistant with long-term memory.
When the user asks about something they have told you before, use the
`load_memory` tool to search your long-term memory for relevant context.""",
tools=[load_memory], # Reactive: the model decides when to use it
)
# New session — the agent has no transcript of conversation-01
await run_session(
memory_enabled_runner,
"What is my favorite color?",
"conversation-02", # Brand new session, same user
)
The model sees the user’s question, recognizes that it might benefit from past context, and calls load_memory as a tool. The runtime searches memory, returns matching snippets, and the model uses them to answer.
Proactive — the runtime pre-loads relevant memories before each turn:
from google.adk.tools import preload_memory
proactive_agent = LlmAgent(
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
name="proactive_agent",
instruction="""You are a helpful assistant. Relevant memories about the user
will be automatically loaded into your context before each turn.""",
tools=[preload_memory], # Proactive: the runtime uses this automatically
)
The preload_memory tool is a hint to the runtime: “before calling the model, run a similarity search on the user’s message and inject matching memories into the context.” The model does not need to call it explicitly; the framework does the work.
How to think about it
The reactive/proactive distinction is not just an implementation detail. It is a design decision about who decides what is relevant. In reactive mode, the model decides. The model has to recognize that the current question might benefit from past context, and then call the memory tool. In proactive mode, the runtime decides. It runs a similarity search on the user’s question and prepends matching memories to the context.
Reactive is more flexible. The model can choose to ignore memory when it is not relevant. It is also more expensive — every conversation might trigger a memory lookup. Proactive is more predictable. The retrieval happens the same way every time, with the same quality. It is also more brittle — if the similarity search is bad, the wrong memories get prepended.
For most real systems, you want both. Use proactive for the user’s identity and stable preferences (name, location, language). Use reactive for everything else, with the model deciding when it is relevant.
The lab also exposes a subtle but important point: memory is not “raw conversation history.” The lab’s InMemoryMemoryService stores keyword-searchable chunks. A production memory service uses an LLM to consolidate — to extract facts (“user is allergic to peanuts”) instead of storing verbatim conversations. The difference matters. Storing raw conversations explodes in size. Storing extracted facts stays bounded.
Real-world lesson
Memory is where agents become personal. A session-aware agent remembers what you said today. A memory-aware agent remembers what you said across the months. The user experience is qualitatively different. It is also qualitatively harder to build, because:
- Memory writes are easy. Extract a fact, store it. Done.
- Memory reads are hard. Which of the 10,000 stored facts is relevant to the current question? Semantic search helps. LLM re-ranking helps more. You will iterate on this.
- Memory updates are hardest. The user said “I like blue” in March and “actually I prefer green” in May. The memory needs to be updated, not appended to. Most memory systems get this wrong by storing both and letting the model pick.
The other real-world lesson is about privacy. A memory service stores user data. That makes it a regulated system. The lab uses in-memory storage and acknowledges that production systems need encryption, access control, and a deletion story. If you build a memory service, treat it like a database — because it is one.
Knowledge extracted
- Session is short-term. Memory is long-term. Both are needed.
- Reactive memory puts the choice on the model. Proactive puts it on the runtime. Use both, with different strategies for different kinds of facts.
- Memory is not raw conversation. Consolidate, store facts, search semantically.
- Memory updates are the hardest part. Store latest, not history.
Day 4a — Observability
What the lab teaches
When an agent makes three model calls, two tool calls, and one sub-agent dispatch, you have no idea what happened by reading the output. Observability is the plugin system that lets you inject callbacks at every step.
The lab defines a CountingPlugin with five callbacks:
before_agent/after_agent— wrap the whole agent runbefore_model/after_model— wrap every model callbefore_tool/after_tool— wrap every tool call
The plugin is registered on the agent (or on the runner, depending on the framework). Every time one of those events happens, your callback fires. You can log, count, time, ship to a SaaS, or anything else.
The code: a counting plugin
The lab’s example plugin is a small class with two callbacks:
import logging
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.callback_context import CallbackContext
from google.adk.models.llm_request import LlmRequest
from google.adk.plugins.base_plugin import BasePlugin
class CountInvocationPlugin(BasePlugin):
"""A custom plugin that counts agent and tool invocations."""
def __init__(self) -> None:
"""Initialize the plugin with counters."""
super().__init__(name="count_invocation")
self.agent_count: int = 0
self.tool_count: int = 0
self.llm_request_count: int = 0
# Callback 1: Runs before an agent is called. You can add any custom logic here.
async def before_agent_callback(
self, *, agent: BaseAgent, callback_context: CallbackContext
) -> None:
"""Count agent runs."""
self.agent_count += 1
logging.info(f"[Plugin] Agent run count: {self.agent_count}")
# Callback 2: Runs before a model is called. You can add any custom logic here.
async def before_model_callback(
self, *, callback_context: CallbackContext, llm_request: LlmRequest
) -> None:
"""Count LLM requests."""
self.llm_request_count += 1
logging.info(f"[Plugin] LLM request count: {self.llm_request_count}")
A few things to notice:
BasePlugin is the framework’s base class. Subclassing it gives you a stable interface and registers your callbacks with the runtime automatically. The name argument becomes your plugin’s identifier in traces and logs.
The callbacks are async. Every callback in the framework is async def because the lifecycle is non-blocking. If you have blocking work to do (file I/O, network calls), wrap it in asyncio.to_thread so you do not stall the event loop.
The kwargs are explicit. before_agent_callback takes agent and callback_context. before_model_callback takes callback_context and llm_request. The signature is the contract. If you add a wrong parameter, the framework will not call your callback.
The code: full lifecycle plugin
The lab then expands the plugin to cover all five callback points:
class FullLifecyclePlugin(BasePlugin):
"""Logs every step of the agent lifecycle."""
def __init__(self) -> None:
super().__init__(name="full_lifecycle")
self.events: list[dict] = []
async def before_agent_callback(self, *, agent, callback_context):
self.events.append({"event": "before_agent", "agent": agent.name})
logging.info(f"before_agent: agent={agent.name}")
async def after_agent_callback(self, *, agent, callback_context):
self.events.append({"event": "after_agent", "agent": agent.name})
logging.info(f"after_agent: agent={agent.name}")
async def before_model_callback(self, *, callback_context, llm_request):
self.events.append({"event": "before_model", "model": llm_request.model})
logging.info(f"before_model: model={llm_request.model}")
async def after_model_callback(self, *, callback_context, llm_response):
self.events.append({"event": "after_model", "text": str(llm_response)[:100]})
logging.info(f"after_model: response={str(llm_response)[:100]}")
async def before_tool_callback(self, *, tool, tool_args, tool_context):
self.events.append({"event": "before_tool", "tool": tool.name, "args": tool_args})
logging.info(f"before_tool: tool={tool.name}, args={tool_args}")
Wiring it up is one line on the runner:
from google.adk.runners import InMemoryRunner
from google.adk.plugins import PluginManager
plugin = FullLifecyclePlugin()
runner = InMemoryRunner(
agent=weather_agent,
plugins=[plugin], # Register the plugin
)
When the agent runs, the log file fills up with the full lifecycle:
before_agent #1: agent=weather_agent
before_model #1
LiteLLM completion() model= glm-5; provider = openai
after_model #1
before_tool #1: tool=get_weather, args={'city': 'Tokyo'}
get_weather called with city='Tokyo'
before_model #2
...
after_agent #1: agent=weather_agent
That is the full audit trail. Every model call, every tool call, every agent entry and exit. The plugin collected it automatically; the agent code did not change.
How to think about it
The first realization is that observability is not optional. An agent that calls tools, dispatches to sub-agents, and makes multiple model calls in a single turn is a distributed system. Distributed systems need observability. The lab’s plugin pattern is the lightweight way to get it without buying a SaaS.
The second realization is that what you log matters more than how you log it. The lab logs event names and arguments. In a real system, you want:
- The full prompt and response for every model call. The model is the system, and you cannot debug it without the inputs and outputs.
- The tool call and tool response for every tool invocation. Tools are the agent’s hands, and tool failures are the most common production bug.
- The session state at the start and end of every agent turn. This is the data flow between agents.
- Token counts and latencies for every model call. Cost and speed live in here.
- A trace ID that ties all of the above together. Without it, you cannot follow a request through the system.
The third realization is that the plugin pattern is the place to put cross-cutting concerns. Rate limiting, retry logic, PII redaction, cost capping, A/B routing — all of these are plugins. The agent code stays clean. The framework code stays clean. The behavior is added by composition.
Real-world lesson
I have seen agent systems fail in production because the team had no idea what the agent was actually doing. The agent would return a wrong answer, the logs would show the final response, and there was no way to see which tool call had failed or which model turn had hallucinated. The team spent a week adding logging after the fact. The lab’s plugin pattern is what they should have had from Day 1.
The other real-world lesson is that observability data is its own product. A team that ships a good observability story for their agent system has a competitive advantage over a team that ships the same agent with print() statements. The data is what lets you improve the agent, debug it, defend it to a security team, and bill for it accurately.
Knowledge extracted
- Observability is not optional. An agent is a distributed system.
- The plugin pattern is the right place for cross-cutting concerns. Keep agent code clean; add behavior by composition.
- Log the prompts, the tool calls, the state. Not just the final response.
- Observability data is a product, not a feature. Invest in it from the start.
Day 4b — Evaluation
What the lab teaches
You cannot ship an agent without a way to test it. The lab ships with an eval CLI that takes a directory containing an agent and a set of test cases, runs the agent against each case, and scores the result.
Two scores per test case:
- Tool trajectory — did the agent call the right tools, in the right order, with the right arguments? This is a literal sequence match against the expected trajectory.
- Response match — does the final text response look like the expected response? This is a fuzzy text similarity, often embedding-based.
The lab’s exercise is interesting: the agent is deliberately flawed. Its instruction says it can control “ALL smart devices in the house.” The eval is designed to catch that.
The code: a test case
The lab’s eval set is a JSON file. Each case specifies the user message, the expected final response, and the expected tool trajectory:
test_cases = {
"eval_set_id": "home_automation_integration_suite",
"eval_cases": [
{
"eval_id": "living_room_light_on",
"conversation": [
{
"user_content": {
"parts": [
{"text": "Please turn on the floor lamp in the living room"}
]
},
"final_response": {
"parts": [
{
"text": "Successfully set the floor lamp in the living room to on."
}
]
},
"intermediate_data": {
"tool_uses": [
{
"name": "set_device_status",
"args": {
"location": "living room",
"device_id": "floor lamp",
"status": "ON",
},
}
]
},
}
],
},
{
"eval_id": "kitchen_on_off_sequence",
"conversation": [
{
"user_content": {
"parts": [{"text": "Switch on the main light in the kitchen."}]
},
"final_response": {
"parts": [
{
"text": "Successfully set the main light in the kitchen to on."
}
]
},
"intermediate_data": {
"tool_uses": [
{
"name": "set_device_status",
"args": {
"location": "kitchen",
"device_id": "main light",
"status": "ON",
},
}
]
},
}
],
},
],
}
Three pieces per case. user_content is what the user says. final_response is the expected text. intermediate_data.tool_uses is the expected tool trajectory — the exact tool name and the exact arguments, in order.
The structure of the test case tells you the design intent. A test case is asserting: “given this user message, the agent should call these tools with these arguments, and produce a final response that looks like this.” The first two are the structural contract. The last is the cosmetic contract.
The code: running the eval
The eval CLI takes a path to the agent directory and runs the suite:
adk eval path/to/home_automation_agent/ \
--config_file=path/to/test_config.json \
--evalset_path=path/to/integration.evalset.json
Behind the scenes, the CLI:
- Imports the agent from the directory (using the framework’s standard project layout)
- Loads the test cases from the evalset
- For each case, creates a fresh session and runs the agent
- Compares the actual trajectory to
intermediate_data.tool_uses - Compares the actual response to
final_response.parts[*].text - Reports a pass/fail with a score for each
The output looks something like:
Eval Run #1: living_room_light_on
✅ tool_trajectory_avg_score: 1.0 (PASS)
❌ response_match_score: 0.375 (FAIL — threshold 0.7)
Eval Run #2: kitchen_on_off_sequence
❌ tool_trajectory_avg_score: 0.0 (FAIL)
❌ response_match_score: 0.324 (FAIL)
Eval Run #3: floor_lamp_living_room
❌ tool_trajectory_avg_score: 0.0 (FAIL)
❌ response_match_score: 0.488 (FAIL)
The trajectory failures are the interesting signal: the agent is calling the wrong tool, or calling it with the wrong arguments. That is a real bug. The response match failures are partly a model artifact — different models phrase things differently, and the fuzzy scorer is sensitive to phrasing.
How to think about it
The tool trajectory score is the more important of the two, and the more reliable. It is a structural check. The agent either called set_device_status(location="living room", device_id="floor lamp", status="ON") or it did not. There is no fuzzy interpretation. If your agent is supposed to call tool A with argument X, and the test expects A→X, then anything else is a fail.
The response match score is noisier. It uses text similarity to compare the agent’s final response to the expected response. If the agent says “It’s 70°F in Tokyo” and the expected is “70 degrees Fahrenheit, Tokyo,” that is a match. If the agent says “Currently 70 degrees Fahrenheit with clear skies” and the expected is “70°F,” that is also a match — the cosine similarity is high. But if the agent adds an extra sentence or uses different punctuation, the score drops. The score is sensitive to phrasing in ways that have nothing to do with correctness.
The lab’s exercise shows the intended use case. You write a test that exercises a specific behavior. You run the eval. The trajectory score tells you whether the agent did the right thing. The response score tells you whether it phrased it the way you wanted. You care about the first one. The second one is decoration.
Real-world lesson
The deliberate-flaw exercise is the most underrated part of the course. The point is not “look, the eval found the bug.” The point is “this is what an eval is for.” An eval is a regression suite. It is the thing that catches you when you change an instruction and accidentally break a different behavior.
In a real system, you want evals for:
- Happy paths — every common user request, with a known-good trajectory
- Edge cases — invalid input, ambiguous intent, out-of-scope questions
- Failure modes — tool errors, timeouts, the model hallucinating
- Regression cases — bugs that were fixed and you want to make sure stay fixed
A team that does not have an eval suite is one prompt change away from breaking a critical user flow. A team that has one can move fast and sleep well.
A second real-world lesson: evals are not free. They cost tokens to run. They take time. They need to be maintained. The lab’s three cases ran in a few minutes. A real eval suite with 100 cases takes an hour and costs real money. Build the suite incrementally. Start with the cases that catch the bugs you have already had.
Knowledge extracted
- Tool trajectory is the structural check. It is reliable. Care about it.
- Response match is the cosmetic check. It is noisy. Use it as decoration, not as a gate.
- Evals are regression suites. Build them for happy paths, edge cases, failure modes, and fixed bugs.
- A real eval suite costs time and money. Build it incrementally.
Day 5a — Agent-to-agent protocol
What the lab teaches
Sometimes the right answer is two agents that know nothing about each other except an HTTP endpoint. The Agent-to-Agent (A2A) protocol is a standard for that. One agent publishes an “agent card” describing its capabilities. Another agent fetches the card, decides the remote agent is useful, and sends it a task.
The lab has you build a two-agent system:
- A product catalog agent with a
get_product_infotool, exposed on port 8001 viato_a2a() - A customer support agent that uses
RemoteA2aAgentto talk to the catalog agent
The two agents run in separate processes. They share no memory, no session, no state. The customer support agent has never seen the catalog agent’s code. It only knows what the agent card tells it: the name, the description, the tools, the input/output schemas.
The code: the catalog agent
The catalog agent is a regular ADK agent with a single tool:
def get_product_info(product_name: str) -> str:
"""Get product information for a given product."""
product_catalog = {
"iphone 15 pro": "iPhone 15 Pro, $999, Low Stock (8 units), 128GB, Titanium finish",
"samsung galaxy s24": "Samsung Galaxy S24, $799, In Stock (31 units), 256GB, Phantom Black",
"dell xps 15": "Dell XPS 15, $1,299, In Stock (45 units), 15.6\" display, 16GB RAM, 512GB SSD",
"macbook pro 14": "MacBook Pro 14\", $1,999, In Stock (22 units), M3 Pro chip, 18GB RAM, 512GB SSD",
"sony wh-1000xm5": "Sony WH-1000XM5 Headphones, $399, In Stock (67 units), Noise-canceling, 30hr battery",
"ipad air": "iPad Air, $599, In Stock (28 units), 10.9\" display, 64GB",
"lg ultrawide 34": "LG UltraWide 34\" Monitor, $499, Out of Stock, Expected: Next week",
}
product_lower = product_name.lower().strip()
if product_lower in product_catalog:
return f"Product: {product_catalog[product_lower]}"
else:
available = ", ".join([p.title() for p in product_catalog.keys()])
return f"Sorry, I don't have information for {product_name}. Available products: {available}"
product_catalog_agent = LlmAgent(
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
name="product_catalog_agent",
description="External vendor's product catalog agent that provides product information and availability.",
instruction="""
You are a product catalog specialist from an external vendor.
When asked about products, use the get_product_info tool to fetch data from the catalog.
Provide clear, accurate product information including price, availability, and specs.
If asked about multiple products, look up each one.
Be professional and helpful.
""",
tools=[get_product_info]
)
The description field is the part that travels. When this agent is exposed via A2A, the description becomes the agent card’s primary text. The consumer’s model reads the description to decide whether the remote agent is useful for the current request. A bad description means a bad delegation decision.
The code: exposing the agent via A2A
to_a2a() is the one function that turns an ADK agent into an A2A-compatible service:
from google.adk.a2a.utils.agent_to_a2a import to_a2a
# Convert the product catalog agent to an A2A-compatible application
# This creates a FastAPI/Starlette app that:
# 1. Serves the agent at the A2A protocol endpoints
# 2. Provides an auto-generated agent card
# 3. Handles A2A communication protocol
product_catalog_a2a_app = to_a2a(
product_catalog_agent, port=8001 # Port where this agent will be served
)
Three things happen under the hood:
- A FastAPI/Starlette app is generated that serves the agent at the A2A protocol endpoints
- An agent card is auto-generated that includes the agent’s name, description, version, and skills (the tools)
- The A2A protocol is wired up — request/response formatting, task endpoints, the works
The card is published at /.well-known/agent-card.json, a standard well-known path. The consumer’s model fetches it from there.
The code: the consumer agent
The customer support agent is on the other side. It uses RemoteA2aAgent to talk to the catalog:
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
# A2A client that wraps the remote product catalog
remote_catalog_agent = RemoteA2aAgent(
name="product_catalog_client",
description="Provides product details from the external catalog via A2A.",
agent_card="http://localhost:8001/.well-known/agent-card.json",
)
# The customer support agent that uses the remote one as a tool
customer_support_agent = LlmAgent(
name="customer_support_agent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
instruction="""You are a customer support agent. When the user asks about
specific products, delegate to the product_catalog_client tool to get the
canonical information. Do not make up product details.""",
tools=[AgentTool(remote_catalog_agent)],
)
runner = InMemoryRunner(agent=customer_support_agent)
# Test 1: Single product lookup
await runner.run_debug("Tell me about the iPhone 15 Pro")
# Test 2: Cross-product comparison
await runner.run_debug("Compare the Dell XPS 15 and the MacBook Pro 14 for me")
The RemoteA2aAgent fetches the agent card on initialization, builds a tool spec from it, and exposes itself to the parent agent as a regular tool. The parent does not know it is talking to a separate process. From its perspective, product_catalog_client is just a tool that returns product information. The HTTP, the JSON, the protocol — all hidden behind the AgentTool wrapper.
How to think about it
The decision to use A2A instead of a local sub-agent is the right framing. The lab includes a decision table:
| Use a local sub-agent when… | Use A2A when… |
|---|---|
| The other agent is part of your codebase | The other agent is owned by a different team |
| The latency budget is tight (no network hop) | The other agent is in a different language/framework |
| You control the deployment | The other agent is in a different deployment cycle |
| You can refactor it freely | The other agent is a black box |
A2A is the microservices decision applied to agents. You accept the network hop, the serialization cost, and the failure modes of distributed systems, in exchange for team independence and tool reuse.
The agent card is the key abstraction. It is a small JSON document that says “I am an agent that does X, with inputs Y and outputs Z, and here is how to reach me.” The customer support agent’s model reads this card and decides whether to delegate. If the card is well-written, the delegation works. If it is not, the delegation is a coin flip.
Real-world lesson
The most important real-world lesson is the organizational one. A2A is what you reach for when you have multiple teams building agents and you do not want to be the bottleneck for all of them. If team A has built a great product-catalog agent, team B can use it via A2A without coordinating with team A’s deployment schedule. Team A’s agent evolves independently. Team B sees the changes when they refetch the card.
The technical lesson is the failure modes. Network calls fail. Timeouts happen. The remote agent might be down. A2A systems need to handle these the same way any microservice does: timeouts, retries, circuit breakers, fallbacks. The lab does not go deep on this, but a production A2A system needs to.
A second real-world lesson is the observability of cross-agent calls. When the customer support agent delegates to the catalog agent, you want the trace to span both. The trace ID has to propagate. The latencies have to be measured on both sides. The lab’s plugin pattern from Day 4a applies here too — register the plugin on both the caller and the callee, and use a shared trace ID.
Knowledge extracted
- A2A is the microservices decision for agents. Use it when the other agent is owned by someone else
- The agent card is the contract. It is auto-generated, but it is the thing the caller’s model reads
- A2A systems need the same failure handling as any microservice: timeouts, retries, fallbacks
- Observability has to span the call. Trace IDs propagate across the wire
Day 5b — Deployment
What the lab teaches
The same agent that runs in your notebook should be deployable as an HTTP service. The course’s framework ships with an api_server CLI that wraps your agent in a FastAPI server. The server exposes a /run endpoint that takes a JSON body (the user’s message) and returns the agent’s response (streamed as Server-Sent Events).
The lab’s deployment target is a managed agent runtime. The general idea — wrap the agent, expose it over HTTP, stream the response — applies to any target: a serverless platform, Kubernetes, a plain VM, or a local laptop.
The code: the agent project structure
The framework’s deployment CLI expects a specific project layout. The agent is a directory with a root_agent and an __init__.py:
my_agent/
├── __init__.py # from . import agent as agent
├── agent.py # contains root_agent
└── requirements.txt
The agent.py file is the agent definition:
import os
from google.adk.agents import LlmAgent
from google.adk.models.google_llm import Gemini
from google.genai import types
retry_config = types.HttpRetryOptions(
attempts=5, exp_base=7, initial_delay=1,
http_status_codes=[429, 500, 503, 504],
)
def get_weather(city: str) -> dict:
"""Get the current weather for a given city.
Args:
city: The name of the city
Returns:
Dictionary with weather data or error
"""
if city.lower() in ["san francisco", "new york", "london", "tokyo", "paris"]:
return {"status": "success", "report": f"The weather in {city} is sunny with 72°F."}
else:
return {
"status": "error",
"error_message": f"Weather info for '{city}' is not available. "
f"Try: San Francisco, New York, London, Tokyo, Paris"
}
root_agent = LlmAgent(
name="weather_agent",
model=Gemini(model="gemini-2.5-flash-lite", retry_options=retry_config),
description="A helpful weather assistant.",
instruction="""You are a weather assistant. When the user asks about weather,
use the get_weather tool to look up the data. If the tool returns an error,
relay the error message to the user along with the suggested cities.""",
tools=[get_weather],
)
The __init__.py re-exports the agent so the CLI’s import path (agent.agent.root_agent) works:
# __init__.py
from . import agent as agent
The code: starting the server
The CLI starts a FastAPI server:
adk api_server my_agent --port 8765
The server exposes:
POST /run— take a user message, return the agent’s response (streamed)GET /list-apps— list available agents- Various health and metadata endpoints
The request body for /run is a standard ADK event payload:
{
"app_name": "weather_agent",
"user_id": "user-123",
"session_id": "session-456",
"new_message": {
"role": "user",
"parts": [{"text": "What's the weather in Tokyo?"}]
},
"streaming": true
}
The response is a stream of Server-Sent Events. Each event is one step in the agent’s lifecycle:
event: function_call
data: {"name": "get_weather", "args": {"city": "Tokyo"}}
event: function_response
data: {"result": {"status": "success", "report": "The weather in Tokyo is sunny with 72°F."}}
event: text
data: {"text": "It's currently 72°F and sunny in Tokyo. Enjoy the day!"}
event: done
data: {}
A client that consumes this stream can render each step as it arrives: “Calling weather tool…” → “Got weather data…” → “Here’s the answer.” The user sees the agent think, instead of staring at a spinner for five seconds.
How to think about it
The mental model is that the deployment wrapper is thin. It does three things:
- HTTP plumbing. Parse the request, validate the input shape, return the response in a consistent format.
- Session management. Look up the session by
user_idandsession_id, append the new event, return the session state. - Streaming. The agent’s loop is incremental — function call, function response, more function calls, then text. Streaming lets the client see the progress in real time instead of waiting for the whole response.
That is it. The agent code does not change. The agent’s tools, instructions, sub-agents, plugins, and session logic are exactly the same. The only thing the deployment wrapper does is turn the agent into a service.
The streaming part is operationally important. A non-streaming response is a bad UX. The user waits five seconds for the first character, then gets the whole thing at once. A streaming response shows the agent’s progress in real time — the function calls, the model thinking, the text appearing word by word. Most production systems use streaming.
Real-world lesson
The single biggest mistake I see teams make is treating deployment as a separate problem from the agent. They build the agent in a notebook, then spend weeks figuring out how to expose it. The right approach is to design agent and deployment together — the agent is a directory, the deployment is a wrapper, the two share the same data model.
A second real-world lesson: the deployment wrapper is the place to add cross-cutting concerns that do not belong in the agent. Authentication, rate limiting, request logging, billing meters, A/B routing — all of these are wrapper concerns. The agent stays focused on the task. The wrapper handles everything else.
A third lesson: agents are stateful services. Most HTTP services are stateless. They take a request, do work, return a response, and forget. Agents are not like that. They have sessions, memory, conversation history. The deployment target has to support that. A serverless function with a 30-second timeout is not going to host a long conversation. A container that can hold a connection for hours is.
Knowledge extracted
- The deployment wrapper is thin. HTTP, session lookup, streaming. That is it.
- Streaming is operationally important. Users want to see the agent thinking, not wait five seconds for a single response.
- Cross-cutting concerns go in the wrapper, not the agent. Auth, rate limiting, billing, logging.
- Agents are stateful services. Pick a deployment target that holds a connection.
The arc, and what it teaches
The course is structured the way a real agent project is structured. The order is not arbitrary.
| Stage | The question | The lab that answers it |
|---|---|---|
| Design | What kind of agent do I need? | Day 1b (patterns) |
| Build | How do I give it the right tools? | Day 2a (custom tools) |
| Integrate | How does it call the rest of the world? | Day 2b (MCP) |
| Store | How does it remember? | Day 3a, 3b (sessions, memory) |
| Debug | How do I see what it is doing? | Day 4a (observability) |
| Verify | How do I know it is correct? | Day 4b (evaluation) |
| Compose | How do multiple agents work together? | Day 5a (A2A) |
| Ship | How does it reach users? | Day 5b (deployment) |
If you build an agent system in the real world, you hit these questions in the same order. The course gives you a vocabulary for each, and a working artifact you can adapt.
The patterns that transfer
Three patterns show up in every codelab, and they are the ones that transfer to any framework:
1. The model is a function in a loop. Every agent runtime implements this loop. Whatever framework you use, the differences are in the API surface, not the abstraction. Once you understand the loop, you can read any framework’s docs in an hour.
2. Communication is via state, not calls. Agents in a multi-agent system do not call each other. They write to a shared scratchpad and read from it. The scratchpad is the session state. The mechanism is output_key and placeholder substitution. The lesson is broader than any one framework: in any multi-agent system, prefer state-based communication over message-passing. It is debuggable, replayable, and survives across agents.
3. Tools are APIs. Treat the tool surface as a public API, because that is what it is. Dictionary returns. Docstrings. Type hints. Structured errors. The same rules you would apply to a REST endpoint. The model is an untrusted caller, and the only documentation it has is the docstring.
The patterns that are framework-specific
Some of what the course teaches is tightly coupled to the specific framework. If you are using a different one, the specific classes (SequentialAgent, ParallelAgent, LoopAgent, McpToolset, DatabaseSessionService) will not translate. The patterns behind them will. The SequentialAgent is a fixed-pipeline workflow in any framework. The DatabaseSessionService is a persistent session store in any framework. The McpToolset is MCP integration in any framework.
When you read the course, separate the two layers. The framework layer is the specific API. The pattern layer is the design. The pattern layer is what lasts.
The parts the course is missing
Two things the course does not cover, and that a real system needs:
Failure handling at the agent level. The course assumes tools succeed. In production, tools fail. The network times out. The rate limit hits. The third-party API returns a 500. The model needs to be able to retry, fall back, or surface the error to the user. None of the codelabs explicitly address this. (Day 2b’s approval gate is the closest, but it is about human-in-the-loop, not error recovery.)
Cost and latency budgets. The course does not teach you to reason about token costs, model latency, or tool-call frequency. In production, these dominate. An agent that makes ten model calls to answer a simple question is too expensive. An agent that streams a 30-second response is too slow. The Day 1b parallel pattern is a step toward this, but the explicit treatment of “you have a budget, here is how to stay within it” is not there.
Both gaps are real, and both are learnable. They are also worth a follow-up post.
How to use the course
If you are evaluating whether to take the course: take it. The content is dense but not long. The codelabs run in an afternoon if you do them straight through. The frameworks are transferable. The exercises force you to build things, not just read about them.
If you have already taken it: this post is the index you can come back to when you are about to start a real system. Each section is a question you should be able to answer before you write code. If you cannot answer it, go back to the relevant lab.
If you are building a real agent: use the course’s structure as your project structure. The eight questions in the table above are your eight milestones. The labs are the order to tackle them. By the time you finish, you have a system that is observable, evaluable, deployable, and composable. That is the minimum bar for production.
A final note on frameworks
The course is built on one framework. The patterns are not. Whatever framework you use, the eight questions are the same. The answers might look different in the API, but the design decisions are the same.
Pick the framework that fits your team. Pick the patterns from the course. Build the system you need. The framework is a tool. The patterns are the work.