sanzone.dev

Everything you type during the session, in order. Copy from here, not off the screen. Where a slide shows a trimmed file, this has the whole thing.

Setup happens the day before, on the prerequisites page. If you skipped it, nothing here will run.


Step 1 of 11: Scaffold

We are NOT deploying anything in this session, so the scaffold runs with --prototype. Scaffold it, step into the folder, install, then open it up and look around.

agents-cli scaffold create pokedex --agent adk --prototype --agent-guidance-filename AGENTS.md

You should see:

1Info: Prototype mode: using deployment_target='none'.
2> Verifying GCP credentials...
3> ✓ Connected to project: p-d-digex-vertex-001
4
5✅ Success! Your agent project is ready.
6... documentation, tip and get-started lines elided ...

This is the command that checks your credentials; agents-cli info does not. Look for the connected-to-project line: the Success line prints even when that check fails. If that line names your-gcp-project-id rather than the session project, stop here and fix it before you go on: run gcloud config set project p-d-digex-vertex-001 and scaffold again. Everything downstream will look fine and then fail on your first question.

cd pokedex

Everything from here on runs inside the project folder.

agents-cli install

You should see:

1  ▸ uv sync
2Using CPython 3.12.11
3        ... build and package lines elided ...
4Installed 128 packages in 10.58s

About a minute the first time, and seconds after that because uv caches packages. uv picks its own Python (the project pins >=3.11,<3.14), so whatever is on your PATH does not matter.

code .

Let's open the project and look at the structure. I'm using VS Code, use whatever editor or IDE you like.

Open .env while you are in there. That is where secrets and local settings live, and it is in .gitignore so it never gets committed. It is also where the GCP project you are talking to Gemini through is set:

1# Vertex AI Configuration (default)
2GOOGLE_GENAI_USE_VERTEXAI=true
3GOOGLE_CLOUD_PROJECT=p-d-digex-vertex-001
4GOOGLE_CLOUD_LOCATION=global

Check that middle line now. The scaffold wrote it from your gcloud config, and if gcloud had no project set it wrote the literal placeholder your-gcp-project-id instead. That is not an error and nothing warns you: the scaffold succeeds, the playground starts, and the first question fails with Permission denied on resource project your-gcp-project-id. If you see the placeholder, fix it in two places:

gcloud config set project p-d-digex-vertex-001

then edit that line in .env to the same id and save. The .env is read at start-up, so restart the playground afterwards.

Underneath those three the scaffold also wrote a commented-out block for reaching Gemini with a Google AI Studio key instead. Leave that commented out; it is not how we connect.

Step 2 of 11: Talk to what you already have

You have a working agent before you write anything. The scaffold shipped one, with two demo tools. Talk to it first, so that when you replace them you know exactly what you are replacing.

agents-cli playground

You should see:

1┌─────────────────────────────────────────────────────────────────────────────┐
2│ Starting your agent playground...                                           │
3│                                                                             │
4│ Running command:       uv run adk web . --host 127.0.0.1 --port 8080 --relo │
5│ Will be available at:  http://127.0.0.1:8080/dev-ui/?app=app                │
6└─────────────────────────────────────────────────────────────────────────────┘
7WARNING: The --reload flag is not supported on Windows because it forces Uvicorn to use SelectorEventLoop, which does not support subprocesses (needed for executing tools). Forcing --no-reload.
8
9... startup log lines elided ...

If it asks about telemetry, either answer is fine; nothing here depends on it. One Windows thing: live reload does not work, so after any code change stop the playground with Ctrl+C and start it again, or you will be testing the old version.

agents-cli run "What can you do?"

In the playground, paste just the question:

What can you do?

You should see:

1Local server started on port 18080 (PID 41280)
2  Stop with: agents-cli run --stop-server
3[user]: What can you do?
4[pokedex]: I am a versatile AI assistant designed to help with a wide range of tasks. Here are some of the main things I can do:
5
6* **Answer Questions & Explain Concepts:** Provide clear explanations on topics ranging from science, history, and math to pop culture and general trivia.
7...
8* **Real-Time Lookups:** Check current information such as weather forecasts and the current time in cities around the world.
9...
10
11... session id and shutdown lines elided ...

Weather and time, down in the fifth bullet. Those are the scaffold's two demo tools, sitting in app/agent.py. Nothing here knows anything about Pokemon yet. The agent answers as pokedex because the scaffold named it after the folder, and the first two lines are agents-cli starting a local server for this one run; it stops it again at the end.

agents-cli run "What's the weather in San Francisco?"

In the playground, paste just the question:

What's the weather in San Francisco?

You should see:

1Local server started on port 18080 (PID 26048)
2  Stop with: agents-cli run --stop-server
3[user]: What's the weather in San Francisco?
4[pokedex]: 
5[tool_call: get_weather({"query": "San Francisco"})]
6[tool_response: get_weather -> {"result": "It's 60 degrees and foggy."}]The weather in San Francisco is currently 60°F and foggy.
7
8... session id and shutdown lines elided ...

That is the whole loop, before you have written anything: the model picked a tool, the tool ran, and the model answered from what it returned. The weather is hardcoded in app/agent.py, which is the point. Next you replace these with a tool that fetches something real.

Step 3 of 11: Tool 1, get_pokemon

Now make it yours. Create app/tools.py in three pastes, check the tool works on its own, then give it to the agent. The docstring is not for people. It is what the model reads to decide when to call your tool and what to pass it.

Create app/tools.py. Complete file, paste it as shown:

1"""Tools the Pokedex agent can call.
2
3Every tool here talks to PokeAPI (https://pokeapi.co/api/v2/), which needs no
4key and no signup. Standard library only: `urllib`, no `requests`.
5"""
6
7import json
8from typing import Any
9from urllib.error import HTTPError, URLError
10from urllib.parse import quote
11from urllib.request import Request, urlopen
12
13POKEAPI_BASE_URL = "https://pokeapi.co/api/v2"
14DEFAULT_TIMEOUT_SECONDS = 10.0
15# PokeAPI is behind Cloudflare, which rejects urllib's default
16# "Python-urllib/3.12" User-Agent with a 403. Any other value works.
17HEADERS = {"Accept": "application/json", "User-Agent": "pokedex-workshop/0.1"}

