Every agent framework has to answer three questions: what does the agent remember between turns, who can see it, and when does it disappear? ADK for Java answers them with a small set of classes: a session service that stores sessions, a Session holding an event list and a state map, key prefixes that widen or narrow the scope of each state entry, and context objects that give callbacks and tools a controlled view of all of it. The answers are precise, and most bugs in ADK agents come from guessing them.
An earlier version of this page described a runtime.newSession(userId) call and a sessionStore.save(session) step. Neither exists in ADK Java. Sessions are created and loaded through BaseSessionService, and state is persisted as a side effect of appending events, not by saving a session object. This page describes the classes as they are in the google/adk-java source, checked on 2026-09-30, and shows what lives and what dies at each scope.
The objects and their contract
A Session is identified by three strings: appName, userId and id. It carries a state map, a list of events and a last-update time. You never construct one directly in application code; you ask a BaseSessionService. The interface is reactive, built on RxJava: createSession(appName, userId) returns Single<Session>, getSession(appName, userId, sessionId, Optional<GetSessionConfig>) returns Maybe<Session> because the session may not exist, and appendEvent(session, event) returns Single<Event>. There are also listSessions, listEvents and deleteSession, plus overloads that take a SessionKey and one that accepts initial state.
The core module ships InMemorySessionService, which keeps everything in concurrent maps inside the JVM, and VertexAiSessionService, backed by Vertex AI. Anything else, such as PostgreSQL, is your own implementation of BaseSessionService. The contract matters more than the backend: a session's durable state is whatever the service reconstructs from appended events and their state deltas.
State is a map that records its own changes
Session state is exposed through the State class, which implements ConcurrentMap<String, Object>. It wraps two maps: the underlying state and a delta. put(key, value) writes to both. remove(key) removes from the state and records a special State.REMOVED sentinel in the delta, so a deletion can be persisted as an event rather than silently lost.
The reason for the delta is that persistence in ADK is event-sourced. When a callback or tool calls context.state().put(...), the context's State was built as new State(session.state(), eventActions.stateDelta()). The value becomes visible immediately to later code in the same turn, because the session map changed, and it is also recorded in the stateDelta of the event that the step produces. When the runner hands that event to appendEvent, the service applies the delta to its stored copy. A write that never reaches an event's stateDelta is, from the service's point of view, a write that never happened.
The four scopes, decided by a key prefix
One map, four lifetimes. The prefix on the key decides where the value is stored and who sees it on the next read. The prefixes are the constants State.APP_PREFIX, USER_PREFIX and TEMP_PREFIX, with the values app:, user: and temp:.
| Key form | Scope | InMemorySessionService stores it | Survives |
|---|---|---|---|
| app:feature_flags | Application: every user and session of this appName | In a per-app map, prefix stripped, merged back on getSession | Until the service (or process, for in-memory) goes away |
| user:preferred_language | One userId across all of that user's sessions | In a per-app, per-user map, merged back on getSession | New sessions for the same user |
| cart_id | This session only | In the session's own state map | Every later turn of this session |
| temp:raw_lookup | This invocation | Not handled specially by the in-memory service; the base appendEvent ignores it | Treat as gone after the turn; do not rely on either outcome |
| A Java local in your tool | One method call | Nowhere | Nothing; use state if the next step needs it |
In InMemorySessionService, appendEvent routes app: keys to a map keyed by appName and user: keys to a map keyed by appName and userId, both with the prefix stripped. Everything else goes into the session's own state map. getSession returns a copy of the stored session with the app and user maps merged back in under their prefixes. That merge is why a user: value written in Monday's session appears in Tuesday's brand-new session.
The temp: prefix needs care. The default appendEvent in BaseSessionService explicitly ignores temp: keys when it applies a delta, which is the intended contract: values scoped to the current invocation, handy for passing a raw tool result to an after-tool callback. The in-memory service, however, applies its own loop first and writes every key that is not app: or user: into the session map, and it stores the very session object the runner was mutating. Do not build on either outcome. Use temp: for data you are happy to lose after the turn, never read it on a later turn, and never use it to hold secrets you expect to disappear.
Invocation and tool scope
An invocation is one call to Runner.runAsync: one user message and everything the agents do in response, however many model calls and tool calls that takes. runAsync(userId, sessionId, content) fetches the session, fails with Session not found unless RunConfig's autoCreateSession is enabled (it defaults to false), appends the user's message as an event, and builds an InvocationContext. That object carries an invocationId of the form e- followed by a random UUID, the session, the current agent, the user content, the RunConfig, the session, artifact and memory services, and an endInvocation flag a callback can set to stop the turn. It dies when the returned Flowable completes.
Callbacks receive a CallbackContext and tools receive a ToolContext, which extends it. Both are created per step, around a specific event's EventActions. ToolContext adds the functionCallId of the model's tool call, the actions object, searchMemory(query) for the long-term memory service, and methods to request a confirmation from the user. Anything you keep in a field of the context object is gone after that call; anything you put in state() is in the event.
A runnable example
A small support agent that remembers a user's language across sessions, keeps an order id within one session, and records the model's final answer with outputKey. FunctionTool injects the ToolContext into the parameter whose name is toolContext, taking the name from @Schema if present and otherwise from reflection, which only sees real names when compiled with -parameters; the annotation removes that dependency.
import com.google.adk.agents.LlmAgent;
import com.google.adk.artifacts.InMemoryArtifactService;
import com.google.adk.agents.RunConfig;
import com.google.adk.events.Event;
import com.google.adk.runner.Runner;
import com.google.adk.sessions.InMemorySessionService;
import com.google.adk.sessions.Session;
import com.google.adk.tools.Annotations.Schema;
import com.google.adk.tools.FunctionTool;
import com.google.adk.tools.ToolContext;
import com.google.genai.types.Content;
import com.google.genai.types.Part;
import java.util.Map;
public class SupportAgent {
public static Map<String, Object> rememberOrder(
@Schema(name = "orderId", description = "The order the user is asking about") String orderId,
@Schema(name = "toolContext") ToolContext toolContext) {
toolContext.state().put("order_id", orderId); // session scope
toolContext.state().put("temp:lookup_raw", "..."); // this turn only
Object lang = toolContext.state().get("user:language"); // written in an earlier session
return Map.of("status", "ok", "language", lang == null ? "en" : lang);
}
public static void main(String[] args) {
LlmAgent agent = LlmAgent.builder()
.name("support")
.model("gemini-2.0-flash")
.instruction("Help with orders. Call rememberOrder when the user names an order.")
.tools(FunctionTool.create(SupportAgent.class, "rememberOrder"))
.outputKey("last_answer") // final text into state
.build();
InMemorySessionService sessions = new InMemorySessionService();
Runner runner = Runner.builder()
.agent(agent).appName("support").sessionService(sessions)
.artifactService(new InMemoryArtifactService()) // build() requires one
.build();
Session s = sessions.createSession("support", "u-42").blockingGet();
// stateDelta rides on the user's message event, so appendEvent routes user: correctly
for (Event e : runner.runAsync("u-42", s.id(),
Content.fromParts(Part.fromText("Where is order A-1001?")),
RunConfig.builder().build(),
Map.of("user:language", "de")).blockingIterable()) {
System.out.println(e.author() + ": " + e.stringifyContent());
}
Session after = sessions.getSession("support", "u-42", s.id(), java.util.Optional.empty())
.blockingGet();
System.out.println(after.state()); // order_id, last_answer, user:language (in-memory: temp: may show too)
}
}The model name is an example; use whichever model your project is configured for. blockingGet and blockingIterable keep the example short; in a server, subscribe asynchronously instead. The language is passed as the stateDelta argument of runAsync, which the runner attaches to the user's message event, rather than as creation state. That choice matters: InMemorySessionService.createSession stores the initial state map as the session's own state without prefix routing, so a user: key passed there sits in that one session and never reaches the user map.
Worked example: two sessions, one user
Follow user u-42 through two sessions. On Monday, session S1 is created and the first turn carries user:language = de in its stateDelta. The turn calls rememberOrder: the tool's writes land in the tool response event's stateDelta as order_id = A-1001 and temp:lookup_raw. The agent's final response event carries last_answer. After the turn, S1's own state holds order_id and last_answer; the user map for u-42 holds language = de.
On Tuesday the user opens a new chat, so the application creates session S2. getSession for S2 returns user:language = de, merged from the user map, but not order_id or last_answer, which were session-scoped to S1. If the model needs the order, it has to ask again or the application has to look it up. That is usually right: session state is conversational working memory, and facts that should persist belong in user: state or in the long-term memory service. Had the tool written user:last_order instead, S2 would see it; the decision is a product choice made one prefix at a time.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| State set in a tool is missing next turn | Written with session.state() on a copy fetched outside the run, not through a context | Write only through context state() or the runAsync stateDelta argument, so it lands in an event |
| Session not found on the first message | runAsync looks the session up and autoCreateSession defaults to false | Create the session first, or enable autoCreateSession deliberately |
| One user sees another user's value | Per-user data written without the user: prefix while the sessions are shared, or with app: by mistake | Review every app: key in code review; app: is global |
| Latency grows with conversation length | Every event is loaded and replayed into the prompt | GetSessionConfig numRecentEvents for reads; summarise; move long-term facts to the memory service |
| Everything vanishes on deploy | InMemorySessionService in production | A persistent BaseSessionService implementation |
| A user: value set at creation is missing in the next session | InMemorySessionService.createSession stores initial state on the session without prefix routing | Write user: and app: keys through an event, such as the runAsync stateDelta argument |
| Tool never sees its ToolContext | Parameter name is arg1 at runtime because the build drops parameter names, and there is no @Schema name | Annotate it @Schema(name = "toolContext") or compile with -parameters |
Two of these deserve emphasis. Concurrent turns on the same session are not serialized for you: two runAsync calls on one session id each read state, each produce deltas, and the later append wins key by key. Serialize turns per session in the application if your clients can double-submit. And state is not a blob store: large values bloat every getSession and every event. Put files and model outputs you only need to reference in artifacts and keep a key in state.
Operating sessions in production
Choose the backend before launch. InMemorySessionService is for tests and demos; a restart loses every session, as the runtime boot sequence explains. For a durable implementation, the essential property is that appendEvent applies deltas atomically with the event write, so a crash cannot store an event whose state change was lost.
Bound what you load. getSession accepts a GetSessionConfig with numRecentEvents or afterTimestamp; use it for read paths that do not need the full history. Expire idle sessions and call deleteSession when a user asks to be forgotten, remembering that user: and app: data live outside the session and need their own deletion path. Log the invocationId on every line a turn produces, and attach it to traces, so one turn can be reassembled across callbacks and tools; the callback architecture page shows where to hook that in, and the execution loop shows when each event is appended.
What to do next
- List every state key your agent writes and label it app:, user:, session or temp:, and justify each app: key, because it is visible to every user.
- Grep your code for session.state().put outside a callback or tool and move those writes to a context state() call or the runAsync stateDelta argument.
- Write a test that runs two sessions for one user and asserts which keys appear in the second.
- Annotate every tool's context parameter @Schema(name = "toolContext") and add a test that fails if the tool cannot see state.
- Replace InMemorySessionService with a durable implementation before production, with atomic event-and-delta writes.
- Add numRecentEvents to read paths, a session expiry job, and a user deletion path that also clears user: state.