Building Persistent AI Agents with ADK and CloudSQL

1. Introduction

In this hands-on session, you will move beyond basic, stateless chatbots to create a Smart Cafe Concierge–an AI agent powered by Gemini that acts as a friendly barista. It takes coffee orders tracked in session state, remembers long-term dietary preferences in user-scoped state, and persists everything to a Cloud SQL PostgreSQL database. By the end, your agent remembers that you're lactose intolerant even after you restart the application and start a brand new conversation.

Here is the system architecture that we will build

a98bbd65ddedd29c.jpeg

Prerequisites

  • A Google Cloud account with a trial billing account
  • Basic familiarity with Python
  • No prior experience with ADK, AI agents, or Cloud SQL required

What you'll learn

  • Create an AI agent using Google's Agent Development Kit (ADK) with custom tools
  • Define tools that read and write session state via ToolContext
  • Distinguish between session-scoped state and user-scoped state (user: prefix)
  • Provision a Cloud SQL PostgreSQL instance and connect to it from Cloud Shell
  • Migrate from local storage (which is default when you use adk web command) to DatabaseSessionService for persistent storage with dedicated database
  • Verify that agent memory persists across application restarts and across separate conversation sessions

What you'll need

  • A working computer and a reliable internet connection.
  • A browser, such as Chrome, to access Google Cloud Console
  • A curious mind and an eagerness to learn.

2. Set Up Your Environment

This step prepares your Cloud Shell environment and configures your Google Cloud project

Open Cloud Shell

Open Cloud Shell in your browser. Cloud Shell provides a pre-configured environment with all the tools you need for this codelab. Click Authorize when prompted to

Your interface should look similar to this

86307fac5da2f077.png

This will be our main interface, IDE on top, terminal on the bottom

Set up your working directory

Create your working directory. All code you write in this codelab lives here — separate from the reference repo:

# Create your working directory
mkdir -p ~/build-agent-adk-cloudsql

# Change cloudshell workspace and working directory into previously created dir
cloudshell workspace ~/build-agent-adk-cloudsql && cd ~/build-agent-adk-cloudsql

To spawn your terminal find View -> Terminal

ccc3214812750f1c.png

Set up your Google Cloud project and initial environment variables

Download the project setup script into your working directory:

curl -sL https://raw.githubusercontent.com/alphinside/cloud-trial-project-setup/main/setup_verify_trial_project.sh -o setup_verify_trial_project.sh

Run the script. It verifies your trial billing account, creates a new project (or validates an existing one), saves your project ID to a .env file in the current directory, and sets the active project in terminal

bash setup_verify_trial_project.sh && source .env

When running this, you will be prompted with suggested project ID name, you can press Enter to continue

54b615cd15f2a535.png

After waiting for a while, if you see this output in your console, then you are ready to go to next step e576b4c13d595156.png

The executed script do the following steps:

  1. Verify you have an active trial billing account
  2. Check for an existing project in .env (if any)
  3. Create a new project or reuse the existing one
  4. Link the trial billing account to your project
  5. Save the project ID to .env
  6. Set the project as the active gcloud project

Verify the project is set correctly by checking the yellow text next to your working directory in the Cloud Shell terminal prompt. It should display your project ID.

9e11ee21cd23405f.png

Enable required APIs

Enable the Google Cloud APIs needed for this codelab:

gcloud services enable \
  aiplatform.googleapis.com \
  sqladmin.googleapis.com \
  compute.googleapis.com
  • Vertex AI API (aiplatform.googleapis.com) — your agent uses Gemini models through Vertex AI.
  • Cloud SQL Admin API (sqladmin.googleapis.com) — you provision and manage a PostgreSQL instance for persistent storage.
  • Compute Engine API (compute.googleapis.com) — required for creating Cloud SQL instances.

Config Gemini and Cloud products region

Before continuing, let's also set up the necessary location/region configuration for the product that we interact with. Add the following configuration to our .env file

# This is for our Gemini endpoint
echo "GOOGLE_CLOUD_LOCATION=global" >> .env

# This is for our other Cloud products
echo "REGION=us-central1" >> .env

source .env

Let's continue to the next step