The docstring, imports and constants. HEADERS is the one that matters: PokeAPI sits behind Cloudflare, which rejects urllib's default user agent with a 403. Any other value works.

Add to the bottom of app/tools.py. Keep what is already there:

1def _get_json(url: str) -> dict[str, Any]:
2    """Fetch one URL and parse its JSON body. Shared by all three tools."""
3    request = Request(url, headers=HEADERS)
4    with urlopen(request, timeout=DEFAULT_TIMEOUT_SECONDS) as response:
5        return json.loads(response.read().decode("utf-8"))

Not a tool, just a shared fetch helper. All three tools call it, so the timeout and headers live in one place.

Add to the bottom of app/tools.py. Keep what is already there:

1def get_pokemon(name: str) -> dict[str, Any]:
2    """Look up one Pokemon by name and return the facts recorded about it.
3
4    Args:
5        name: The Pokemon's name, e.g. "gengar" or "magikarp". Case does not
6            matter. This tool does not accept a Pokedex number.
7
8    Returns:
9        A dict with the Pokemon's name, types, height, weight, abilities and
10        base stats. It also returns species_url, which is the only way to
11        reach this Pokemon's evolution chain: pass that exact URL to
12        get_evolution_chain. Never build an evolution chain address yourself.
13        On an unknown name, returns {"error": "..."} instead.
14    """
15    url = f"{POKEAPI_BASE_URL}/pokemon/{quote(name.strip().lower())}"
16    try:
17        raw = _get_json(url)
18    except HTTPError as exc:
19        if exc.code == 404:
20            return {"error": f"No Pokemon named {name!r} exists in PokeAPI."}
21        return {"error": f"PokeAPI returned HTTP {exc.code} for {name!r}."}
22    except URLError as exc:
23        return {"error": f"Could not reach PokeAPI: {exc.reason}"}
24
25    return {
26        "name": raw["name"],
27        "types": [t["type"]["name"] for t in raw["types"]],
28        "height": raw["height"],
29        "weight": raw["weight"],
30        "abilities": [a["ability"]["name"] for a in raw["abilities"]],
31        "stats": {s["stat"]["name"]: s["base_stat"] for s in raw["stats"]},
32        "species_url": raw["species"]["url"],
33    }

Tool 1. Takes a Pokemon name, returns what PokeAPI records about it: types, height, weight, abilities and base stats. It also returns species_url, which is the only thing tool 2 can be called with.

uv run python -c "from app.tools import get_pokemon; print(get_pokemon('gengar'))"

You should see:

1{'name': 'gengar', 'types': ['ghost', 'poison'], 'height': 15,
2 'weight': 405, 'abilities': ['cursed-body'],
3 'stats': {'hp': 60, 'attack': 65, 'defense': 60,
4           'special-attack': 130, 'special-defense': 75, 'speed': 110},
5 'species_url': 'https://pokeapi.co/api/v2/pokemon-species/94/'}

This calls your tool directly, as a plain Python function. No agent, no Gemini, nothing but your code and PokeAPI. Get in the habit: if this works and the agent still misbehaves, the problem is the model or the instruction, not the tool. If this fails, stop here and fix it, because nothing downstream can work.

uv run python -c "import json; from app.tools import _get_json, get_pokemon, POKEAPI_BASE_URL; raw = _get_json(f'{POKEAPI_BASE_URL}/pokemon/gengar'); print('raw ', len(json.dumps(raw))); print('slim', len(json.dumps(get_pokemon('gengar'))))"

You should see:

1raw  373255
2slim 281

This one fetches Gengar twice and measures both. First the raw response straight from PokeAPI, untouched. Then the same Pokemon through your tool, which keeps seven fields and throws the rest away.

373,255 characters down to 281. Gengar's move list alone is 95% of that raw response: 125 moves, each with links to every game it appeared in. The model does not need any of it, and everything you hand it costs context and money. Deciding what to keep is part of writing the tool, not something you optimise later.

Create app/agent.py. Complete file, paste it as shown:

1from google.adk.agents import Agent
2from google.adk.apps import App
3from google.adk.models import Gemini
4from google.genai import types
5
6from app.tools import get_pokemon
7
8MODEL = "gemini-3.8-flash"
9
10root_agent = Agent(
11    name="root_agent",
12    model=Gemini(
13        model=MODEL,
14        retry_options=types.HttpRetryOptions(attempts=3),
15    ),
16    instruction="You are a Pokedex. Use your tools to look Pokemon up, and report what they return.",
17    tools=[get_pokemon],
18)
19
20app = App(
21    root_agent=root_agent,
22    name="app",
23)

Replace the whole file. The scaffold filled it with weather and time demo tools; this swaps them for your tool. Two lines matter: the import, and tools=[get_pokemon], which is what makes the model able to call it at all.

agents-cli run "Tell me about Gengar."

In the playground, paste just the question:

Tell me about Gengar.

You should see:

1[user]: Tell me about Gengar.
2[root_agent]:
3[tool_call: get_pokemon({"name": "gengar"})]
4[tool_response: get_pokemon -> {"name": "gengar", "types": ["ghost", "poison"], "height": 15, "weight": 405, "abilities": ["cursed-body"], "stats": {"hp": 60, "attack": 65, "defense": 60, "special-attack": 130, "special-defense": 75, "speed": 110}, "species_url": "https://pokeapi.co/api/v2/pokemon-species/94/"}]
5
6Here are the details for **Gengar**:
7
8* **Type:** Ghost / Poison
9* **Height:** 1.5 m (15 decimeters)
10* **Weight:** 40.5 kg (405 hectograms)
11* **Abilities:** Cursed Body
12
13### **Base Stats:**
14* **HP:** 60
15* **Attack:** 65
16* **Defense:** 60
17* **Special Attack:** 130
18* **Special Defense:** 75
19* **Speed:** 110

Same shape as the weather demo, except the tool is yours and the numbers came from PokeAPI. Ask it about evolutions right now and it cannot tell you, because you have only given it one tool.

Step 4 of 11: Tool 2, get_evolution_chain

