Workshop · 2026 v0.2
agentworkshop.dev

Build production agents with Google ADK.

An interactive workshop in seven modules.
agentworkshop.dev 01 / 41
An application that attempts to achieve a goal by observing the world and acting upon it using an LLM and the tools at its disposal.
Google · Agents (whitepaper)
agentworkshop.dev 02 / 41
What we'll do today

ADK covers the entire agent lifecycle.

🎯
Define
goal
✏️
Design
agent
🔧
Build
tools
⚙️
Configure
▶️
Run &
debug
📊
Evaluate
🚀
Deploy
agentworkshop.dev 03 / 41
Why ADK

Five reasons it earns its place.

🧰
Easy interface
Code-first APIs that read like Python you'd write anyway.
🧩
Modular & flexible
Compose agents, swap models, mix tools — without rewrites.
⚡
Instant local testing
make playground spins up an inspector. No deploy, no wait.
🎬
Native multimodal
Text, images, audio, video — first-class through Gemini.
🚀
Deployment-ready
Cloud Run, Agent Engine, or your own container — same code.
agentworkshop.dev 04 / 41
Module 01

Foundation.

An LlmAgent, in a loop, with tools.
agentworkshop.dev 05 / 41
1.0 — What an agent IS

An agent is a loop. The loop is short.

  • One call is a chat completion: the model answers, you're done.
  • A loop is an agent: the model can call a tool, see the result, and decide what to do next.
  • Bounded by max_steps. Stops when the model decides it's done.
agentworkshop.dev 06 / 41
agent.py python
# The shape of an agent — about 30 lines, no framework.
def run(client, system, user, tools, max_steps=8):
    messages = [
        {"role": "system", "content": system},
        {"role": "user",   "content": user},
    ]
    for step in range(max_steps):
        reply = client.messages.create(
            model="gemini-3.7-flash",
            messages=messages, tools=tools,
        )
        if reply.stop_reason == "end_turn":
            return reply.content
        # Otherwise: the model asked to use a tool.
        results = [dispatch(c) for c in reply.content
                   if c.type == "tool_use"]
        messages.append({"role": "assistant", "content": reply.content})
        messages.append({"role": "user",      "content": results})
    raise StepLimitExceeded(max_steps)
1.1 — The primitive

An LlmAgent is a model, an instruction, and tools.

  • name — identifies the agent in the event stream
  • model — any Gemini ID, or a LiteLlm wrapper for non-Google models
  • instruction — the system prompt
  • tools — Python functions the model can call
agentworkshop.dev 07 / 41
agent.py python
from google.adk.agents import LlmAgent

root_agent = LlmAgent(
    name="root_agent",
    model="gemini-3.7-flash",
    instruction="You are a helpful AI assistant.",
    tools=[get_current_server_time, get_weather],
)
1.2 — Three families

Three flavors of agent. Most code is LlmAgent.

Agent typeMain componentPrimary functionWhen to use
LlmAgent An LLM (e.g. Gemini) Reasoning + acting via tools Chatbots, routers, anything that needs to choose
WorkflowAgent A pre-defined graph Deterministic orchestration Pipelines, fan-out/in, loops with exit conditions
CustomAgent Your Python code Anything the above can't express Conditional branching, integrations, escape hatch
agentworkshop.dev 08 / 41
1.3 — Models

Pass any Gemini ID — or any model via a LiteLlm wrapper.

  • Direct string — the simple path for Vertex-hosted Gemini.
  • Wrapper — point at Ollama, OpenAI, Anthropic, or anything LiteLlm knows about.
  • Same LlmAgent either way. The agent doesn't care which model is behind it.
agentworkshop.dev 09 / 41
agent.py python
from google.adk.agents import LlmAgent
from google.adk.models.lite_llm import LiteLlm

# Option 1 — direct string (Gemini via Vertex AI):
assistant = LlmAgent(
    name="personal_assistant",
    model="gemini-3.7-flash",
    instruction="You are a helpful personal assistant.",
)

