The Session is the unit of a conversation

Before any backend, any scope prefix, any resumption logic, there is one object you have to be clear about: the Session. In ADK a session is a single conversation thread — one user talking to one app over some span of turns — represented as a plain data object the runtime passes around. It is not a connection, not a process, and not a request; those come and go while the session persists. When a user opens a chat and sends three messages, gets two tool-backed answers, and comes back an hour later to ask a follow-up, all of that belongs to one session.

Framing it this way matters because it separates the conversation from the machinery serving it. A Runner drives the agent loop, a model produces tokens, tools perform side effects — but the durable record of what the conversation is lives in the session, not in any of those. That separation is what lets a different process pick the conversation up later, and it is why nearly every ADK memory concept — state, events, resumption, long-term memory — is described in relation to the session. Get the Session object right in your head and the rest of the model falls into place.

Advertisement

Anatomy of a Session object

A Session is a small, well-defined record. In ADK it carries a handful of fields, and knowing exactly what each one is removes most of the confusion people have about ‘where does the agent’s memory live?’

FieldWhat it is
idThe unique identifier of this conversation thread
app_nameWhich agent application the session belongs to
user_idWhich user the conversation is with
eventsThe ordered, append-only list of everything that happened
stateA dictionary of current working values the agent reads and writes
last_update_timeTimestamp of the most recent change to the session

Two of these fields do most of the work and are worth holding apart in your mind. The events list is the history — the immutable, chronological account of every message, model response, and tool call. The state dict is the working set — the current values the agent needs right now, like the order being discussed or the step in a workflow. History is what happened; state is where things stand. The other four fields — the identity triple plus the timestamp — exist to name the session, route it to the right user, and order it in time. That is the whole object.

Advertisement

Identity: the app_name / user_id / session_id triple

A session is not addressed by its id alone. ADK identifies a session by a triple: (app_name, user_id, session_id). All three are required to look one up, and that is a deliberate design choice, not bureaucracy. The app_name partitions sessions by application, so two different agents can share a store without colliding. The user_id scopes the conversation to a person, which is the boundary that keeps one user’s thread from ever being handed to another. The session_id picks the specific conversation among the possibly many that one user has had with that app.

The practical consequence is that the triple is a security boundary as much as an addressing scheme. Because get_session takes all three, a request that presents a valid session_id but the wrong user_id does not silently fetch someone else’s conversation — the lookup is scoped by construction. Treat the triple as the primary key of a conversation: generate session_id values that are hard to guess, always carry the authenticated user_id from your auth layer rather than trusting the client, and never reuse one user’s session_id under another user’s identity. The scoping of state values within a session — the user: and app: prefixes — is a separate mechanism covered in the state-management article; this is scoping of the session itself.

Events: how a Session accumulates history

The events list is where a session grows. Every meaningful thing that happens in a turn becomes an Event appended to the end of the list: the user’s message, the model’s response, a function (tool) call, a function response, and any state changes those carried. The list is ordered and effectively append-only — new events go on the end, existing ones are not rewritten. That single discipline is what makes a session reconstructable: replay the events in order and you have rebuilt the exact conversation.

Each Event is itself a small structured record. It has an author (who produced it — the user, or a named agent), a content payload (the message or tool interaction), an actions object that can carry a state_delta (the changes this event makes to session.state), a timestamp, and an invocation_id tying it to the run that produced it. This is why the events list is more than a chat transcript: it is the ledger that also records how state evolved, event by event. When people say ADK sessions are ‘event sourced,’ this is what they mean — the history is the source of truth, and the current state is the accumulation of every event’s delta applied in order.

The SessionService interface

You rarely touch a Session’s storage directly. Instead you go through a SessionService — the interface (an implementation of ADK’s BaseSessionService) that owns the lifecycle of sessions and hides where they actually live. Its surface is small and CRUD-shaped, which is the point: a handful of operations cover everything the runtime needs.

OperationWhat it does
create_sessionMake a new session (optionally with initial state and a chosen id)
get_sessionLoad an existing session by its identity triple
list_sessionsEnumerate a user’s sessions for an app
delete_sessionRemove a session and its history
append_eventAdd an event to a session and apply its state changes

The value of pinning this interface down is decoupling. Your agent code, and the Runner that drives it, are written against these methods — not against a database, a file, or a cloud API. Swap an in-memory implementation for a database-backed or a managed one and none of your agent logic changes, because it only ever asked the service to create, get, list, delete, and append. The comparison of those concrete backends is its own article; what matters here is that they are interchangeable precisely because they all satisfy this same small contract.

Creating a session

Every conversation starts with create_session. You choose a service implementation, then ask it for a new session under a given app and user. You may hand it an initial state dict and an explicit session_id; if you omit the id, the service generates one. In current ADK the service methods are asynchronous, so you await them.