The evolution chain lives at a URL that only tool 1's response contains. There is no way to guess it from a name or an id. That is the whole lesson of this step. Two more pastes onto the bottom of app/tools.py: a helper, then the tool.

Add to the bottom of app/tools.py. Keep what is already there:

1def _stage(node: dict[str, Any]) -> dict[str, Any]:
2    """One link of a chain, and everything it evolves into. Recursive."""
3    return {
4        "name": node["species"]["name"],
5        "evolves_to": [_stage(child) for child in node["evolves_to"]],
6    }

Another helper. One link of the chain plus everything it evolves into, calling itself for each branch.

Add to the bottom of app/tools.py. Keep what is already there:

1def get_evolution_chain(species_url: str) -> dict[str, Any]:
2    """Return the full evolution chain a Pokemon belongs to.
3
4    Args:
5        species_url: The species_url returned by get_pokemon, used exactly as
6            given. This is not a name and not a number, and you cannot build
7            it yourself: a Pokemon's Pokedex number and its evolution chain
8            number are different numbers. Magikarp is Pokemon 129 and lives on
9            chain 64. Asking for chain 129 does not fail, it returns a
10            complete chain belonging to some other Pokemon. Call get_pokemon
11            first and pass through what it gave you.
12
13    Returns:
14        A dict with the chain's id and its first stage. Each stage carries a
15        name and an evolves_to list, which is empty at the end of a branch and
16        holds more than one entry where a chain splits. On an unreachable or
17        malformed URL, returns {"error": "..."} instead.
18    """
19    try:
20        species = _get_json(species_url)
21        chain_url = species["evolution_chain"]["url"]
22        chain = _get_json(chain_url)
23    except (HTTPError, URLError) as exc:
24        return {"error": f"Could not follow {species_url!r}: {exc}"}
25    except (KeyError, TypeError):
26        return {"error": f"{species_url!r} is not a PokeAPI species URL."}
27
28    return {"chain_id": chain["id"], "chain": _stage(chain["chain"])}

Tool 2. Takes species_url from tool 1, used exactly as given. It is deliberately not a name or a number, so the model cannot guess it and has to call tool 1 first.

uv run python -c "from app.tools import _get_json, _stage; chain = _get_json('https://pokeapi.co/api/v2/evolution-chain/129/'); print(_stage(chain['chain']))"

You should see:

{'name': 'celebi', 'evolves_to': []}

Chain 129 is Celebi, which has no evolutions at all. A model reading that reports 'Magikarp does not evolve'. The opposite of the truth, with no error anywhere.

uv run python -c "from app.tools import get_pokemon, get_evolution_chain; print(get_evolution_chain(get_pokemon('eevee')['species_url']))"

You should see:

{'chain_id': 67, 'chain': {'name': 'eevee', 'evolves_to': [{'name': 'vaporeon', 'evolves_to': []}, {'name': 'jolteon', 'evolves_to': []}, {'name': 'flareon', 'evolves_to': []}, {'name': 'espeon', 'evolves_to': []}, {'name': 'umbreon', 'evolves_to': []}, {'name': 'leafeon', 'evolves_to': []}, {'name': 'glaceon', 'evolves_to': []}, {'name': 'sylveon', 'evolves_to': []}]}}

Eight branches off one stage. Each is an entry in that stage's evolves_to list.

Create app/agent.py. Complete file, paste it as shown:

1from google.adk.agents import Agent
2from google.adk.apps import App
3from google.adk.models import Gemini
4from google.genai import types
5
6from app.tools import get_evolution_chain, get_pokemon
7
8MODEL = "gemini-3.8-flash"
9
10root_agent = Agent(
11    name="root_agent",
12    model=Gemini(
13        model=MODEL,
14        retry_options=types.HttpRetryOptions(attempts=3),
15    ),
16    instruction="You are a Pokedex. Use your tools to look Pokemon up, and report what they return.",
17    tools=[get_pokemon, get_evolution_chain],
18)
19
20app = App(
21    root_agent=root_agent,
22    name="app",
23)

Same as before with two lines changed: get_evolution_chain is added to the import and to the tools list. Writing a tool does not give it to the agent, you have to hand it over.

agents-cli run "What does Gengar evolve from?"

In the playground, paste just the question:

What does Gengar evolve from?

You should see:

1[user]: What does Gengar evolve from?
2[root_agent]:
3[tool_call: get_pokemon({"name": "gengar"})]
4[tool_response: get_pokemon -> {"name": "gengar", ..., "species_url": "https://pokeapi.co/api/v2/pokemon-species/94/"}]
5[tool_call: get_evolution_chain({"species_url": "https://pokeapi.co/api/v2/pokemon-species/94/"})]
6[tool_response: get_evolution_chain -> {"chain_id": 40, "chain": {"name": "gastly", "evolves_to": [{"name": "haunter", "evolves_to": [{"name": "gengar", "evolves_to": []}]}]}}]
7
8Gengar evolves from **Haunter** (which in turn evolves from **Gastly**).

Look at the order. It called get_pokemon first, took the species_url out of the answer, and passed it straight into get_evolution_chain. Nothing told it to do that. It had no choice: species_url is the only thing the second tool accepts, and the only place to get one is the first tool.

Step 5 of 11: Tool 3, compare_pokemon

About fifteen lines, because it reuses tool 1's fetch. It reports types, stats and abilities. It does NOT say which one to use. Step 7 is where that line gets enforced.

Add to the bottom of app/tools.py. Keep what is already there:

1def compare_pokemon(names: list[str]) -> dict[str, Any]:
2    """Compare two or more Pokemon field by field.
3
4    Args:
5        names: The Pokemon to compare, e.g. ["gengar", "alakazam"].
6
7    Returns:
8        A dict keyed by field name (types, abilities, and one entry per base
9        stat), each holding one value per Pokemon, in the order asked for.
10        Reporting these numbers is what this tool is for. It does not say which
11        Pokemon is better, stronger, or the right one to use, and neither
12        should you: those are not facts PokeAPI records. Any name that fails to
13        resolve is listed under "errors" and left out of the comparison.
14    """
15    found, errors = {}, {}
16    for name in names:
17        result = get_pokemon(name)
18        if "error" in result:
19            errors[name] = result["error"]
20        else:
21            found[result["name"]] = result
22
23    fields = {
24        "types": {n: p["types"] for n, p in found.items()},
25        "abilities": {n: p["abilities"] for n, p in found.items()},
26    }
27    # Take the stat names from what came back rather than hardcoding six, so a
28    # Pokemon that reports a different set is compared instead of crashing.
29    for stat in {s: None for p in found.values() for s in p["stats"]}:
30        fields[stat] = {n: p["stats"].get(stat) for n, p in found.items()}
31
32    return {"compared": list(found), "fields": fields, "errors": errors}