# Option 2 — wrapper class (any LiteLlm-supported model):
local_assistant = LlmAgent(
    name="local_assistant",
    model=LiteLlm(model="ollama/llama3"),
    instruction="You are a local assistant.",
)
1.4 — Tools

Tools are Python functions the model can call.

  • Type hints become the JSON schema.
  • Docstring becomes the tool description.
  • No decorator required — just pass the function in.
  • The model decides when to call.
agentworkshop.dev 10 / 41
tools.py python
import random

def get_weather(city: str) -> str:
    """Get the current weather conditions for a given city.

    Args:
        city: The city to get the weather conditions for.

    Returns:
        A string with the simulated weather conditions.
    """
    temperature = random.randint(-10, 40)
    conditions = random.choice(["sunny", "cloudy", "rainy", "snowy"])
    return f"The weather in {city} is {temperature}°C and {conditions}."
1.5 — The full menu

Six ways to give an agent capabilities.

Function tools
Plain Python you write. Type hints + docstring become the schema.
Built-in tools
google_search, code_executor, BigQuery — shipped with ADK.
OpenAPI tools
Point at any OpenAPI spec; ADK generates the tool layer.
Third-party tools
Langchain, CrewAI, others — wrappable via the tool interface.
MCP tools
Model Context Protocol servers — both as client and server.
Google Cloud tools
Vertex AI Vector Search, Application Integration, more.
agentworkshop.dev 11 / 41
1.6 — Built-in tools

google_search, code_executor, bigquery — no setup.

  • Import, drop into tools=[…], ship.
  • Each grounded LLM response carries groundingMetadata (verified URLs + Search Suggestion chip).
  • Module 03 will harvest that metadata for trustworthy citations.
agentworkshop.dev 12 / 41
agent.py python
from google.adk.agents import LlmAgent
from google.adk.tools import (
    google_search, code_executor, bigquery
)

researcher = LlmAgent(
    name="research_assistant",
    model="gemini-3.7-flash",
    instruction="Research questions thoroughly.",
    tools=[google_search, code_executor, bigquery],
)
# Caveats: only one built-in per agent; most need Gemini 2.0+;
# not allowed in sub-agents.
1.7 — Putting it together

Pass functions into tools=[…]. The model picks.

  • No registration boilerplate.
  • Selection happens inside the LLM, not in your code.
  • Tools can be sync or async.
  • Add or remove tools by editing the list.
LlmAgent · personal_assistant
"What's the time?" or "Do I have meetings tomorrow?"
get_current_time()
check_calendar(date)
Fig 1.7 Tool selection happens in the model, not the framework.
agentworkshop.dev 13 / 41
An agent's brain is a model. Its hands are tools. ADK lets you swap the brain and equip the hands — flexibly.
Module 01 · Foundation takeaways
agentworkshop.dev 14 / 41
Module 02

Agent orchestration.

From one agent to a graph of agents.
agentworkshop.dev 15 / 41
2.1 — Beyond a single agent

A Workflow is a graph of nodes and edges.

  • Nodes are LlmAgent instances or @node Python functions.
  • Edges define data flow.
  • START is the entry token.
  • No outer conversational agent needed — the graph IS the orchestration.
START
planner
researcher
compiler
Fig 2.1 The newsroom pipeline as a Workflow graph.
agentworkshop.dev 16 / 41
2.2 — The pipeline

Three nodes. Three edges. One root agent.

agent.py python
from google.adk.workflow import Workflow
from google.adk.apps import App

root_agent = Workflow(
    name="news_workflow",
    edges=[
        ("START", planner_agent),
        (planner_agent, research_agent),
        (research_agent, compiler_agent),
    ],
)

app = App(root_agent=root_agent, name="app")
agentworkshop.dev 17 / 41
2.3 — How agents compose

Agents contain agents. Tools equip agents.

Root agent
Child agent 1
Child agent 2
tool_a
tool_b
tool_c
agentworkshop.dev 18 / 41
2.4 — Grounded research

Built-in tools ground LLM output in real-world data.

  • ADK ships google_search as a built-in.
  • Each grounded LLM response carries groundingMetadata — verified URLs and a Search Suggestion chip.
  • Module 03 will harvest that metadata.
