Prerequisites

What you will learn

You already built an agent for your travel assistant app, Traivel. Now you will teach it to remember things.

Project setup

No setup needed; continue with your codebase from Part I.

State lets tools access (read and write) data that persists within a session. Tools access it via the second parameter, the Context object.

Before:

// tools.ts
export const searchDestinations = new FunctionTool({
  // ...
  execute: ({ region, category, budget }) => fetchDestinations(region, category, budget),
});

After:

import { FunctionTool, type Context } from "@google/adk";

export const searchDestinations = new FunctionTool({
  // ...
  execute: ({ region, category, budget }, toolContext?: Context) => {
    // ... do something with `toolContext`
  },
});

Key patterns:

We are adding two state reads and writes to searchDestinations:

Copy this into searchDestinations in tools.ts:

execute: ({ region, category, budget }, toolContext?: Context) => {
  // Read saved user preferences
  const prefs = toolContext?.state.get<{ region?: string; budget?: string }>("user:preferences");
  const effectiveRegion = region ?? prefs?.region;
  const effectiveBudget = budget ?? prefs?.budget;

  const result = await fetchDestinations(effectiveRegion, category, effectiveBudget);

  // Remember last destination viewed
  if ("destinations" in result && result.destinations?.[0]) {
    toolContext?.state.set("temp:last_destination", result.destinations[0].name);
  }

  return result;
},

Next we'll add more tools that make use of user-level states.

Add new tools that manage user's wishlist and preferences.

export const manageWishlist = new FunctionTool({
  name: "manage_wishlist",
  description:
    "Manage the user's travel wishlist. Add or remove destinations, or list the current wishlist. Use this when users express interest in saving or remembering a destination.",
  parameters: z.object({
    action: z
      .enum(["add", "remove", "list"])
      .describe("The action to perform: add a destination, remove it, or list the full wishlist."),
    destination: z
      .string()
      .optional()
      .describe("The destination name (required for add/remove actions)."),
  }),
  execute: ({ action, destination }, toolContext?: Context) => {
    const wishlist = toolContext?.state.get<string[]>("user:wishlist", []) ?? [];

    if (action === "list") {
      if (wishlist.length === 0) {
        return {
          status: "empty",
          message: "Your wishlist is empty. Tell me about a destination you'd like to save!",
        };
      }
      return {
        status: "success",
        wishlist,
        message: `You have ${wishlist.length} destination(s) on your wishlist: ${wishlist.join(", ")}.`,
      };
    }

    if (!destination) {
      return {
        status: "error",
        message: "Please specify a destination name for add/remove actions.",
      };
    }

    if (action === "add") {
      if (wishlist.includes(destination)) {
        return {
          status: "already_exists",
          message: `${destination} is already on your wishlist!`,
        };
      }
      wishlist.push(destination);
      toolContext?.state.set("user:wishlist", wishlist);
      return {
        status: "added",
        wishlist,
        message: `Added ${destination} to your wishlist. You now have ${wishlist.length} destination(s).`,
      };
    }

    if (action === "remove") {
      const index = wishlist.indexOf(destination);
      if (index === -1) {
        return {
          status: "not_found",
          message: `${destination} is not on your wishlist.`,
        };
      }
      wishlist.splice(index, 1);
      toolContext?.state.set("user:wishlist", wishlist);
      return {
        status: "removed",
        wishlist,
        message: `Removed ${destination} from your wishlist. You now have ${wishlist.length} destination(s).`,
      };
    }

    return { status: "error", message: "Unknown action." };
  },
});

export const savePreference = new FunctionTool({
  name: "save_preference",
  description:
    "Save a user's travel preference (budget level or region preference) so future searches use it as default. Call this when the user explicitly states a preference.",
  parameters: z.object({
    preferred_region: z
      .enum(["domestic", "international"])
      .optional()
      .describe("The user's preferred travel region."),
    preferred_budget: z
      .enum(["budget", "mid-range", "luxury"])
      .optional()
      .describe("The user's preferred budget level."),
  }),
  execute: ({ preferred_region, preferred_budget }, toolContext?: Context) => {
    const prefs = toolContext?.state.get<{ region?: string; budget?: string }>("user:preferences") ?? {};
    const saved: string[] = [];

    if (preferred_region) {
      prefs.region = preferred_region;
      saved.push(`region: ${preferred_region}`);
    }
    if (preferred_budget) {
      prefs.budget = preferred_budget;
      saved.push(`budget: ${preferred_budget}`);
    }

    if (saved.length === 0) {
      return {
        status: "error",
        message: "No preference provided. Specify preferred_region or preferred_budget.",
      };
    }

    toolContext?.state.set("user:preferences", prefs);
    return {
      status: "saved",
      message: `Preferences saved: ${saved.join(", ")}. Future searches will use these as defaults.`,
    };
  },
});

