Skip to content
abhinavbhatia
Sheet 02 / 05 — Blogs← Archive

Production-ready agents, Prerequisite: The Python you need to read agent code

All the Python this series uses, explained for readers who are new to the language: the interpreter, threads and async, the basic data types, functions, type hints, classes, Pydantic models, error handling, decorators and tests, each shown with real agent code.

Every part of this series has code in it. The code is Python, because the series uses the Python version of Google’s Agent Development Kit (ADK), and so do most published agent examples.

You don’t need to be a Python developer to follow along. You need to be able to read an agent file and understand what each line does, which is a much smaller skill. This post covers exactly that part of Python, using code from the system we build in the series, and it covers everything the later parts use. If a later part shows something you don’t recognise, the explanation is here.

If you already write Python, skip to Part 1. If you want a quick refresher, read the cheat sheet at the end.

You need ✓ what Python is, and threads vs async ✓ strings, numbers, lists, dicts ✓ functions, type hints, docstrings ✓ functions passed as values ✓ imports and project folders ✓ classes, dataclasses, Pydantic ✓ errors: try / except ✓ environment variables, JSON, logs ✓ async / await, and why ✓ decorators, yield, with ✓ tests with pytest You can skip · algorithms and data structures · writing big class hierarchies · metaclasses, generics, typing tricks · performance tuning · web frameworks in depth · machine learning maths · memorising syntax Goal: read every code sample in Parts 1 to 12 and know what each line does.

the Python you need to read agent code in this series, and what you can skip

If you prefer to watch first, here is the heart of this prerequisite as a short animated explainer.

Video: the Python you need to read agent code, in three minutes. The narration is a synthetic voice; the script and animation are mine.

Where are we in the architecture?

This prerequisite does not add a box. It gives you the Python you need to read the code behind every box below. Part 1.25 walks through the whole picture, layer by layer.

THE COMPLETE ACME ARCHITECTURE AGENT RUNTIME (PART 11) each agent is its own engine with its own identity A2A · A2UI A2A A2A Shopper Acme's customers P11.75 GE for CX customer chat, voice, reps P11.75 Legal edition industry skills, connectors P11.75 User Maria in marketing P1 Gemini Enterprise where people use agents P12 Other front doors own app (AG-UI), Chat, Slack P12.5 Plugins + Model Armor · checks around every step P9 Router agent Workflow: extract, route, write P1 Supply agent Databricks specialist P3 Marketing agent BigQuery specialist P2 Code sandbox runs generated code P3.5 Skills versioned in Agent Registry P4 Sessions + Memory Bank what it remembers about each user P8 AGENT GATEWAY · EGRESS (FORWARD PROXY) · P12 Gemini models the reasoning engine P1 Other models Model Garden P13 Knowledge RAG and search P14 BigQuery marketing data P2 Databricks supply data P3 Market watch managed agent, public web P1.75 Agent Identity one per agent P7 Auth manager acts for the user P7 Secret Manager no secrets in code P11 Human approval for the one write P9 Cloud Build CI on every change P11 Eval gate blocks a worse agent P10 Cloud Trace one per question P11 Cloud Logging redacted events P11 Agent Analytics BigQuery plugin P9 IDENTITY AND SAFETY SHIPPING MODELS, KNOWLEDGE, DATA OBSERVABILITY deploy Pn = the part that adds the component. Icons: Google Cloud, Gemini and Databricks official marks.

the complete Acme architecture: Maria asks in Gemini Enterprise; the router runs on Agent Runtime behind plugins and Model Armor and calls the marketing and supply agents over A2A; they reach BigQuery and Databricks through Agent Gateway; identity and secrets on the left, models, knowledge and the market watch managed agent for public research on the right, traces, logs and analytics below; above it, shoppers reach Gemini Enterprise for CX, whose conversation insights land in BigQuery, and the Legal industry edition plugs into Gemini Enterprise

What is Python?

Python is a general-purpose programming language. You write the code in plain text files ending in .py, and a program called the Python interpreter reads each file and runs its statements in order. The original and most widely used interpreter is CPython, and in this post “Python” means CPython.

Three things about Python explain most of what you will see in agent code:

  • It is easy to read. Python was designed to be readable. Blocks of code are marked by indentation, not brackets, so the way the code is laid out on the page is its structure.
  • It is dynamically typed. You don’t have to declare the type of a variable; Python works out the type of each value while the program runs. That makes code quick to write. It is also why agent code adds type hints and Pydantic models: they bring type checking back where getting it right matters.
  • It coordinates faster parts. Python is slow compared with languages such as C or Rust, but the heavy computing rarely happens in Python. Libraries do that work in faster languages underneath, and in an agent the slowest step, model inference (the model working out its reply), runs on Google’s servers.
Your agent code Python: tools, instructions, routing rules Python libraries ADK, google-genai, Pydantic, BigQuery client The fast parts underneath written in C, C++ or Rust; Python calls them The Python interpreter reads your .py file and runs it, line by line Gemini model runs on Google's servers, not in your Python. Your code sends a request and waits for the reply. BigQuery, Databricks also elsewhere. Python sends SQL and waits. Python is the glue. Most of the real work, and most of the waiting, happens somewhere else.

what Python is: you write readable Python, the interpreter runs it, the heavy work happens in libraries written in faster languages, and the model itself runs on Google's servers

What are threads and the GIL, and do they matter for agents?

The short answer: the GIL stops Python from computing on several threads at once, but it does not stop Python from waiting on several things at once. Agents mostly wait, so the GIL rarely matters for them.

Picture a small kitchen with three cooks and one sharp knife. Only the cook holding the knife can chop. If all three cooks have a pile of onions, they pass the knife around and the onions take as long as if one cook had done them all. But if each cook has a cake in the oven, the knife does not matter: all three cakes bake at the same time while the cooks stand around. The cooks are threads, the knife is the GIL, chopping is computation, and the oven is a network call.

A program can do several things at once in two ways. It can use threads (several streams of work inside one running program, sharing its memory), or it can use processes (separate copies of the program, each with its own memory).