agentworkshop.dev 19 / 41
agent.py python
from google.adk.agents import LlmAgent
from google.adk.tools import google_search

research_agent = LlmAgent(
    name="research_agent",
    model=model,
    instruction="You are a news researcher. Run a Google "
                "Search for each query in the SearchPlan. "
                "Return a compilation of the facts you find.",
    tools=[google_search],
)
2.5 — Schemas between nodes

Each node returns a Pydantic model. The next node receives it as node_input.

  • No output_key plumbing for intermediates.
  • No string parsing in downstream nodes.
  • ADK enforces the schema at LLM-output time.
agentworkshop.dev 20 / 41
agent.py python
from pydantic import BaseModel, Field
from google.adk.agents import LlmAgent

class SearchPlan(BaseModel):
    queries: list[str] = Field(
        description="2-3 specific Google Search queries."
    )

planner_agent = LlmAgent(
    name="planner_agent",
    model=model,
    instruction="...",
    output_schema=SearchPlan,
)
2.6 — End-to-end

Three agents. Two data hand-offs. No glue code.

START
topic
planner
SearchPlan
researcher
raw text
compiler
newspaper
END
agentworkshop.dev 21 / 41
Module 03

Dynamic parallelism and grounded citations.

The hardest workshop module. Also the most useful in production.
agentworkshop.dev 22 / 41
3.0 — A tour of design patterns

Six canonical patterns for multi-agent systems.

🍳
Chef
Sequential pipeline
🛒
Shopping
Parallel fan-out
✏️
Writer & Editor
Reflection / loop
🍴
Startup Crew
Coordinator–Dispatcher
🏗
Construction
Hierarchical decomposition
📚
Book Publishing
Human-in-the-loop
agentworkshop.dev 23 / 41
3.1 — Why static parallelism doesn't scale

You don't know how many workers you need until runtime.

  • Planner decides topic count per request.
  • Static parallelism (1.x ParallelAgent) requires N at construction time.
  • Dynamic parallelism spawns workers per request.
Static
Hard-coded N — brittle.
[ planner → [w1, w2, w3] → compiler ]
N decided at construction. Same shape every request, even if the planner says otherwise.
Dynamic
N at runtime — fits the request.
[ planner → fan-out(N) → compiler ]
N comes from the planner's structured output. The orchestrator spawns exactly that many workers.
agentworkshop.dev 24 / 41
3.2 — The dynamic-parallel pattern

One function. asyncio.gather over ctx.run_node.

agent.py python
import asyncio
from google.adk.workflow import node

@node(rerun_on_resume=True)
async def research_orchestrator(ctx, node_input: dict) -> list:
    topics = node_input.get("topics", [])

    async def research_one(topic: str) -> dict:
        start = len(ctx.session.events)
        article = await ctx.run_node(researcher_agent, node_input=topic)
        end = len(ctx.session.events)
        citations, rendered = extract_citations_from_events(
            ctx.session.events[start:end]
        )
        if isinstance(article, dict):
            article["citations"] = citations
            if rendered:
                article["search_entry_point_html"] = rendered
        return article

    tasks = [research_one(t) for t in topics]
    return await asyncio.gather(*tasks)
agentworkshop.dev 25 / 41
3.3 — Citations live inside the schema

Schemas describe the shape of data flowing between nodes.

  • TopicPlan — planner's output.
  • Article — each researcher's output (carries citations).
  • NewspaperPage — compiler's final output.
  • The webapp reads the final shape directly.
agentworkshop.dev 26 / 41
schemas.py python
from pydantic import BaseModel, Field
from typing import Optional

class Citation(BaseModel):
    title: str
    url: str

class ArticleDraft(BaseModel):
    title: str
    teaser: str
    content: str

class Article(ArticleDraft):
    citations: list[Citation] = Field(default_factory=list)
    search_entry_point_html: Optional[str] = None

class NewspaperPage(BaseModel):
    articles: list[Article]

class TopicPlan(BaseModel):
    topics: list[str]
