Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Sessions and state

A session is the container for one conversation.

That is the whole definition, and it is worth being precise about the boundaries because almost every framework draws them slightly differently.

A session is tied to one user. It covers one continuous interaction — from “hello” to whenever the user goes away. A user can have many sessions, and they are deliberately disconnected from each other: session A does not know what happened in session B. If you want that kind of continuity, you want memory, which is a different system and the subject of Chapters 4 through 6.

Inside a session there are exactly two things, and confusing them causes real bugs.

Events are the chronological log — the append-only record of what happened. A user message. An agent reply. A tool call. A tool result. Events are immutable facts about the past.

State is the working scratchpad — a small structured blob of data relevant to right now. What is in the cart. Which document we are editing. Which of the six required fields the user has supplied so far. State is mutable. It is overwritten, not appended.

The log is what happened. The state is where we are. You need both, and they want different code.

Saying it out loud. A session is the container for one conversation — one user, one continuous interaction, and deliberately disconnected from their other sessions. If you want continuity across sessions, that’s memory, which is a different system. Inside a session there are exactly two things and confusing them causes real bugs. Events are the chronological, append-only log: a user message, an agent reply, a tool call, a tool result — immutable facts about the past. State is the small structured scratchpad for right now: what’s in the cart, which of the six required fields the user has given you, and it gets overwritten rather than appended. The log is what happened, the state is where we are, and they want different code.

Three things people conflate

Before anything else, let us separate three terms that get used interchangeably and should not be.

Conversation history is the verbatim turn-by-turn transcript. Every message, unabridged, in order. It is what you would show a support engineer debugging a complaint.

Session state is the structured scratchpad for the current task. {"cart": ["SKU-1001"], "shipping_confirmed": false, "step": 3}. It is small, it is typed if you are disciplined, and it is the thing your business logic actually branches on.

Memory is extracted, processed information that outlives the session. “The user prefers window seats.” “The user is a Gold tier customer who has complained twice about delivery times.” It is not the transcript. It is what someone concluded from the transcript, distilled and persisted.

Here is the fastest way to keep them straight.

LifetimeFormatMutabilityScope
Conversation historyOne sessionVerbatim messagesAppend-onlyOne session
Session stateOne sessionStructured dictMutableOne session
MemoryIndefiniteExtracted facts / summariesConsolidatedUsually the user, across all sessions

Some frameworks muddy this by calling the session “short-term memory.” That is not wrong exactly, but it makes it hard to talk about the actual distinction, so for the rest of this book: a session is raw dialogue, a memory is extracted information.

And one more distinction that will matter constantly:

The session history is not the context. The history is the full transcript, stored durably. The context is the carefully assembled payload you send to the model for one specific turn — which might be a filtered subset of the history, plus a summary, plus some memories, plus a preamble. These are different objects with different lifecycles. Keeping the history intact while sending a trimmed context is a standard and very useful pattern, and it is much easier to reason about once you stop calling both of them “the conversation.”

Saying it out loud. Three terms get used interchangeably and shouldn’t be. Conversation history is the verbatim transcript — what you’d show a support engineer debugging a complaint. Session state is the small structured scratchpad your business logic actually branches on. Memory is extracted information that outlives the session: not the transcript, but what someone concluded from the transcript. The distinction I’d hammer on is that the session history is not the context. The history is the full transcript stored durably; the context is the payload you assemble for one specific turn, which might be a filtered subset plus a summary plus some memories. Keeping the history intact while sending a trimmed context is the default production pattern, and it only becomes easy to reason about once you stop calling both things “the conversation.”

Variance across frameworks

Every framework agrees that you need a place to put the conversation. None of them agree on what that place looks like.

The framework’s job, in principle, is to be a universal translator. You work with its internal objects — an Event, a Message, a state dict — and it converts those into whatever wire format the model provider expects. For Gemini, that is a List[Content], where each Content has a role and a list of parts. For OpenAI and Anthropic it is a list of messages with roles and content blocks. The framework maps between them so your agent logic does not have to know.

That abstraction is genuinely valuable — it decouples your logic from your model choice and keeps you out of vendor lock-in. It also creates the problem in the next section.

Two representative approaches, because they sit at opposite ends of the design space.

Google’s ADK 2.0 uses an explicit Session object containing a list of Event objects and a separate state dict. The separation is structural: the events are one drawer of the filing cabinet, the state is another. ADK 2.0 also moved toward a graph-based execution model, so multi-agent flows are defined as explicit graphs rather than emergent delegation.

