When you hand a single agent a task like "research this, analyze it, then write a report," things tend to break down: context gets muddled mid-task, and one failing step halts everything. That's not an agent capability problem — it's a design problem.
Google Agent Development Kit (ADK) provides a structural answer. By separating concerns between orchestrators and sub-agents, each piece can focus on what it does well. After three months of using ADK in real projects, I've come to believe that where you draw the boundary between agents determines almost everything.
The Core Building Blocks
ADK offers four agent types you can compose freely:
- LlmAgent: Calls an LLM to reason, generate, or make decisions
- SequentialAgent: Runs sub-agents one after another
- ParallelAgent: Runs sub-agents concurrently
- LoopAgent: Repeats until a condition is met
Complex workflows emerge from combining these four. Start with installation:
pip install google-adkExample: A Competitive Intelligence Workflow
For a concrete example, let's build a workflow that researches competitor announcements and generates an executive summary. It needs three sub-agents:
- SearchAgent: Runs web searches for a given competitor
- AnalysisAgent: Extracts key insights from search results
- ReportAgent: Writes a structured summary report
from google.adk.agents import LlmAgent, SequentialAgent, ParallelAgent
from google.adk.tools import google_search
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
# Per-company search agent (designed for parallel execution)
def create_search_agent(company_name: str) -> LlmAgent:
return LlmAgent(
name=f"search_{company_name}",
model="gemini-2.0-flash",
instruction=f"""
Search for recent product updates and feature releases from {company_name}.
Focus on the past 30 days. Return results as bullet points with URLs.
""",
tools=[google_search],
output_key=f"search_result_{company_name}",
)
# Analysis agent
analysis_agent = LlmAgent(
name="analysis_agent",
model="gemini-2.0-flash",
instruction="""
Analyze the competitor research gathered in previous steps.
Organize your findings around:
- The most significant product changes observed
- Changes that could affect our product or roadmap
- Differentiation points between competitors
""",
output_key="analysis_result",
)
# Report generation agent
report_agent = LlmAgent(
name="report_agent",
model="gemini-2.0-flash-thinking-exp",
instruction="""
Based on the analysis, write an executive competitive intelligence summary.
Structure:
- Overall summary (3 sentences max)
- Top 3 developments to watch (prioritized)
- 2 concrete recommended actions
""",
output_key="final_report",
)Combining Parallel and Sequential Execution
Running competitor searches in parallel and then feeding the combined results into analysis is the natural shape of this workflow:
COMPETITORS = ["OpenAI", "Anthropic", "Mistral"]
# Fan out: search all competitors concurrently
parallel_search = ParallelAgent(
name="parallel_search",
sub_agents=[create_search_agent(c) for c in COMPETITORS],
)
# Full pipeline: parallel search → analysis → report
competitive_analysis_workflow = SequentialAgent(
name="competitive_analysis",
sub_agents=[parallel_search, analysis_agent, report_agent],
)ADK manages the threading inside ParallelAgent automatically. Three concurrent searches cut wall-clock time to roughly a third of sequential execution.
State Passing Between Agents
Every value written to an output_key by one agent is automatically stored in the session state and available to subsequent agents.
session_service = InMemorySessionService()
runner = Runner(
agent=competitive_analysis_workflow,
app_name="competitive_analysis_app",
session_service=session_service,
)
async def run_analysis():
session = await session_service.create_session(
app_name="competitive_analysis_app",
user_id="user_001",
)
message = types.Content(
role="user",
parts=[types.Part(text="Run competitive analysis")],
)
async for event in runner.run_async(
user_id="user_001",
session_id=session.id,
new_message=message,
):
if event.is_final_response():
print(event.content.parts[0].text)
# Inspect intermediate state
final_session = await session_service.get_session(
app_name="competitive_analysis_app",
user_id="user_001",
session_id=session.id,
)
print("Analysis:", final_session.state.get("analysis_result"))
print("Report:", final_session.state.get("final_report"))Designing Error Handling
The trickiest part of production ADK workflows is error propagation. When one sub-agent inside a ParallelAgent throws an exception, it surfaces immediately to the parent by default.
Rather than wrapping agents in try-except, I've found it more reliable to encode graceful degradation directly in the agent's instruction:
def create_resilient_search_agent(company_name: str) -> LlmAgent:
return LlmAgent(
name=f"search_{company_name}",
model="gemini-2.0-flash",
instruction=f"""
Search for recent news about {company_name}.
If the search fails or returns no results, respond with exactly:
"{company_name}: no data available"
Never propagate raw error messages.
""",
tools=[google_search],
output_key=f"search_result_{company_name}",
)The LLM absorbs error states naturally when you tell it what to return on failure. That said, tool-level exceptions (network errors, auth failures) still need Python-level handling — this approach only covers LLM reasoning failures.
Testing Locally with ADK Web UI
ADK ships with a local web interface that shows agent execution step-by-step:
adk webOpen http://localhost:8000 in your browser. You can trace each agent's inputs, outputs, and state changes in real time. This visual feedback loop shortens the iteration cycle significantly compared to reading logs.
The One Design Rule That Changes Everything
After running a dozen ADK workflows in production, the design principle that matters most is: every agent should have a single, expressible responsibility.
If you can't describe what an agent does in one sentence, it's doing too much. ADK's sequential and parallel primitives are flexible enough that restructuring is never painful — but bloated agents are much harder to test and debug.
As a next step, define the data passed through output_key as Pydantic models. It makes inter-agent contracts explicit and catches type mismatches before they become runtime surprises.