Don't trust the LLM with URLs.
Module 03 · The Big Lesson
agentworkshop.dev 27 / 41
3.4 — Verified URLs from grounding metadata

Slice session.events around each ctx.run_node call. Pull URLs from groundingMetadata.

  • ADK surfaces grounding metadata on each event.
  • The orchestrator (not the researcher) does the extraction.
  • Compiler is instructed to preserve verbatim.
  • Search Suggestion chip HTML is required by Google's grounding terms.
agentworkshop.dev 28 / 41
orchestrator.py python
async def research_one(topic: str) -> dict:
    start = len(ctx.session.events)
    article = await ctx.run_node(researcher_agent, node_input=topic)
    end = len(ctx.session.events)

    citations, rendered = extract_citations_from_events(
        ctx.session.events[start:end]
    )
    article["citations"] = citations
    article["search_entry_point_html"] = rendered
    return article
3.5 — Compliance

Render the Search Suggestion chip verbatim.

  • Display alongside the grounded response — no font, color, or styling changes.
  • HTML lives in groundingMetadata.searchEntryPoint.renderedContent.
  • Source citations link to the source webpage.
  • Required by Google Search grounding terms.
Search "agentic AI 2026"
Rules
  • Render the chip's HTML verbatim — preserve its inline styles.
  • Display it adjacent to the grounded response, not hidden.
  • Link each citation to its real source URL.
agentworkshop.dev 29 / 41
Module 04

Vector search and routing.

Two workflows. One app. Conditional routes.
agentworkshop.dev 30 / 41
4.1 — Vector Search

Add a Vertex AI Vector Search archive — as its own workflow.

  • Single-node Workflow is fine.
  • output_key="compiled_news" is load-bearing — webapp reads from session.state.
  • Same NewspaperPage schema as the news flow → same UI components.
agentworkshop.dev 31 / 41
agent.py python
from google.adk.agents import LlmAgent
from google.adk.workflow import Workflow
from utils.vector_store import search_archive

def search_news_archive(query: str, top_k: int = 5) -> list:
    return search_archive(query, top_k)

archive_reader_agent = LlmAgent(
    name="archive_reader_agent",
    model=model,
    instruction="You are an archival librarian. "
                "Always search the archive first.",
    tools=[search_news_archive],
    output_schema=NewspaperPage,
    output_key="compiled_news",
)

archive_workflow = Workflow(
    name="archive_workflow",
    edges=[("START", archive_reader_agent)],
)
4.2 — In-graph branching

Route between paths with a RoutingMap dict.

  • RoutingMap dict is the supported form.
  • (source, target, "route") 3-tuples are NOT supported (cheatsheet errata).
  • App-level vs agent-level routing is a real trade-off, not a default.
agentworkshop.dev 32 / 41
agent.py python
from google.adk.events.event import Event
from google.adk.workflow import Workflow, node

@node
def router(node_input):
    text = _user_text(node_input).lower()
    if "archive" in text or "past" in text:
        return Event(output=node_input, route="archive")
    return Event(output=node_input, route="news")

root_agent = Workflow(
    name="news_workflow",
    edges=[
        ("START", router),
        (router, {"news": planner_agent,
                  "archive": archive_reader_agent}),
        (planner_agent, research_orchestrator),
        (research_orchestrator, compiler_agent),
    ],
)
4.3 — Routing inside the agent layer

When intent must come from the prompt, let the LLM route.

  • A central LlmAgent analyzes intent.
  • It transfers to a specialist sub-agent via transfer_to_agent.
  • Each specialist has focused instructions and tools.
HelpDeskCoordinator (LlmAgent)
Billing (LlmAgent)
Support (LlmAgent)
agentworkshop.dev 33 / 41
4.4 — Beyond ADK Web

Wrap a workflow in a Runner. Stream events. Read final state.

  • Runner is per-session and per-workflow.
  • Events stream as Server-Sent Events.
  • Final result reads from session.state.get("compiled_news").
  • output_key is what makes that read non-empty.
Browser
Flask /stream
Runner
Workflow
agentworkshop.dev 34 / 41
4.5 — A trade-off, not a default