3. Set Up Cloud SQL

This step provisions a Cloud SQL PostgreSQL instance and switches your agent from in-memory to database-backed storage. Instance creation takes a few minutes, so you'll start it first and we can still continue our discussion on to the next topic while waiting for it to be finished

Start the instance creation

Add the database password to your .env file and reload it, we will use cafe-agent-pwd-2025 as the password.

echo "DB_PASSWORD=cafe-agent-pwd-2025" >> .env
source .env

Run this command to create a Cloud SQL PostgreSQL instance. It takes a few minutes to complete — leave it running and continue to the next section.

gcloud sql instances create cafe-concierge-db \
  --database-version=POSTGRES_17 \
  --edition=ENTERPRISE \
  --region=${REGION} \
  --availability-type=ZONAL \
  --project=${GOOGLE_CLOUD_PROJECT} \
  --tier=db-f1-micro \
  --root-password=${DB_PASSWORD} \
  --quiet &

Several notes about the above command:

  • db-f1-micro is the smallest (and cheapest) Cloud SQL tier — sufficient for this codelab.
  • --root-password sets the password for the default postgres user.
  • The suffix & in the command runs the command in the background so you can keep working.

The process will be run in background, however the console output will occasionally shown in the current terminal. Let's open a new terminal tab in Cloud Shell (click the + icon) so we can be more focused.

b01e3fbd89f17332.png

Navigate to your working directory again and activate the project using the previous setup script.

cd ~/build-agent-adk-cloudsql
bash setup_verify_trial_project.sh && source .env

Then, let's continue to the next section

4. Build the Cafe Concierge Agent

This step creates the project structure for your ADK agent and defines a basic Cafe Concierge with a menu tool.

Initialize the Python project

This codelab uses uv, a fast Python package manager that handles virtual environments and dependencies in one tool. It's pre-installed in Cloud Shell.

Initialize a Python project and add the ADK as a dependency:

uv init
uv add google-adk==1.25.0 asyncpg

uv init creates a pyproject.toml and a virtual environment. uv add installs the dependency and records it in pyproject.toml.

Initialize the agent project structure

ADK expects a specific folder layout: a directory named after your agent containing __init__.py, agent.py, and also .env inside the agent directory

ADK has built-in command to help you establish this quickly, run the following command

uv run adk create cafe_concierge \
    --model gemini-2.5-flash \
    --project ${GOOGLE_CLOUD_PROJECT} \
    --region ${GOOGLE_CLOUD_LOCATION}

This command will create an agent structure with gemini-2.5-flash as the brain. Your directory should now look like this:

build-agent-adk-cloudsql/
├── cafe_concierge/
│   ├── __init__.py
│   ├── agent.py
│   └── .env
├── pyproject.toml
├── .env      
├── .venv/
└── ...

Write the agent

Open cafe_concierge/agent.py in the Cloud Shell Editor

cloudshell edit cafe_concierge/agent.py

and overwrite the file with the following code

# cafe_concierge/agent.py
from google.adk.agents import LlmAgent
from google.adk.tools import ToolContext

CAFE_MENU = {
    "espresso": {
        "price": 3.50,
        "description": "Rich and bold single shot",
        "tags": ["vegan", "dairy-free", "gluten-free"],
    },
    "latte": {
        "price": 5.00,
        "description": "Espresso with steamed milk",
        "tags": ["gluten-free"],
    },
    "oat milk latte": {
        "price": 5.50,
        "description": "Espresso with steamed oat milk",
        "tags": ["vegan", "dairy-free", "gluten-free"],
    },
    "cappuccino": {
        "price": 4.50,
        "description": "Espresso with equal parts steamed milk and foam",
        "tags": ["gluten-free"],
    },
    "cold brew": {
        "price": 4.00,
        "description": "Slow-steeped for 12 hours, served over ice",
        "tags": ["vegan", "dairy-free", "gluten-free"],
    },
    "matcha latte": {
        "price": 5.50,
        "description": "Ceremonial grade matcha with steamed milk",
        "tags": ["gluten-free"],
    },
    "croissant": {
        "price": 3.00,
        "description": "Buttery, flaky French pastry",
        "tags": [],
    },
    "banana bread": {
        "price": 3.50,
        "description": "Homemade with walnuts",
        "tags": ["vegan"],
    },
}