from google.adk.sessions import InMemorySessionService

session_service = InMemorySessionService()

# Create a fresh conversation for this user, optionally seeding state.
session = await session_service.create_session(
    app_name="support_bot",
    user_id="user-42",
    session_id="conv-2026-07-23-abc",   # omit to auto-generate
    state={"cart_items": 0, "tier": "free"},
)

print(session.id)                # conv-2026-07-23-abc
print(session.events)            # [] — empty at birth
print(session.state["tier"])     # free

What you get back is a Session object with its identity set, an empty events list, the initial state you provided (or an empty dict), and a fresh last_update_time. That is the shell a conversation grows into. Note that creation is where any seed state belongs — a system prompt variable, a default preference, the user’s tier — because it is present before the first turn runs. In most real apps you do not call this by hand on every message; the Runner is given the service and creates or reuses the session for you.

Retrieving and resuming a session

The complement of creation is get_session: given the identity triple, hand back the full session — its accumulated events and its current state — so a new turn continues where the last one left off. This is the load half of the lifecycle, and it is what makes a conversation survive across disconnects and process restarts.

# Later — a new request, possibly on a different runner process.
session = await session_service.get_session(
    app_name="support_bot",
    user_id="user-42",
    session_id="conv-2026-07-23-abc",
)

if session is None:
    # No such conversation for this user — start a new one.
    session = await session_service.create_session(
        app_name="support_bot", user_id="user-42",
        session_id="conv-2026-07-23-abc",
    )

# Full history and state are back, ready for the next turn.
print(len(session.events))       # every prior event, in order
print(session.state.get("cart_items"))

Two details matter. First, get_session returns None when nothing matches the triple, so the honest pattern is get-or-create: try to load, and fall back to create. Second, the object you get back is a fully rehydrated conversation — the events replayed and the state folded — not a stub. That is resumption at the object level: the mechanism of durably storing and reconstructing across crashes is covered in the resumption article, but the interface to it is simply this call. Some services also accept a config to bound what you load (for example, only the most recent events), which keeps rehydration cheap for very long conversations.

append_event: the one write path

If create and get are how sessions enter and leave memory, append_event is how they change. It is the single, deliberate write path for an active conversation, and it does two things at once: it adds the event to the session’s events list, and it applies that event’s state_delta to session.state. History and state advance together, atomically, through one call.

from google.adk.events import Event, EventActions
from google.genai import types

event = Event(
    author="user",
    content=types.Content(role="user",
        parts=[types.Part(text="Add 2 widgets to my cart")]),
    actions=EventActions(state_delta={"cart_items": 2}),
)

await session_service.append_event(session, event)
# session.events now ends with this event;
# session.state["cart_items"] is now 2;
# session.last_update_time has advanced.

Routing every mutation through append_event is what keeps the two halves of a session consistent. You do not poke session.state in one place and shove a message into session.events in another and hope they agree; the event carries both, and applying the event is the commit. In normal use the Runner constructs and appends these events as the agent runs — you author tools and state deltas, and the framework calls append_event — but understanding that this is the sole write path demystifies how state and history stay in lockstep.

last_update_time and ordering

The quietest field on a Session is last_update_time, and it earns its place. It records when the session last changed — advanced on every append_event — and it turns the session from a bag of data into something you can reason about temporally. That has several concrete uses. It lets a UI sort a user’s conversations by recency so the thread they were just in floats to the top. It lets you expire or archive sessions that have gone quiet for long enough. And it gives you a cheap freshness check: is the session I hold in memory still the latest, or did another turn land since I loaded it?

That last use points at a real hazard. A session loaded into one process is a snapshot; if two turns for the same conversation are processed concurrently, each can hold a stale copy and clobber the other’s appends. The last_update_time is the signal that lets a service detect and reject a write against an out-of-date view. The append-only event model helps here too — because writes are appends keyed to a position rather than whole-object overwrites — but the practical guidance is simple: do not run two turns of the same session in parallel, and let the service, not your code, be the arbiter of ordering.

The lifecycle of a session

Put the operations in order and a session’s life is a clean arc. It is worth seeing end to end, because each stage maps to exactly one part of the interface.

StageWhat happensInterface
CreationEmpty shell with identity and any seed statecreate_session
Active turnsEvents append; state and history advance togetherappend_event
InterruptionConnection or process ends; the durable copy remains(store persists)
ResumptionReload by triple; full history and state returnget_session
RetirementConversation ends; session removed or archiveddelete_session

The shape to notice is that creation and retirement bracket the session, append_event is the whole middle, and resumption is not a special mode but simply a get_session that happens after an interruption. There is no distinct ‘save’ step in this picture: because each append advances the durable record, the session is always at its latest committed point, and a resume just reads that point back. That is the payoff of the append-only design — the lifecycle has no gap where progress is held only in volatile memory waiting to be flushed.