A session in Google’s Agent Development Kit is one conversation thread, and it is the only place the runtime looks to answer ‘what has happened so far?’ Inside it are two things that are easy to conflate and expensive to confuse: an append-only list of events, the durable record of everything that occurred, and State, a key-value scratchpad that is not stored so much as computed — the fold of every state delta those events carry. That decision is why ADK conversations can be replayed, audited, and resumed, and why you must never reach into a session dictionary and assign to it. This article is about the mechanism: what a session holds, how an event carries a state change, what the user:, app: and temp: prefixes really do, which SessionService gives which durability guarantee, what breaks when two turns hit one session id at once, and where the session layer hands off to memory.

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.

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.

Advertisement

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.

When two turns race on one session

The uncomfortable question: what if a user double-taps send, or a webhook fires while they are typing, and two invocations run against the same session id at once? ADK’s model is one active invocation per session, and the failure modes when you violate it are worth understanding.

Both runs load the session, so both start from the same fold, and both then append events. The event log stays coherent — appends are ordered, nothing is overwritten, and you can see afterwards exactly what happened. State is where it hurts: two deltas touching the same key resolve last-writer-wins, so an increment implemented as read-then-write loses an update, and a two-step workflow whose steps interleave can leave state in a combination neither run intended. Meanwhile the second run’s model never saw the first run’s events, so it may re-call a tool that already ran.

Persistent services push back on the worst of it: a database-backed service compares the timestamp of the session you are holding against what is in storage and rejects an append built on a stale read, surfacing the race as an error rather than silent corruption. That is a safety net, not a concurrency model. The real fixes are upstream — serialize turns per session id with a lock or a per-session queue, disable the send button until the turn completes, make tools idempotent on a key you already hold, and prefer whole-value writes over read-modify-write on shared keys.

Where the session ends and memory begins

Sessions answer ‘what happened in this conversation?’ They are loaded whole on every turn, which is exactly why they cannot also answer ‘what has this user ever told us?’ That second question grows with a user’s lifetime and has to be searched rather than loaded — a different data structure behind a different service, ADK’s MemoryService, which has its own article.

The handoff is what belongs here. A finished session is the raw material memory ingests: its events, or a distillation of them, are added to a searchable store so a later conversation retrieves them by relevance instead of by session id. That is another argument for task-shaped sessions — a distilled six-month mega-session makes a far worse memory entry than a distilled twenty-turn ticket. The reverse direction matters too: when an agent retrieves something from memory that stays relevant for the current task, copy it into session state so subsequent turns read it locally instead of paying a retrieval on every turn. Memory is the archive; session state is the working set.

Designing the key namespace, and the traps

Treat state keys as a reviewed schema rather than a scratchpad: for each key, record its scope prefix, which component writes it, which components read it, whether it is templated into a prompt, and whether it may hold personal data. That registry is what makes incident response and onboarding possible.

Three constraints shape what a value may be at all. It must serialize — a persistent service stores state as JSON, so normalize datetimes to ISO strings and Pydantic objects with model_dump() on the way in rather than discovering the problem at load time. It must stay small — state is read every turn and often rendered into the system instruction, so a scraped document parked in state is a cost and latency tax forever; large or binary payloads belong in the artifact service with state holding only the reference. It must be safe to persist — anything without a temp: prefix outlives the turn and, under user:, outlives the conversation, so raw tokens and card numbers never belong there.

TrapSymptomFix
Assigning to session.state directlyValue vanishes on the next loadWrite through tool_context / callback_context
User fact under an app: keyOther users see itPrefix audit; review every app: write
Secret in unprefixed stateCredential persisted in the DBtemp:, or a secret manager
Typo in a templated keyEmpty or literal {key} in the promptCentral key constants; optional {key?}
Non-serializable valueWorks in memory, fails on a DB serviceNormalize to JSON types on write
Concurrent turns, one sessionLost updates, duplicated tool callsSerialize per session id; idempotent tools

Notice how many of those are invisible under InMemorySessionService and obvious under a database one. The cheapest habit in ADK development is to run against a persistent service — even a local SQLite file — from day one, so the write path you are building is the write path you will ship.

An ADK session is one conversation thread, addressed by app, user, and session id, and it holds two things: an append-only list of events and a state dictionary that is not stored so much as folded out of the state_delta those events carry. That is why every value has provenance and why the write path is non-negotiable — assign through tool_context.state or callback_context.state, never to a session object you fetched, or the change never leaves your process. Prefixes select the store: bare keys are session-scoped, user: follows the person across conversations, app: is shared by everyone and is a data leak waiting to happen if misused, and temp: is dropped at append time, which makes it the right place for auth context and secrets. Swap InMemorySessionService for a database or Vertex-managed service and the API is identical while the guarantees change completely, so develop against a persistent one. Keep state small, JSON-serializable, and free of raw secrets; keep sessions task-shaped rather than immortal; and serialize turns per session id, because two concurrent invocations resolve last-writer-wins on shared keys.