Standard Python has a mechanism called the Global Interpreter Lock (GIL), which lets only one thread run Python bytecode (the compiled form of your code) at any moment. You can start ten threads, but they take turns. Because of this, people often call Python single-threaded. That is not accurate. Python has real operating system threads, and they do run at the same time while they are waiting on I/O. When a thread waits for a network response or a file read, it lets go of the lock and another thread runs. The GIL only gets in the way when several threads are all doing heavy Python computation at the same moment.

As of October 2026, there is also a free-threaded build of Python with no GIL. It has been officially supported since Python 3.14, but it is optional: the standard Python download still includes the GIL.

How does this apply to Acme? Mostly it does not. When Maria asks about the Acme Pure Refill Launch, the agent’s “cakes” are the call to Gemini and the query on campaign_performance, and the chopping is the few milliseconds of Python in between. Look at where a single agent request spends its time:

ONE QUESTION, START TO FINISH (NOT TO SCALE) wait Gemini thinks wait BigQuery query wait Gemini writes build prompt run tool check Blue: Python is working milliseconds. Speed of Python barely shows here. Dashed: Python is waiting seconds. What matters is serving other users during the wait. Agents are I/O-bound: they mostly wait for other systems. That is what async is for.

where one agent request spends its time: a few milliseconds running Python, and whole seconds waiting for the model and the database, which is why threads versus async matters more than raw speed

An agent is I/O-bound. I/O (input and output) here means sending requests over the network and waiting for the answers. Python runs for a few milliseconds, then waits seconds for the model or the database. The GIL does not slow down waiting. What matters is that while one user’s request is waiting, the server keeps working on other users’ requests. Python gives you two ways to do this:

  • Threads, which suit a small number of slow tasks. The evaluation tools in Part 10 grade many test answers on a thread pool (a fixed group of threads that share the work), because each grading call spends most of its time waiting for a model.
  • async, which handles thousands of waits at once at very little cost per wait. ADK and its web server are built on async, which I explain in detail below.

The GIL matters when Python itself does heavy computation for a long time, such as training a model in pure Python. That kind of work uses libraries such as NumPy or JAX, which do the computing outside the interpreter, or uses several processes. You need neither in this series.

Here is the difference in one experiment. Start three threads and give each the same job, first a job that waits and then a job that computes (timings are rough and depend on the machine):

1. Each thread runs time.sleep(2), standing in for a 2-second model call.
   Total: about 2 seconds. While one thread sleeps it releases the GIL, so all three wait at once.
2. Each thread sums 50 million numbers in a pure-Python loop.
   Total: about three times as long as one sum. Only one thread can run bytecode at a time,
   so the threads take turns and finish no sooner than running the sums one after another.

An agent request looks like case 1, not case 2, which is why the GIL rarely matters here. Case 1 is the cakes in the oven; case 2 is three cooks sharing one knife.

Is Python the best language for AI?

For building agents today, Python is the most practical choice, although it is not the only one that works.

Why it is the default:

  • The tools come to Python first. The main machine learning frameworks (PyTorch, JAX, TensorFlow), data libraries (pandas, NumPy) and model SDKs, including Google’s Gen AI SDK, ship for Python first, and new features usually appear there first.
  • Most examples are in Python. Documentation, tutorials, notebooks and sample agents mostly use it, so help is easy to find.
  • It is quick to change. Building an agent means trying something, measuring it and adjusting it, over and over. Short, readable code makes each round faster.
  • Speed is rarely the problem. As the diagram above shows, most of the time in a request is spent waiting for the model and the data, not running Python.

Where it is weaker:

  • Raw speed. Heavy computation is much slower in Python than in Go, Java, C++ or Rust.
  • Errors show up late. Because types are checked while the program runs, some errors only appear in production. Type hints, Pydantic and tests close most of that gap.
  • Packaging used to be hard. Managing libraries and their versions was painful for a long time. Modern tools such as uv have made it much better.

ADK is also available in Java, Go and TypeScript. If your organisation’s systems are written in one of those languages, using that version of ADK is a reasonable choice. This series uses Python because it has the most examples and gets new features first.

The common mistake is to pick the language for raw speed. In an agent the model call dominates, so pick it for the people and systems around the agent. A short test, in order:

1. Is there no strong reason to do otherwise?           -> Python (most examples, features first)
2. Does your team run and support only Java, Go or TS?  -> ADK in that language
3. Does one step do heavy computation in a tight loop?  -> keep Python, push that step into a
                                                           library or a separate service

Acme fits line 1: a small team, examples to copy, and every request spends its time waiting on Gemini, BigQuery and Databricks.

How do I run Python?

python3 --version          # check it is installed; you need 3.11 or newer
python3 my_agent.py        # run a file

Agent projects depend on third-party libraries (code written by others, such as ADK). You install these into a virtual environment: a separate set of installed packages for each project, so that two projects never fight over library versions.

Think of two hobbies, woodwork and baking, each with its own toolbox. If they shared one box, the baker’s new whisk might replace the woodworker’s old one that a half-finished project still needs. Separate boxes mean each hobby keeps exactly the tools it was built with. Each box is a virtual environment, and each tool is a library at a fixed version. For Acme, the same rule means any two agent projects that are built and deployed separately each get their own box, so one can move to a new ADK version without breaking the other.

python3 -m venv .venv            # create the environment once
source .venv/bin/activate        # switch it on in this terminal
pip install google-adk           # install ADK into it

From Part 2 onwards we use agents-cli, which handles this for you with uv, a faster package installer. The idea is the same: each project has one set of libraries, listed in a file called pyproject.toml. With uv, uv run python my_agent.py runs a file inside the project’s environment without you having to activate it first.

Two rules of Python syntax apply from the very first line:

  • Indentation matters. The lines indented by four spaces under def, if or for are its body. Python does not use curly brackets for blocks.
  • # starts a comment. Python ignores the rest of the line.

What kinds of data does an agent handle?

Almost every value an agent deals with is one of five data types (kinds of value). Once you can recognise them, you can read the input and output of any tool.

Start with a shop receipt. The shop’s name is text, the total is a number, the lines are an ordered list of items, and each line is a little record with labelled parts (item, quantity, price). The whole receipt is a record that holds a list of records. In Python, text is a string, the total is an int or float, the lines are a list, and each labelled record is a dict:

