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.