def get_menu() -> dict:
    """Returns the full cafe menu with prices, descriptions, and dietary tags.

    Use this tool when the customer asks what's available, wants to see
    the menu, or asks about specific items.
    """
    return CAFE_MENU


root_agent = LlmAgent(
    name="cafe_concierge",
    model="gemini-2.5-flash",
    instruction="""You are a friendly and knowledgeable barista at "The Cloud Cafe".

Your job:
- Help customers browse the menu and answer questions about items.
- Take coffee and food orders.
- Remember and respect dietary preferences.

Be conversational, warm, and concise. If a customer mentions a dietary
restriction, acknowledge it and suggest suitable options from the menu.
""",
    tools=[get_menu],
)

This defines a basic agent with one tool: get_menu(). The agent can answer questions about the menu but can't track orders or remember preferences yet.

Verify the agent runs

Start the ADK dev UI from your working directory:

cd ~/build-agent-adk-cloudsql
uv run adk web

Open the URL shown in the terminal (typically http://localhost:8000) using Cloud Shell's Web Preview feature. Select cafe_concierge from the agent dropdown in the top-left corner.

Type the following text on the chat bar and verify the agent responds with menu items and prices.

What's on the menu?

376ee6b189657e7a.png

Stop the dev UI with Ctrl+C before proceeding.

5. Add Stateful Order Management

The agent can show the menu, but it can't take orders or remember preferences. This step adds four tools that use ADK's state system to track orders within a conversation and store dietary preferences across conversations.

Understand session events and state

Every ADK conversation lives inside a Session object. A session tracks two distinct things: events and state. Understanding the difference is key to building agents that remember the right things in the right way.

Events are the chronological log of everything that happens in a conversation. Every user message, every agent response, every tool call and its return value — each one is recorded as an Event and appended to the session's events list. Events are immutable: once recorded, they never change. Think of events as the full transcript of a conversation.

State is a key-value scratchpad that the agent reads and writes during a conversation. Unlike events, state is mutable — values change as the conversation evolves. State is where the agent stores structured data it needs to act on: the current order, the customer's preferences, a running total. Think of state as sticky notes the agent keeps beside the transcript.

Here's how they relate:

cd9871699451867d.png

Tools read and write state through ToolContext — an object that ADK automatically injects into any tool function that declares it as a parameter. You don't create it yourself. Through tool_context.state, a tool can read and write the session's state scratchpad. ADK inspects the function signature: parameters with type ToolContext are injected, all other parameters are filled by the LLM based on the conversation.

When a tool writes to tool_context.state, ADK records that change as a state_delta inside the event. The SessionService then applies the delta to the session's current state. This means state changes are always traceable back to the event that caused them. This is also true for other form of context such as callback_context

Understand state prefixes

State keys use prefixes to control their scope:

Prefix

Scope

Survives restart? (with DB)

(none)

Current session only

Yes

user:

All sessions for this user

Yes

app:

All sessions, all users

Yes

temp:

Current invocation only

No

In this codelab you use two of these prefixes: unprefixed keys for session-scoped data (the current order — relevant only to this conversation) and user: keys for user-scoped data (dietary preferences — relevant across all conversations for this user).

Add the stateful tools

Open cafe_concierge/agent.py in the Cloud Shell Editor.

cloudshell edit cafe_concierge/agent.py

Then, add the following four functions above the root_agent definition:

# cafe_concierge/agent.py (add below get_menu, above root_agent)

def place_order(tool_context: ToolContext, items: list[str]) -> dict:
    """Places an order for the specified menu items.

    Use this tool when the customer confirms they want to order something.

    Args:
        tool_context: Provided automatically by ADK.
        items: A list of menu item names the customer wants to order.
    """
    valid_items = []
    invalid_items = []
    total = 0.0

    for item in items:
        item_lower = item.lower()
        if item_lower in CAFE_MENU:
            valid_items.append(item_lower)
            total += CAFE_MENU[item_lower]["price"]
        else:
            invalid_items.append(item)

    if not valid_items:
        return {"error": f"None of these items are on our menu: {invalid_items}"}

    order = {"items": valid_items, "total": round(total, 2)}
    tool_context.state["current_order"] = order

    result = {"order": order}
    if invalid_items:
        result["warning"] = f"These items are not on our menu: {invalid_items}"
    return result


def get_order_summary(tool_context: ToolContext) -> dict:
    """Returns the current order summary for this session.

    Use this tool when the customer asks about their current order,
    wants to review what they ordered, or asks for the total.

    Args:
        tool_context: Provided automatically by ADK.
    """
    order = tool_context.state.get("current_order")
    if order:
        return {"order": order}
    return {"message": "No order has been placed yet in this session."}


def set_dietary_preference(tool_context: ToolContext, preference: str) -> dict:
    """Saves a dietary preference that persists across all conversations.

    Use this tool when the customer mentions a dietary restriction or
    preference (e.g., "I'm vegan", "I'm lactose intolerant",
    "I have a nut allergy").

    Args:
        tool_context: Provided automatically by ADK.
        preference: The dietary preference to save (e.g., "vegan",
            "lactose intolerant", "nut allergy").
    """
    existing = tool_context.state.get("user:dietary_preferences", [])
    if not isinstance(existing, list):
        existing = []

    preference_lower = preference.lower().strip()
    if preference_lower not in existing:
        existing.append(preference_lower)

    tool_context.state["user:dietary_preferences"] = existing
    return {
        "saved": preference_lower,
        "all_preferences": existing,
    }


def get_dietary_preferences(tool_context: ToolContext) -> dict:
    """Retrieves the customer's saved dietary preferences.

    Use this tool when you need to check the customer's dietary
    restrictions before making recommendations.

    Args:
        tool_context: Provided automatically by ADK.
    """
    preferences = tool_context.state.get("user:dietary_preferences", [])
    if preferences:
        return {"preferences": preferences}
    return {"message": "No dietary preferences saved yet."}

Two things to notice:

  1. place_order and get_order_summary use unprefixed keys (current_order). This state is tied to the current session — a new conversation starts with an empty order.
  2. set_dietary_preference and get_dietary_preferences use the user: prefix (user:dietary_preferences). This state is shared across all sessions for the same user.

Update the agent with new tools and instructions

Replace the existing root_agent definition at the bottom of the file with:

# cafe_concierge/agent.py (replace the existing root_agent)

root_agent = LlmAgent(
    name="cafe_concierge",
    model="gemini-2.5-flash",
    instruction="""You are a friendly and knowledgeable barista at "The Cloud Cafe".

Your job:
- Help customers browse the menu and answer questions about items.
- Take coffee and food orders.
- Remember and respect dietary preferences.

The customer's saved dietary preferences are: {user:dietary_preferences?}

IMPORTANT RULES:
- When a customer mentions a dietary restriction, ALWAYS save it using the
  set_dietary_preference tool before doing anything else.
- Before recommending items, check the customer's dietary preferences. If they
  have preferences saved, only recommend items compatible with those
  restrictions. Check the menu item tags to determine compatibility.
- When placing an order, confirm the items and total with the customer.

Be conversational, warm, and concise.
""",
    tools=[
        get_menu,
        place_order,
        get_order_summary,
        set_dietary_preference,
        get_dietary_preferences,
    ],
)

The instruction uses the state injection template {user:dietary_preferences?} to inject this customer's saved preferences directly into the prompt.

Verify the complete file

Your cafe_concierge/agent.py should now contain:

  • The CAFE_MENU dictionary
  • Five tool functions: get_menu, place_order, get_order_summary, set_dietary_preference, get_dietary_preferences
  • The root_agent definition with all five tools

6. Test the Agent with the ADK Dev UI

This step runs the agent and exercises all the stateful features: ordering, preference tracking, and cross-session memory (within the same process). You'll also inspect the Events and State panels to see how ADK tracks the conversation internally.

Start the dev UI

cd ~/build-agent-adk-cloudsql
uv run adk web

Open the Web Preview on port 8000 and select cafe_concierge from the dropdown.

Conversation 1: Place an order and set preferences

Try these prompts in sequence:

What's on the menu?
I'm lactose intolerant
What would you recommend?
I'll have an oat milk latte and a banana bread
What's my order?

Inspect session events

All of the Event will be captured and displayed on the web UI, you will see that in the chatbox it is not only your prompt and response, but also also tool_call and tool_response

9051b46978c8017b.png

You should see a list of events in order. Each event has an author (who produced it) and a type (what kind of interaction it represents):

Author

Type

What it respresents

user

message

A message you typed in the chat

cafe_concierge

message

The agent's text response

cafe_concierge

tool_call

The agent decided to call a tool (shows function name + arguments)

cafe_concierge

tool_response

The return value from a tool call

Click on one of the tool_call events — for example, the set_dietary_preference call. You should see:

  • Function name: set_dietary_preference
  • Arguments: {"preference": "lactose intolerant"}

Now click on the corresponding tool_response event directly below it. You should see the return value:

  • Response: {"saved": "lactose intolerant", "all_preferences": ["lactose intolerant"]}

b528f4efd6a9f337.png

Look for the state_delta field inside the tool_response event. This shows exactly what state changed as a result of this tool call:

state_delta: {"user:dietary_preferences": ["lactose intolerant"]}

Every state change is traceable to a specific event. This is how ADK ensures the state scratchpad stays in sync with the conversation history.

Inspect session state

Click the State tab. Unlike the events log (which shows the full history), the state tab shows a snapshot of what the agent knows right now — the current value of every state key.

5e06fb54f3f0d8d6.png

You should see two entries:

  • current_order{"items": ["oat milk latte", "banana bread"], "total": 9.0}
  • user:dietary_preferences["lactose intolerant"]

Notice the difference in key names:

  • current_order has no prefix — it's session-scoped. It exists only in this conversation and disappears when the session ends.
  • user:dietary_preferences has the user: prefix — it's user-scoped. It's shared across every session for this user.

This distinction is invisible in the code (both use tool_context.state), but it controls how far the data reaches. You'll see this play out in the next test.

Conversation 2: Verify cross-session user state

Click the New Session button in the dev UI to start a fresh conversation. This creates a new session for the same user.

57408cfae5f041ac.png

Try this prompt:

What do you recommend for me?

Check the State tab in the new session. The user:dietary_preferences key carries over, but current_order is gone — that state was tied to the previous session.

764eb3885251307d.png

7. Observe Local Storage Limitation

The agent remembers preferences across sessions — but only while the local storage exists. This step demonstrates the fundamental limitation of local storage.

Start the agent again

You stopped the dev UI at the end of the previous step. Now let's remove the local storage and start it again, to simulate serverless environment which is stateless:

cd ~/build-agent-adk-cloudsql
rm -f cafe_concierge/.adk/session.db
uv run adk web

Now, open the Web Preview on port 8000 and select cafe_concierge.

Test preference recall

Type:

Do you remember my dietary preferences?

The agent has no recollection. The dietary preferences, the order history — all gone.

82a5e05434cafe83.png

Everything was wiped clean when we deleted the local storage, which typically happened when we utilize a serverless environment. The session.db stores all state in process memory. Removing it erases all of it.

The solution: specify DatabaseSessionService, which in this tutorial will store all session data in a PostgreSQL in Cloud SQL database. The agent code and tools stay exactly the same — only the storage backend changes.

Stop the dev UI with Ctrl+C before proceeding.

8. Revisit Database Setup

At this point, our database instance creation is supposed to be already finished. Let's verify it, run the following command

gcloud sql instances describe cafe-concierge-db --format="value(state)"

You should see the following output, mark it as finished

RUNNABLE

Create the database

Create a dedicated database for the agent's session data:

gcloud sql databases create agent_db --instance=cafe-concierge-db

Start the Cloud SQL Auth Proxy

Cloud SQL Auth Proxy provides a secure, authenticated connection from Cloud Shell to your Cloud SQL instance without needing to whitelist IP addresses. It's already pre-installed on the cloud shell.

cloud-sql-proxy ${GOOGLE_CLOUD_PROJECT}:${REGION}:cafe-concierge-db --port 5432 &

The & suffix in the command makes the proxy run in the background. You should see output confirming the proxy is ready like shown below

[your-project-id:your-region:cafe-concierge-db] Listening on 127.0.0.1:5432
The proxy has started successfully and is ready for new connections!

Verify the connection

Test that you can connect to the database through the proxy:

psql "host=127.0.0.1 port=5432 dbname=agent_db user=postgres password=$DB_PASSWORD" -c "SELECT 'Connection ok' AS status;"

You should see:

      status
---------------------
 Connection ok
(1 row)

9. Verify Persistent Memory Across Sessions

This step proves that your agent's memory survives reset when we ensure that cafe_concierge/.adk/session_db (the local database) is removed and spans across conversation sessions.

Start the agent

Make sure the Cloud SQL Auth Proxy is still running (check with jobs). If it's not, restart it:

if ss -tlnp | grep -q ':5432 '; then
  echo "Cloud SQL Auth Proxy is already running."
else
  cloud-sql-proxy ${GOOGLE_CLOUD_PROJECT}:${REGION}:cafe-concierge-db --port 5432 &
fi

And then, let's start the ADK dev UI by specifying the database as the session service

uv run adk web --session_service_uri postgresql+asyncpg://postgres:${DB_PASSWORD}@127.0.0.1:5432/agent_db

Open the Web Preview on port 8000 and select cafe_concierge.

Test 1: Place an order and set preferences

In the first session, run through these prompts:

Show me the menu
I'm vegan
What can I eat?
I'll have a cold brew and banana bread

Test 2: Survive a restart

Stop the dev UI with Ctrl+C and ensure the local session.db is removed

rm -f cafe_concierge/.adk/session.db

Then, re-run the dev UI server

uv run adk web --session_service_uri postgresql+asyncpg://postgres:${DB_PASSWORD}@127.0.0.1:5432/agent_db

Open the Web Preview on port 8000, select cafe_concierge, and start a new session. Then ask

What are my dietary preferences?

The agent responds with your saved preferences — vegan. The data survived the restart because it's now stored in PostgreSQL, not in local storage. This will be the same case if we create new session as the user: state carries over to every new session for this user.

9c139bf89becb748.png

Inspect the database directly

Open a new terminal tab in Cloud Shell and query the database to see the stored data:

psql "host=127.0.0.1 port=5432 dbname=agent_db user=postgres password=$DB_PASSWORD" -c "\dt"

You should see tables that ADK created automatically to store sessions, events, and state like this example

                List of relations
 Schema |         Name          | Type  |  Owner   
--------+-----------------------+-------+----------
 public | adk_internal_metadata | table | postgres
 public | app_states            | table | postgres
 public | events                | table | postgres
 public | sessions              | table | postgres
 public | user_states           | table | postgres
(5 rows)

Summary of state behavior

State key

Prefix

Scope

Shared across sessions?

current_order

(none)

Session

No

user:dietary_preferences

user:

User

Yes

10. Congratulations / Clean Up

Congratulations! You've successfully built a persistent, stateful AI agent using ADK and Cloud SQL.

What you've learned

  • How to create an ADK agent with custom tools that read and write session state
  • The difference between session-scoped state (no prefix) and user-scoped state (user: prefix)
  • Why default adk local session.db is only suitable for development — all data is lost on removal (and easy to remove, no backup), not suitable for serverless deployment which is stateless
  • How to provision a Cloud SQL PostgreSQL instance and connect to it with Cloud SQL Auth Proxy
  • How to connect to DatabaseSessionService with PostgreSQL on CloudSQL with a minimal code change — same tools, same agent, different backend
  • How user-scoped state persists across separate conversation sessions

Clean up

To avoid incurring charges to your Google Cloud account, clean up the resources created in this codelab.

The easiest way to clean up is to delete the project. This removes all resources associated with the project.

gcloud projects delete ${GOOGLE_CLOUD_PROJECT}

Option 2: Delete individual resources

If you want to keep the project but remove only the resources created in this codelab:

gcloud sql instances delete cafe-concierge-db --quiet