Jul 10, 2026~25 min readAI Agents

Building AI Agents with Google ADK — A Practical Guide

From zero to a working multi-tool agent. Environment setup, tool creation, agent orchestration, callbacks, session management, and multi-agent systems — with real code you can run today.

What You'll Learn

  1. 1. What is Google ADK?
  2. 2. Environment Setup & Your First Agent
  3. 3. Building Custom Tools
  4. 4. Walkthrough: A Research Assistant Agent
  5. 5. Callbacks — Intercepting Agent Behavior
  6. 6. Multi-Agent Systems
  7. 7. Session & Memory Management
  8. 8. Deployment Options
  9. 9. Gotchas & Lessons Learned

I spent the last few weeks building agents with Google's Agent Development Kit (ADK), and I wanted to write the guide I wish I had when I started. This isn't a "hello world" — we'll build a real, useful agent with multiple tools, callbacks, and multi-agent orchestration.

Prerequisites: You should be comfortable with Python, have a basic understanding of what LLMs are, and have about 2 hours to follow along. No prior agent-building experience needed.

1. What is Google ADK?

The Agent Development Kit (ADK) is Google's open-source, code-first Python framework for building AI agents. Think of it as a structured way to give an LLM (like Gemini) the ability to use tools, follow instructions, manage state, and collaborate with other agents.

The mental model is simple:

Why ADK Over LangChain / CrewAI / AutoGen?

Fair question. Here's how I think about it:

Aspect ADK LangChain CrewAI
Philosophy Code-first, explicit Abstraction-heavy Role-play metaphor
Debugging Built-in web UI, clear traces LangSmith (separate) Limited
Multi-agent Native (Sequential, Loop, Parallel) LangGraph add-on Native (Crew)
Model support Gemini native + adapters for others Broad Broad
Production path Google Cloud / Vertex AI Self-managed Self-managed
Learning curve Moderate Steep Gentle

ADK's killer feature is the developer experience. The built-in adk web UI lets you test agents interactively, see every tool call, inspect the LLM's reasoning, and debug in real-time. That alone saves hours.

💡

Key insight: ADK is not just for Gemini. You can use adapters to connect OpenAI, Anthropic, or any model that supports function calling. But the Gemini integration is the smoothest out of the box.

2. Environment Setup & Your First Agent

1 Install ADK

You need Python 3.10+. Create a virtual environment and install:

# Create project directory and virtual environment
mkdir my-adk-project && cd my-adk-project
python3 -m venv .venv
source .venv/bin/activate

# Install ADK
pip install google-adkterminal

2 Get a Gemini API Key

Go to Google AI Studio and create an API key. It's free for the tier we need.

3 Scaffold Your First Agent

ADK provides a CLI to bootstrap projects:

adk create my_agentterminal

This creates a clean project structure:

my_agent/
  __init__.py  ← exports the root agent
  agent.py   ← your agent definition
  .env      ← API key goes here

4 Configure Your API Key

Add your key to the .env file:

# .env
GOOGLE_GENAI_USE_VERTEXAI=FALSE
GOOGLE_API_KEY=your-api-key-here.env
⚠️

Never commit your .env file. Add it to .gitignore immediately. If you plan to deploy on Google Cloud, you'll switch GOOGLE_GENAI_USE_VERTEXAI to TRUE and use service account auth instead.

5 Write Your First Agent

Open my_agent/agent.py and replace the contents:

from google.adk.agents import Agent

def get_current_time(timezone: str) -> dict:
    """Get the current time in the specified timezone.

    Args:
        timezone: The timezone to get the time for (e.g., 'Asia/Dubai',
                  'US/Eastern', 'Europe/London').

    Returns:
        A dictionary with the current time.
    """
    from datetime import datetime
    from zoneinfo import ZoneInfo

    try:
        tz = ZoneInfo(timezone)
        now = datetime.now(tz)
        return {
            "status": "success",
            "time": now.strftime("%I:%M %p"),
            "date": now.strftime("%B %d, %Y"),
            "timezone": timezone,
        }
    except Exception as e:
        return {"status": "error", "message": str(e)}