receipt = {"shop": "Corner Market", "total": 7.5,
           "lines": [{"item": "milk", "qty": 2, "price": 1.5},
                     {"item": "bread", "qty": 1, "price": 4.5}]}
receipt["lines"][1]["item"]     # 'bread': second line, its item

How does this apply to Acme? A tool result has exactly the same shape: a record with a status, holding a list of rows, each row a dict such as {"brand": "Acme Pure", "spend_eur": 283590}.

str text "Acme Pure" a brand, a question, a prompt int / float numbers 283590 87.4 spend, OTIF % bool / None yes, no, nothing True None a flag; "no value given" list ordered items ["Acme Pure", "Verde"] the brands a user asked about dict key -> value {"brand": "Acme Pure", "spend_eur": 283590} one result, named fields # a tool's answer: a dict that holds a list of dicts = JSON {"status": "ok", "rows": [{"campaign": "Acme Pure Refill Launch", "spend_eur": 283590}, {"campaign": "Verde Spring Refresh", "spend_eur": 120400}]}

the data shapes in every agent: text, numbers, lists, dictionaries, and dictionaries inside dictionaries, which is exactly what JSON looks like

  • A string (str) is text in quotes: "Acme Pure".
  • An int or float is a number (a whole number or a decimal): 283590, 87.4.
  • True, False and None mean true, false and no value.
  • A list is an ordered sequence in square brackets: ["Acme Pure", "Verde"]. You get an item by its position, counting from zero: brands[0] is "Acme Pure".
  • A dict (dictionary) is a set of key and value pairs in curly brackets: {"brand": "Acme Pure", "spend_eur": 283590}. You get a value by its key: result["spend_eur"].

A tool returns a table of results as a dict that holds a list of dicts. That shape matches JSON exactly, so when ADK hands a tool’s result back to the model, it serialises the dict to JSON (turns it into JSON text).

Two more ways of reading a dict appear all through the series, because agent state (the data an agent keeps during a conversation, covered in Part 8) is a dict:

state.get("user:region")            # the value, or None if the key is missing
state.get("user:region", "all")     # the value, or "all" if missing
state["temp:token"] = token         # set a value

d["key"] raises an error if the key is missing, while d.get("key") returns None. The series also uses := (the assignment expression), which stores a value and tests it in one step: if region := state.get("user:region"): reads “put the region in region, and carry on only if it has a value”. Keys are plain strings, so "user:region" is one key; the user: prefix is an ADK naming convention, explained in Part 8.

state = {"user:region": "Western Europe"}
if region := state.get("user:region"):
    print("filter to", region)      # runs: region is "Western Europe"

state = {}
if region := state.get("user:region"):
    print("filter to", region)      # skipped: region is None

Our tools also use a list comprehension, which builds a new list from an existing one in a single expression:

refused = [m for m in measures if m not in allowed]
# "every m in measures that is not in allowed"

Read it from the middle outwards: for each m in measures, keep m if it is not in allowed. The same syntax with curly brackets builds a dict (a dict comprehension): {row["brand"]: row["spend_eur"] for row in rows}.

What is a function?

A function is a named block of code that takes inputs (parameters) and gives back an output. In ADK, every tool is a function, so this section is worth reading carefully.

The bottom line: a function is a recipe with a name, and in ADK the model reads only the recipe card, never the method.

Think of a recipe card pinned in a restaurant kitchen. It has a title (“Pancakes”), a list of ingredients with amounts (“2 eggs, 200 g flour”), a note at the top (“serves 4; use oat milk if asked”) and, underneath, the method. A waiter who has never cooked can still take an order from the card: they know what the dish is called, what the kitchen needs, and what the customer gets. They never read the method; the kitchen follows it. In a Python function, the title is the name, the ingredients and amounts are the parameters and their type hints, the note is the docstring, the method is the body, and the dish is the return value.

How does this apply to Acme? The model is the waiter and ADK is the kitchen. The model reads the name, type hints and docstring of get_spend, writes the order (a function call), and ADK runs the body. Where people get tripped up is thinking the model runs the code; it never sees it.

A FUNCTION, LINE BY LINE 1 def get_spend( 2 brand: str, 3 region: str | None = None, 4 ) -> dict: 5 """Return media spend in EUR for one brand. 6 7 Args: 8 brand: One of Acme Pure, Verde, Harvest Row. 9 region: Optional. Defaults to all regions. 10 """ 11 spend = 283590 12 return {"status": "ok", "brand": brand, "spend_eur": spend} 13 14 get_spend("Acme Pure") # positional 15 get_spend(brand="Verde", region="Germany") # keyword 1 1 def + name Defines a function. Indentation = its body. 2 2 Type hint brand must be text (str). ADK shows this to the model. 3 3 Optional + default str | None = text or nothing. = None if not given. 4 4 Docstring Triple-quoted text under def. The model's manual for the tool. 5 5 return Sends a value back. Here a dict, which ADK turns to JSON. 6 6 Keyword arguments name=value: order does not matter. Agents use this a lot.

anatomy of a Python function: def and a name, parameters with type hints and a default, a docstring describing the arguments, and a return value; then two ways to call it

Four things to notice:

  1. Type hints (brand: str) say what type each parameter should be. Python does not enforce them while the program runs, but ADK reads them to build the tool schema that tells the model what to send. So in agent code, type hints are a must.
  2. str | None = None means a string or no value, and no value if you leave it out. This is how you declare an optional parameter.
  3. The docstring, the text in triple quotes just below def, describes the function and each argument. ADK sends it to the model as the tool’s description, so write it with the model as the reader.
  4. Keyword arguments (brand="Verde") name each argument where you call the function. Agent code uses them everywhere because they are clear and do not depend on the order of the arguments.

Here is what the model does with all of that, using the get_spend in the diagram above with the toy data from the Try it section at the end of this post:

1. Maria asks: "What did Verde spend in Germany?"
2. The model has never seen the function's code. It has seen only the name, the type hints and
   the docstring, which lists "One of Acme Pure, Verde, Harvest Row".