LangGraph takes the opposite view: there is no Session object, because the state is the session. One state object holds everything, including the conversation as a list of Message objects. Crucially that state is mutable — it can be transformed, rewritten, compacted. Persistence comes from a checkpointer, which snapshots the state and keys it by a thread_id:

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore

graph = builder.compile(checkpointer=InMemorySaver(), store=InMemoryStore())

result = graph.invoke(
    {"messages": [{"role": "user", "content": "Hi, my name is Bob."}]},
    {"configurable": {"thread_id": "thread-1"}},
)

Note what LangGraph made explicit here, because it maps exactly onto the distinction we drew above. The checkpointer is thread-scoped — that is your session. The store is cross-thread — that is your memory. Two different objects, passed at compile time, for two different jobs. InMemorySaver is development-only; production uses PostgresSaver or SqliteSaver.

The design difference is not cosmetic. An append-only event log is easier to audit and replay; you can always reconstruct exactly what the model saw. A mutable state object makes compaction trivial — you just rewrite the message list — at the cost of destroying the original record unless you keep it somewhere else. Neither is right. But you should know which one you have, because it determines whether “what exactly did we send the model on turn 14” is a query or an impossibility.

Saying it out loud. Every framework agrees you need somewhere to put the conversation and none of them agree what that looks like. Google’s ADK keeps an explicit session object with an event list and a separate state dict — the separation is structural. LangGraph takes the opposite view: there’s no session object because the state is the session, one mutable object holding everything including the messages, with persistence coming from a checkpointer keyed by thread ID. Notice LangGraph makes the whole distinction explicit — the checkpointer is thread-scoped, that’s your session; the store is cross-thread, that’s your memory. The design difference isn’t cosmetic: an append-only event log is easy to audit and replay, while a mutable state object makes compaction trivial because you just rewrite the list, at the cost of destroying the original record. Neither is right, but know which you have, because it decides whether “what exactly did we send the model on turn 14” is a query or an impossibility.

Sessions in multi-agent systems

When several agents collaborate, someone has to decide what they can see of each other’s work. There are two patterns, and the choice is about coupling.

Shared, unified history. All agents read from and write to one log. Every message, tool call, and observation from every agent lands in the same chronological record.

This is right for tightly coupled work where one agent’s output is directly the next agent’s input, and where you want a single source of truth for the whole run. Even with a shared log, a sub-agent will typically process it before sending it to the model — filtering to relevant events, or tagging each event with which agent produced it so the model can tell whose thought was whose. That tagging is not optional in practice; an untagged shared log reads to a model like one very confused agent talking to itself.

Separate, individual histories. Each agent keeps a private log and behaves as a black box. Its intermediate reasoning, tool calls, and dead ends stay inside. Communication happens only through explicit messages carrying a final result.

This is right when the sub-task is genuinely self-contained, and it is the isolation lever from Chapter 1 applied at the agent level. The sub-agent gets a clean context window and returns a conclusion, so the parent’s window never fills with forty pages of intermediate work. Two common implementations: agent-as-a-tool, where one agent invokes another exactly like a function call, and the A2A protocol, where agents exchange structured messages directly.

The rule of thumb I would apply: share history when agents need to reason about each other’s reasoning; isolate when they only need each other’s answers. Most systems need less sharing than their designers initially think.

One production note worth knowing: multi-agent runs are increasingly long-lived. Agent runtimes now support operations that pause for hours or days waiting on a webhook or a human approval, then resume without losing state. That turns your session store from “a cache for a chat” into “a durable workflow record,” which raises the bar on everything in the production section below.

Saying it out loud. When several agents work together, somebody has to decide what they can see of each other’s work, and it’s really a coupling question. Share one unified log when agents need to reason about each other’s reasoning — but tag every event with which agent produced it, because an untagged shared log reads to a model like one very confused agent talking to itself. Give each agent a private history when the sub-task is self-contained, so the sub-agent gets a clean window and returns a conclusion instead of dumping forty pages of dead ends into the parent. My rule of thumb is share when they need each other’s reasoning, isolate when they only need each other’s answers — and most systems need far less sharing than their designers assume.

Interoperability, and why memory is the answer

Here is a trade-off that catches teams by surprise.

The same abstraction that decouples your agent from the model also couples it to the framework. Your session store’s schema is typically shaped around the framework’s internal objects — ADK Events, LangGraph Messages, whatever. Which means an agent built on LangGraph cannot natively read a session persisted by an ADK agent. The records are structurally incompatible, and a clean handoff between them is not possible without a translation layer.

A2A helps with messaging — agents can talk to each other across frameworks. It does not solve shared state, because any A2A message carrying session events is carrying framework-specific objects that the receiver has to decode.

