State in the Agent Development Kit is one deceptively simple thing: a dictionary hanging off the session, session.state, that every agent, tool, and callback in a run can read and write. It is the agent’s shared scratchpad — where the order id being discussed, the user’s tier, the current workflow step, and the last tool’s result all live so the next step can find them. But that dict is not an ordinary Python dictionary you mutate and forget. Its keys carry scope prefixes that decide how long a value lives and who can see it; its writes are captured as deltas on events and persisted by the SessionService, not saved by reference; and its values flow straight into your prompts through instruction templating. Get the mechanics right and state is the quiet backbone that makes a multi-step, multi-agent system coherent. Get them wrong — mutate a nested object in place, pick the wrong prefix, race two parallel branches on one key — and you get data that silently fails to persist, preferences that leak across users, and bugs that only appear in production. This piece is about those mechanics, end to end.

State is a shared scratchpad, not the session

It helps to fix the boundary first. The Session is the container for one conversation — its identity (app_name, user_id, session_id), its ordered list of events, and its state. The SessionService is the backend that stores and loads that session. State is the narrower thing this article is about: the key-value working set the agent actually reads and writes turn to turn, exposed as session.state, a dict-like object.

Think of state as the mutable scratchpad the whole run shares, while the event history is the immutable ledger of what happened. You do not append to state to remember that a message was sent — that is an event. You write to state to record the current value of something the next step needs: state['order_id'] = '8842'. Every participant in a run — the root agent, any sub-agents, tools, and callbacks — sees the same state dict for that session, which is exactly why it works as a coordination channel. One tool writes state['cart_total']; a later agent’s instruction reads it; a callback validates it. The dict is the wire between them, and its discipline — scopes, deltas, serialization — is what the rest of this article unpacks.

Advertisement

The four scopes live in the key

The single most important thing about ADK state is that a key’s prefix decides its lifetime and visibility. There is no separate API for ‘user memory’ versus ‘session memory’ — you choose by how you name the key. Four scopes exist:

PrefixExample keyLifetime & visibility
(none)order_idThis session only; dies when the conversation ends
user:user:tierFollows the user across all their sessions in this app
app:app:feature_flagsShared by every user of the app; read-mostly config
temp:temp:auth_payloadThis invocation only; never persisted to the backend

So state['prefers_metric'] = True is forgotten when the thread closes and re-learned forever; state['user:prefers_metric'] = True follows the person into their next conversation; and state['app:prefers_metric'] = True would make it everyone’s preference — a one-character typo that turns personalization into a data-leak incident. The prefixes are effectively the type system of conversational state: ownership and lifetime are encoded in the name, which is why mature teams treat the key namespace as a reviewed schema rather than a free-for-all.

How the SessionService reads each scope

The scopes are not magic strings the agent interprets — the SessionService is what gives them meaning. When it loads a session and materializes session.state, it merges values from different physical stores keyed by the prefix. Session-scoped keys come from that one session’s record. user: keys are stored against the user_id and joined in for whichever session that user opens, so the same fact is visible from every one of their conversations. app: keys are stored against the app_name and shared across all users. temp: keys are never written to any store at all.

The practical consequence is that your code reads all four the same way — state.get('user:tier') is just a dict lookup — but the durability behind them differs completely. It also means the backend you chose matters for scopes: an InMemorySessionService keeps user: and app: values only for the life of the process, which is fine for tests but will surprise you if you expect cross-session persistence in dev. A database- or Vertex-backed service actually persists them. The API surface is identical; the guarantees are not, which is a distinction worth keeping in mind whenever a ‘remembered’ value mysteriously resets.

Writing state from a tool with ToolContext

Inside a tool, the door to state is the ToolContext. Declare a parameter of that type and ADK injects it; the model never sees or fills it. Through it you read and write the same shared dict:

def record_dispute(amount: float, tool_context: ToolContext) -> dict:
    # read existing state
    user_tier = tool_context.state.get("user:tier", "standard")
    # write session-scoped working data
    tool_context.state["disputed_amount"] = amount
    tool_context.state["evidence_status"] = "pending"
    # write a cross-session user fact
    tool_context.state["user:has_open_dispute"] = True
    return {"status": "logged", "tier": user_tier}

Two things are happening that are easy to miss. First, the assignments do not immediately hit the database; they are staged. ADK collects the keys you touched during the tool call and attaches them as a state_delta to the event that records this tool’s result. Second, because it is the same session state object, whatever you write here is visible to the next agent, the next tool, and the instruction templater on the following turn. The tool is not returning state to the caller through its return value — the return value goes back to the model as the function result — it is depositing state into the shared scratchpad as a side effect, and that side effect is what persists.

Writing state from callbacks with CallbackContext

Callbacks reach state the same way, through a CallbackContext (and, in tool-level hooks, a ToolContext). Because callbacks sit at the boundaries of every step — before/after the agent, the model call, and each tool — they are the natural place to seed state before work begins and to normalize it afterward.