root_agent = Agent(
    name="time_agent",
    model="gemini-2.0-flash",
    description="An agent that tells the current time in any timezone.",
    instruction="""You are a helpful assistant that tells the current time.
When a user asks for the time, use the get_current_time tool.
If they don't specify a timezone, default to Asia/Dubai.
Always respond in a friendly, concise way.""",
    tools=[get_current_time],
)agent.py

And export it from __init__.py:

from .agent import root_agent__init__.py

6 Run It

ADK gives you two ways to test:

# Option 1: Interactive CLI
adk run my_agent

# Option 2: Web UI (recommended — much better for debugging)
adk webterminal

Open http://localhost:8000 in your browser. You'll see a chat interface where you can talk to your agent, see tool calls in real-time, and inspect the full execution trace.

Try asking: "What time is it in Tokyo?"

You should see the agent receive your message, decide to call get_current_time with timezone="Asia/Tokyo", get the result, and format a friendly response. That's the full agent loop — perception → reasoning → action → response.

Pro tip: The adk web UI shows a timeline of every step — LLM calls, tool invocations, token counts, latency. Use it heavily during development. It's the best debugging tool ADK offers.

3. Building Custom Tools

Tools are the most important concept in ADK. An agent without tools is just a chatbot. Tools give it the ability to do things — fetch data, calculate, write files, call APIs.

The Anatomy of a Tool

A tool in ADK is just a Python function with type hints and a docstring:

def calculate_compound_interest(
    principal: float,
    annual_rate: float,
    years: int,
    compounds_per_year: int = 12,
) -> dict:
    """Calculate compound interest on an investment.

    Args:
        principal: The initial investment amount in dollars.
        annual_rate: The annual interest rate as a decimal (e.g., 0.05 for 5%).
        years: The number of years to calculate for.
        compounds_per_year: How many times interest compounds per year.
                            Defaults to 12 (monthly).

    Returns:
        A dictionary with the final amount and total interest earned.
    """
    amount = principal * (1 + annual_rate / compounds_per_year) ** (
        compounds_per_year * years
    )
    interest = amount - principal
    return {
        "principal": round(principal, 2),
        "final_amount": round(amount, 2),
        "interest_earned": round(interest, 2),
        "effective_rate": round((amount / principal - 1) * 100, 2),
    }python

The docstring is critical. ADK extracts the function name, description, and parameter descriptions to build the tool schema that the LLM sees. Write clear, specific docstrings — they directly affect how well the LLM uses your tool.

Rules for Good Tools

  1. Use type hints on every parameter. ADK needs them to generate the JSON schema.
  2. Return dicts, not strings. Structured output gives the LLM more to work with.
  3. Keep tools focused. One tool = one action. Don't build a Swiss Army knife.
  4. Handle errors gracefully. Return {"status": "error", "message": "..."} instead of raising exceptions.
  5. Write descriptive docstrings. The LLM reads them to decide when and how to use the tool.

Using ToolContext for State

Sometimes a tool needs access to the conversation state — maybe to remember previous results or to pass data between tool calls. ADK provides ToolContext for this:

from google.adk.tools import ToolContext

def save_note(
    title: str,
    content: str,
    tool_context: ToolContext,
) -> dict:
    """Save a note to the session state.

    Args:
        title: The title of the note.
        content: The note content.
        tool_context: Injected automatically by ADK.

    Returns:
        Confirmation of the saved note.
    """
    # Read existing notes from state
    notes = tool_context.state.get("notes", [])
    notes.append({"title": title, "content": content})

    # Write back to state
    tool_context.state["notes"] = notes

    return {"status": "saved", "total_notes": len(notes)}python
⚠️

Critical gotcha: The parameter must be named exactly tool_context (type ToolContext). ADK passes callback arguments by keyword name. If you rename it to ctx or anything else, you'll get a TypeError at runtime. This applies to all callback parameters in ADK.