The architectural pattern that actually works is to stop trying to share sessions and share memory instead.

A memory layer holds processed, canonical information: facts, summaries, extracted entities. Its data structures are deliberately boring — strings and dictionaries — and specifically not coupled to anyone’s internal representation. That makes it a genuine common layer. A LangGraph agent and an ADK agent can both write to it and both read from it, without either one knowing the other exists.

This is a good argument for building your memory layer as a separate service from the start, even if today you only have one framework. It costs you very little now and it is the difference between adding a second framework in an afternoon versus a quarter.

Saying it out loud. Here’s the tradeoff that surprises teams: the same abstraction that decouples your agent from the model couples it to the framework. Your session store’s schema is shaped around that framework’s internal objects, so a LangGraph agent simply cannot read a session an ADK agent persisted — the records are structurally incompatible. A2A helps with messaging, agents talking across frameworks, but it doesn’t solve shared state, because a message carrying session events is carrying framework-specific objects the receiver still has to decode. So the pattern that actually works is to stop trying to share sessions and share memory instead. A memory layer holds processed, canonical information in deliberately boring structures — strings and dicts, coupled to nobody’s internals — which is what makes it a genuine common layer. That’s the argument for building memory as a separate service from day one: it costs almost nothing now and it’s the difference between adding a second framework in an afternoon versus a quarter.

Production considerations

Moving a session store from prototype to production is mostly about six concerns. Work the list.

Isolation

A session is owned by exactly one user, and the store enforces that.

Every read and every write must be authenticated and authorized against the owner. Not checked in the agent logic, where it will eventually be forgotten — checked in the store, on every access, with no way around it. The failure mode here is one user seeing another user’s conversation, which is the kind of bug that ends products.

PII redaction on the write path

Redact sensitive data before it is persisted, not when it is read.

The reasoning is blast radius. If PII never lands in the store, a breach of the store does not expose PII, and your GDPR and CCPA story gets dramatically simpler. Redaction at read time protects nothing — the data is already sitting in your database.

Retention and TTL

Sessions should not live forever. Set a TTL, delete inactive sessions automatically, and write down an actual retention policy that says how long you keep them and what happens at the end. This is a cost control and a compliance control at the same time.

Deterministic ordering

Events must land in the log in a deterministic order. If two concurrent operations can both append, and the sequence number is assigned by the caller, you will eventually get two events with the same index and a transcript that reads out of order. Assign the sequence inside the store, inside a transaction.

Performance

Session data is on the hot path of every single turn. Agent runtimes are stateless, so the whole session gets pulled from a central database at the start of each turn, and that network transfer is user-visible latency.

The lever is size: transfer less. Filter or compact the history before it goes to the agent — for example, dropping old function-call outputs that no longer affect the current state. Chapter 3 is entirely about how to do this without breaking things.

Size limits

Put a hard cap on session size and alert when it is approached. An unbounded session is a slow-motion outage: it gets more expensive every turn until something fails.

Saying it out loud. Taking a session store to production is about six concerns. Isolation: a session is owned by one user and the store enforces it on every read and write — not in the agent logic where it’ll eventually be forgotten, because the failure mode is one user seeing another’s conversation, and that’s the kind of bug that ends products. Redact PII on the write path, not the read path, because the point is blast radius — if it never lands in the store, a breach of the store doesn’t expose it. Set a TTL, since sessions living forever is both a cost problem and a compliance problem. Assign sequence numbers inside the store, in a transaction, or concurrent appends give you a transcript that reads out of order. Watch performance, because the whole session is pulled from a central database at the start of every turn and that transfer is user-visible latency. And cap session size, because an unbounded session is a slow-motion outage that gets more expensive every turn until something fails.

Build it: a session store with pluggable backends

Here is a session store small enough to read and real enough to use. The design goal is that the backend is swappable — in-memory for tests, SQLite for a single node, and you can add Postgres or Redis by implementing five methods.

Start with the data model. Note that Event and Session do exactly what the definitions at the top of this chapter said: events are the log, state is the scratchpad.

from __future__ import annotations
import json, sqlite3, time, uuid
from dataclasses import dataclass, field, asdict
from typing import Any, Iterable, Protocol

@dataclass
class Event:
    """One immutable thing that happened in a conversation."""
    kind: str                      # user | agent | tool_call | tool_result | system
    content: Any                   # text, or a structured payload for tool events
    seq: int = 0                   # monotonic, assigned by the store
    ts: float = field(default_factory=time.time)
    author: str | None = None      # which agent produced it, in multi-agent systems

