The session is the unit of conversation

Everything an ADK agent knows about the exchange it is in lives in a Session, addressed by a triple — app_name, user_id, and a session id. That triple is the whole addressing scheme; there is no ambient ‘current conversation’. You create a session, pass its id to the Runner on every turn, and the runtime loads it, runs the agent tree against it, and writes the new events back.

The object itself is deliberately small: the identifying triple, an ordered list of events, a state dictionary, and a last-update timestamp. A session is not a user profile, not an analytics record, and not a general key-value store that happens to be nearby — each of those has a different lifetime, and collapsing them into the session is the first mistake most teams make.

Which makes when a new session should start the real design question. A session is a clean slate for session-scoped state, so the natural boundary is a genuinely new task: a new support ticket, a new booking, a new document. One immortal session per user feels tidy and ages badly — the event list grows without bound, and stale working values from three tasks ago linger in state and leak into prompts. Short, task-shaped sessions plus cross-session user: state is almost always the better shape.

Advertisement

Events are the record, State is the fold

ADK does not store the conversation as a mutable document that each turn edits. It stores an append-only sequence of events: the user’s message, the model’s replies, every function call and function response, and the control actions the runtime took. Nothing in that list is ever rewritten, and each event is authored, ordered, and timestamped — so ‘why did the agent say that?’ always has an answer that does not depend on someone having remembered to log it.

State is the derived view. Events can carry a state_delta — a small dictionary of key/value changes — and the current value of session.state is what you get by applying those deltas in order. The service materializes the fold so that reads are a plain dictionary lookup rather than a replay, but the delta chain is the record of truth. This buys the three things agent systems need most: provenance (every value traces to the event and author that set it), resumability (a crashed run picks up from the last appended event), and replayability (an eval can re-run a real conversation and watch state evolve step by step). The corollary governs everything below: a state change that did not travel on an event did not really happen.

ADK sessions, state, and memory — the agent's three timescalesturn, conversation, and foreverSessionone conversation threadEventsappend-only historyStatefold of state deltasScopessession / user: / app: / temp:SessionServicein-mem / DB / managedContext windowingwhat the model seesMemoryServicecross-session recallArtifactsblobs out of contextInstruction templating{key} injection from stateCompactionsummaries bound growthOps — state key registry + session TTLs + PII disciplinepersistwindowdistillreferenceinjectsummarizeoffloadoperateoperate
ADK's memory hierarchy: events are the durable record, state is the working fold with scopes, memory distills across sessions, artifacts hold the blobs.
Advertisement

Anatomy of an event

An event is a small record with a fixed set of load-bearing fields. author is either the literal "user" or the name of the agent that produced it, which is what makes multi-agent transcripts readable. invocation_id groups every event generated by a single user turn — one message can produce a dozen once tool calls start — and is the handle you want when correlating logs. content holds the payload as a GenAI Content object: text parts, function-call parts, function-response parts. And actions carries the side effects.

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

Event(
    author="order_agent",
    invocation_id=invocation_id,
    content=types.Content(
        role="model",
        parts=[types.Part(text="Your order shipped Tuesday.")],
    ),
    actions=EventActions(state_delta={"order_id": "8842",
                                      "order_status": "shipped"}),
)

You rarely construct one by hand; the runtime emits them and you read them. The field to know is actions, an EventActions carrying state_delta alongside its siblings — the artifact delta, a transfer-to-agent request, an escalation flag. Streaming turns also emit partial events, so when consuming the runner’s stream, filter to the ones representing a completed response rather than treating every yield as a finished answer.

You never assign to state — you emit a delta

The write path is the part people get wrong, because the ergonomics look like a dictionary. Inside a tool you receive a ToolContext; inside a callback, a CallbackContext. Both expose a state mapping, and assigning to it looks like mutation but is really recording a pending delta. When the turn produces its event, that delta rides along and the session service applies it while appending.

from google.adk.tools import ToolContext

def lookup_order(order_id: str, tool_context: ToolContext) -> dict:
    order = orders_api.fetch(order_id)
    tool_context.state["order_id"] = order_id            # -> state_delta
    tool_context.state["order_status"] = order.status    # -> state_delta
    tool_context.state["user:last_order"] = order_id     # persists across sessions
    return {"status": order.status, "eta": order.eta}

The anti-pattern is reaching for the session object directly — session.state["order_id"] = "8842" on a Session you fetched from the service. That mutates a local dictionary. No event carries it, nothing persists it, and the next get_session returns the old value. Worse, it may appear to work under InMemorySessionService, where your local object is the stored one, then break silently the day you move to a database service.