4. Walkthrough: A Research Assistant Agent

Let's build something more useful — a Research Assistant that can search the web, summarize content, and save findings. This is a practical pattern you'll reuse across projects.

Project Structure

research_assistant/
  __init__.py
  agent.py
  tools.py
  .env

Step 1: Define the Tools

# research_assistant/tools.py

import json
from urllib.request import urlopen, Request
from urllib.parse import quote_plus
from google.adk.tools import ToolContext


def web_search(query: str) -> dict:
    """Search the web for information on a topic.

    Args:
        query: The search query string.

    Returns:
        A dictionary with search results including titles and snippets.
    """
    # Using a free search API for demo purposes
    # In production, use Google Custom Search API or SerpAPI
    url = f"https://api.duckduckgo.com/?q={quote_plus(query)}&format=json"
    try:
        req = Request(url, headers={"User-Agent": "ResearchBot/1.0"})
        with urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read())

        results = []
        for topic in data.get("RelatedTopics", [])[:5]:
            if "Text" in topic:
                results.append({
                    "text": topic["Text"],
                    "url": topic.get("FirstURL", ""),
                })

        return {
            "status": "success",
            "query": query,
            "results": results,
            "abstract": data.get("Abstract", ""),
        }
    except Exception as e:
        return {"status": "error", "message": str(e)}


def save_finding(
    topic: str,
    summary: str,
    source: str,
    confidence: str,
    tool_context: ToolContext,
) -> dict:
    """Save a research finding to the session.

    Args:
        topic: The topic or title of the finding.
        summary: A concise summary of what was found.
        source: Where this information came from.
        confidence: How confident we are: 'high', 'medium', or 'low'.
        tool_context: Injected automatically by ADK.

    Returns:
        Confirmation with the total number of saved findings.
    """
    findings = tool_context.state.get("findings", [])
    findings.append({
        "topic": topic,
        "summary": summary,
        "source": source,
        "confidence": confidence,
    })
    tool_context.state["findings"] = findings
    return {
        "status": "saved",
        "finding_number": len(findings),
        "topic": topic,
    }


def get_findings(tool_context: ToolContext) -> dict:
    """Retrieve all saved research findings from this session.

    Args:
        tool_context: Injected automatically by ADK.

    Returns:
        All findings saved so far in this research session.
    """
    findings = tool_context.state.get("findings", [])
    return {
        "total": len(findings),
        "findings": findings,
    }tools.py

Step 2: Define the Agent

# research_assistant/agent.py

from google.adk.agents import Agent
from .tools import web_search, save_finding, get_findings

root_agent = Agent(
    name="research_assistant",
    model="gemini-2.0-flash",
    description="A research assistant that searches the web, "
                 "analyzes information, and saves structured findings.",
    instruction="""You are a thorough research assistant. Your job is to help
users research topics by searching the web and organizing findings.

## How you work:

1. When the user asks you to research something, use `web_search` to find
   relevant information. Search multiple angles — don't stop at one query.

2. For each useful piece of information you find, use `save_finding` to
   store it with a clear summary, source, and confidence level.

3. When the user asks for a summary or report, use `get_findings` to
   retrieve everything you've saved, then synthesize it into a clear,
   structured response.

## Guidelines:
- Always cite your sources
- Be honest about confidence levels — if the data is thin, say so
- Suggest follow-up questions the user might want to explore
- Don't make up information — only report what you find""",
    tools=[web_search, save_finding, get_findings],
)agent.py

Step 3: Export and Run

# research_assistant/__init__.py

from .agent import root_agent__init__.py
# Run with the web UI
adk webterminal

Now try: "Research the current state of AI agents in enterprise — what are companies actually using in production?"

Watch the agent make multiple search queries, evaluate the results, save findings with confidence levels, and then synthesize a report. This is a real agent workflow, not just a single LLM call.