Tool 3. Takes a list of names and reports them field by field. About fifteen lines, because it reuses tool 1 for the fetching.

uv run python -c "from app.tools import compare_pokemon; print(compare_pokemon(['gengar', 'alakazam']))"

You should see:

{'compared': ['gengar', 'alakazam'], 'fields': {'types': {'gengar': ['ghost', 'poison'], 'alakazam': ['psychic']}, 'abilities': {'gengar': ['cursed-body'], 'alakazam': ['synchronize', 'inner-focus', 'magic-guard']}, 'hp': {'gengar': 60, 'alakazam': 55}, 'attack': {'gengar': 65, 'alakazam': 50}, 'defense': {'gengar': 60, 'alakazam': 45}, 'special-attack': {'gengar': 130, 'alakazam': 135}, 'special-defense': {'gengar': 75, 'alakazam': 95}, 'speed': {'gengar': 110, 'alakazam': 120}}, 'errors': {}}

Reporting these numbers is facts. Saying which one to use is not, and Step 7 refuses it.

Step 6 of 11: Instruction

The instruction and the tools are one contract. Change what a tool returns without changing the instruction, and it breaks in ways unit tests will not catch. This step breaks it on purpose to show that.

Create app/agent.py. Complete file, paste it as shown:

1from google.adk.agents import Agent
2from google.adk.apps import App
3from google.adk.models import Gemini
4from google.genai import types
5
6from app.tools import compare_pokemon, get_evolution_chain, get_pokemon
7
8MODEL = "gemini-3.8-flash"
9
10AGENT_INSTRUCTION = """
11You are a Pokedex. You look Pokemon up and report what the entry says.
12
13Resolve before you look up.
14- get_pokemon takes a name. Call it first, every time.
15- get_evolution_chain takes the species_url that get_pokemon returned, used
16  exactly as it was given to you. Never write an evolution chain address
17  yourself and never put a Pokedex number in one. A Pokemon's number and its
18  chain's number are different numbers, and asking for the wrong chain returns
19  a real chain for a different Pokemon instead of an error.
20- compare_pokemon takes a list of names and reports them field by field.
21
22Report what the tools returned, and stop there.
23- If a tool returns an "error" key, say what it says. Do not fill the gap.
24- Do not add types, stats, abilities or evolutions that no tool returned, even
25  when you are confident about them.
26- An empty evolves_to list means that stage is the end of a branch. Several
27  entries in one evolves_to list mean the chain splits there.
28
29Report facts, not strategy.
30- Types, base stats, abilities and evolutions are facts. Report them.
31- Which Pokemon is best, what beats what, what to put on a team, and what to
32  use against an opponent are not in the entry. You do not answer those.
33""".strip()

Add to the bottom of app/agent.py. Keep what is already there:

1root_agent = Agent(
2    name="root_agent",
3    model=Gemini(
4        model=MODEL,
5        retry_options=types.HttpRetryOptions(attempts=3),
6    ),
7    instruction=AGENT_INSTRUCTION,
8    tools=[get_pokemon, get_evolution_chain, compare_pokemon],
9)
10
11app = App(
12    root_agent=root_agent,
13    name="app",
14)

Do NOT type this. It is here to be read, not pasted. This is what the tool in app/tools.py would look like written the obvious wrong way:

1def get_evolution_chain(chain_id: int) -> dict[str, Any]:
2    """Return an evolution chain by its id."""
3    chain = _get_json(f"{POKEAPI_BASE_URL}/evolution-chain/{chain_id}/")
4    return {"chain_id": chain["id"], "chain": _stage(chain["chain"])}

The only change is the parameter: chain_id: int instead of species_url: str. Now the model has no URL to pass through, so it has to come up with a number, and PokeAPI returns a real chain for whatever number it picks.

agents-cli run "What does Turtonator evolve into?"

In the playground, paste just the question:

What does Turtonator evolve into?

You should see:

1[tool_response: get_pokemon -> "species_url": ".../pokemon-species/776/"]   <- ignored
2[tool_call: get_evolution_chain({"chain_id": 397})]  -> "sandygast"
3[tool_call: get_evolution_chain({"chain_id": 398})]  -> "pyukumuku"
4[tool_call: get_evolution_chain({"chain_id": 399})]  -> "type-null"
5[tool_call: get_evolution_chain({"chain_id": 400})]  -> "minior"
6[tool_call: get_evolution_chain({"chain_id": 401})]  -> "komala"
7[tool_call: get_evolution_chain({"chain_id": 402})]  -> "turtonator"
8-> "Turtonator does not evolve into any other Pokemon."

Run this with the id-taking signature above. 402 happened to be right. 401 was Komala and would have looked identical. Six calls, every one HTTP 200.

Step 7 of 11: Plugin, NoStrategyGuard

A before_model plugin. ADK runs plugins in registration order and stops at the first one that returns something. A flagged turn gets fixed text back and the model is never called.

Create app/plugins/__init__.py. Complete file, paste it as shown:

1from app.plugins.no_strategy_guard import NoStrategyGuard
2
3__all__ = ["NoStrategyGuard"]

Create app/plugins/no_strategy_guard.py. Complete file, paste it as shown:

1"""A Pokedex reports what the entry says. It does not tell you what to use.
2
3This is a `before_model` plugin: it runs before the agent's model is called,
4and returning an LlmResponse instead of None ends the turn there. The model is
5never invoked, so it never gets the chance to answer.
6"""
7
8import logging
9import re
10
11from google.adk.agents.callback_context import CallbackContext
12from google.adk.models.llm_request import LlmRequest
13from google.adk.models.llm_response import LlmResponse
14from google.adk.plugins.base_plugin import BasePlugin
15from google.genai import types
16
17logger = logging.getLogger(__name__)
18
19STRATEGY_PATTERNS = [
20    re.compile(p, re.IGNORECASE)
21    for p in (
22        r"\bwhich (one )?(is|are)\b.*\b(better|best|stronger|strongest|weaker|weakest)\b",
23        r"\b(better|best|stronger|strongest|worst)\b.*\b(pokemon|choice|pick|option)\b",
24        r"\bshould i (use|pick|choose|catch|train|evolve)\b",
25        r"\b(counter|counters|beat|beats|defeat|defeats|win against)\b",
26        r"\b(super effective|weak against|strong against|type matchup)\b",
27        r"\b(team|moveset|build|strategy|tier list|competitive)\b",
28        r"\bwho would win\b",
29    )
30]
31
32REFUSAL = (
33    "I'm a Pokedex, so I report what the entry records: types, height, weight, "
34    "abilities, base stats and evolutions. Which Pokemon to use, what beats "
35    "what, and how to build a team aren't in the entry, so I can't answer "
36    "those. Ask me to look one up or compare two and I'll give you the numbers."
37)
38
39
40def _user_text(callback_context: CallbackContext, llm_request: LlmRequest) -> str:
41    content = getattr(callback_context, "user_content", None)
42    parts = getattr(content, "parts", None) or []
43    return "\n".join(p.text for p in parts if getattr(p, "text", None))
44
45
46class NoStrategyGuard(BasePlugin):
47    """Ends a turn that asks for strategy, before the model is called."""
48
49    def __init__(self) -> None:
50        super().__init__(name="no_strategy_guard")
51
52    async def before_model_callback(
53        self,
54        *,
55        callback_context: CallbackContext,
56        llm_request: LlmRequest,
57    ) -> LlmResponse | None:
58        text = _user_text(callback_context, llm_request)
59        match = next((p for p in STRATEGY_PATTERNS if p.search(text)), None)
60        if match is None:
61            return None
62
63        logger.info("no_strategy_guard fired: pattern=%r", match.pattern)
64        return LlmResponse(
65            content=types.Content(role="model", parts=[types.Part(text=REFUSAL)]),
66            turn_complete=True,
67        )

Edit app/agent.py:

1# at the top of app/agent.py, with the other imports:
2from app.plugins.no_strategy_guard import NoStrategyGuard
3
4# and add one line to the App(...) already at the bottom:
5app = App(
6    root_agent=root_agent,
7    name="app",
8    plugins=[NoStrategyGuard()],
9)
agents-cli run "Which is better, Gengar or Alakazam?"

In the playground, paste just the question:

Which is better, Gengar or Alakazam?

You should see:

1[user]: Which is better, Gengar or Alakazam?
2[root_agent]: I'm a Pokedex, so I report what the entry records: types,
3height, weight, abilities, base stats and evolutions. Which Pokemon to
4use, what beats what, and how to build a team aren't in the entry, so I
5can't answer those. Ask me to look one up or compare two and I'll give
6you the numbers.
7
8# and the turn either side of it, unchanged:
9[user]: Compare Gengar and Alakazam.
10[tool_call: compare_pokemon({"names": ["gengar", "alakazam"]})]   -> full table

No tool call and no model call on the flagged turn. The plugin returned a response, so the turn ended there.

Step 8 of 11: Testing

The fast tests. No network, no Gemini. This is what CI would run.

Create tests/unit/test_tools.py. Complete file, paste it as shown:

1"""Unit tests for the Pokedex tools.
2
3These never touch the network. `_get_json` is replaced with a stub that
4returns canned PokeAPI payloads, so what is under test is the shaping and the
5two-hop lookup, not PokeAPI's uptime. This is the layer CI runs.
6"""
7
8from urllib.error import HTTPError
9
10import pytest
11
12from app import tools
13
14GENGAR = {
15    "name": "gengar",
16    "types": [{"type": {"name": "ghost"}}, {"type": {"name": "poison"}}],
17    "height": 15,
18    "weight": 405,
19    "abilities": [{"ability": {"name": "cursed-body"}}],
20    "stats": [
21        {"stat": {"name": "hp"}, "base_stat": 60},
22        {"stat": {"name": "attack"}, "base_stat": 65},
23        {"stat": {"name": "defense"}, "base_stat": 60},
24        {"stat": {"name": "special-attack"}, "base_stat": 130},
25        {"stat": {"name": "special-defense"}, "base_stat": 75},
26        {"stat": {"name": "speed"}, "base_stat": 110},
27    ],
28    "species": {"url": "https://pokeapi.co/api/v2/pokemon-species/94/"},
29    "moves": ["...349,000 bytes of move data we throw away..."],
30}
31
32SPECIES_129 = {
33    "evolution_chain": {"url": "https://pokeapi.co/api/v2/evolution-chain/64/"}
34}
35CHAIN_64 = {
36    "id": 64,
37    "chain": {
38        "species": {"name": "magikarp"},
39        "evolves_to": [{"species": {"name": "gyarados"}, "evolves_to": []}],
40    },
41}
42
43
44def test_get_pokemon_keeps_only_the_fields_worth_keeping(monkeypatch):
45    monkeypatch.setattr(tools, "_get_json", lambda url: GENGAR)
46
47    result = tools.get_pokemon("Gengar")
48
49    assert result == {
50        "name": "gengar",
51        "types": ["ghost", "poison"],
52        "height": 15,
53        "weight": 405,
54        "abilities": ["cursed-body"],
55        "stats": {
56            "hp": 60,
57            "attack": 65,
58            "defense": 60,
59            "special-attack": 130,
60            "special-defense": 75,
61            "speed": 110,
62        },
63        "species_url": "https://pokeapi.co/api/v2/pokemon-species/94/",
64    }
65    assert "moves" not in result
66
67
68def test_get_pokemon_lowercases_and_strips_the_name(monkeypatch):
69    seen = []
70    monkeypatch.setattr(tools, "_get_json", lambda url: seen.append(url) or GENGAR)
71
72    tools.get_pokemon("  Gengar  ")
73
74    assert seen == ["https://pokeapi.co/api/v2/pokemon/gengar"]
75
76
77def test_get_pokemon_reports_an_unknown_name_instead_of_raising(monkeypatch):
78    def not_found(url):
79        raise HTTPError(url, 404, "Not Found", {}, None)
80
81    monkeypatch.setattr(tools, "_get_json", not_found)
82
83    result = tools.get_pokemon("mrmime")
84
85    assert "error" in result
86    assert "mrmime" in result["error"]
87
88
89def test_get_evolution_chain_follows_the_species_url_it_was_given(monkeypatch):
90    """The chain address comes out of the species record, never from an id."""
91    seen = []
92
93    def fake(url):
94        seen.append(url)
95        return SPECIES_129 if "pokemon-species" in url else CHAIN_64
96
97    monkeypatch.setattr(tools, "_get_json", fake)
98
99    result = tools.get_evolution_chain("https://pokeapi.co/api/v2/pokemon-species/129/")
100
101    assert seen == [
102        "https://pokeapi.co/api/v2/pokemon-species/129/",
103        "https://pokeapi.co/api/v2/evolution-chain/64/",
104    ]
105    assert result["chain_id"] == 64
106    assert result["chain"]["name"] == "magikarp"
107    assert result["chain"]["evolves_to"][0]["name"] == "gyarados"
108
109
110def test_get_evolution_chain_reports_a_bad_url_instead_of_raising(monkeypatch):
111    monkeypatch.setattr(tools, "_get_json", lambda url: {"no": "evolution_chain key"})
112
113    result = tools.get_evolution_chain("https://example.com/not-a-species")
114
115    assert "error" in result
116
117
118def test_compare_pokemon_reports_every_field_for_every_name(monkeypatch):
119    monkeypatch.setattr(tools, "_get_json", lambda url: GENGAR)
120
121    result = tools.compare_pokemon(["gengar"])
122
123    assert result["compared"] == ["gengar"]
124    assert result["fields"]["types"] == {"gengar": ["ghost", "poison"]}
125    assert result["errors"] == {}
126
127
128def test_compare_pokemon_lists_a_bad_name_instead_of_dropping_it(monkeypatch):
129    def not_found(url):
130        raise HTTPError(url, 404, "Not Found", {}, None)
131
132    monkeypatch.setattr(tools, "_get_json", not_found)
133
134    result = tools.compare_pokemon(["nosuchmon"])
135
136    assert result["compared"] == []
137    assert "nosuchmon" in result["errors"]
138
139
140@pytest.mark.parametrize(
141    "question",
142    [
143        "Which is better, Gengar or Alakazam?",
144        "Should I use Gengar?",
145        "What counters Gengar?",
146        "Who would win, Gengar or Alakazam?",
147        "Build me a team around Gengar.",
148    ],
149)
150def test_guard_flags_strategy_questions(question):
151    from app.plugins.no_strategy_guard import STRATEGY_PATTERNS
152
153    assert any(p.search(question) for p in STRATEGY_PATTERNS), question
154
155
156@pytest.mark.parametrize(
157    "question",
158    [
159        "Tell me about Gengar.",
160        "Compare Gengar and Alakazam.",
161        "What type is Sylveon?",
162        "What does Magikarp evolve into?",
163    ],
164)
165def test_guard_leaves_lookup_questions_alone(question):
166    from app.plugins.no_strategy_guard import STRATEGY_PATTERNS
167
168    assert not any(p.search(question) for p in STRATEGY_PATTERNS), question
uv run pytest tests/unit

You should see:

1collected 17 items
2
3tests\unit\test_tools.py ................          [100%]
4
5===================== 17 passed in 4.23s =====================

No network: _get_json is stubbed.

Step 9 of 11: Split into specialists

One agent holding three tools has taken you this far. Now split it up. Three specialists, one kind of question each, and a root agent that looks nothing up at all. It decides who a request belongs to and hands the turn over. You are rewriting app/agent.py, so clear it out and start from the top.

Create app/agent.py. Complete file, paste it as shown:

1from google.adk.agents import Agent
2from google.adk.apps import App
3from google.adk.models import Gemini
4from google.genai import types
5
6from app.plugins.no_strategy_guard import NoStrategyGuard
7from app.tools import compare_pokemon, get_evolution_chain, get_pokemon
8
9MODEL = "gemini-3.8-flash"
10
11# Every specialist is a Pokedex, so every specialist follows these. Written
12# once and pasted into each instruction rather than repeated three times, so a
13# rule cannot end up true for one specialist and not another.
14REPORTING_RULES = """
15Report what the tools returned, and stop there.
16- If a tool returns an "error" key, say what it says. Do not fill the gap.
17- Do not add types, stats, abilities or evolutions that no tool returned, even
18  when you are confident about them.
19
20Report facts, not strategy.
21- Types, base stats, abilities and evolutions are facts. Report them.
22- Which Pokemon is best, what beats what, what to put on a team, and what to
23  use against an opponent are not in the entry. You do not answer those.
24""".strip()
25
26DEX_INSTRUCTION = f"""
27You are the Pokedex entry specialist. You report what one Pokemon IS: its
28types, its height and weight, its abilities and its base stats.
29
30- get_pokemon takes a name. Call it first, every time.
31
32{REPORTING_RULES}
33""".strip()
34
35EVOLUTION_INSTRUCTION = f"""
36You are the evolution specialist. You report what a Pokemon evolves from and
37what it evolves into.
38
39Resolve before you look up. This takes two calls, always, in this order.
40- get_pokemon takes a name and returns a species_url.
41- get_evolution_chain takes that species_url, used exactly as it was given to
42  you. Never write an evolution chain address yourself and never put a Pokedex
43  number in one. A Pokemon's number and its chain's number are different
44  numbers, and asking for the wrong chain returns a real chain for a different
45  Pokemon instead of an error.
46
47- An empty evolves_to list means that stage is the end of a branch. Several
48  entries in one evolves_to list mean the chain splits there.
49
50{REPORTING_RULES}
51""".strip()
52
53COMPARE_INSTRUCTION = f"""
54You are the comparison specialist. You put two or more Pokemon side by side.
55
56- compare_pokemon takes a list of names and reports them field by field.
57
58{REPORTING_RULES}
59""".strip()
60
61# description is not documentation. It is the only thing the router reads when
62# it decides who a request belongs to, so it says what this specialist owns.
63dex_agent = Agent(
64    name="dex",
65    description=(
66        "Reports what one Pokemon is: its types, height, weight, abilities "
67        "and base stats. Does not know about evolution chains."
68    ),
69    model=Gemini(model=MODEL, retry_options=types.HttpRetryOptions(attempts=3)),
70    instruction=DEX_INSTRUCTION,
71    tools=[get_pokemon],
72    disallow_transfer_to_peers=True,
73    disallow_transfer_to_parent=True,
74)
75
76evolution_agent = Agent(
77    name="evolution",
78    description=(
79        "Reports what a Pokemon evolves from and what it evolves into, "
80        "including chains that split. Does not report types or base stats."
81    ),
82    model=Gemini(model=MODEL, retry_options=types.HttpRetryOptions(attempts=3)),
83    instruction=EVOLUTION_INSTRUCTION,
84    tools=[get_pokemon, get_evolution_chain],
85    disallow_transfer_to_peers=True,
86    disallow_transfer_to_parent=True,
87)
88
89compare_agent = Agent(
90    name="compare",
91    description=(
92        "Puts two or more named Pokemon side by side, field by field: types, "
93        "abilities and every base stat."
94    ),
95    model=Gemini(model=MODEL, retry_options=types.HttpRetryOptions(attempts=3)),
96    instruction=COMPARE_INSTRUCTION,
97    tools=[compare_pokemon],
98    disallow_transfer_to_peers=True,
99    disallow_transfer_to_parent=True,
100)