3. The model replies with a function call, not text:
   get_spend(brand="Verde", region="Germany")
4. ADK runs the function and returns its dict to the model:
   {"status": "ok", "brand": "Verde", "region": "Germany", "spend_eur": 120400}
5. The model writes the answer from that dict.

The model chose the tool and filled in the arguments, spelling “Verde” exactly as the docstring lists it. Your code chose the figure. A vague docstring (“brand: a brand”) would leave the model guessing at the spelling.

Which other type hints will I see?

  • list[str]: a list of strings. dict[str, int]: a dict with string keys and whole number values.
  • Any: any type. Used where the shape really is open, such as a row that comes back from a database.
  • -> None: the function returns nothing useful.
  • Callable[[dict], None]: a function that takes a dict and returns nothing. Used when a function takes another function as a parameter.
  • Literal["marketing", "supply", "both"]: exactly one of these strings. Useful when a model has to choose from a fixed set.

Can I pass a function around like a value?

Yes. Passing check_sql hands over the function to call later; writing check_sql() calls it now. It is the difference between leaving your phone number with a receptionist (“ring me when the parcel arrives”) and ringing the receptionist yourself right now. The number is the function without brackets; the ring is the call with brackets; the receptionist is ADK, which rings at the right moment.

In Python a function is a value like any other, and you can hand it to another function without calling it:

def check_sql(tool, args, tool_context):
    if "DELETE" in args.get("sql", "").upper():
        return {"status": "error", "error": "read-only"}   # stop: use this instead
    return None                                              # carry on as normal

agent = Agent(name="marketing", tools=[run_query], before_tool_callback=check_sql)

check_sql without brackets passes a reference to the function, so ADK can call it later, before every tool call. check_sql() with brackets would call it straight away. This is how callbacks work in Part 3 and plugins in Part 9: you register a function with ADK, and ADK calls it at the right moment while handling a request. Note the convention: returning None means “carry on as normal”, and returning a value means “use this result instead”.

Here is check_sql at work on two tool calls the model might make:

1. The model calls run_query with {"sql": "DELETE FROM campaign_performance"}.
   ADK first calls check_sql(tool, args, tool_context).
   check_sql returns {"status": "error", "error": "read-only"}.
   run_query never runs. The model receives the error and tells Maria it can only read data.
2. The model calls run_query with {"sql": "SELECT SUM(media_spend_eur) FROM campaign_performance"}.
   check_sql returns None, so ADK carries on and run_query runs as normal.

The model decided what to send; your code decided whether it ran. And if you had written before_tool_callback=check_sql(), Python would call it immediately with no arguments and stop with TypeError: check_sql() missing 3 required positional arguments, before the agent even exists.

Two related pieces of syntax:

  • *args and **kwargs in a function’s definition collect any extra positional and keyword arguments. def log_call(**kwargs): accepts any keyword arguments as one dict. Calling run(**settings) does the reverse: it unpacks a dict into keyword arguments.
  • lambda defines a small function with no name in a single expression: sorted(rows, key=lambda r: r["spend_eur"]) sorts rows by spend.

Both, on concrete values:

settings = {"brand": "Verde", "region": "Germany"}
get_spend(**settings)          # same as get_spend(brand="Verde", region="Germany")

rows = [{"campaign": "Acme Pure Refill Launch", "spend_eur": 283590},
        {"campaign": "Verde Spring Refresh", "spend_eur": 120400}]
sorted(rows, key=lambda r: r["spend_eur"])
# Verde Spring Refresh (120400) comes first, then Acme Pure Refill Launch (283590)

What is at the top of an agent file?

Almost every agent file starts with this pattern:

SETTINGS AND TEXT 1 import os 2 3 MODEL = os.getenv("MODEL", "gemini-flash-latest") 4 5 INSTRUCTION = """You are the marketing specialist. 6 Rules: 7 - Every number must come from a tool result. 8 - Never guess.""" 9 10 brand = "Acme Pure" 11 note = f"Spend for {brand}: EUR {283590:,}" 12 # note == 'Spend for Acme Pure: EUR 283,590' 1 1 import Brings in a module. os = talk to the system. 2 2 Environment variable Read MODEL from the system; use the default if not set. 3 3 Triple quotes Text over many lines. How prompts are written. 4 4 f-string f before the quote: {..} is replaced by the value.

three things you see at the top of every agent file: reading a setting from an environment variable with a default, a multi-line prompt in triple quotes, and an f-string that puts values into text

  • os.getenv("MODEL", "gemini-flash-latest") reads a setting from the environment and uses the second argument if it is not set. Agent settings such as the model name or the project ID live in environment variables (named settings the operating system passes to a program), often loaded from a .env file, so the same code runs on your laptop and in the cloud. Secrets such as passwords never go in code; Part 7 shows where they are kept instead.
  • Triple quotes let a string run over several lines, which is how agent instructions (prompts) are written.
  • An f-string starts with f. Anything inside {curly brackets} in it is replaced by its value. {283590:,} formats the number with thousands separators.

Two standard library modules (modules that come with Python) appear in nearly every file:

import json, logging

text = json.dumps({"brand": "Acme Pure"})      # dict -> JSON text
data = json.loads(text)                       # JSON text -> dict

log = logging.getLogger(__name__)
log.info("query ran", extra={"rows": 12})     # a log line, not a print

json turns dicts into JSON text and back again, for any data sent over the network. logging writes messages with a severity level (debug, info, warning, error) that the platform can collect and search. Production code logs instead of calling print, and Part 11 builds on this for traces and cost reports. __name__ is the name of the current module, so each log entry records where it came from.

How do the files in a project fit together?

Agent code is split across several files, and import statements connect them.

YOUR PROJECT acb-demo-marketing/ ├─ pyproject.toml dependencies ├─ .env settings └─ app/ a package ├─ __init__.py marks the package ├─ agent.py the Agent ├─ tools.py the tools └─ skills/ SKILL.md files IMPORTS IN AGENT.PY from google.adk.agents import Agent # installed library: by name from .tools import query_marketing_measures # the dot = this same folder Installed with pip / uv pip install google-adk lives outside your project Rule of thumb: a dot means "from my own code". No dot means "from an installed library".

