ADK Java keeps conversations in a session service. The default InMemorySessionService loses everything when the process stops, so any agent that runs on more than one instance or survives a deploy needs a durable one. For teams already on Google Cloud, Firestore is the obvious candidate: serverless, per-document pricing, no connection pool to size. ADK Java ships a contributed module for it, google-adk-firestore-session-service, with a session service, a keyword memory service and a runner that wires both.

This page reads that module the way you should before putting it in production: what it stores where, what one agent turn writes, and which behaviours differ from the in-memory service your tests probably use. The details below come from reading the contrib source on the adk-java main branch as of 2026-10-04. Contrib code moves faster than core, so re-read the classes for the version you pin; the method of checking matters more than any one line.

Related reading: session context and state prefixes, a Postgres event log with a transactional appendEvent for contrast, and scale-out and same-session concurrency.

Adding the module and wiring a runner

The artifact lives in the com.google.adk group. The project documentation says to keep its version identical to google-adk, so drive both from one property.

<properties>
  <adk.version><!-- the google-adk release you use --></adk.version>
</properties>
<dependency>
  <groupId>com.google.adk</groupId>
  <artifactId>google-adk</artifactId>
  <version>${adk.version}</version>
</dependency>
<dependency>
  <groupId>com.google.adk</groupId>
  <artifactId>google-adk-firestore-session-service</artifactId>
  <version>${adk.version}</version>
</dependency>

There are two ways to wire it. FirestoreDatabaseRunner extends Runner and builds a GcsArtifactService, a FirestoreSessionService and a FirestoreMemoryService for you. It reads settings from adk-firestore.properties on the classpath, or adk-firestore-{env}.properties when an environment is selected, and it throws at construction if gcs.adk.bucket.name is missing, even if your agent never saves an artifact. The keys are firebase.root.collection.name for the top-level collection, gcs.adk.bucket.name and keyword.extraction.stopwords. If you do not want a bucket, construct Runner yourself with the same pieces, which is what the convenience class does internally.

Firestore db = FirestoreOptions.getDefaultInstance().getService();

// Option 1: the convenience runner (needs gcs.adk.bucket.name in adk-firestore.properties)
Runner runner = new FirestoreDatabaseRunner(agent, "support-app", db);

// Option 2: explicit wiring, same constructor the convenience class calls
Runner explicit = new Runner(
    agent,
    "support-app",
    new GcsArtifactService("my-artifact-bucket", StorageOptions.getDefaultInstance().getService()),
    new FirestoreSessionService(db),
    new FirestoreMemoryService(db),
    new ArrayList<>());   // plugins

Session s = runner.sessionService()
    .createSession("support-app", userId, new ConcurrentHashMap<>(), null)
    .blockingGet();
runner.runAsync(userId, s.id(), Content.fromParts(Part.fromText(message)))
    .blockingForEach(event -> System.out.println(event.stringifyContent()));

Credentials come from the usual Google Cloud application default credentials. On Cloud Run that is the service account attached to the revision, which needs an IAM role granting Firestore read and write on the project, and object access on the artifact bucket.

Where everything is stored

What FirestoreSessionService writes for one appended eventRunner.runAsyncuser, session, messageappendEventup to 5 independent writeseach event{root}/{userId}/sessions/{sessionId}fields: id, appName, userId, state, updateTime.../sessions/{sessionId}/user-event/{autoId}event fields, timestamp string, keywords[]app-state/{appName}keys written with the _app_ prefix (merge)user-state/{appName}/users/{userId}keys written with the _user_ prefix (merge)state, updateTimeevent docFirestoreMemoryServicecollection group user-eventkeyword searchNo transaction and no version check: the writes are issued together and awaited together.getSession reads the session document and its user-event subcollection only.
Document paths and the writes issued by one appendEvent call.

Sessions are keyed by user first. The session document lives at {root}/{userId}/sessions/{sessionId}, where {root} is the configured root collection, and it stores id, appName, userId, the session-scoped state map and updateTime. Events are documents in a user-event subcollection under the session, with auto-generated IDs. Shared state lives in two separate top-level collections: app-state/{appName} and user-state/{appName}/users/{userId}.

Two consequences follow from keying by user rather than by application. First, a session ID is unique per user across all applications sharing the root collection: createSession uses Firestore's create, which fails if the document exists, and the service turns that into a SessionException. Second, getSession checks the stored appName against the one you pass and reports not-found on a mismatch, so one app cannot read another app's session for the same user even though they share a path. Give each environment its own root collection or its own database, so staging and production never collide on IDs.