Three agents, one kind of question each. The instructions are the ones you wrote in Step 6, split up and given to whoever owns them. REPORTING_RULES holds the part every specialist shares, written once so a rule cannot end up true for one specialist and not another.

evolution gets get_pokemon as well as get_evolution_chain, even though dex already has it. That is on purpose. Each specialist has to answer its own question start to finish, on its own. Step 11 is where that starts to matter.

Both transfer flags are set for a reason, and disallow_transfer_to_parent is the one doing the work. disallow_transfer_to_peers stops a specialist handing sideways to another specialist; disallow_transfer_to_parent stops it handing the turn back to the router. Leave that second one off and a specialist who cannot finish a question simply returns it, the router picks somebody else, and the question quietly gets answered in two hops -- which is precisely what Step 10 is about to tell you cannot happen.

Add to the bottom of app/agent.py. Keep what is already there:

1ROUTER_INSTRUCTION = """
2You are a Pokedex. You do not look anything up yourself. You decide which
3specialist a request belongs to and transfer to it.
4
5- What a Pokemon is: types, height, weight, abilities, base stats -> dex
6- What it evolves from or into -> evolution
7- Two or more Pokemon side by side -> compare
8
9Transfer. Do not answer from your own knowledge, and do not write a sentence
10of your own before or after the transfer.
11
12Which Pokemon is best, what beats what, what to put on a team, and what to use
13against an opponent are not in the entry. You do not answer those, and you do
14not transfer them either.
15""".strip()
16
17root_agent = Agent(
18    name="root_agent",
19    model=Gemini(
20        model=MODEL,
21        retry_options=types.HttpRetryOptions(attempts=3),
22    ),
23    instruction=ROUTER_INSTRUCTION,
24    sub_agents=[dex_agent, evolution_agent, compare_agent],
25)
26
27app = App(
28    root_agent=root_agent,
29    name="app",
30    plugins=[NoStrategyGuard()],
31)

sub_agents is the whole change. The root agent has no tools now and looks nothing up. ADK gives it transfer_to_agent for free once it has children, and the descriptions you just wrote are what it reads to pick one.

agents-cli run "What does Turtonator evolve into?"

In the playground, paste just the question:

What does Turtonator evolve into?

You should see:

1[user]: What does Turtonator evolve into?
2[root_agent]:
3[tool_call: transfer_to_agent({"agent_name": "evolution"})]
4[tool_response: transfer_to_agent -> {"result": null}]
5[evolution]:
6[tool_call: get_pokemon({"name": "turtonator"})]
7[tool_response: get_pokemon -> {"name": "turtonator", "types": ["fire", "dragon"], ..., "species_url": "https://pokeapi.co/api/v2/pokemon-species/776/"}]
8[tool_call: get_evolution_chain({"species_url": "https://pokeapi.co/api/v2/pokemon-species/776/"})]
9[tool_response: get_evolution_chain -> {"chain_id": 402, "chain": {"name": "turtonator", "evolves_to": []}}]
10
11Turtonator does not evolve into any other Pokemon. It does not evolve from anything and has no further evolutions.

One question, one owner. root_agent wrote nothing of its own. It called transfer_to_agent, and the evolution specialist's answer went straight to you. ADK calls this the coordinator/dispatcher pattern, and it is the right shape for most questions.

Step 10 of 11: Where the router fails

Nothing to type in this step. Ask one question that belongs to two specialists and watch the router come up short. Nothing is broken. The reason is what a transfer is, and what you told the specialists they may not do.

agents-cli run "What type is Magikarp, and what does it evolve into?"

In the playground, paste just the question:

What type is Magikarp, and what does it evolve into?

You should see:

1[user]: What type is Magikarp, and what does it evolve into?
2[root_agent]:
3[tool_call: transfer_to_agent({"agent_name": "dex"})]
4[tool_response: transfer_to_agent -> {"result": null}]
5[dex]:
6[tool_call: get_pokemon({"name": "magikarp"})]
7[tool_response: get_pokemon -> {"name": "magikarp", "types": ["water"], ..., "species_url": "https://pokeapi.co/api/v2/pokemon-species/129/"}]
8
9**Magikarp** is a **Water**-type Pokemon.
10
11I do not have access to evolution chain information, so I cannot report what it evolves into.

Half the question got answered, and nothing is broken. dex owns the type, evolution owns the chain, and a transfer is a hand-off rather than a call: once root_agent transferred it was out of the turn, so nothing was left to ask the second specialist. Look at the last line. dex noticed the gap and could not do anything about it.