how an agent project is organised: the app folder is a package, files import from each other with a dot, and installed libraries such as ADK are imported by name

  • from google.adk.agents import Agent brings in the Agent class from the installed ADK library.
  • from .tools import query_marketing_measures brings in a function from tools.py in the same folder. The leading dot means a relative import: import from the current package.
  • A folder that contains an __init__.py file is a package, which Python treats as one unit you can import. In our projects, the app/ folder is the package.

You will see this line at the bottom of scripts:

if __name__ == "__main__":
    main()

It means: run main() only when this file is run directly, not when another module imports it.

What are classes and objects?

You won’t need to write many classes in this series, but you will see them everywhere, because Agent, App and Runner are all classes.

The bottom line: a class is a blank form; an object is one filled-in copy of it. A gym’s membership form has the same boxes for everyone (name, plan, start date) and says what the gym can do with a member (renew, freeze). Fill it in for Sam and you have one member; fill it in for Maria and you have another. Same form, two separate records. The form is the class, each filled-in copy is an object, the boxes are attributes, and the things you can do with a member are methods.

How does this apply to Acme? Agent is the blank form, with boxes such as name, model, instruction and tools. Agent(name="marketing", ...) fills in one copy for the marketing agent, and another call fills in a separate copy for the supply agent.

CLASSES, OBJECTS AND ADK 1 class Campaign: 2 def __init__(self, name: str, spend: int): 3 self.name = name 4 self.spend = spend 5 6 def spend_k(self) -> int: 7 return round(self.spend / 1000) 8 9 c = Campaign("Acme Pure Refill Launch", 283590) 10 c.name # 'Acme Pure Refill Launch' 11 c.spend_k() # 284 12 13 # ADK works the same way: 14 root_agent = Agent(name="spend_agent", tools=[get_spend]) 15 app = App(name="spend_app", root_agent=root_agent) 1 1 class A template for objects. You mostly use, not write. 2 2 __init__ and self Runs when an object is made. self = this object. 3 3 Method A function that belongs to the object. 4 4 Create an object Call the class like a function. c is the object. 5 5 Agent is a class Agent(...) builds an agent object from keyword args. 6 6 Objects inside objects The App holds the agent. You pass objects around.

classes and objects: a class is a template, calling it creates an object that holds data and functions; Agent and App in ADK are classes you call with keyword arguments

  • A class is a template. Calling it like a function creates an object (an instance of the class).
  • An object holds data in attributes (c.name) and has its own functions, called methods (c.spend_k()). The dot reads “the name of c”.
  • __init__ runs when the object is created, and self means the object itself.

So root_agent = Agent(name="spend_agent", tools=[get_spend]) means: create an Agent object with this name and these tools, and call it root_agent. Then App(root_agent=root_agent) creates a second object that contains the first.

One class can build on another. class RateLimitPlugin(BasePlugin): means a RateLimitPlugin is a BasePlugin, with some behaviour changed. This is called inheritance. ADK provides the base class with methods that do nothing, and you override (replace) only the ones you need. That is how plugins work in Part 9.

Here is the same idea on the Campaign class from the diagram:

class LaunchCampaign(Campaign):          # a LaunchCampaign is a Campaign...
    def spend_k(self) -> str:            # ...with spend_k replaced
        return f"{round(self.spend / 1000)}k"

c = LaunchCampaign("Acme Pure Refill Launch", 283590)
c.name        # 'Acme Pure Refill Launch'  (inherited: __init__ comes from Campaign)
c.spend_k()   # '284k'                     (overridden: Campaign's version returns 284)

What is a dataclass?

Many classes only need to hold a few named fields. A dataclass writes the __init__ method for you:

from dataclasses import dataclass, field

@dataclass
class Step:
    kind: str                                  # "local", "federated" or "delegate"
    measures: list
    objects: list = field(default_factory=list)  # a new empty list for each Step
    reason: str = ""

step = Step(kind="local", measures=["spend_eur"])

The router in Part 5 stores its plan as dataclasses like this one. field(default_factory=list) gives every new object its own empty list, instead of one list shared by all of them.

s1 = Step(kind="local", measures=["spend_eur"])
s2 = Step(kind="federated", measures=["otif_pct"])
s1.objects.append("campaign_performance")
s1.objects    # ['campaign_performance']
s2.objects    # []  (its own list, untouched)

Python will not even let you write the shared version: objects: list = [] stops with ValueError: mutable default <class 'list'> for field objects is not allowed: use default_factory.

What is a Pydantic model?

The bottom line: a dataclass is a labelled box; a Pydantic model is a labelled box with a clerk who checks what goes in.

Think of a passport desk. The form says “date of birth: a date”. A clerk who just files forms (a dataclass) accepts “a long time ago”. A clerk who checks forms (Pydantic) turns the text “1990-03-07” into a proper date, and hands back “date of birth: not a valid date” for “a long time ago”, before anything is stamped.

A dataclass holds data but does not check it: Step(kind=42) is accepted. Data that comes from outside your code, whether from a model, an API or a user, should be checked, and that is the job of Pydantic, a validation library (a library that checks data against rules). ADK and the Gen AI SDK use it throughout.

How does this apply to Acme? A Pydantic model is a class that declares the exact shape of some data: the field names and their types. It builds on BaseModel, and the clerk’s two answers appear below as a converted spend_eur and a ValidationError:

from pydantic import BaseModel, Field, ValidationError

class SpendRow(BaseModel):
    brand: str
    spend_eur: int
    ctr: float | None = Field(default=None, description="clicks / impressions")

row = SpendRow.model_validate({"brand": "Acme Pure", "spend_eur": "283590"})
row.spend_eur            # 283590, an int: the text was converted safely
row.model_dump()         # back to a plain dict