What one appended event writes

Every event the runner produces passes through appendEvent. Walking the source, one call does this:

  1. Splits the event's state delta. Keys starting with _app_ go to an app-state update, keys starting with _user_ go to a user-state update, both with the prefix stripped and written with merge. Everything else updates the in-memory session state, with a null value meaning delete.
  2. If session-scoped state changed, writes the whole state map back to the session document's state field.
  3. Serialises the event, adds appName, userId and a keywords array extracted from its text parts, and writes it as a new user-event document.
  4. Updates the session document's updateTime from the event timestamp.
  5. Waits for all of those writes together.

Two properties matter for production. The writes are independent requests, not a transaction or a batch, so a failure part-way through can leave an event stored without its state change or the other way round. And the state write replaces the whole map with no version check, so two processes appending to the same session each write their own copy and the later one wins for every key, including keys the earlier one changed. Compare the Postgres design linked above, which wraps the event and delta in one transaction.

State scopes: check the prefixes

Core ADK Java defines three state prefixes in the State class: app: for values shared by every user of the application, user: for values that follow a user across sessions, and temp: for values that last one invocation and are never persisted. InMemorySessionService routes app: and user: keys into shared maps and merges them back into every session it returns, and the base class drops temp: keys when it applies a delta. FirestoreSessionService overrides appendEvent completely, and at the time of writing it routes only the literal prefixes _app_ and _user_.

That has three visible effects if your agent was written against the core conventions. A key written as user:language is stored as an ordinary session key, so the next session for that user does not see it. A key written as app:feature_flags is likewise session-scoped. And temp: keys are persisted with the session because the override does not filter them. Separately, getSession builds the session's state from the session document alone; it does not read app-state or user-state back, so even keys routed with _user_ are written but not returned in a new session's state.

Do not paper over this by guessing. Pin the version, then write a test that runs against the Firestore emulator and asserts where each key lands:

@Test
void stateKeysLandWhereWeExpect() {
  FirestoreSessionService svc = new FirestoreSessionService(emulatorFirestore());
  Session first = svc.createSession("app", "u1", new ConcurrentHashMap<>(), null).blockingGet();

  Event e = Event.builder()
      .id(Event.generateEventId())
      .author("agent")
      .actions(EventActions.builder()
          .stateDelta(new ConcurrentHashMap<>(Map.of(
              "user:language", "de",       // core convention
              "_user_tier", "gold",          // contrib convention
              "temp:raw", "scratch")))
          .build())
      .build();
  svc.appendEvent(first, e).blockingGet();

  Session second = svc.createSession("app", "u1", new ConcurrentHashMap<>(), null).blockingGet();
  Session reread = svc.getSession("app", "u1", first.id(), Optional.empty()).blockingGet();

  // Record what your pinned version does, then turn these prints into assertions.
  System.out.println("second session state: " + second.state());
  System.out.println("first session reread: " + reread.state());
}

Then choose deliberately. If you need cross-session data, the most robust option with this module is to own it: a tool or callback that reads and writes a profile document you design, keyed by user, with a transaction. That also gives you one place to implement deletion requests.

Reading sessions back

getSession reads the session document, then queries the user-event subcollection ordered by the timestamp field. If you pass a GetSessionConfig with numRecentEvents, it uses limitToLast; with afterTimestamp, it adds a greater-than filter. Without either it loads every event, so read cost grows with conversation length: a session with 400 events costs about 401 document reads per turn, plus deserialising all of them. Long-running assistants should cap history on the read path.

One subtle point. The timestamp is stored as the string form of a Java Instant, and Firestore orders strings byte by byte. Instant.toString drops a zero fraction, so an event at exactly 10:00:01.000 is stored as ...10:00:01Z while one at 10:00:01.500 is ...10:00:01.500Z. Because '.' sorts before 'Z', the later event sorts first. An event landing exactly on a whole second, about one in a thousand, can therefore come back out of order relative to its neighbours in that second, and afterTimestamp compares the same strings. If ordering within a turn matters to you, verify it with the emulator or store your own sortable sequence field.

The keyword memory service