5. Callbacks — Intercepting Agent Behavior

Callbacks are ADK's hook system. They let you intercept and modify behavior at key points during execution. Think of them as middleware for your agent.

Three Types of Callbacks

Callback When it fires Use case
before_model_callback Before the LLM is called Caching, request validation, logging
after_model_callback After the LLM responds Response filtering, metrics
before_tool_callback Before a tool is executed Auth checks, rate limiting, audit
after_tool_callback After a tool finishes Result validation, logging

Example: Rate Limiting Tool Calls

Let's add a callback that prevents the agent from making more than 5 search calls per session (useful for controlling API costs):

from google.adk.agents import Agent
from google.genai import types as genai_types

def rate_limit_searches(
    callback_context, tool, args, tool_context
):
    """Limit web searches to 5 per session."""
    if tool.name != "web_search":
        return None  # let other tools pass through

    count = tool_context.state.get("search_count", 0)
    if count >= 5:
        # Return a result directly — the tool is never called
        return {
            "status": "rate_limited",
            "message": "Search limit reached (5/5). "
                       "Please summarize what you have.",
        }

    tool_context.state["search_count"] = count + 1
    return None  # proceed with the tool call


root_agent = Agent(
    name="research_assistant",
    model="gemini-2.0-flash",
    instruction="...",
    tools=[web_search, save_finding, get_findings],
    before_tool_callback=rate_limit_searches,  # ← add the callback
)python

Example: Blocking Unsafe Requests

A before_model_callback can intercept the request before it reaches the LLM. If it returns a response, the LLM is never called:

from google.genai import types as genai_types

def block_pii_requests(callback_context, llm_request):
    """Block requests that ask for personal information."""
    # Check the latest user message
    last_msg = llm_request.contents[-1].parts[0].text.lower()

    pii_keywords = ["social security", "credit card", "password"]
    if any(kw in last_msg for kw in pii_keywords):
        # Return a canned response — LLM is bypassed entirely
        return genai_types.GenerateContentResponse(
            candidates=[genai_types.Candidate(
                content=genai_types.Content(
                    parts=[genai_types.Part(
                        text="I can't help with requests "
                             "involving personal information."
                    )]
                )
            )]
        )
    return None  # proceed normally


root_agent = Agent(
    ...
    before_model_callback=block_pii_requests,
)python

Pattern: Return None from a callback to let the normal flow continue. Return a value to short-circuit — the original action is skipped and your return value is used instead.

6. Multi-Agent Systems

This is where ADK gets really powerful. Instead of one agent doing everything, you can compose multiple specialized agents into workflows. ADK provides three built-in orchestration patterns:

SequentialAgent

Runs agents one after another, passing state between them. Perfect for pipelines.

from google.adk.agents import Agent, SequentialAgent

# Agent 1: Researcher — finds information
researcher = Agent(
    name="researcher",
    model="gemini-2.0-flash",
    instruction="""Research the given topic thoroughly.
Save all findings to the session state under the key 'raw_research'.
Focus on facts, statistics, and expert opinions.""",
    tools=[web_search, save_finding],
)

# Agent 2: Writer — synthesizes research into a report
writer = Agent(
    name="writer",
    model="gemini-2.0-flash",
    instruction="""You are a technical writer.
Read the research findings from the session state (key: 'findings').
Write a clear, well-structured report with sections, key takeaways,
and a conclusion. Cite sources where possible.""",
    tools=[get_findings],
)

# Compose them into a pipeline
root_agent = SequentialAgent(
    name="research_pipeline",
    sub_agents=[researcher, writer],
    description="Research a topic and produce a structured report.",
)python

ParallelAgent

Runs agents concurrently. Great when you need independent tasks done simultaneously.

from google.adk.agents import ParallelAgent

# Search multiple sources simultaneously
parallel_search = ParallelAgent(
    name="parallel_searcher",
    sub_agents=[
        news_searcher,     # searches news sources
        academic_searcher, # searches academic papers
        social_searcher,   # searches social media
    ],
)python