def before_agent(callback_context: CallbackContext):
    st = callback_context.state
    # seed a turn counter; runs deterministically every turn
    st["turns"] = st.get("turns", 0) + 1
    # stash request-scoped auth that must NOT persist
    st["temp:caller_id"] = callback_context._invocation_context.user_id

A callback write behaves exactly like a tool write: it is captured as a delta and folded into state, visible downstream. This is where the temp: scope earns its keep — a before_agent hook can validate an auth token from the API gateway and drop the decoded payload into temp: so later steps in this invocation can read it, while guaranteeing the sensitive blob is never written to the session store. The rule of thumb: use callbacks to set state that is computed deterministically (counters, request context, derived flags) and tools to set state that is the product of the actual work (an order id, a computed total, a resolution status).

The output_key shortcut

There is a common case where writing state by hand is needless ceremony: you just want an agent’s final text response saved under a known key so a later agent can use it. ADK gives LlmAgent an output_key for exactly this. Set it, and the agent’s final response is automatically written to state[output_key] when the turn completes — no tool, no callback.

summarizer = LlmAgent(
    name="summarizer",
    model="gemini-2.0-flash",
    instruction="Summarize the dispute in one sentence.",
    output_key="dispute_summary",   # response -> state['dispute_summary']
)

This is the idiomatic way to wire a pipeline of agents together. In a SequentialAgent, the first agent writes its answer to state['dispute_summary'] via output_key, and the next agent’s instruction reads it back with {dispute_summary}. State becomes the bus that carries a result from one step to the next without any glue code. The value written is the response text (or a structured object if the agent is configured with an output schema), and like every other write it lands as a delta on the event stream, so it persists and replays like anything else.

Writes become deltas on events

The mechanism that makes all of this durable is the state delta. ADK does not persist session.state by serializing the whole dict after every turn. Instead, each write you make is recorded as a change — a key and its new value — and those changes are bundled into an EventActions.state_delta on the event that the step produced. When the SessionService appends that event, it applies the delta to the stored state. State, in other words, is the fold of every delta ever applied, not a blob that is overwritten.

This design buys the same properties event sourcing buys everywhere. Every state change has provenance: you can see which event, from which author, wrote a given key. Replaying the events reproduces the exact state, which is what makes the dev UI’s step-by-step state inspector and eval replays possible — you can watch state fold delta by delta until the wrong value appears and pinpoint the tool that wrote it. It also explains a subtlety that trips people up: because persistence is delta-based and tied to the append of an event, a change only survives if ADK actually noticed you made it. That is the crux of the biggest pitfall in the framework, which the next sections address directly.

Advertisement

Pitfall: mutating in place vs assigning

Delta tracking keys off assignment to the state object. When you do state['x'] = value, ADK sees the write and stages a delta. When you instead reach inside an existing value and mutate it in place, there is no assignment for the framework to catch, and the change can silently fail to persist:

# RISKY: in-place mutation, no top-level assignment
tool_context.state["items"].append(new_item)      # may not be captured
tool_context.state["profile"]["seen"] = True      # may not be captured

# SAFE: read, modify a copy, reassign the whole key
items = list(tool_context.state.get("items", []))
items.append(new_item)
tool_context.state["items"] = items                # delta captured

The fix is a habit: treat state values as immutable. Read the current value, build the new value, and assign it back to the key at the top level. This guarantees a delta is recorded and keeps your mental model clean — a state key changes exactly when you assign to it, never as a spooky side effect of a reference you handed out earlier. It costs a copy, which for the small working-set values state is meant to hold is nothing, and it removes an entire class of ‘it worked in memory but vanished after a reload’ bugs that are miserable to diagnose because the code looks correct.

Pitfall: state must be serializable

Because state is persisted and replayed, its values have to survive a round trip through the backend — which means they must be JSON-serializable. Stick to primitives, strings, booleans, numbers, and plain lists and dicts of those. The moment you try to stash a database connection, an open file handle, a datetime object, a NumPy array, or a custom class instance, you are asking for either an outright serialization error or a value that comes back as something you did not put in.

Two consequences follow. First, store the serializable representation, not the live object: an ISO-8601 string instead of a datetime, an id or dict instead of an ORM model. Reconstruct the rich object at read time if you need it. Second, state is the wrong home for anything large or binary. A 30-page PDF, an image, a big report — these belong in the artifact service, stored under a name with versioning, with only a reference (or a short description) kept in state. Keeping bulky payloads out of state keeps the working set small, keeps serialization fast, and keeps the value from bloating every event delta that touches it. State is a scratchpad for small, structured facts; treat it as one.

Pitfall: races across parallel agents

