Chapter 10: State and Persistence: Sessions and Conversation Transcripts
Learning Objectives
Distinguish ephemeral session state from durable conversation history, and explain why each demands a different storage backend.
Design a session store keyed by (platform, user, channel) with a TTL, and reason about its concurrency hazards.
Model conversation transcripts as append-only turns and assemble a bounded LLM context window from them.
Select appropriate storage backends — cache, database, or object store — by matching each backend's guarantees to each kind of state.
Pre-Quiz: Kinds of State
A worker crashes and the Redis-backed session for an active conversation is lost. Why is this described as "inconvenient but not catastrophic"?
Redis automatically replays every lost write from its transaction log
The session is a projection of the durable transcript and can be rebuilt from it
Sessions are replicated across all workers, so another worker still holds a copy
The user will simply retype everything they said, restoring the state
In a multi-channel gateway, the same person is U12345 on Slack and +15551234567 over SMS. Why does the chapter recommend scoping sessions by the full (platform, user, channel) tuple rather than by user alone?
Redis keys cannot contain more than one identifier
It keeps each channel's context isolated while cross-channel continuity is handled by the durable layer
Platform identifiers are the only values guaranteed to be globally unique
It lets the same session be shared across every channel automatically
How does externalizing memory into a shared store preserve the statelessness that makes workers interchangeable?
Each worker caches every user's session locally so it never needs the store
Workers hold no memory between turns; they load the session at the start and write it back at the end, so any worker can serve any message
Messages from one user are always routed to the same dedicated worker
The store forwards conversation memory directly into each worker's process at startup
Kinds of State
Key Points
The first architectural decision is not which database but which kind of state a piece of data is; backend, key, expiry, and retention all follow.
Ephemeral session state is small, hot, read/written every turn, and disposable — it exists only to answer the next turn.
Durable transcript history is large, cold, append-only, queryable, and governed by policy — it is the system of record.
Session identity in a multi-channel gateway is the tuple (platform, user, channel), encoded as a deterministic scoping key.
Keep workers stateless and push all memory into shared stateful stores, so the gateway scales horizontally while conversations stay coherent.
Before writing storage code, classify the data. "Memory" is not one thing: there is fast, disposable, right-now context — the session — and the slow, permanent, complete record — the transcript. Conflating them yields systems that are either too slow (durable storage on the hot path) or too forgetful (everything expires). Keeping them separate is the discipline at the heart of the chapter.
Session state is short-term data scoped to an active conversation: recent message history, the active workflow step, in-progress slot filling (for example, a booking where we have the date but not the time). It is small, hot, and disposable — losing it is inconvenient but not catastrophic because it can be rebuilt from the durable record. A conversation transcript is the opposite: the persistent, append-only record that survives sessions, restarts, and channels, kept for context reconstruction, analytics, retention, and audit.
Analogy: A barista's session is the sticky note on the cup — scribbled fast, thrown away once the drink is handed over. The transcript is the point-of-sale system — every order ever placed, queryable, retained for the tax year. You never run the espresso line off the accounting database, and you never file taxes from discarded sticky notes.
Figure 10.1: Ephemeral session state versus durable conversation history
Dimension
Ephemeral session state
Durable conversation history
Purpose
Answer the next turn
Be the record of what was said
Lifetime
Minutes to hours; expires
Indefinite; governed by retention policy
Size
Small (recent turns, active workflow)
Large (full history + derived memory)
Access pattern
High read/write, every turn, hot path
Append-mostly, occasional reads, off hot path
Write semantics
Last-write-wins (overwrite)
Append-only (never overwrite)
On loss
Inconvenient; rebuildable
Catastrophic; irreplaceable
Typical backend
Redis / Memcached / in-memory
Relational / document DB, object or vector store
Once state is a session, we decide what it is a session of. In a single-channel product "the user" suffices; in a multi-channel gateway it does not. The robust convention scopes by the tuple of platform, user, and channel — session:slack:U12345:C0777 versus session:sms:+15551234567 — so a user's Slack context does not bleed into their SMS context. Cross-channel memory, when desired, is achieved not by sharing one session but by having both channels' sessions project from the same durable history keyed to the underlying human.
Figure 10.3: The two layers of state — ephemeral session versus durable history
flowchart LR
IN["Inbound turn"] --> W["Stateless worker"]
W <-->|"read / write every turn (hot path)"| S["Ephemeral session state (recent turns, active workflow, slots)"]
S -.->|"fast, small, disposable"| CACHE["Hot cache (Redis / in-memory)"]
W -->|"append-only, off hot path"| T["Durable conversation transcript (complete record of what was said)"]
T -.->|"large, cold, queryable"| DB["Persistent store (database / object / vector)"]
T ==>|"rebuild / rehydrate on session loss"| S
Workers are interchangeable because they are stateless — they hold no conversation memory between messages. The resolution is a clean division of labor: the worker is stateless; the store is stateful. On each turn a worker loads the session at the start, does its work, and writes it back at the end. Because the session lives in an external store every worker can reach, it does not matter which worker handles which message. Memory is externalized, so the things that process messages stay memoryless.
Figure 10.4: Deriving a scoping key from the (platform, user, channel) tuple
graph TD
MSG["Inbound message"] --> P["platform (e.g. slack)"]
MSG --> U["user (e.g. U12345)"]
MSG --> C["channel (e.g. C0777)"]
P --> KEY["Scoping key session:slack:U12345:C0777"]
U --> KEY
C --> KEY
KEY --> SESS["Isolated session (this platform + user + channel)"]
SESS -.->|"cross-channel continuity keyed to the human"| HIST["Durable history (underlying human)"]
Visual animation — coming soon
Post-Quiz: Kinds of State
A worker crashes and the Redis-backed session for an active conversation is lost. Why is this described as "inconvenient but not catastrophic"?
Redis automatically replays every lost write from its transaction log
The session is a projection of the durable transcript and can be rebuilt from it
Sessions are replicated across all workers, so another worker still holds a copy
The user will simply retype everything they said, restoring the state
In a multi-channel gateway, the same person is U12345 on Slack and +15551234567 over SMS. Why does the chapter recommend scoping sessions by the full (platform, user, channel) tuple rather than by user alone?
Redis keys cannot contain more than one identifier
It keeps each channel's context isolated while cross-channel continuity is handled by the durable layer
Platform identifiers are the only values guaranteed to be globally unique
It lets the same session be shared across every channel automatically
How does externalizing memory into a shared store preserve the statelessness that makes workers interchangeable?
Each worker caches every user's session locally so it never needs the store
Workers hold no memory between turns; they load the session at the start and write it back at the end, so any worker can serve any message
Messages from one user are always routed to the same dedicated worker
The store forwards conversation memory directly into each worker's process at startup
Pre-Quiz: Session Storage
Why does the session store apply an LTRIM history -N -1 after every append to the Redis list of recent turns?
To sort the turns chronologically before they are read back
To keep the hot store bounded with a sliding window so an active session never grows without limit
To permanently delete old turns from the durable transcript
To trigger Redis to persist the list to disk
A chatbot uses a sliding TTL that calls EXPIRE on every access. What user-facing behavior does this produce compared to a fixed TTL measured from creation?
Every session lives forever because the TTL is constantly reset
An actively used conversation stays alive while an abandoned one decays
Sessions expire faster the more the user interacts with them
The TTL applies only to the metadata hash, never to the history list
Two messages from the same user land on two workers concurrently. One writes the collected booking date, the other the collected time, each via a naive read-modify-write of the whole state. What goes wrong, and what is the recommended fix?
Nothing goes wrong; Redis serializes whole-object writes so both slots survive
One slot is silently lost to last-write-wins; prefer atomic per-field ops (or WATCH/MULTI/EXEC) instead of read-modify-write
Both writes fail and the session is dropped; the fix is to disable the TTL
The transcript is corrupted; the fix is to stop appending to it during writes
Session Storage
Key Points
Redis is the canonical session backend: fast, externally addressable, and first-class TTL support for "conversation went quiet, forget it."
Store a session as co-located keys under a shared prefix — a hash for metadata, a bounded list for recent turns — and trim with LTRIM.
Give every key a TTL; prefer a sliding TTL refreshed on each access, applied identically to metadata and history so they never fragment.
Concurrent session writes are last-write-wins; acceptable for ordering-tolerant state but hazardous for read-modify-write slot filling.
Defuse the hazard with atomic per-field ops (HSET, RPUSH, HINCRBY) or optimistic WATCH/MULTI/EXEC transactions.
Redis stores are flat key-value spaces, but a naming convention gives them structure. The established pattern uses hierarchical, colon-delimited keys that co-locate everything about one session — a metadata key and a history key sharing a common prefix, e.g. agent:session:{id}:meta and agent:session:{id}:history. A hash (HSET) holds metadata (user id, channel, created_at, message_count, active-workflow state); a list (RPUSH/LRANGE) holds ordered recent turns as JSON, giving atomic append and fast ordered reads. To keep the hot store bounded, LTRIM history -N -1 keeps only the last N turns (50 is common), maintaining a sliding window.
The worked async store below derives a deterministic scoping key and appends inside a pipeline with transaction=True: the push, trim, counter increment, and both EXPIRE calls execute atomically, so history and metadata never fall out of sync and no key is left without a TTL.
The single most important operational rule for a session cache is that every key should carry a TTL. Redis deletes expired keys automatically, so TTL is both memory reclamation and the natural "forget an idle conversation" behavior. Keys without a TTL accumulate until out-of-memory — a classic production failure. Typical chatbot TTLs run 60 minutes to 2 hours (7200 seconds is common). A fixed TTL expires a set time after creation and can cut a user off mid-conversation; a sliding TTL re-arms expiry on every access, so active sessions persist and abandoned ones decay. Apply the same TTL to both the metadata hash and the history list so they expire together.
When Redis is unavailable, or session state must survive a restart, a database can back the store: a sessions table keyed by (channel, user_id) with a JSON/JSONB state column and an expires_at timestamp, expiry enforced by a WHERE expires_at > now() filter plus a background purge job. Many gateways use both — Redis as the hot cache, checkpointed periodically to a database for durability.
Because workers are interchangeable and messages flow through a queue, two messages from the same user can be processed nearly simultaneously by two workers. Both load the session, both mutate it, both write it back; whichever writes last wins, silently discarding the other's changes — the last-write-wins hazard. For ordering-tolerant conversational state this is acceptable (and the transcript still captures both messages). Where it bites is structured read-modify-write state — slot filling. Two mitigations keep this safe: prefer atomic field-level operations (HSET one field, RPUSH one turn, HINCRBY a counter) so Redis serializes updates; and where several fields must change together based on their current values, use optimistic concurrency with WATCH/MULTI/EXEC, which aborts and lets you retry if the key changed underneath you.
Visual animation — coming soon
Post-Quiz: Session Storage
Why does the session store apply an LTRIM history -N -1 after every append to the Redis list of recent turns?
To sort the turns chronologically before they are read back
To keep the hot store bounded with a sliding window so an active session never grows without limit
To permanently delete old turns from the durable transcript
To trigger Redis to persist the list to disk
A chatbot uses a sliding TTL that calls EXPIRE on every access. What user-facing behavior does this produce compared to a fixed TTL measured from creation?
Every session lives forever because the TTL is constantly reset
An actively used conversation stays alive while an abandoned one decays
Sessions expire faster the more the user interacts with them
The TTL applies only to the metadata hash, never to the history list
Two messages from the same user land on two workers concurrently. One writes the collected booking date, the other the collected time, each via a naive read-modify-write of the whole state. What goes wrong, and what is the recommended fix?
Nothing goes wrong; Redis serializes whole-object writes so both slots survive
One slot is silently lost to last-write-wins; prefer atomic per-field ops (or WATCH/MULTI/EXEC) instead of read-modify-write
Both writes fail and the session is dropped; the fix is to disable the TTL
The transcript is corrupted; the fix is to stop appending to it during writes
Pre-Quiz: Conversation Transcripts
A user corrects a message they sent earlier. Under the append-only transcript model, how is that correction stored?
The original row is updated in place to reflect the corrected content
A new event is appended; the prior row is never mutated
The original row is deleted and the corrected one takes its place
The correction is written only to the session, not the transcript
The full transcript exceeds the model's context window. What does the gateway send to the model on each turn?
The entire transcript, trusting the model to ignore what it cannot fit
A budgeted view assembled fresh each turn via recency windowing, running summaries, and/or retrieval
Only the very first message of the conversation
A permanently truncated transcript with old turns deleted from disk
Under regimes like GDPR, what is the correct posture on transcript retention and audit?
There is one legally mandated retention period that applies to all chatbot data
Justify the retention period by purpose, honor deletion requests, and keep separate immutable audit logs of access and purges
Transcripts should be deleted immediately after each session ends
Audit logs can be edited freely as long as the transcript stays append-only
Conversation Transcripts
Key Points
The transcript is the durable, ordered, append-only record of every message; the session is a fast, lossy projection of it.
Model each turn with a stable message_id, conversation_id, user_id, channel, role, content, created_at, and rich metadata (tokens, model, tool calls, safety scores).
Corrections are stored as new events, never in-place mutations — a frozen=True dataclass encodes this in the type system.
Never send the raw transcript to the model; build a bounded context window via recency windowing, running summaries, and/or retrieval.
Durable transcripts carry retention and audit obligations: retention class, tiered storage, honored deletions, and separate immutable audit logs.
The session answers the next turn; the transcript is the truth. A practical transcript model captures, per message: a stable message_id, the owning conversation_id, the user_id and channel, a role (user / assistant / system / tool), the content, a created_at timestamp, and metadata such as token counts, model and version, tool invocations, and safety scores. A conversations header row holds conversation-level facts; the messages rows hold the ordered turns that reference it. The cardinal rule: transcripts are append-only — a correction is a new event, never an in-place mutation. A frozen=True dataclass makes a TranscriptTurn immutable, so the only way to change history is to append another turn.
Figure 10.5: Transcript data model — a conversation header and its ordered messages
The full transcript routinely exceeds an LLM's context window — the bounded set of tokens the model accepts per call. The gateway cannot concatenate everything; it assembles a budgeted view fresh each turn. Three strategies, often combined:
Recency windowing — include the last N turns verbatim, newest-first, stopping when the budget is reached.
Hierarchical summarization — compress earlier context into a running summary before appending new turns.
Retrieval-augmented context — embed each turn and, at inference time, retrieve the most relevant past turns rather than the most recent.
Turns dropped from the view are not lost — they remain in the durable transcript, and in a fuller implementation are exactly the ones fed to a summarization pass that updates the running summary. The principle bears repeating: the transcript is the complete truth on disk, and what reaches the model is a carefully budgeted view of it, assembled anew each turn.
Figure 10.6: Building a bounded context window from the durable transcript
flowchart TD
T["Full durable transcript"] --> RS["Add running summary of older context"]
RS --> BUDGET{"Token budget remaining?"}
BUDGET -->|"Yes"| NEXT["Add next turn, newest-first"]
NEXT --> BUDGET
BUDGET -->|"No — budget spent"| DROP["Stop: older turns dropped from view"]
DROP --> WIN["Bounded context window sent to model"]
DROP -.->|"dropped turns feed summarization; stay in transcript"| T
Durable transcripts trigger obligations ephemeral sessions never do. Under GDPR there is no single correct retention policy period — it depends on the purpose of processing — but regulators expect you to justify your choice and honor deletion requests. The practical mechanisms are declarative retention rules and tiered storage: keep recent transcripts hot, move them to cold storage after roughly 30 days, and purge after the retention window closes, all automatically. A gateway stamps each conversation with a retention class that drives this lifecycle. Because transcripts drive decisions, they must support audit: log every access and every retention action (deletions, exports) in comprehensive, immutable logs kept separately from the transcript. The append-only discipline makes the record tamper-evident by construction; the strongest research systems add Merkle-root hash chains, but most gateways simply treat transcripts as append-only and log all reads, exports, and deletions.
Post-Quiz: Conversation Transcripts
A user corrects a message they sent earlier. Under the append-only transcript model, how is that correction stored?
The original row is updated in place to reflect the corrected content
A new event is appended; the prior row is never mutated
The original row is deleted and the corrected one takes its place
The correction is written only to the session, not the transcript
The full transcript exceeds the model's context window. What does the gateway send to the model on each turn?
The entire transcript, trusting the model to ignore what it cannot fit
A budgeted view assembled fresh each turn via recency windowing, running summaries, and/or retrieval
Only the very first message of the conversation
A permanently truncated transcript with old turns deleted from disk
Under regimes like GDPR, what is the correct posture on transcript retention and audit?
There is one legally mandated retention period that applies to all chatbot data
Justify the retention period by purpose, honor deletion requests, and keep separate immutable audit logs of access and purges
Transcripts should be deleted immediately after each session ends
Audit logs can be edited freely as long as the transcript stays append-only
Pre-Quiz: Matching Storage to Need
What is the "two-tier pattern" the chapter recommends as the consensus architecture?
A primary database plus a read-replica of the same database
A fast cache (Redis) for active session data in front of a persistent store for long-term history
Two copies of Redis, one for reads and one for writes
An object store fronted by a message queue
A shared history-provider instance is used across many concurrent sessions. Where must the session-specific database key live, and why?
Inside the provider, so it is cached for the fastest possible lookup
In the session object, because the shared provider must hold no per-session state
In a global variable shared by all workers
In the transcript metadata column, overwritten on each turn
Why does the chapter recommend a version field on serialized session and transcript payloads?
Redis requires a version field to compute a key's TTL
So schema evolution is handled as a migration: old records still deserialize when new optional fields are added
To let the model count tokens more accurately
Because JSON cannot be parsed without an explicit version marker
Matching Storage to Need
Key Points
Choose the backend by state kind and required guarantees, not by whatever store is at hand.
The consensus is a two-tier pattern: a fast cache for active sessions in front of a persistent store for long-term history.
The session holds a pointer (a database key) into the durable record; a shared history-provider must hold no per-session state.
Match consistency and durability to the data: sessions tolerate loss and last-write-wins; the transcript must never lose an append.
Serialize both stores in versioned, forward-compatible formats (JSON/JSONB) so schema evolution is a migration, not a break.
We close by turning the two-kinds-of-state framework into a backend-selection discipline. The consensus architecture is a two-tier pattern: a fast cache for active session data plus a persistent store for long-term history. The Microsoft Agent Framework encodes exactly this split — local session state in memory for services that need no server-side persistence, versus service-managed or custom durable storage, with the session merely holding a pointer (a database key) into the durable record.
Figure 10.2: Storage-backend selection by state kind
State kind
Example data
Backend
Why
Ephemeral session
Recent turns, active workflow, slots
Redis / Memcached
Sub-millisecond reads on the hot path; native TTL eviction
Durable transcript
Full message history, headers
Relational / document DB
Queryable, ACID, append-only, retention-governed
Derived long-term memory
Summaries, embeddings
Vector store + DB
Semantic retrieval of relevant past turns
Large / archival artifacts
Exports, cold transcripts, attachments
Object storage
Cheap, durable, lifecycle-tiered, off the query path
One guardrail deserves emphasis for a stateless gateway: a shared history-provider instance must hold no per-session state; session-specific values like the database key live in the session object, not the provider. This is the same statelessness discipline from the first section, applied to the storage layer itself.
Each data type carries different consistency and durability expectations, and the backend must match them. Ephemeral session writes tolerate last-write-wins and can be lost on eviction, because the session is regenerable from the transcript. The durable transcript must never lose a write and is append-only. To resume a conversation cleanly after a worker restart, persist the full serialized session — not just the message text — then rehydrate it; a separate audit/eval history provider captures enriched I/O without affecting the primary context-building path, acknowledging that the audit copy and the context copy are different concerns.
Finally, serialization and schema evolution matter because both stores hold structured data that changes shape over time. JSON (or JSONB in Postgres) is the pragmatic default — human-readable, widely supported, forgiving of added fields. Plan for evolution: include a version field on serialized payloads, make new fields optional so old records still deserialize, and treat a format change as a migration rather than a silent break. The transcript, being the long-lived system of record, is where schema drift hurts most and where forward-compatible serialization pays off.
Visual animation — coming soon
Post-Quiz: Matching Storage to Need
What is the "two-tier pattern" the chapter recommends as the consensus architecture?
A primary database plus a read-replica of the same database
A fast cache (Redis) for active session data in front of a persistent store for long-term history
Two copies of Redis, one for reads and one for writes
An object store fronted by a message queue
A shared history-provider instance is used across many concurrent sessions. Where must the session-specific database key live, and why?
Inside the provider, so it is cached for the fastest possible lookup
In the session object, because the shared provider must hold no per-session state
In a global variable shared by all workers
In the transcript metadata column, overwritten on each turn
Why does the chapter recommend a version field on serialized session and transcript payloads?
Redis requires a version field to compute a key's TTL
So schema evolution is handled as a migration: old records still deserialize when new optional fields are added
To let the model count tokens more accurately
Because JSON cannot be parsed without an explicit version marker