LoopAgent

Runs an agent in a loop until a condition is met. Perfect for iterative refinement — like "keep improving this draft until it scores above 8/10."

from google.adk.agents import LoopAgent

# Draft → Review → Revise → Repeat until quality threshold
editing_loop = LoopAgent(
    name="editing_loop",
    sub_agents=[drafter, reviewer, reviser],
    max_iterations=3,  # safety net to prevent infinite loops
)python

Agent Delegation (Parent-Child)

You can also give an agent sub_agents and let the LLM decide which sub-agent to delegate to. This is the most flexible pattern — the parent agent acts as a router:

# The parent agent decides which specialist to call
root_agent = Agent(
    name="coordinator",
    model="gemini-2.0-flash",
    instruction="""You are a coordinator. Based on the user's request,
delegate to the appropriate specialist:
- For research questions → use the researcher agent
- For data analysis → use the analyst agent
- For writing tasks → use the writer agent

Always explain which specialist you're delegating to and why.""",
    sub_agents=[researcher, analyst, writer],
)python
💡

When to use what: Use SequentialAgent for pipelines (research → write → review). Use ParallelAgent when tasks are independent (search 3 sources at once). Use LoopAgent for iterative refinement. Use parent-child delegation when the routing decision requires LLM reasoning.

7. Session & Memory Management

Sessions are how ADK manages conversation state. Every interaction with an agent happens within a session, and state persists across turns within that session.

Running an Agent Programmatically

Beyond the CLI and web UI, you'll want to run agents from your own code — for APIs, scripts, or integration:

import asyncio
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types as genai_types
from research_assistant.agent import root_agent

async def main():
    # Create a session service (in-memory for dev)
    session_service = InMemorySessionService()

    # Create a runner
    runner = Runner(
        agent=root_agent,
        app_name="research_app",
        session_service=session_service,
    )

    # Create a session
    session = await session_service.create_session(
        app_name="research_app",
        user_id="vikas",
    )

    # Send a message and collect the response
    user_msg = genai_types.Content(
        role="user",
        parts=[genai_types.Part(text="Research Google ADK vs LangChain")],
    )

    response_text = ""
    async for event in runner.run_async(
        user_id="vikas",
        session_id=session.id,
        new_message=user_msg,
    ):
        if event.content and event.content.parts:
            for part in event.content.parts:
                if part.text:
                    response_text += part.text

    print(response_text)

asyncio.run(main())python

Session State Scopes

ADK has three levels of state:

# In a tool function:
def my_tool(query: str, tool_context: ToolContext) -> dict:
    # Session-scoped (persists across turns)
    tool_context.state["last_query"] = query

    # App-scoped (shared across sessions)
    tool_context.state["app:total_queries"] = (
        tool_context.state.get("app:total_queries", 0) + 1
    )

    # Temporary (gone after this turn)
    tool_context.state["temp:processing"] = True

    return {"result": "..."}python

8. Deployment Options

Once your agent works locally, you have several deployment paths:

Option 1: FastAPI Wrapper (Self-hosted)

Wrap your agent in a simple API. This works anywhere — Railway, Render, your own server:

from fastapi import FastAPI
from pydantic import BaseModel
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types as genai_types
from research_assistant.agent import root_agent

app = FastAPI()
session_service = InMemorySessionService()
runner = Runner(
    agent=root_agent,
    app_name="research_api",
    session_service=session_service,
)

class ChatRequest(BaseModel):
    message: str
    session_id: str | None = None

@app.post("/chat")
async def chat(req: ChatRequest):
    if not req.session_id:
        session = await session_service.create_session(
            app_name="research_api", user_id="api_user"
        )
        session_id = session.id
    else:
        session_id = req.session_id

    user_msg = genai_types.Content(
        role="user",
        parts=[genai_types.Part(text=req.message)],
    )

    response_parts = []
    async for event in runner.run_async(
        user_id="api_user",
        session_id=session_id,
        new_message=user_msg,
    ):
        if event.content and event.content.parts:
            for part in event.content.parts:
                if part.text:
                    response_parts.append(part.text)

    return {
        "response": "".join(response_parts),
        "session_id": session_id,
    }python