@dataclass
class Session:
    """Events (the log) plus state (the scratchpad). Two different things."""
    session_id: str
    user_id: str
    app_name: str
    events: list[Event] = field(default_factory=list)
    state: dict[str, Any] = field(default_factory=dict)
    created_at: float = field(default_factory=time.time)
    updated_at: float = field(default_factory=time.time)

    def to_messages(self, keep_last: int | None = None) -> list[dict]:
        """Render events into the message list a model API expects.

        This is the boundary between your storage format and the provider's
        wire format. Keep it in one place so swapping providers is one edit.
        """
        events = self.events if keep_last is None else self.events[-keep_last:]
        role = {"user": "user", "agent": "assistant", "system": "system"}
        out = []
        for e in events:
            if e.kind in role:
                out.append({"role": role[e.kind], "content": str(e.content)})
            elif e.kind == "tool_call":
                out.append({"role": "assistant",
                            "content": f"[calls {e.content['name']}({json.dumps(e.content['args'])})]"})
            elif e.kind == "tool_result":
                out.append({"role": "user",
                            "content": f"[result of {e.content['name']}: {e.content['result']}]"})
        return out

to_messages is the single most important method in this file and it is easy to overlook. It is the translation boundary — the place where your storage format becomes a provider’s wire format. Keep it in one function and swapping providers is one edit; scatter it across your codebase and it is a migration. Note also that it takes keep_last: the history is complete in storage, and the context is a view over it.

Now the backend contract. Five methods. That is the whole interface.

class SessionBackend(Protocol):
    def load(self, session_id: str) -> Session | None: ...
    def save(self, session: Session) -> None: ...
    def append(self, session_id: str, events: Iterable[Event]) -> None: ...
    def delete(self, session_id: str) -> None: ...
    def list_for_user(self, user_id: str) -> list[str]: ...

The in-memory implementation is four lines per method — a dict keyed by session ID — and it is honest about being disposable. Write it, use it in tests, and never let it near production.

The SQLite backend is the one that teaches something. Two tables — one row per session for the state, one row per event for the log — which is the shape you want in Postgres too.

class SQLiteBackend:
    """Durable, ordered, single-node. A real starting point for production."""

    def __init__(self, path: str = ":memory:") -> None:
        self.db = sqlite3.connect(path, check_same_thread=False)
        self.db.execute("PRAGMA journal_mode=WAL")
        self.db.executescript("""
            CREATE TABLE IF NOT EXISTS sessions (
                session_id TEXT PRIMARY KEY,
                user_id    TEXT NOT NULL,
                app_name   TEXT NOT NULL,
                state      TEXT NOT NULL,
                created_at REAL NOT NULL,
                updated_at REAL NOT NULL
            );
            CREATE TABLE IF NOT EXISTS events (
                session_id TEXT NOT NULL,
                seq        INTEGER NOT NULL,
                kind       TEXT NOT NULL,
                author     TEXT,
                content    TEXT NOT NULL,
                ts         REAL NOT NULL,
                PRIMARY KEY (session_id, seq)
            );
            CREATE INDEX IF NOT EXISTS idx_user ON sessions(user_id);
        """)
        self.db.commit()

    # load() / save() / delete() / list_for_user() are the obvious SELECT,
    # UPSERT and DELETE statements against these two tables -- the full file
    # is in the repo. The one that is not obvious is append().

    def append(self, session_id, events):
        # The store assigns seq inside a transaction. Never let the caller
        # pick it -- two concurrent turns would collide.
        cur = self.db.cursor()
        cur.execute("BEGIN IMMEDIATE")
        (next_seq,) = cur.execute(
            "SELECT COALESCE(MAX(seq) + 1, 0) FROM events WHERE session_id = ?",
            (session_id,)).fetchone()
        for e in events:
            e.seq = next_seq
            cur.execute("INSERT INTO events VALUES (?,?,?,?,?,?)",
                        (session_id, e.seq, e.kind, e.author,
                         json.dumps(e.content), e.ts))
            next_seq += 1
        cur.execute("UPDATE sessions SET updated_at = ? WHERE session_id = ?",
                    (time.time(), session_id))
        self.db.commit()

Look at append. BEGIN IMMEDIATE, then compute the next sequence number inside the transaction, then insert. That is the deterministic-ordering requirement made concrete. If you let the caller pass a seq, two concurrent turns will pick the same number and your primary key will reject one of them — which is at least loud, but the version where you have no primary key and silently interleave is much worse.

Finally, the service layer, which is where the production concerns live so that no backend has to implement them twice.

class SessionNotFound(Exception): pass
class AccessDenied(Exception): pass