When the UI knows the intent, route in the app. When the prompt does, route in the LLM.

  • App-level: deterministic, testable, one button = one path.
  • Agent-level: handles free-form intent (chat-style).
  • LLM-based router adds latency before either branch starts.
App-level
UI knows the intent.
2 buttons → ?mode=… → app picks workflow
Deterministic. Each branch independently testable. Best for known UI flows.
Agent-level
Prompt knows the intent.
LlmAgent → transfer_to_agent → specialist
Handles free-form text. Costs an LLM call before the work begins. Best for chat.
agentworkshop.dev 35 / 41
4.6 — Telemetry, logs, feedback

OpenTelemetry → Cloud Trace. log_event(...) to Cloud Logging. /feedback for the eval loop.

  • Set LOGS_BUCKET_NAME and traces ship to Cloud Trace + GCS.
  • log_event(...) writes structured logs in prod, stdout locally.
  • 👍 / 👎 feedback POSTs through the same path → eval ground truth.
agentworkshop.dev 36 / 41
webapp/app.py python
from app.app_utils.telemetry import setup_telemetry
from app.app_utils.logging import log_event

setup_telemetry()  # no-op locally; ships to Cloud Trace in prod

# After each workflow run:
log_event({
    "event": "workflow_completed",
    "mode": mode,
    "session_id": session_id,
    "article_count": len(compiled_data["articles"]),
})

@app.route("/feedback", methods=["POST"])
def feedback():
    f = Feedback.model_validate(request.json)
    log_event(f.model_dump(), severity="INFO")
    return {"ok": True}
4.7 — Bonus: a conversational front door

Put a chat agent in front of a Workflow.

  • A Workflow is not an Agent — sub_agents=[wf] and AgentTool(wf) both reject it (its base is BaseNode, not BaseAgent).
  • Expose the pipeline as a function tool that runs it through a Runner — the same embedded-Runner trick the webapp uses.
  • Now a chat LlmAgent decides when to fire the whole newsroom.
  • Trade-off: the nested Runner gets its own session, so the inner run's events don't show in the outer trace.
agentworkshop.dev 37 / 41
agent.py python
# A Workflow is NOT an Agent — expose it as a function tool
# that runs it through a Runner.
async def run_news_pipeline(topic: str) -> dict:
    """Research the news on a topic; return a NewspaperPage."""
    runner = Runner(
        app_name="news_tool",
        agent=news_workflow,        # Runner duck-types the Workflow
        session_service=InMemorySessionService(),
    )
    # ... drain runner.run_async, then read session state:
    return session.state.get("compiled_news", {})

concierge_agent = LlmAgent(
    name="concierge",
    model=model,
    instruction="Chat normally. Call run_news_pipeline when "
                "the user asks for news on a topic.",
    tools=[run_news_pipeline],      # auto-wrapped as a FunctionTool
)
After today

Three places to put a finished agent.

Vertex AI Agent Engine
Managed lifecycle. Sessions, traces, scaling — handled.
Cloud Run
Your container, Google's runtime. Pay per request.
Custom infrastructure
GKE, Docker host, on-prem. Same agent, your platform.
agentworkshop.dev 38 / 41
The bigger picture

ADK is one layer. The others extend it.

🤖
Agent Development Kit
Open-source, code-first toolkit for building, evaluating, and deploying AI agents.
🔌
Model Context Protocol
Open protocol that standardizes how applications provide context to LLMs.
⚙️
Vertex AI Agent Engine
Managed platform to deploy, manage, and scale AI agents in production.
🤝
Agent2Agent (A2A) protocol
Open standard for communication and collaboration between AI agents.
✦
Agentspace
Enterprise platform giving employees access to a curated set of agents.
agentworkshop.dev 39 / 41
An LlmAgent with tools. A Workflow with edges. Pydantic between nodes. Grounding metadata for citations. Two workflows for routing. That's the curriculum.
agentworkshop.dev
agentworkshop.dev 40 / 41
Start now

agentworkshop.dev

Module 01: 20 minutes. Bring a Google Cloud project.
agentworkshop.dev 41 / 41