Option 2: Google Cloud / Vertex AI (Production)

For production workloads, ADK integrates natively with Google Cloud's Agent Platform. Change your .env to use Vertex AI and deploy to Cloud Run:

# .env for production
GOOGLE_GENAI_USE_VERTEXAI=TRUE
GOOGLE_CLOUD_PROJECT=your-project-id
GOOGLE_CLOUD_LOCATION=us-central1.env

This gives you managed session storage, autoscaling, monitoring, and integration with other Google Cloud services. The code stays the same — only the config changes.

9. Gotchas & Lessons Learned

After building several agents with ADK, here are the things that tripped me up (so they don't trip you up):

1. Parameter Names in Callbacks Are Sacred

ADK passes callback arguments by keyword. If your callback expects callback_context, you cannot rename it to ctx. You'll get a TypeError with no helpful error message.

# ❌ WRONG — will crash at runtime
def my_callback(ctx, request):
    ...

# ✅ CORRECT — use the exact parameter names
def my_callback(callback_context, llm_request):
    ...python

2. Tool Docstrings Are Not Optional

The LLM uses your docstring to decide when to call a tool and what arguments to pass. A vague docstring = a confused agent. I've seen agents ignore perfectly good tools just because the docstring was unclear.

3. Gemini Flash vs Pro

Start with gemini-2.0-flash for development — it's fast and cheap. Switch to gemini-2.5-pro only when you need better reasoning for complex multi-step tasks. For most tool-use scenarios, Flash is surprisingly good.

4. State Keys Are Global Within a Session

If you have multiple agents in a multi-agent setup, they all share the same session state. Use namespaced keys to avoid collisions:

# Instead of:
tool_context.state["results"] = data

# Use:
tool_context.state["researcher:results"] = datapython

5. The adk web UI Is Your Best Friend

Seriously, use it. Every tool call, every LLM response, every state mutation is visible. When your agent does something unexpected, the trace will show you exactly why. Don't try to debug agents with print() statements.

6. Keep Instructions Structured

Use markdown formatting in your agent's instruction string. Headers, bullet points, numbered steps — they help the LLM follow complex instructions more reliably than wall-of-text paragraphs.

7. Error Handling in Tools

If a tool raises an unhandled exception, the agent gets confused. Always return structured error responses instead:

# ❌ Don't do this
def fetch_data(url: str) -> dict:
    response = urlopen(url)  # crashes on bad URL
    return json.loads(response.read())

# ✅ Do this
def fetch_data(url: str) -> dict:
    try:
        response = urlopen(url, timeout=10)
        return {"status": "success", "data": json.loads(response.read())}
    except Exception as e:
        return {"status": "error", "message": str(e)}python

What's Next?

You now have the building blocks to create real agents with Google ADK. Here's where I'd suggest going next:

  1. Build something you'll actually use. An agent that searches your docs, a research bot for your domain, a code review assistant.
  2. Try the Workflow API for more complex orchestration — graph-based execution flows give you deterministic control over agent pipelines.
  3. Experiment with model adapters — try swapping Gemini for Claude or GPT to see how different models handle the same tools and instructions.
  4. Read the official docs — especially the sections on evaluation and testing, which I didn't cover here.

The best way to learn agents is to build them. Pick a problem you face daily, give an LLM the tools to solve it, and see what happens. That's how I learned, and it's how I recommend you learn too.

💬

Found an error or have a question? Reach me at sharmavikas.9798@gmail.com. I update these guides as the framework evolves.

Sources & Further Reading:
ADK Official SiteGitHub RepositoryPython QuickstartCallbacks DocumentationMulti-Agent CodelabPyPI (v2.4.0)

Enjoyed this? Leave a clap (or twenty)