class SessionService:
    """Everything an agent needs, with the production concerns applied once."""

    def __init__(self, backend, *, ttl_seconds=30 * 86400,
                 max_events=2000, redactor=None):
        self.backend = backend
        self.ttl = ttl_seconds
        self.max_events = max_events
        self.redactor = redactor or (lambda x: x)

    def create(self, user_id: str, app_name: str = "default") -> Session:
        s = Session(session_id=str(uuid.uuid4()), user_id=user_id, app_name=app_name)
        self.backend.save(s)
        return s

    def get(self, session_id: str, user_id: str) -> Session:
        """Always pass the caller's user_id. Ownership is checked here, once."""
        s = self.backend.load(session_id)
        if s is None:
            raise SessionNotFound(session_id)
        if s.user_id != user_id:
            raise AccessDenied(f"session {session_id} is not owned by {user_id}")
        if time.time() - s.updated_at > self.ttl:
            self.backend.delete(session_id)
            raise SessionNotFound(f"{session_id} (expired)")
        return s

    def append(self, session_id: str, user_id: str, *events: Event) -> None:
        s = self.get(session_id, user_id)
        for e in events:
            e.content = self.redactor(e.content)
        self.backend.append(session_id, events)
        if len(s.events) + len(events) > self.max_events:
            print(f"[warn] session {session_id[:8]} exceeded {self.max_events} "
                  f"events -- compact it or start a new one")

    def set_state(self, session_id: str, user_id: str, **updates) -> Session:
        s = self.get(session_id, user_id)
        s.state.update(updates)
        self.backend.save(s)
        return s

The signature get(session_id, user_id) is the important design decision in the whole file. There is no way to load a session without asserting who is asking. Isolation is not something the caller remembers to do; it is something the caller cannot avoid.

Running it against both backends:

--- InMemory backend ---
events: 4 | state: {'open_order': 'ORD-991', 'tier': 'gold'}
  user      Where is my order? I'm at [EMAIL]
  assistant [calls find_order({"email": "ada@example.com"})]
  user      [result of find_order: ORD-991 shipped]
  assistant Order ORD-991 shipped on Tuesday.
  isolation enforced: session 182d6c70-... is not owned by u_99

--- SQLite backend ---
events: 4 | state: {'open_order': 'ORD-991', 'tier': 'gold'}
  user      Where is my order? I'm at [EMAIL]
  assistant [calls find_order({"email": "ada@example.com"})]
  user      [result of find_order: ORD-991 shipped]
  assistant Order ORD-991 shipped on Tuesday.
  isolation enforced: session 335283bb-... is not owned by u_99

Identical behavior across both backends, which is the point of the interface.

Now look closely at that output, because it contains a real bug and it is one you will ship.

The user’s email was redacted in the text event. It was not redacted in the tool_call event, because the naive redactor only handles strings and that content is a dict. Structured events carry PII too — tool arguments, tool results, state values — and a redactor that only walks text is a redactor that gives you a false sense of security. Fix it by recursing into dicts and lists, and then test it against your actual event shapes rather than against a string.

That is the general shape of this problem. The session store is easy. The parts around the session store — isolation, redaction, ordering, expiry — are where the work is, and they are the parts a tutorial usually skips.

Saying it out loud. If I were building this, the shape is a data model where events are the log and state is the scratchpad, a five-method backend interface so the storage is swappable, and a service layer on top where the production concerns live once instead of in every backend. Two details carry most of the value. The render-to-messages function is the single translation boundary between your storage format and the provider’s wire format — keep it in one place and switching providers is one edit, scatter it and it’s a migration. And the getter takes both the session ID and the caller’s user ID, so there’s literally no way to load a session without asserting who’s asking; isolation isn’t something the caller remembers, it’s something the caller can’t avoid. The bug I’d flag from experience is redaction: a naive redactor that only walks strings misses the PII sitting in tool arguments and tool results, which are dicts — so it has to recurse, and you test it against your real event shapes, not against a string.

What you should be able to do now

  • State the difference between conversation history, session state, and memory, and classify any piece of data into one of the three in a few seconds.
  • Explain why the session history and the context sent to the model are different objects, and why keeping the history intact while trimming the context is the default pattern.
  • Describe how ADK’s event-log-plus-state model differs from LangGraph’s mutable-state model, and say what each design makes easy and what it makes impossible.
  • Choose between shared and isolated session history for a multi-agent system, and justify the choice in terms of coupling.
  • Explain why a framework-agnostic memory layer, not A2A messaging, is the real answer to cross-framework state sharing.
  • Implement a session store with a swappable backend that enforces owner isolation on every access, assigns sequence numbers transactionally, applies a TTL, and redacts PII on the write path — including inside structured event payloads.

Further reading