Building AI Agents with ADK: Orchestrating Graph Workflows

1. Overview

Fulfilling a multi-constraint travel request (such as planning a $100 day trip in Kyoto with historic temples, local ramen, and zero modern art museums) requires extracting structured preferences, researching venues concurrently, synthesizing an itemized draft, and auditing the total cost before responding. In ADK 1.x, orchestrating those stages required nesting SequentialAgent, ParallelAgent, and LoopAgent wrappers, saving every intermediate output into session state, and invoking an extra LLM tool call just to exit a refinement loop.

In this codelab, you build a multi-agent travel planner on Agent Platform using Agent Development Kit (ADK) 2 graph workflows (Workflow). Across three progressive stages, you connect specialist Agent nodes (powered by gemini-3.8-flash) in a sequential edge chain (node_input), fan out concurrent google_search researchers synchronized with JoinNode, and enforce budget and exclusion rules with a deterministic Python router (Event(route=...)) that skips refinement whenever the initial draft passes audit.

Prerequisites

What you learn

  • How to declare a multi-agent pipeline in ADK 2 using Workflow edge tuples
  • How ADK 2 passes outputs directly along edges as node_input and binds session state keys as function parameters
  • How to fan out concurrent research branches and merge their outputs with JoinNode
  • How to route bounded maker-checker cycles deterministically in Python with Event(route=...)
  • How to inspect compiled workflow graphs, parallel execution timelines, and state deltas in the ADK Web UI

What you need

  • A Google account (use a personal @gmail.com account if claiming workshop credits)
  • A workshop credit link from your instructor (plus a phone camera to scan the QR code) or an active Cloud Billing account
  • Google Chrome (an Incognito window is recommended to avoid mixing personal and work accounts)

2. Claim Google Cloud credits (Optional)

If you are attending a workshop with an event credit link, claim your Google Developer Program credit to provision a billing account without a credit card.

Claim your event credit

  1. Open the workshop credit link in an Incognito window and sign in with your @gmail.com account.
  2. On Claim Your Benefit, click Claim Credit:

Google Builders Hub Claim Your Benefit card showing the Claim Credit button

  1. If prompted to create a Google Developer Program profile, complete the form.
  2. When Scan to Verify You're Human appears, scan the QR code with your phone and complete verification (refresh and click Claim Credit again if the QR code does not appear):

Google Developer Program Scan to Verify You're Human QR code dialog

  1. Confirm Successfully claimed your event credit! appears, then click Open Billing Account:

Google Builders Hub showing Successfully claimed your event credit and the Open Billing Account button

🔍 Check your work

Billing account setup takes up to 3 minutes:

  1. Open Google Cloud Billing (or copy the URL into your Incognito window) and select Your billing accounts:
https://console.cloud.google.com/billing
  1. Refresh until your GDP Credit account shows status Active:

Google Cloud Billing account management showing an active GDP Credit billing account

3. Create a Google Cloud project (Optional)

Create a dedicated Google Cloud project so all resources from this codelab stay isolated and easy to clean up.

  1. Open Create a Project (or copy the URL into your Incognito window):
https://console.cloud.google.com/projectcreate
  1. Enter your project details:
    • Project name: For example, google-cloud-workshop
    • Billing account (if shown): Select GDP Credit (or your active billing account)
    • Parent resource: No organization (or select your organization if required)
  2. Note the generated Project ID below the project name, then click Create:

Create a Google Cloud Project screen showing Project name, Project ID, Parent resource, and Create button

🔍 Check your work

Click Notifications in the top-right toolbar and click Select Project once project creation finishes:

Google Cloud console Notifications menu showing Create Project and the Select Project link

4. Set up your environment

Configure Google Cloud Shell, enable the Agent Platform API, and create an isolated Python 3.12 virtual environment with uv.

Configure Cloud Shell

  1. Click Activate Cloud Shell (>_) in the Google Cloud console toolbar, or open Google Cloud Shell directly (or copy the URL into your Incognito window):
https://shell.cloud.google.com/?show=ide,terminal

Activate Cloud Shell button in the Google Cloud console toolbar

  1. Click Authorize if prompted:

Authorize Cloud Shell dialog

  1. Select your active Google Cloud Project ID from the numbered menu in your terminal:
unset CLOUDSDK_CORE_PROJECT
PS3="Select your project (e.g. 1): "
select PROJECT_ID in $(gcloud projects list --format="value(projectId)"); do
  [ -n "$PROJECT_ID" ] && gcloud config set project "$PROJECT_ID" && break
done

You should see output similar to (untagged projects also print an informational [environment: untagged] line):

1) google-cloud-workshop-4210
2) my-cloud-project-8812
Select your project (e.g. 1): 1
Updated property [core/project].

Verify your selected Project ID appears highlighted in yellow in the Cloud Shell prompt:

Cloud Shell terminal showing selected Project ID highlighted in yellow

Enable required APIs

Enable the Agent Platform API so your ADK agents can invoke Gemini models:

gcloud services enable aiplatform.googleapis.com

You should see output similar to:

Operation "operations/acat.p2-..." finished successfully.

Create a Python virtual environment and install ADK

Create the ~/adk-graph-agents workspace and activate a Python 3.12 virtual environment:

cd ~
mkdir -p adk-graph-agents && cd ~/adk-graph-agents

which uv >/dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"

uv venv .venv --python 3.12
source .venv/bin/activate

Confirm the (.venv) prefix appears at the start of your terminal prompt:

Cloud Shell terminal showing the .venv prefix on an active Python virtual environment

Install ADK 2 inside the active virtual environment:

uv pip install --no-cache "google-adk==2.14.0"

🔍 Check your work

Verify the installed adk CLI version:

adk --version

You should see output similar to:

adk, version 2.14.0

5. Scaffold the travel planner

Generate the starter travel_planner package with adk create and test the default single-agent baseline in adk web.

Generate the starter package

Run adk create from ~/adk-graph-agents:

cd ~/adk-graph-agents
adk create travel_planner

Select the following options when prompted (if prompted with Enable telemetry? [Y/n]: on first run, press Enter or type n and press Enter):

  1. Choose a model for the root agent: Type 2 to select gemini-3.8-flash (option 1 selects gemini-3.5-flash) and press Enter.
  2. Choose a backend: Type 2 to select the Google Cloud Agent Platform backend (option 2 in the CLI menu below) and press Enter.
  3. Enter Google Cloud project ID: Press Enter to accept your detected Project ID in brackets.
  4. Enter Google Cloud region: Type global (to use the global Gemini endpoint) and press Enter.

adk create writes GOOGLE_GENAI_USE_ENTERPRISE=1, your project ID, and GOOGLE_CLOUD_LOCATION=global into travel_planner/.env, so your credentials remain intact when you replace agent.py later.

You should see output similar to:

Choose a model for the root agent:
1. gemini-3.5-flash
2. gemini-3.8-flash
3. Other models (fill later)
Choose model (1, 2, 3): 2
1. Google AI
2. Vertex AI
3. Login with Google
Choose a backend (1, 2, 3): 2

You need an existing Google Cloud account and project, check out this link for details:
https://google.github.io/adk-docs/get-started/quickstart/#gemini---google-cloud-vertex-ai

Enter Google Cloud project ID [google-cloud-workshop-4210]:
Enter Google Cloud region [us-central1]: global

Agent created in /home/your_username/adk-graph-agents/travel_planner:
- .env
- .gitignore
- __init__.py
- agent.py

⚠️  WARNING: Secrets (like GOOGLE_API_KEY) are stored in .env.

