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.
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.
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.
| Prefix | Example key | Visible to | Lifetime |
|---|---|---|---|
| none | order_id | this session only | until the session is deleted |
user: | user:preferred_contact | every session of this user | until explicitly deleted |
app: | app:support_hours | every user of the app | until changed; read-mostly config |
temp: | temp:auth_claims | the current invocation | discarded — 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.
| Service | Where it stores | Survives restart | Use it for |
|---|---|---|---|
InMemorySessionService | process dictionaries | No | tests, local dev, examples |
DatabaseSessionService | a relational DB you own | Yes | self-hosted production |
VertexAiSessionService | managed Vertex AI service | Yes | Agent 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.
| Trap | Symptom | Fix |
|---|---|---|
Assigning to session.state directly | Value vanishes on the next load | Write through tool_context / callback_context |
User fact under an app: key | Other users see it | Prefix audit; review every app: write |
| Secret in unprefixed state | Credential persisted in the DB | temp:, or a secret manager |
| Typo in a templated key | Empty or literal {key} in the prompt | Central key constants; optional {key?} |
| Non-serializable value | Works in memory, fails on a DB service | Normalize to JSON types on write |
| Concurrent turns, one session | Lost updates, duplicated tool calls | Serialize 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.
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.