There is a quieter write path worth knowing: an LlmAgent configured with output_key writes its final response into state under that key. It is the idiomatic way one pipeline stage hands a result to the next, and it goes through the same delta mechanism — no special case.

The four scopes — session, user:, app:, temp:

State keys are namespaced by prefix, and the prefix is not decoration: it selects which store the value lands in and how long it lives. ADK exposes the prefixes as constants on State (State.APP_PREFIX, State.USER_PREFIX, State.TEMP_PREFIX), but in practice you type them into the key.

PrefixExample keyVisible toLifetime
noneorder_idthis session onlyuntil the session is deleted
user:user:preferred_contactevery session of this useruntil explicitly deleted
app:app:support_hoursevery user of the appuntil changed; read-mostly config
temp:temp:auth_claimsthe current invocationdiscarded — never persisted

Two of these earn their keep immediately. user: is how ‘prefers metric units’ survives the end of a conversation instead of being re-learned forever; the service handles the cross-session join, so a fresh session already sees it. temp: is the opposite guarantee: values written under it are dropped when the event is appended, which makes it the correct home for per-request material that tools and callbacks need but that must not be left behind — validated auth claims from your gateway, a decrypted token, an intermediate blob.

And app: deserves a warning. It is shared by every user, so a tool that writes a user-specific fact under an app: key does not merely produce a bug, it produces a cross-tenant data leak. One character of prefix separates personalization from an incident.

Reading state, and the pending-delta subtlety

Reads come through whichever context object your code holds. A tool reads tool_context.state; agent and model callbacks read callback_context.state; a custom BaseAgent inspecting the conversation reads ctx.session.state. Instructions can also interpolate state with brace placeholders such as {order_status} or {user:tier} — that templating path is covered in the LlmAgent article; the only thing to note here is that it resolves against the same fold.

The subtlety that costs an afternoon: within a single turn, a context’s state reflects deltas that have not been committed yet. If a before_tool callback writes a key and the tool then reads it, the tool sees the new value, because the context merges pending changes over the materialized fold. A separate process calling get_session at that instant sees the old value, because the event has not been appended. Reading a context is not the same as reading the persisted session, and code that assumes otherwise passes its unit test and fails under concurrency. Treat state as turn-consistent, not globally consistent: inside one invocation you get read-your-writes; across invocations, only what the service has actually appended counts.

The SessionService contract

Persistence hides behind one small interface. A SessionService creates sessions, fetches them, lists them for a user, deletes them, and appends events. In Python these are coroutines, so they are awaited; the Runner holds a reference to the service and does the loading and appending for you on every turn.

from google.adk.sessions import InMemorySessionService

service = InMemorySessionService()

session = await service.create_session(
    app_name="support", user_id="u_42", state={"cart": []},
)
session = await service.get_session(
    app_name="support", user_id="u_42", session_id=session.id,
)
await service.list_sessions(app_name="support", user_id="u_42")
await service.delete_session(
    app_name="support", user_id="u_42", session_id=session.id,
)

create_session accepts an initial state, which is the clean way to seed a conversation with context you already know — account tier, locale, ticket id — instead of teaching the model to ask for it. get_session takes an optional config that can cap how much history is loaded, so a database-backed service need not drag ten thousand events into memory to answer one turn. The method you will not call yourself is the append: the runner appends events as the agent produces them, and that append is what applies the state delta and drops the temp: keys.

Three implementations, three durability stories

The interface is uniform; the guarantees underneath are not, and choosing wrongly is a production incident waiting for a restart.

ServiceWhere it storesSurvives restartUse it for
InMemorySessionServiceprocess dictionariesNotests, local dev, examples
DatabaseSessionServicea relational DB you ownYesself-hosted production
VertexAiSessionServicemanaged Vertex AI serviceYesAgent Engine deployments

In-memory is genuinely useful and genuinely dangerous. It is instant, needs no setup, and is what InMemoryRunner wires up for you — but every session dies with the process, and it is single-process, so two web workers behind a load balancer do not share sessions at all. It also forgives the direct-mutation anti-pattern above, which means the bugs it hides surface only after you migrate.

from google.adk.sessions import DatabaseSessionService

service = DatabaseSessionService(db_url="sqlite:///./sessions.db")
# production: "postgresql+psycopg://user:pass@host/dbname"

Database-backed persistence stores sessions, events, and the app- and user-scoped state in tables, which is what makes the scopes real across processes; because rows are serialized, values must be JSON-friendly. Vertex-managed sessions move the same responsibilities to a hosted service, the default when you deploy to Agent Engine.