Next steps:
- Chat with your agent in the terminal: adk run travel_planner
- Or in the browser: adk web .
- Have your coding agent evaluate and deploy it: uvx google-agents-cli setup
  (other install options: https://adk.dev/get-started/agents-cli/)

Open ~/adk-graph-agents in the Cloud Shell code editor:

which cloudshell >/dev/null 2>&1 && cloudshell workspace ~/adk-graph-agents || true

Reactivate your virtual environment in the reloaded terminal:

cd ~/adk-graph-agents
source .venv/bin/activate

Test the starter single agent

Start the ADK Web UI to observe how a single unorchestrated starter agent answers a travel prompt:

cd ~/adk-graph-agents
adk web --allow_origins "*"

Click the Web Preview icon in the top-right Cloud Shell toolbar, select Change Port, enter 8000, and click Change and Preview:

Cloud Shell Web Preview port configuration dialog set to 8000

🔍 Check your work

Select travel_planner in the top-left dropdown of adk web and send "Help me plan a trip to Japan":

ADK Development Web UI showing a basic single-agent conversation

You should see a single-pass conversational reply in the chat pane (with a single root_agent invocation in Events):

root_agent -> responds in one pass without structured preference extraction, live search, or budget verification

Press Ctrl + C in your Cloud Shell terminal to stop adk web before editing files in the next step.

6. Build a linear graph workflow

Build your first ADK 2 Workflow: a sequential chain connecting the reserved "START" entry point to user_profiler_agent and itinerary_synthesizer_agent (any node with no outgoing edges acts as a terminal node). Every edge (a, b) automatically runs Agent (an alias for LlmAgent) nodes in isolated single_turn mode and delivers node a's output as node_input (the incoming user message) to node b, while {user_preferences} in an instruction string interpolates the matching session state key saved via output_key.

Architecture diagram showing travel_workflow (Workflow) connecting START to user_profiler_agent and itinerary_synthesizer_agent

Create the initial specialists

Ensure adk web is stopped (Ctrl + C), then open a new file named travel_planner/specialists.py:

cloudshell edit ~/adk-graph-agents/travel_planner/specialists.py

Paste the following code into travel_planner/specialists.py to define user_profiler_agent and itinerary_synthesizer_agent (importing google_search upfront for the next step):

from google.adk import Agent
from google.adk.tools import google_search

MODEL = "gemini-3.8-flash"

# --- Specialist 1: The User Profiler ---
user_profiler_agent = Agent(
    model=MODEL,
    name="user_profiler_agent",
    description="Extracts travel preferences and constraints from user input.",
    instruction="""
        Extract destination, duration (default 1 day), budget, interests, and exclusions.
        Return a single JSON object with keys: "destination", "duration", "budget", "interests", "exclusions".
    """,
    output_key="user_preferences",  # Saved for multi-hop constraints downstream
)

# --- Specialist 2: The Synthesizer ---
itinerary_synthesizer_agent = Agent(
    model=MODEL,
    name="itinerary_synthesizer_agent",
    description="Compiles preferences and research into a final Markdown itinerary.",
    instruction="""
        You are an elite Travel Concierge. Using the incoming input and
        User Preferences ({user_preferences}), write a Markdown itinerary
        with an itemized USD and local currency cost breakdown.
    """,
    output_key="final_itinerary",
)

Define the sequential edge chain

Open a new file named travel_planner/workflows.py:

cloudshell edit ~/adk-graph-agents/travel_planner/workflows.py

Paste the following Workflow definition (where a tuple of 3 or more nodes compiles into a sequential chain from "START"):

from google.adk import Workflow
from .specialists import (
    itinerary_synthesizer_agent,
    user_profiler_agent,
)

# 1. SEQUENTIAL GRAPH: START -> user_profiler_agent -> itinerary_synthesizer_agent
travel_workflow = Workflow(
    name="travel_workflow",
    description="Orchestrates the end-to-end travel planning process as a graph workflow.",
    edges=[
        ("START", user_profiler_agent, itinerary_synthesizer_agent),
    ],
)

Expose the workflow as root_agent

Open travel_planner/agent.py:

cloudshell edit ~/adk-graph-agents/travel_planner/agent.py

Replace the entire contents of travel_planner/agent.py with these two lines:

from .workflows import travel_workflow

root_agent = travel_workflow

🔍 Check your work

Start adk web from ~/adk-graph-agents:

cd ~/adk-graph-agents
adk web --allow_origins "*"

Open Web Preview on port 8000, click + New Session, and send:

Help me plan a trip to Kyoto

In the Events view, you should see an event sequence similar to:

1. user_profiler_agent -> outputs JSON {"destination": "Kyoto", "duration": "1 day", ...} and writes state["user_preferences"]
2. itinerary_synthesizer_agent -> receives preferences via node_input and outputs the Markdown itinerary

ADK 2.14.0 Web UI showing START to user_profiler_agent to itinerary_synthesizer_agent graph execution and generated Kyoto itinerary

Click any event in Events and click the Graph icon (account_tree in the event detail toolbar) to inspect the compiled START -> user_profiler_agent -> itinerary_synthesizer_agent topology, then check the State tab to verify user_preferences and final_itinerary are populated.

Press Ctrl + C in your terminal to stop adk web before continuing.

7. Fan out and join with JoinNode

Ground the itinerary in live web data by adding two concurrent google_search specialists (activity_finder_agent and restaurant_finder_agent) and synchronizing them with JoinNode:

  • Fan-out tuple: Placing (activity_finder_agent, restaurant_finder_agent) inside the edge tuple delivers user_profiler_agent's output as node_input to both researchers concurrently.
  • Fan-in barrier (JoinNode): Placing JoinNode(name="research_join") right after the tuple waits until every incoming branch completes and passes one merged dict ({"activity_finder_agent": ..., "restaurant_finder_agent": ...}) as node_input to itinerary_synthesizer_agent instead of triggering synthesis twice.

Architecture diagram showing travel_workflow fanning out from user_profiler_agent via node_input to activity_finder_agent and restaurant_finder_agent in parallel, merging at research_join (JoinNode), and passing node_input (dict) to itinerary_synthesizer_agent

Append the research specialists

Confirm adk web is stopped (Ctrl + C), then open travel_planner/specialists.py:

cloudshell edit ~/adk-graph-agents/travel_planner/specialists.py

Append activity_finder_agent and restaurant_finder_agent to the bottom of travel_planner/specialists.py (neither needs output_key because research_join forwards both outputs directly via node_input):

# --- Specialist 3: The Activity Finder ---
activity_finder_agent = Agent(
    model=MODEL,
    name="activity_finder_agent",
    description="Finds 3 attractions matching the user's destination and interests.",
    instruction="""
        Using the travel preferences in the input, call google_search to find 3 specific attractions
        (including budget-friendly options).
        Return a JSON list with keys: "attraction_name", "estimated_cost_local", "estimated_cost_usd", "notes".
    """,
    tools=[google_search],
)

# --- Specialist 4: The Restaurant Finder ---
restaurant_finder_agent = Agent(
    model=MODEL,
    name="restaurant_finder_agent",
    description="Finds 3 restaurants matching the destination and budget.",
    instruction="""
        Using the travel preferences in the input, call google_search to find 3 highly-rated restaurants
        across price points.
        Return a JSON list with keys: "restaurant_name", "cuisine", "estimated_cost_local", "estimated_cost_usd".
    """,
    tools=[google_search],
)

Add fan-out and JoinNode to workflows.py

Open travel_planner/workflows.py:

cloudshell edit ~/adk-graph-agents/travel_planner/workflows.py

Replace the entire contents of travel_planner/workflows.py with the fan-out and JoinNode graph:

from google.adk import Workflow
from google.adk.workflow import JoinNode
from .specialists import (
    activity_finder_agent,
    itinerary_synthesizer_agent,
    restaurant_finder_agent,
    user_profiler_agent,
)

research_join = JoinNode(name="research_join")

# 2. FAN-OUT / FAN-IN GRAPH: Profile -> (Activities || Restaurants) -> Join -> Synthesize
travel_workflow = Workflow(
    name="travel_workflow",
    description="Orchestrates the end-to-end travel planning process as a graph workflow.",
    edges=[
        (
            "START",
            user_profiler_agent,
            (activity_finder_agent, restaurant_finder_agent),
            research_join,
            itinerary_synthesizer_agent,
        ),
    ],
)

🔍 Check your work

Start adk web to test concurrent search and JoinNode synchronization:

cd ~/adk-graph-agents
adk web --allow_origins "*"

Open Web Preview on port 8000, click + New Session, and send:

Help me plan a trip to Kyoto focused on historic temples and ramen

In the Events view, you should see an event sequence similar to:

1. user_profiler_agent -> emits JSON preferences via node_input
2. activity_finder_agent & restaurant_finder_agent -> invoke google_search concurrently
3. research_join -> merges both researcher outputs into one dict node_input
4. itinerary_synthesizer_agent -> drafts final_itinerary from the merged dict + {user_preferences}

Press Ctrl + C in your terminal to stop adk web before continuing.

8. Route revision cycles

Enforce budget and negative constraints by adding an itinerary_critic_agent, an itinerary_refiner_agent, and two deterministic Python function nodes (route_critique and finalize_itinerary) that return ADK Event objects:

  • route: Selects which named branch key in a dict edge ({"revise": ..., "approved": ...}) runs next.
  • output: Passes a typed Python value or string along the edge as node_input to the next node without rendering a chat message.
  • message and state: message emits user-visible text in the chat transcript, while state commits key-value updates (stateDelta) to session state.

Architecture diagram showing travel_workflow with itinerary_critic_agent feeding route_critique, which routes 'revise' to itinerary_refiner_agent (looping back to itinerary_critic_agent) or 'approved' to finalize_itinerary

Append the critic and refiner specialists

Confirm adk web is stopped (Ctrl + C), then open travel_planner/specialists.py:

cloudshell edit ~/adk-graph-agents/travel_planner/specialists.py

Append itinerary_critic_agent and itinerary_refiner_agent to the bottom of travel_planner/specialists.py (the refiner needs no google_search or exit_loop tool because it swaps items already gathered in {final_itinerary} and only runs when route_critique selects "revise"):

# --- Specialist 5: The Budget & Constraint Critic ---
itinerary_critic_agent = Agent(
    model=MODEL,
    name="itinerary_critic_agent",
    description="Audits the itinerary draft against budget and negative constraints.",
    instruction="""
        Audit the itinerary in the input against User Preferences ({user_preferences}).
        Verify that the USD total stays within the user's budget and no excluded venue types appear.
        If all checks pass, respond EXACTLY with the single word "APPROVED".
        Otherwise, output 1-2 concise revision instructions explaining what to swap or adjust.
    """,
)

# --- Specialist 6: The Itinerary Refiner ---
itinerary_refiner_agent = Agent(
    model=MODEL,
    name="itinerary_refiner_agent",
    description="Revises the itinerary draft using the critic's feedback.",
    instruction="""
        Revise Current Itinerary ({final_itinerary}) using the auditor critique in the input
        so all USD budget and exclusion rules in {user_preferences} pass.
        Output ONLY the updated Markdown itinerary.
    """,
    output_key="final_itinerary",
)

Add route_critique and the cycle

Open travel_planner/workflows.py:

cloudshell edit ~/adk-graph-agents/travel_planner/workflows.py

Replace the entire contents of travel_planner/workflows.py with the complete graph workflow:

from google.adk import Event, Workflow
from google.adk.workflow import JoinNode
from .specialists import (
    activity_finder_agent, itinerary_critic_agent, itinerary_refiner_agent,
    itinerary_synthesizer_agent, restaurant_finder_agent, user_profiler_agent,
)

MAX_REVISIONS = 2
research_join = JoinNode(name="research_join")


def route_critique(node_input: str, revision_count: int = 0):
    """Routes to 'approved' when constraints pass, or 'revise' up to MAX_REVISIONS."""
    approved = node_input.strip().rstrip(".").upper() == "APPROVED"
    if approved or revision_count >= MAX_REVISIONS:
        return Event(route="approved", output=approved)
    return Event(
        route="revise", output=node_input, state={"revision_count": revision_count + 1}
    )


def finalize_itinerary(node_input: bool, final_itinerary: str, revision_count: int = 0):
    """Emits the verified itinerary and resets revision_count for the next turn."""
    status = (
        f"Itinerary approved after {revision_count} revision(s)."
        if node_input
        else f"Max revisions ({revision_count}) reached; returning best-effort draft."
    )
    return Event(
        message=f"{status}\n\n{final_itinerary}",
        output=final_itinerary,
        state={"revision_count": 0},
    )


travel_workflow = Workflow(
    name="travel_workflow",
    description="Orchestrates the end-to-end travel planning process as a graph workflow.",
    edges=[
        (
            "START",
            user_profiler_agent,
            (activity_finder_agent, restaurant_finder_agent),
            research_join,
            itinerary_synthesizer_agent,
            itinerary_critic_agent,
            route_critique,
        ),
        (route_critique, {"revise": itinerary_refiner_agent, "approved": finalize_itinerary}),
        (itinerary_refiner_agent, itinerary_critic_agent),
    ],
)

🔍 Check your work

Verify that travel_planner/workflows.py compiles cleanly and prints the directed graph edges:

cd ~/adk-graph-agents && source .venv/bin/activate
python3 -c "from travel_planner.agent import root_agent; print('\n'.join(f'{e.from_node.name} -> {e.to_node.name} (route={e.route})' for e in root_agent.graph.edges))"

You should see output similar to:

__START__ -> user_profiler_agent (route=None)
user_profiler_agent -> activity_finder_agent (route=None)
user_profiler_agent -> restaurant_finder_agent (route=None)
activity_finder_agent -> research_join (route=None)
restaurant_finder_agent -> research_join (route=None)
research_join -> itinerary_synthesizer_agent (route=None)
itinerary_synthesizer_agent -> itinerary_critic_agent (route=None)
itinerary_critic_agent -> route_critique (route=None)
route_critique -> itinerary_refiner_agent (route=revise)
route_critique -> finalize_itinerary (route=approved)
itinerary_refiner_agent -> itinerary_critic_agent (route=None)

Next, start adk web to test both immediate approval and budget-triggered revisions:

adk web --allow_origins "*"

Open Web Preview on port 8000, click + New Session, and send the multi-constraint Kyoto prompt:

Design a full-day itinerary for Kyoto with a total budget of $100. My main interests are historical landmarks, temples, and authentic local cuisine. Please do not include any modern art museums or galleries.

To watch the route="revise" loop fire in action, send a follow-up prompt with a tight $15 budget:

Now design a full-day Kyoto itinerary with paid temple admissions, lunch, and dinner on a strict total budget of $15 USD.

In the Events view, you should see an execution sequence similar to:

1. user_profiler_agent -> writes JSON constraints to state["user_preferences"]
2. (activity_finder_agent, restaurant_finder_agent) -> run google_search concurrently -> research_join
3. itinerary_synthesizer_agent -> writes initial draft to state["final_itinerary"]
4. itinerary_critic_agent -> outputs "APPROVED" (or revision instructions when over budget)
5. route_critique -> emits route="approved" (skipping refiner) or route="revise" -> itinerary_refiner_agent -> itinerary_critic_agent
6. finalize_itinerary -> emits "Itinerary approved after N revision(s)." (or max-revisions notice) and resets revision_count=0

Press Ctrl + C in your terminal to stop adk web.

9. Troubleshooting

The agent dropdown is empty in adk web

  • Cause: adk web was started inside travel_planner/ instead of the parent ~/adk-graph-agents directory, or the Cloud Shell session restarted.
  • Fix: Press Ctrl + C, return to the parent directory, and restart with cd ~/adk-graph-agents && source .venv/bin/activate && adk web --allow_origins "*".

404 NOT_FOUND model error

  • Cause: You pressed Enter to accept the default us-central1 region during adk create instead of typing global.
  • Fix: Open ~/adk-graph-agents/travel_planner/.env and ensure GOOGLE_CLOUD_LOCATION=global is set.

ERROR: [Errno 98] address already in use on port 8000

  • Cause: A previous adk web server is still running in Cloud Shell.
  • Fix: Press Ctrl + C in the active terminal or stop the existing process with pkill -f "adk web" before starting adk web again.

10. Clean up

Remove the local workspace and optionally disable the API or delete a dedicated workshop project.

Delete the local project files

Remove the ~/adk-graph-agents directory and virtual environment:

cd ~
rm -rf ~/adk-graph-agents

Disable the Agent Platform API (Optional)

Disable the Agent Platform API if you no longer need it in this project:

gcloud services disable aiplatform.googleapis.com --quiet

🔍 Check your work

Confirm that ~/adk-graph-agents has been removed:

test ! -d ~/adk-graph-agents && echo "Workspace ~/adk-graph-agents removed"

You should see output similar to:

Workspace ~/adk-graph-agents removed

11. Congratulations

You built a multi-agent travel planner using ADK 2 Workflow, unifying sequential chains, concurrent google_search fan-out with JoinNode, and conditional maker-checker cycles in a single declarative edge graph.

What you accomplished

  • Declared a multi-agent pipeline using ADK 2 Workflow(edges=[...])
  • Passed data directly between Agent, JoinNode, and Python function nodes via node_input and state-bound parameters while keeping session state minimal
  • Fanned out concurrent google_search branches and synchronized them with JoinNode
  • Replaced LoopAgent and tool-based loop escalation with a deterministic Event(route=...) router that skips unnecessary LLM calls on approved drafts

Next steps