What did you notice about the prefixes?

State keys use prefixes to control scope and persistence:

Prefix

Scope

Persisted with DB?

user:

Per user, all sessions

Yes

app:

All users, all sessions

Yes

temp:

Current invocation only

No, always ephemeral

(none)

Per session only

Yes

user:wishlist survives across sessions (with a database), while temp:last_destination is gone after each call.

ADK includes a ready-to-use GOOGLE_SEARCH tool. It lets the agent fetch real-time info via the Gemini API. No extra configuration needed.

Add this to the root agent:

// agent.ts
import { 
  // ... other imports,
  GOOGLE_SEARCH,
} from "@google/adk";

export const rootAgent = new LlmAgent({
  // ... name, model, description, instruction, tools
  tools: [
    // ... other tools
    GOOGLE_SEARCH,
  ],
});

The agent decides when to use it. If the user asks about a destination, search_destinations handles it. If they ask about current conditions — weather, events, prices — the agent uses google_search.

Update the agent instruction to tell it about this capability:

- **Real-time info**: Use \`google_search\` when users ask about current weather, events, prices, or anything that needs up-to-date information.

Test:

npx adk run agent.ts

So far, sessions live in memory. It's gone when the process stops. Let's persist them to SQLite DB.

The ADK CLI accepts a --session_service_uri flag:

# In-memory sessions (default — lost on restart)
npx adk web

# Persistent sessions with SQLite
npx adk web --session_service_uri "sqlite://./traivel.db"

A traivel.db file appears in your project directory.

Test persistence

  1. Start with persistence: npx adk web --session_service_uri "sqlite://./traivel.db"
  2. 💬 "Add Bali to my wishlist" — tool writes user:wishlist
  3. 💬 "Save my budget as mid-range" — tool writes user:preferences
  4. Stop the server (Ctrl+C), then restart with the same flag
  5. 💬 "What's on my wishlist?" — the data persisted in SQLite

Inspect the database

ADK creates 5 tables:

Table

Stores

adk_internal_metadata

Schema version

app_states

app:* state (JSON blob)

user_states

user:* state, keyed by (app_name, user_id)

sessions

Session-level state

events

Every event — messages, responses, tool calls

State keys are split by prefix into separate tables. user:preferences lands in user_states.state — stored without the user: prefix. temp: keys are never persisted.

# List all tables
sqlite3 traivel.db ".tables"

# User state (preferences, wishlist)
sqlite3 traivel.db "SELECT user_id, state FROM user_states;"

# Session state + metadata
sqlite3 traivel.db "SELECT id, user_id, state FROM sessions;"

# Event count per session
sqlite3 traivel.db "SELECT session_id, COUNT(*) FROM events GROUP BY session_id;"

Persistent state stores values you explicitly set. Memory goes further — it lets the agent search across past conversation events for relevant context.

The CLI has a --session_service_uri flag for persistent sessions, but no flag for memory. To wire up InMemoryMemoryService, use a programmatic Runner.

Create runner.ts:

import { Runner, DatabaseSessionService, InMemoryMemoryService } from "@google/adk";
import { rootAgent } from "./agent";

const APP_NAME = "traivel";

// In production, get this from DB e.g. based on auth cookie.
const MOCK_USER_ID = "johndoe123";

async function main() {
  // --- 1. Initiate session service --- //
  const sessionService = new DatabaseSessionService("sqlite://./traivel.db");
  await sessionService.init();

  // --- 2. Initiate memory service (in memory; lost on server restart)  --- //
  const memoryService = new InMemoryMemoryService();

  // --- 3. Create the Runner  --- //
  const runner = new Runner({
    agent: rootAgent,
    appName: APP_NAME,
    sessionService,
    memoryService,
  });

  // --- 4. Run a conversation turn --- //
  const session = await sessionService.createSession({
    appName: APP_NAME,
    userId: MOCK_USER_ID,
  });

  const events = runner.runAsync({
    userId: MOCK_USER_ID,
    sessionId: session.id,
    // Sample user question
    newMessage: { role: "user", parts: [{ text: "Recommend a budget beach destination" }] },
  });

  for await (const event of events) {
    if (event.content?.parts?.[0]?.text) {
      console.log(`Agent: ${event.content.parts[0].text}`);
    }
  }

  // --- 5. Write session to memory --- //
  const fullSession = await sessionService.getSession({
    appName: APP_NAME,
    userId: MOCK_USER_ID,
    sessionId: session.id,
  });
  if (fullSession) {
    await memoryService.addSessionToMemory(fullSession);
  }

  // --- 6. Read from memory --- //
  const memories = await memoryService.searchMemory({
    appName: APP_NAME,
    userId: MOCK_USER_ID,
    query: "beach", // Search query based on sample data
  });
  console.log(`✓ Memory search found ${memories.memories.length} match(es)`);
}

main().catch(console.error);

How memory works

After a session ends, call memoryService.addSessionToMemory(session) to index it. In future sessions, the agent can search past events using keyword matching.

For example: if a user mentioned "I love diving" in Session 1, the agent can retrieve that in Session 3 when recommending destinations — even though it's a different session.

Enable memory retrieval in the agent

ADK provides a built-in PRELOAD_MEMORY tool. It searches memory using the user's query and injects matching past events as context. Add it to the agent's tools:

import { PRELOAD_MEMORY } from "@google/adk";

export const rootAgent = new LlmAgent({
  // ...
  tools: [
    searchDestinations, checkVisaRequirement,
    manageWishlist, savePreference,
    skillToolset, PRELOAD_MEMORY,
  ],
});

Without PRELOAD_MEMORY, the memory service is configured and addSessionToMemory() works, but no agent turn retrieves memories. Adding PRELOAD_MEMORY without a memoryService on the Runner is a no-op — it silently skips the lookup.

Update the instruction to reference past conversations:

Memory:
- If a memoryService is configured, previous conversations provide context about the user's travel preferences.
- Reference past discussions when relevant (e.g., "Last time you were interested in Bali...").

Session vs Memory vs State

Session

Memory

Scope

One conversation

Across conversations

What's stored

Live state + event stream

Past events (keyword-indexed)

Access pattern

state.get/set

searchMemory(query)

Run and verify

npm run runner

This runs a single conversation turn and logs events. Check the database:

sqlite3 traivel.db "SELECT session_id, COUNT(*) FROM events GROUP BY session_id;"

Run the agent as a local REST API server:

# In-memory sessions
npx adk api_server

# With persistent sessions
npx adk api_server --session_service_uri "sqlite://./traivel.db"

This starts an HTTP server at http://localhost:8000 with endpoints for session management and querying.

Create a session

curl -X POST \
  http://localhost:8000/apps/traivel_agent/users/learner/sessions \
  -H "Content-Type: application/json" \
  -d '{}'

Response includes a session_id. Use it in the next calls.

Send a message

The JS API server uses /run (not /query like the Python version):

curl -X POST http://localhost:8000/run \
  -H "Content-Type: application/json" \
  -d '{
    "appName": "traivel_agent",
    "userId": "learner",
    "sessionId": "{SESSION_ID}",
    "newMessage": {
      "role": "user",
      "parts": [{"text": "Recommend a budget beach destination"}]
    }
  }'

Check session state

curl http://localhost:8000/apps/traivel_agent/users/learner/sessions/{SESSION_ID}

The response includes a state field with all stored values.

Add to wishlist via chat

curl -X POST http://localhost:8000/run \
  -H "Content-Type: application/json" \
  -d '{
    "appName": "traivel_agent",
    "userId": "learner",
    "sessionId": "{SESSION_ID}",
    "newMessage": {
      "role": "user",
      "parts": [{"text": "Add Bali to my wishlist"}]
    }
  }'

ADK can containerize and deploy the API server to Google Cloud Run:

adk deploy cloud_run .

This generates a Dockerfile running adk api_server, bundles your agent files, and runs gcloud run deploy. The result is a public REST endpoint — the same API you tested locally, just containerized.

You added state, persistence, and memory to Traivel.

API Recap

Feature

API

Scope

Session state (get/set)

toolContext.state.get/set(key, value)

Per-session (or persistent with DB)

User preferences

user: prefix

Survives across sessions (with DB)

Session temp data

temp: prefix

Current invocation only

App-wide data

app: prefix

Shared across all users

Persistent sessions

DatabaseSessionService("sqlite://./db")

SQLite file on disk

Cross-session memory

InMemoryMemoryService

Keyword search over past events

Google Search grounding

GOOGLE_SEARCH

Real-time info via Gemini API

REST API

npx adk api_server

HTTP testing

Files changed from Part I

File

Changes

tools.ts

State reads/writes, manageWishlist, savePreference

agent.ts

New tools, state-aware instructions

runner.ts

(new) Programmatic Runner with DB + Memory

package.json

New scripts (web:db, runner, etc.)

Optional: Part III shows how this agent is added to a Next.js web app with a custom chat UI.