The two disallow_transfer flags from Step 9 are what make that stick, and disallow_transfer_to_parent is the one doing the work here. Take it off and dex hands the turn back, root_agent routes it a second time, and the question gets answered in two hops. That looks like a fix and is not one: the router is re-deciding in the middle of a turn, nobody is composing an answer out of both halves, and how many hops you get stops being something you control. Step 11 is the version you pick on purpose.

Step 11 of 11: Coordinator

Hold each specialist a second way. sub_agents does not change, so everything that already worked keeps working. Adding the same three agents as AgentTools gives the root agent a way to ask a specialist a question and get the answer back, instead of handing the conversation over and being done. Northwell's Polaris agent shipped this change on 2026-08-28, for the same kind of question you just watched come up short.

Edit app/agent.py:

1# at the top of app/agent.py, with the other imports:
2from google.adk.tools.agent_tool import AgentTool
3
4# replace ROUTER_INSTRUCTION with this one, name and all:
5COORDINATOR_INSTRUCTION = """
6You are a Pokedex. You do not look anything up yourself. Each specialist owns
7one kind of question.
8
9- What a Pokemon is: types, height, weight, abilities, base stats -> dex
10- What it evolves from or into -> evolution
11- Two or more Pokemon side by side -> compare
12
13Count the owners before you do anything else.
14
15ONE owner: transfer to it.
16Call transfer_to_agent. The specialist's answer goes to the user as it is, and
17you write nothing of your own before or after it.
18
19TWO owners: call them as tools, then write one answer.
20The specialist tools exist for a request whose parts belong to two different
21specialists. Call both, in the order the question asks, then write a single
22reply from what they returned. A turn in which you call exactly one specialist
23tool is always wrong: that request had one owner and should have transferred.
24
25A specialist called as a tool cannot see this conversation, so put everything
26it needs in the request you pass it. "What does it evolve into" is not a
27question anyone can answer; "What does Magikarp evolve into" is.
28
29Which Pokemon is best, what beats what, what to put on a team, and what to use
30against an opponent are not in the entry. You do not answer those, you do not
31transfer them, and you do not call a tool for them.
32""".strip()
33
34# and give root_agent the same three specialists a second way:
35root_agent = Agent(
36    name="root_agent",
37    model=Gemini(
38        model=MODEL,
39        retry_options=types.HttpRetryOptions(attempts=3),
40    ),
41    instruction=COORDINATOR_INSTRUCTION,
42    # Both of ADK's multi-agent patterns, used together.
43    #
44    # sub_agents is the coordinator/dispatcher pattern. A transfer hands the
45    # turn over, and the specialist's answer reaches the user unchanged. This
46    # is still the path for every request one specialist owns.
47    #
48    # tools is the agent-as-a-tool pattern. An AgentTool runs the specialist
49    # in its own runner and returns its final answer as a tool result, so the
50    # root agent is still holding the turn afterwards. It can ask a second
51    # specialist and then write the reply itself. A transfer can never do
52    # that, because it is a hand-off and not a call, so nothing is left to
53    # take a second step.
54    sub_agents=[dex_agent, evolution_agent, compare_agent],
55    tools=[
56        AgentTool(agent=dex_agent),
57        AgentTool(agent=evolution_agent),
58        AgentTool(agent=compare_agent),
59    ],
60)

The same three specialists, held two ways. sub_agents is unchanged, so everything that already worked keeps working the same way. tools is the new part.

AgentTool wraps an agent so the model can call it like any other function. It runs the specialist in its own runner and returns the specialist's final answer as a tool result. The root agent is still holding the turn when that answer comes back, so it can ask a second specialist and then write the reply itself.

agents-cli run "What type is Magikarp, and what does it evolve into?"

In the playground, paste just the question:

What type is Magikarp, and what does it evolve into?

You should see:

1[user]: What type is Magikarp, and what does it evolve into?
2[root_agent]:
3[tool_call: dex({"request": "What type is Magikarp?"})]
4[tool_call: evolution({"request": "What does Magikarp evolve into?"})]
5[tool_response: dex -> {"result": "Magikarp is a Water-type Pokemon."}]
6[tool_response: evolution -> {"result": "Magikarp evolves into Gyarados."}]
7
8Magikarp is a Water-type Pokemon, and it evolves into Gyarados.

The same question that failed a minute ago. No transfer this time. Two tool calls, two answers back, and root_agent wrote the reply from both.

Look at what it passed each specialist. It sent "What type is Magikarp?", not "the first half". A specialist called as a tool runs in a fresh session and cannot see this conversation, so the request has to carry the name.

agents-cli run "What does Turtonator evolve into?"

In the playground, paste just the question:

What does Turtonator evolve into?

You should see:

1[user]: What does Turtonator evolve into?
2[root_agent]:
3[tool_call: transfer_to_agent({"agent_name": "evolution"})]
4[tool_response: transfer_to_agent -> {"result": null}]
5[evolution]:
6[tool_call: get_pokemon({"name": "turtonator"})]
7[tool_response: get_pokemon -> {"name": "turtonator", "types": ["fire", "dragon"], ..., "species_url": "https://pokeapi.co/api/v2/pokemon-species/776/"}]
8[tool_call: get_evolution_chain({"species_url": "https://pokeapi.co/api/v2/pokemon-species/776/"})]
9[tool_response: get_evolution_chain -> {"chain_id": 402, "chain": {"name": "turtonator", "evolves_to": []}}]
10
11Turtonator does not evolve into anything. It has no evolutions.

Adding the tools did not replace the transfer. A question with one owner still transfers, and it should. On that path the specialist's answer reaches you exactly as it wrote it, with nothing in between to reword it.

That is why the instruction says a turn that calls exactly one specialist tool is always wrong. Without that line the model reaches for the tools every time, which costs an extra model call and puts a rewrite in front of every answer.


That's the whole session.

Before you start Prerequisites Workshop steps Scaffold Talk to what you already have get_pokemon get_evolution_chain compare_pokemon Instruction NoStrategyGuard Testing Split into specialists Where the router fails Coordinator