The shared-dict model is a gift for coordination and a trap for concurrency. The instant you run agents in parallel — a ParallelAgent, or fan-out tool calls executing at once — two branches can write the same state key, and the result is the usual last-write-wins non-determinism: whichever delta is applied last is the value that survives, and which one that is may vary run to run.

The clean discipline is to give each parallel branch its own key namespace and let a downstream step combine them, rather than having branches contend on one shared key:

# each branch writes a distinct key
researcher_a = LlmAgent(name="a", output_key="draft_a", ...)
researcher_b = LlmAgent(name="b", output_key="draft_b", ...)
# a later sequential step reads {draft_a} and {draft_b} and merges

In practice ADK runs each branch with its own view and merges the deltas, so distinct keys never collide; the danger is entirely in shared keys and in in-place mutation of a shared value, which compounds the earlier pitfall. Treat parallel state like any concurrent system: partition writes, avoid read-modify-write on a contended key across branches, and reconcile in a single downstream step you control. The mistake is assuming the shared scratchpad serializes your writes for you — it does not.

Templating state into instructions

State would be far less useful if you had to write handler code to thread it into prompts. You do not: ADK resolves {key} placeholders in an agent’s instruction against state at prompt-assembly time. An instruction of "You are helping a {user:tier} customer with order {order_id}." is filled in per turn from the current state, so the prompt is personalized declaratively.

support = LlmAgent(
    name="support",
    model="gemini-2.0-flash",
    instruction=(
        "You are a support agent for a {user:tier} customer. "
        "Current dispute: {dispute_summary?}. "   # trailing ? = optional
        "Offer follow-up by {user:preferred_contact}."
    ),
)

The templater reads whatever scope the key names — {user:tier} pulls the cross-session fact, {order_id} the session value. A key that might be absent should be marked optional (commonly written {key?}) so a missing value renders empty instead of raising. This is the payoff of the whole scope design: a tool wrote user:preferred_contact three sessions ago, the SessionService joined it back in, and the instruction offers email follow-up unprompted — the personalization came from state plumbing, not from asking the model to remember. Keep templated keys on a short, known list; a prompt that interpolates arbitrary user-provided state is a prompt-injection surface.

Reading state directly, and initial state

Not every read goes through templating. Outside a prompt you read state as a plain dict: session.state.get('order_id') after a run, or tool_context.state['order_id'] inside one. Prefer .get() with a default over indexing so an absent key does not raise — state is sparse by nature, and a key only exists once something has written it.

You can also seed state up front. When you create a session through the service you may pass an initial state dict, which is the right way to plant app: configuration or known user: facts before the first turn:

session = await session_service.create_session(
    app_name="support", user_id="u_42",
    state={"user:tier": "gold", "app:region": "eu"},
)

From there, every subsequent write — from tools, callbacks, or output_key — folds onto that starting point through the delta mechanism. Reading is cheap and synchronous because the service has already materialized the fold; you are looking at a current-value view, not replaying history on each access. That separation — expensive, ordered event log underneath; cheap, flat dict on top — is the core ergonomic win of ADK state, and it is why the working set stays pleasant to use even as the conversation behind it grows long.

Putting the mechanics together

Trace one value through every mechanism and the model clicks. A customer opens a billing dispute. A before_agent callback increments state['turns'] and stashes state['temp:caller_id'] from the gateway — deterministic, request-scoped, never persisted. The user describes the charge; the model calls record_dispute, which writes state['dispute_id'] and state['evidence_status'] (session scope, this thread) and state['user:has_open_dispute'] (follows the user). Each write is staged as a delta and, when the tool’s event is appended, applied by the SessionService.

A summarizer sub-agent with output_key='dispute_summary' writes its one-line summary to state automatically. The support agent’s instruction — "helping a {user:tier} customer; dispute: {dispute_summary?}" — is filled from state at prompt time. Days later, a new session starts empty of session state, but {user:has_open_dispute} and user: preferences are joined back in by the service, so the agent opens with continuity. And when an engineer investigates a wrong answer, they replay the event log and watch state fold delta by delta until a bad write — evidence_status='complete' on a partial upload — reveals itself. Every capability traces to one mechanic: scoped keys, staged deltas, and a cheap fold on top.

ADK state is a shared, dict-like scratchpad on the session that every agent, tool, and callback reads and writes — but it is governed by mechanics you must respect. A key’s prefix (none / user: / app: / temp:) sets its lifetime and visibility, so the namespace is really a schema. Writes from a ToolContext, a CallbackContext, or an output_key are captured as state deltas on events and folded in by the SessionService, which is what gives state its provenance, durability, and replayability. Instruction templating ({key}) turns state into declarative personalization. The pitfalls all stem from the delta model: assign whole values instead of mutating in place, keep values JSON-serializable and small (blobs go to artifacts), and partition keys across parallel branches to avoid races. Master those and state becomes the quiet, reliable backbone of a multi-step agent — distinct from the Session object that holds it and the storage backends that persist it.