FirestoreMemoryService does no embedding and no summarisation. Keywords are extracted when events are appended: lower-cased alphabetic words minus a stop-word list, stored on each event document. addSessionToMemory is therefore a no-op. searchMemory extracts keywords from the query, splits them into chunks of 10, and runs one collection-group query per chunk over all user-event documents with matching appName and userId and an array-contains-any on keywords, then de-duplicates by event ID.

Practical consequences: matching is exact word overlap, so refund does not match refunds; there is no ranking and no limit, so a frequent word can return every event the user ever produced; and the first query will usually fail with an error that names the missing collection-group index, which you then create once per database. Treat it as a reasonable demo and a baseline, and see the MemoryService architecture for what a retrieval-quality memory needs.

Two turns on one session

Because appendEvent has no version check, serialise turns per session yourself. A lease document claimed in a Firestore transaction works across instances:

boolean tryAcquire(Firestore db, String sessionId, String owner, Duration ttl) throws Exception {
  DocumentReference lease = db.collection("session-leases").document(sessionId);
  return db.runTransaction(tx -> {
    DocumentSnapshot snap = tx.get(lease).get();
    Instant now = Instant.now();
    if (snap.exists()) {
      Timestamp until = snap.getTimestamp("until");
      if (until != null && until.toDate().toInstant().isAfter(now)
          && !owner.equals(snap.getString("owner"))) {
        return false;                                  // someone else holds it
      }
    }
    tx.set(lease, Map.of(
        "owner", owner,
        "until", Timestamp.of(Date.from(now.plus(ttl)))));
    return true;
  }).get();
}

Acquire before runAsync, release after the stream completes, and make the TTL longer than your worst turn including tool calls. If acquisition fails, return a busy response rather than queueing indefinitely. Keep app-scoped writes rare as well: every session writing _app_ keys updates one app-state document, and Firestore guidance is to keep sustained writes to a single document around one per second.

Operating it

  • Retention. Firestore TTL policies need a Timestamp-typed field, and updateTime here is a string, so add your own expiry field through a wrapper or a scheduled job. TTL deletion does not remove subcollections, so prefer deleteSession, which deletes events in batches of 500 and then the session.
  • Size. A document is capped at 1 MiB. Large tool outputs inside events or state hit it; put them in artifacts and keep a reference.
  • Cost. Reads scale with events per getSession; writes are two to five per event. Watch both per turn, not per day.
  • Deletion requests. A user's data lives under {root}/{userId}, in user-state, and in any of your own collections. Script all three; see the right to be forgotten.
  • Testing. Run integration tests against the Firestore emulator, never only against the in-memory service.

Worked example

A support agent on Cloud Run, two instances, averaging 12 turns per conversation and about 3 events per turn. A session ends with 36 events, and turn t reads the session document plus the 3(t-1) events already stored, so the conversation costs about 210 reads against 72 to 180 writes. Setting numRecentEvents to 20 caps per-turn reads at 21 and cuts the total to about 175, and keeps the prompt bounded too, which matters more for longer conversations. A user double-clicks send: without the lease both instances run a turn on the same session and the second state write discards the first one's keys; with the lease the second request gets a busy reply. Deploying it follows the Cloud Run guide.

Trade-offs

ChoiceGainsCosts
Contrib Firestore service as isNo schema, no pool, quick startNo atomic append, prefix mismatch, keyword-only memory
Contrib service plus lease and own profile docsSafe concurrency, explicit cross-session dataCode you own and test
Your own BaseSessionService on FirestoreTransactions, versioning, your schemaMost work, track core changes
Postgres or another SQL storeTransactions and queries over historyPools, migrations, capacity planning

What to do next

  • Pin google-adk and the Firestore module to one version property.
  • Run the state-routing test above against the emulator and record what your version does.
  • List every state key your agent writes and decide where each should live with this backend.
  • Add a per-session lease and a busy response before running more than one instance.
  • Set numRecentEvents on every read path and measure reads per turn.
  • Create the collection-group index if you use FirestoreMemoryService, and cap result sizes in your code.
  • Use a separate root collection or database per environment, and script per-user deletion.
Key takeaway: The contrib Firestore module gives ADK Java durable sessions, keyword memory and a ready runner, but it appends events with independent writes and no version check, routes only _app_ and _user_ state keys, and reads only session-scoped state back. Pin the version, test key placement against the emulator, serialise turns with a lease, cap history on reads, manage retention yourself, and own any cross-session data that matters.