SpendRow.model_validate({"brand": "Acme Pure", "spend_eur": "a lot"})
# ValidationError: spend_eur: Input should be a valid integer, unable to parse
# string as an integer
{"brand": "Acme Pure", "spend_eur": "283590", "ctr": 0.031} data from a model or an API {"brand": "Acme Pure", "spend_eur": "a lot"} class SpendRow(BaseModel) brand: str spend_eur: int ctr: float | None = None SpendRow object brand = 'Acme Pure' spend_eur = 283590 (text "283590" turned into int) ValidationError spend_eur: Input should be a valid integer Declare the shape once. Pydantic checks every value, converts what it safely can, and refuses the rest.

a Pydantic model checks data against a declared shape: good data becomes a typed object, bad data raises a ValidationError that names the field and the problem

How it behaves:

  • Good data becomes a typed object. Each field is checked, and values are converted where that is safe, such as the string "283590" to the whole number 283590.
  • Bad data is rejected with a clear reason. The ValidationError names the field and the problem, and no half-valid object is created.
  • Field(description=...) attaches a description that tools and models can read.
  • model_validate checks a dict; model_dump turns the object back into a dict.

Agents need this because a model’s output is text. When the output has to follow a fixed shape, such as a score and an explanation, you give a Pydantic model as the schema (the required shape). The model is told to answer in that shape, and Pydantic checks that it did. This is called structured output, and the evaluation judge in Part 10 works exactly like this:

class Verdict(BaseModel):
    score: int        # 1 to 5
    explanation: str

The same pattern appears in ADK as output_schema on an agent, and inside ADK itself: its events, sessions and settings are all Pydantic models. You can read class X(BaseModel): as “a schema with named, typed fields that checks any data put into it”.

Which one should you reach for? The cheapest that does the job:

Data you build yourself, used once, a few keys         -> plain dict
Data you build yourself, passed around, fixed fields   -> dataclass (the router's Step)
Data from outside: a model, an API, a user             -> Pydantic model (SpendRow, Verdict)

The misconception to drop: type hints alone do not check anything at run time. Only the Pydantic clerk does.

What happens when something fails?

The bottom line: a tool should turn a failure into a clear message the model can act on, not a crash.

Ask a shop assistant for trainers in size 9 when there are none. A good assistant says “no size 9 in stock; we have 8 and 10”. A bad one walks off without a word. The first answer lets you choose again; the second leaves you guessing. A tool that returns {"status": "error", ...} is the good assistant; an uncaught exception is the one who walks off.

Things will fail: a table is missing, a network call times out, or the model asks for a brand that does not exist. In Python, a failure is raised as an exception, and an exception that nobody catches stops the program.

How does this apply to Acme? A tool should never crash the agent. It should return an error that the model can understand and act on, just like the assistant naming sizes 8 and 10:

ERRORS THE MODEL CAN USE 1 def get_spend(brand: str) -> dict: 2 """Return media spend in EUR for one brand.""" 3 if brand not in ("Acme Pure", "Verde", "Harvest Row"): 4 return {"status": "error", 5 "error": f"unknown brand {brand}; use Acme Pure, Verde, Harvest Row"} 6 try: 7 spend = run_query(brand) 8 except TimeoutError: 9 return {"status": "error", "error": "data source timed out, try again"} 10 return {"status": "ok", "brand": brand, "spend_eur": spend} 1 1 Check first Refuse bad input early, with the valid values. 2 2 Return, do not crash The model reads it and retries or explains. 3 3 try / except Run code that may fail. except catches that failure. 4 4 Happy path Same shape every time: status first, then the data.

errors in tools: check the input and return a clear error the model can act on, and catch failures with try/except instead of letting the tool crash

  • try: / except ...: runs code that might fail and handles the exception if it does.
  • Returning {"status": "error", "error": "..."} with a clear message lets the model try again with a valid brand or tell the user exactly what is missing. A crash tells it nothing.
  • Every tool in our system returns the same shape, with status first, so the model only has to learn one pattern.

Asked about “Acme Gold”, get_spend answers unknown brand Acme Gold; use Acme Pure, Verde, Harvest Row, so the model can ask Maria which brand she meant or retry with a real one. That retry inside one turn is the inner loop from Part 1.5.

The series also uses raise, which throws an exception on purpose, and assert, which stops the program if a condition that must be true is false. Both appear in setup code and tests, never in tools.

What are async and await, and why do agents need them?

The bottom line: async lets one program wait for many slow things at the same time, instead of waiting for them one after another.

Picture a waiter with three tables. A blocking waiter takes table 1’s order, stands at the kitchen hatch until that dish is ready, carries it out, and only then walks to table 2. An async waiter takes table 1’s order, hands it to the kitchen, takes table 2’s and table 3’s while the first dish cooks, and carries each plate out as the hatch bell rings. Same kitchen, same dishes, a third of the waiting. The waiter is the event loop, each order is a task, handing it in and walking off is await, and the bell is the answer arriving.

What is the problem?

As the timing diagram earlier showed, an agent spends most of its time waiting. Ordinary (synchronous) Python waits by blocking: the line that is waiting holds up everything after it. Here is a synchronous function that queries three systems, each taking two seconds:

import time

def ask(team: str) -> str:
    time.sleep(2)                    # stands in for a 2-second network call
    return f"{team}: done"

start = time.time()
results = [ask("marketing"), ask("supply"), ask("finance")]
print(results, round(time.time() - start), "seconds")    # 6 seconds

This takes six seconds, because each call waits for the one before it to finish. On a server running this code, every other user is stuck while one user’s request spends six seconds waiting, unless each request gets its own thread. A thread per user works for tens of users, but not for thousands.

How does async fix it?

async code waits in a different way. Instead of blocking, an async function pauses at each wait and hands control back, so other work can run in the meantime. A single scheduler, the event loop, keeps track of every paused task and resumes each one when its answer arrives.

import asyncio, time

async def ask(team: str) -> str:
    await asyncio.sleep(2)           # pause here; let other work run
    return f"{team}: done"

async def main():
    start = time.time()
    results = await asyncio.gather(ask("marketing"), ask("supply"), ask("finance"))
    print(results, round(time.time() - start), "seconds")    # 2 seconds

asyncio.run(main())
WITHOUT ASYNC: ONE AT A TIME ask marketing 2 s ask supply 2 s ask finance 2 s = 6 seconds WITH ASYNC: START ALL THREE, THEN WAIT ONCE ask marketing 2 s ask supply 2 s ask finance 2 s = about 2 seconds results = await asyncio.gather( ask("marketing"), ask("supply"), ask("finance"), ) Same work, same answers. The waiting overlaps, so the user waits a third as long.

three slow calls of two seconds each: without async they run one after another and take six seconds; with async they wait together and take about two

The same work gives the same results in about two seconds, because the three waits overlap. On a server, the same idea lets one process serve many users at once: while one user’s request waits for Gemini, the event loop deals with everyone else’s.

How does this apply to Acme? The three tables are the router’s calls. When Maria’s question needs both campaign spend and supply figures, the router hands one order to the marketing agent and one to the supply agent with asyncio.gather, and carries each answer back as it arrives (Part 5). Meanwhile the same process serves Sam and every other user, the way the waiter serves other tables.

The keywords one at a time:

  • async def defines a coroutine function, a function that can pause. Calling it does not run it straight away; it returns a coroutine that you then await.
  • await something runs it and pauses the current function until it finishes, so other work can run meanwhile. You can only use await inside an async def.
  • asyncio.gather(a, b, c) starts several coroutines at the same time and waits for all of them. This is how an agent asks two specialists at once (Part 5).
  • asyncio.run(main()) starts the event loop from an ordinary script.
  • async for event in ... takes results one at a time as they arrive.
  • async with opens a resource, such as a network connection, and closes it when the block ends, with awaits allowed in between.

The main trap. Inside async code, a blocking call, such as time.sleep(2) or a library that does not support async, blocks the whole event loop and freezes every user. Use the async version of the library, or move the blocking call onto a thread with await asyncio.to_thread(blocking_function).

You will see async most often in the Runner, which streams events while the agent works:

Runner run_async(...) works in the background async for event in runner.run_async(...): print(event) 1 tool call: get_spend(brand="Acme Pure") 2 tool result: {"spend_eur": 283590} 3 final answer: "EUR 283,590" await wait here without blocking other users async/await lets one server wait for many slow model calls at once. You only need to read it.

async for: the Runner hands your loop one event at a time as the agent works, so you can show progress instead of waiting for the end

async for event in runner.run_async(user_id="maria", session_id=session.id, new_message=msg):
    if event.is_final_response():
        print(event.content.parts[0].text)

This means: for each event the agent sends, as soon as it is sent, print the final answer when it arrives. The tool call and the tool result arrive first, as separate events.

Tools don’t have to be async. ADK accepts both an ordinary def tool and an async def tool. Use async def when the tool itself calls something slow that has an async client library.

A quick guide for a tool, simplest first:

The tool only computes, or calls something fast       -> plain def
The tool waits on a service with an async client      -> async def, and await the client
The tool must use a blocking library inside async     -> await asyncio.to_thread(blocking_function)

What are decorators, generators and context managers?

Decorators

The @ line above a function or class is a decorator. It wraps the definition that follows with extra behaviour, without changing its code.

@dataclass                 # write __init__ for this class
class Step: ...

@functools.cache           # remember the result; run the body only once
def get_session_service():
    return VertexAiSessionService(project=PROJECT)

@pytest.fixture            # this function provides test data
def runner(): ...

Each one works like a label: “this class is a dataclass”, “this function’s result is cached”, “this is a test fixture”. Web servers also use decorators such as @app.get("/"), meaning “this function handles requests to /”.

You can see @functools.cache work: call get_session_service() twice and get_session_service() is get_session_service() gives True. Both calls return the same object, and a print inside the body would print only once, on the first call.

yield and generators

return gives back one value and ends the function. yield gives back a value and pauses the function, ready to give the next value when asked. A function that contains yield is a generator.

def stream_rows(rows):
    for row in rows:
        yield row            # hand one row back, pause, continue when asked

for row in stream_rows(all_rows):
    print(row)

Driven by hand, one step at a time:

gen = stream_rows(["row 1", "row 2"])
next(gen)     # 'row 1'  (runs until the first yield, then pauses)
next(gen)     # 'row 2'  (resumes, runs to the next yield, pauses again)
next(gen)     # raises StopIteration: no rows left, which is how a for loop knows to stop

Agents use generators for streaming: each result is sent as soon as it is ready, instead of all results coming back at the end. Combined with async def, the same idea is an async generator, read with async for, and runner.run_async is one.

with: context managers

with open("SKILL.md") as f:
    text = f.read()
# the file is closed here, even if reading failed

with opens a resource and guarantees it is released when the block ends, even if an exception is raised. It is used for files, network connections and database sessions, and an object that supports it is called a context manager.

How do I test code?

From Part 10 onwards, we check that the system works with tests: small functions that run your code and check the result. The test framework is pytest.

The bottom line: a test is a question about your code with a known right answer, asked again every time the code changes. It is the test button on a smoke alarm: press it, expect a beep. If one day there is no beep, you know something broke before there is a fire. In code, pressing the button is calling the function, the beep is the expected result, and assert is you listening.

How does this apply to Acme? The button is “ask get_spend for a brand that does not exist”; the beep is "status": "error":

# tests/test_tools.py
from app.tools import get_spend

def test_unknown_brand_is_an_error():
    result = get_spend("Acme Gold")      # not one of Acme Pure, Verde, Harvest Row
    assert result["status"] == "error"

Run against the get_spend from “What happens when something fails?”, the brand check returns {"status": "error", "error": "unknown brand Acme Gold; use Acme Pure, Verde, Harvest Row"}, the assert holds and pytest reports 1 passed. If someone later deletes the brand check, the tool returns "status": "ok" for a brand that does not exist, the assert fails, and pytest reports 1 failed and points at the assert result["status"] == "error" line.

  • A test is a function whose name starts with test_, in a file whose name starts with test_.
  • assert checks that a condition is true. If it is not, the test fails and shows the line that failed.
  • Run all the tests with pytest, or uv run pytest in an agents-cli project.

Can I read a real agent file now?

Here is the marketing agent from this series, shortened slightly. Every line uses something covered in this post:

READING A REAL AGENT FILE 1 import os 2 from google.adk.agents import Agent 3 from google.adk.apps import App 4 from .tools import query_marketing_measures, unserved_demand_report 5 6 MODEL = os.getenv("MODEL", "gemini-flash-latest") 7 INSTRUCTION = """You are the marketing analytics specialist ...""" 8 9 root_agent = Agent( 10 name="acb_demo_marketing", 11 model=MODEL, 12 instruction=INSTRUCTION, 13 tools=[query_marketing_measures, unserved_demand_report], 14 before_tool_callback=[sql_guard], 15 ) 16 app = App(root_agent=root_agent, name="app", plugins=default_plugins()) 1 1 Imports Library by name; own code with a dot. 2 2 Setting Model name from the environment, with a default. 3 3 Prompt Triple-quoted instruction. Full version in Part 2. 4 4 Object, keyword args Agent is a class. Each name=value sets a field. 5 5 List of functions Functions passed as values, not called: no (). 6 6 Callback list Functions ADK runs before a tool. Part 3.

the marketing agent file read with the Python from this post: imports, an environment variable, a triple-quoted instruction, an object built with keyword arguments, lists of functions, and the App

If you can read this file, you can follow every part of the series. The diagram below shows where each piece comes back:

Part 1 objects: Agent, App, Runner; async for Part 2 tools: type hints, docstrings, dicts Part 3 callbacks: functions as values Part 4 files and folders, YAML, imports Part 5 dataclasses, routing functions Part 6 async calls to other agents, JSON Part 7 environment variables, never a secret in code Part 8 dicts as state, prefixes in key names Part 9 classes with methods: plugins Part 10 pytest, assert, Pydantic for judge output Part 11 logging, decorators, caching Part 12 the command line, no new Python Everything in this list is covered in this post.

where each piece of Python appears in the series, so you know what to come back to

Cheat sheet

  • x = 5: put 5 in the variable x
  • "text" / """text""": a string / a multi-line string
  • f"Hi {name}": a string with a value filled in
  • [a, b] / items[0]: a list / its first item
  • {"k": v} / d["k"] / d.get("k"): a dict / the value for key k / the same, or None if missing
  • [x for x in xs if ok(x)]: a list comprehension that builds a new list from an existing one
  • def f(a: str) -> dict:: a function that takes a string and returns a dict
  • a: str | None = None: an optional parameter, None if left out
  • f(a="x"): call f with a keyword argument
  • tools=[f]: pass a reference to the function, to be called later
  • **kwargs: any extra keyword arguments, as a dict
  • from .tools import f: import f from tools.py in the current package
  • Agent(name="x"): create an Agent object
  • obj.name, obj.run(): an attribute / a method of the object
  • class A(B):: A builds on B and changes some behaviour
  • @dataclass: a class that holds named fields
  • class X(BaseModel):: a Pydantic model that checks its data
  • X.model_validate(d) / x.model_dump(): check a dict / turn it back into a dict
  • try: ... except E: ...: run this block; if exception E is raised, handle it
  • async def / await x: a function that can pause / wait for x without blocking
  • await asyncio.gather(a, b): run a and b at the same time
  • async for e in s:: take each e from s as it arrives
  • yield v: send v and pause
  • with open(p) as f:: open a resource and always release it
  • @something: wrap the next function with extra behaviour
  • os.getenv("K", "d"): environment variable K, or d if not set
  • if x := f():: store x and test it in one step
  • assert x == y: stop (or fail the test) if the condition is false

Try it

Save this as practice.py and run python3 practice.py. It needs one library: pip install pydantic.

import asyncio, time
from pydantic import BaseModel, ValidationError

class SpendRow(BaseModel):
    brand: str
    spend_eur: int

def get_spend(brand: str, region: str | None = None) -> dict:
    """Return media spend in EUR for one brand."""
    toy_data = {"Acme Pure": 283590, "Verde": 120400}
    if brand not in toy_data:
        return {"status": "error", "error": f"unknown brand {brand}; use {list(toy_data)}"}
    return {"status": "ok", "brand": brand, "region": region or "all", "spend_eur": toy_data[brand]}

print(get_spend("Acme Pure"))
print(get_spend(brand="Verde", region="Germany"))
print(get_spend("Harvest Row"))

# Pydantic: one good row, one bad row
print(SpendRow.model_validate({"brand": "Verde", "spend_eur": "120400"}))
try:
    SpendRow.model_validate({"brand": "Verde", "spend_eur": "lots"})
except ValidationError as e:
    print("refused:", e.errors()[0]["msg"])

# async: three 1-second waits take about 1 second, not 3
async def ask(team: str) -> str:
    await asyncio.sleep(1)
    return f"{team}: done"

async def main():
    start = time.time()
    print(await asyncio.gather(ask("marketing"), ask("supply"), ask("finance")))
    print(f"took {time.time() - start:.1f} s")

asyncio.run(main())

Things to try:

  • Change the docstring and notice that the output does not change when you run the script. A docstring only changes behaviour when a model reads it, which is what Part 1 is about.
  • Replace await asyncio.sleep(1) with time.sleep(1). The total goes up to three seconds, which is the blocking trap from the async section.

What did we learn?

  • Agent code needs only a reading knowledge of Python: functions, the basic data types, classes, Pydantic models, async and a few pieces of syntax.
  • An agent is I/O-bound, so it spends most of its time waiting for the model and the data, and async lets one server keep many of those waits going at once.
  • In ADK every tool is a Python function, and its type hints and docstring become the description the model reads.
  • Pydantic models check data that comes from outside your code, which is how structured output from a model is validated.
  • Callbacks and plugins work by passing a function to ADK, and returning None means carry on as normal.
  • If you can read the marketing agent file at the end of this post, you can follow every part of the series.

What comes next?

Production-ready agents, Prerequisite 2: The business terms and the data behind the agents explains the business behind the agents: the marketing and supply terms with real numbers, the four tables and who owns them, and how the system decides whether to federate or delegate each number. Then Part 1 starts the series properly: what a model, a tool and an agent are, the three ADK classes every agent runs on, and the system the series builds.

Sources

Opinions are my own. Every fact in this post comes from the public documentation linked above.