Sessions Module
June 13, 2026 · View on GitHub
Import: from selectools.sessions import JsonFileSessionStore
Stability: stable
import tempfile
from selectools import Agent, AgentConfig, LocalProvider, ConversationMemory, Message, Role
from selectools.sessions import JsonFileSessionStore
with tempfile.TemporaryDirectory() as tmpdir:
store = JsonFileSessionStore(directory=tmpdir)
# First run -- auto-saves after completion
agent = Agent(
tools=[],
provider=LocalProvider(),
memory=ConversationMemory(max_messages=50),
config=AgentConfig(session_store=store, session_id="demo-001"),
)
result = agent.run([Message(role=Role.USER, content="My name is Alice.")])
print(f"Session saved: {store.exists('demo-001')}") # True
# Second run -- auto-loads previous history
agent2 = Agent(
tools=[],
provider=LocalProvider(),
memory=ConversationMemory(max_messages=50),
config=AgentConfig(session_store=store, session_id="demo-001"),
)
history = agent2.memory.get_history()
print(f"Restored {len(history)} messages from session")
!!! tip "See Also" - Memory - Conversation memory that sessions persist - Entity Memory - Entity tracking across sessions
Added in: v0.16.0
File: src/selectools/sessions.py
Classes: SessionStore, SessionSearchResult, JsonFileSessionStore, SQLiteSessionStore, RedisSessionStore, SupabaseSessionStore, MongoSessionStore, DynamoDBSessionStore
Table of Contents
- Overview
- Quick Start
- SessionStore Protocol
- Store Backends
- TTL-Based Expiry
- Agent Integration
- Observer Events
- Choosing a Backend
- Best Practices
- Namespace Isolation
- Cross-Session Search
Overview
The Sessions module provides persistent session storage for selectools agents. It saves and restores full conversation state -- memory, metadata, and configuration -- across process restarts, enabling long-running and resumable agent workflows.
Purpose
- Persistence: Save agent state to disk, SQLite, or Redis between runs
- Resumability: Reload a previous session by ID and continue where you left off
- Multi-User: Maintain separate sessions per user, thread, or workflow
- TTL Expiry: Automatically expire stale sessions after a configurable duration
- Auto-Save: Transparent save after every
run()/arun()call
Quick Start
from selectools import Agent, AgentConfig, OpenAIProvider, ConversationMemory, Message, Role
from selectools.sessions import JsonFileSessionStore
# Create a file-backed session store
session_store = JsonFileSessionStore(directory="./sessions")
# Configure agent with session support
agent = Agent(
tools=[],
provider=OpenAIProvider(),
memory=ConversationMemory(max_messages=50),
config=AgentConfig(
session_store=session_store,
session_id="user-alice-001",
),
)
# First run -- conversation is auto-saved after completion
result = agent.run([Message(role=Role.USER, content="My name is Alice.")])
# Later (even after restart) -- session auto-loads on init
agent2 = Agent(
tools=[],
provider=OpenAIProvider(),
memory=ConversationMemory(max_messages=50),
config=AgentConfig(
session_store=session_store,
session_id="user-alice-001", # same ID resumes session
),
)
result = agent2.run([Message(role=Role.USER, content="What is my name?")])
# Agent remembers: "Alice"
SessionStore Protocol
All backends implement the SessionStore protocol:
from typing import Protocol, Optional, List, Dict, Any
class SessionStore(Protocol):
def save(self, session_id: str, data: Dict[str, Any]) -> None:
"""Persist session data under the given ID."""
...
def load(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Load session data by ID. Returns None if not found or expired."""
...
def exists(self, session_id: str) -> bool:
"""Check whether a session exists and has not expired."""
...
def delete(self, session_id: str) -> None:
"""Delete a session by ID. No-op if it does not exist."""
...
def list_sessions(self) -> List[str]:
"""Return all non-expired session IDs."""
...
Session Data Format
The agent serializes the following into session data:
{
"session_id": "user-alice-001",
"messages": [ # ConversationMemory contents
{"role": "user", "content": "My name is Alice."},
{"role": "assistant", "content": "Hello Alice!"},
],
"metadata": { # Arbitrary user-defined metadata
"user_id": "alice",
"started_at": "2026-03-13T10:00:00Z",
},
"created_at": "2026-03-13T10:00:00Z",
"updated_at": "2026-03-13T10:05:00Z",
}
Store Backends
1. JsonFileSessionStore
Best for: Local development, prototyping, single-instance deployments
Each session is stored as a separate JSON file:
from selectools.sessions import JsonFileSessionStore
store = JsonFileSessionStore(
directory="./sessions", # directory for session files
ttl_seconds=86400, # expire after 24 hours (optional)
)
# Files created: ./sessions/user-alice-001.json
Features:
- No external dependencies
- Human-readable JSON files
- One file per session
- Atomic writes (write-to-temp then rename)
2. SQLiteSessionStore
Best for: Production single-instance, embedded applications
All sessions stored in a single SQLite database:
from selectools.sessions import SQLiteSessionStore
store = SQLiteSessionStore(
db_path="./sessions.db", # SQLite database path
ttl_seconds=604800, # expire after 7 days (optional)
)
Schema:
CREATE TABLE sessions (
session_id TEXT PRIMARY KEY,
data TEXT NOT NULL, -- JSON-serialized session
created_at TEXT NOT NULL, -- ISO 8601 timestamp
updated_at TEXT NOT NULL -- ISO 8601 timestamp
);
Features:
- Single-file persistence
- ACID transactions
- Efficient listing and lookup
- No external dependencies
3. RedisSessionStore
Best for: Multi-instance production, shared state across processes
from selectools.sessions import RedisSessionStore
store = RedisSessionStore(
url="redis://localhost:6379/0", # Redis connection URL
prefix="selectools:session:", # key prefix (default)
ttl_seconds=3600, # expire after 1 hour (optional)
)
Features:
- Shared across processes and machines
- Native TTL support via Redis EXPIRE
- High throughput
- Requires running Redis instance
Installation:
pip install selectools[redis] # Includes redis-py
4. SupabaseSessionStore
Best for: Postgres-backed production deployments on Supabase (managed RLS, PostgREST access)
Since: v0.23.0
from supabase import create_client
from selectools.sessions import SupabaseSessionStore
client = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
store = SupabaseSessionStore(
client=client,
table_name="selectools_sessions", # default
)
Required table DDL (run once in your Supabase project):
create table if not exists public.selectools_sessions (
session_id text primary key,
memory_json jsonb not null,
message_count integer not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.selectools_sessions enable row level security;
The service-role key bypasses RLS — same pattern as the other backends. Enabling RLS with no policies locks anon access out.
Features:
- Postgres persistence with JSONB payloads
- Idempotent saves via
upsert(on_conflict="session_id") - Namespace prefix support and the same
session_id/namespacevalidation guards asRedisSessionStore(null bytes, 512-char cap) - Client ownership stays with the caller — you pass in an already-constructed
supabase.Client
Installation:
pip install selectools[supabase]
Example: examples/96_supabase_session_store.py — runs offline against an in-process fake client; swap in supabase.create_client(...) for production.
5. MongoSessionStore
Best for: document-database deployments already running MongoDB.
Since: Unreleased (beta)
from selectools.sessions import MongoSessionStore
store = MongoSessionStore(
url="mongodb://localhost:27017", # connection URL
database="selectools", # default
collection="sessions", # default
default_ttl=None, # seconds; None = no expiry
)
Each session is one document keyed by _id (the bare session_id, or
namespace:session_id with a namespace). The full ConversationMemory lives in
the memory field, so a save is a single replace_one(..., upsert=True).
Features:
- Single-document-per-session storage; idempotent upsert saves
- Namespace isolation and the same
session_id/namespacevalidation guards as the other backends (null bytes, 512-char cap) - Optional server-side expiry: when
default_ttlis set, a TTL index is created onexpires_atand each save stamps it searchfetches the (optionally namespace-filtered) documents and scores them in-process with term frequency — no$textindex assumed (add one + a server-side query for large fleets)
Installation:
pip install selectools[mongo]
6. DynamoDBSessionStore
Best for: AWS-native / serverless deployments already using DynamoDB.
Since: Unreleased (beta)
from selectools.sessions import DynamoDBSessionStore
store = DynamoDBSessionStore(
table_name="selectools_sessions", # must already exist
region_name=None, # else boto3's normal resolution
default_ttl=None, # seconds; None = no expiry
)
Each session is one item keyed by a string partition key session_key (the bare
session_id, or namespace:session_id). The full ConversationMemory is stored
as a JSON string in memory_json, which sidesteps DynamoDB's Decimal-only number
type for the nested payload. A save is a single put_item (upsert).
Required table (create once, e.g. via console/CDK/Terraform):
- Partition key:
session_key(String)
Features:
- Single-item-per-session storage; idempotent
put_itemupsert saves - Namespace isolation and the same
session_id/namespacevalidation guards as the other backends - Optional server-side TTL: when
default_ttlis set, each save stamps anexpires_atepoch-seconds attribute — enable TTL on that attribute in the table settings for the server to expire stale sessions list/searchpaginate a full tablescanand score in-process — no GSI assumed (add one + a server-side query for large tables)
Installation:
pip install selectools[aws]
TTL-Based Expiry
All backends support optional time-to-live. When ttl_seconds is set, sessions that have not been updated within the TTL window are treated as expired.
# Session expires 1 hour after last update
store = JsonFileSessionStore(directory="./sessions", ttl_seconds=3600)
store.save("s1", {"messages": []})
# Within 1 hour:
store.load("s1") # Returns session data
store.exists("s1") # True
# After 1 hour with no update:
store.load("s1") # Returns None
store.exists("s1") # False
store.list_sessions() # Does not include "s1"
Behavior by backend:
| Backend | TTL Mechanism |
|---|---|
JsonFileSessionStore | Checks updated_at in file on load |
SQLiteSessionStore | Filters by updated_at column on queries |
RedisSessionStore | Uses native Redis EXPIRE command |
Each save() call resets the TTL clock by updating the updated_at timestamp.
Agent Integration
Configuration
Pass a SessionStore and session_id via AgentConfig:
from selectools import Agent, AgentConfig, OpenAIProvider, ConversationMemory
from selectools.sessions import SQLiteSessionStore
store = SQLiteSessionStore(db_path="sessions.db")
agent = Agent(
tools=[...],
provider=OpenAIProvider(),
memory=ConversationMemory(max_messages=50),
config=AgentConfig(
session_store=store,
session_id="thread-abc-123",
),
)
Auto-Load on Init
When both session_store and session_id are set, the agent attempts to load the session during initialization:
flowchart TD
A["Agent.__init__()"] --> B{"session_store.exists(session_id)?"}
B -->|Yes| C["Load session & restore memory"]
C --> D["Fire on_session_load event"]
B -->|No| E["Start with empty memory"]
D --> F["Continue initialization"]
E --> F
Auto-Save After Run
After each run(), arun(), or astream() completes, the agent saves the current state:
graph TD
A["run() / arun() / astream()"] --> B["Execute agent loop"]
B --> C["Produce AgentResult"]
C --> D["session_store.save(session_id, ...)"]
D --> E["Fire on_session_save event"]
E --> F["Return AgentResult"]
Session Metadata
Attach arbitrary metadata to sessions:
agent = Agent(
tools=[...],
provider=OpenAIProvider(),
memory=ConversationMemory(),
config=AgentConfig(
session_store=store,
session_id="user-42",
session_metadata={
"user_id": "42",
"channel": "web",
"created_at": "2026-03-13T10:00:00Z",
},
),
)
Metadata is persisted alongside messages and restored on load.
Observer Events
Two new observer events are fired for session lifecycle:
from selectools import AgentObserver
class SessionWatcher(AgentObserver):
def on_session_load(self, run_id: str, session_id: str, message_count: int) -> None:
print(f"[{run_id}] Loaded session '{session_id}' with {message_count} messages")
def on_session_save(self, run_id: str, session_id: str, message_count: int) -> None:
print(f"[{run_id}] Saved session '{session_id}' with {message_count} messages")
| Event | When | Parameters |
|---|---|---|
on_session_load | After restoring a session during init | run_id, session_id, message_count |
on_session_save | After persisting session state post-run | run_id, session_id, message_count |
Choosing a Backend
Decision Matrix
| Feature | JsonFile | SQLite | Redis |
|---|---|---|---|
| Dependencies | None | None | redis |
| Persistence | File per session | Single DB file | Remote server |
| Multi-process | No (file locks) | Limited | Yes |
| TTL | Application-level | Application-level | Native |
| Scalability | Thousands | Tens of thousands | Millions |
| Setup | Directory path | DB path | Redis URL |
Recommendation Flow
flowchart TD
A{"Prototyping?"} -->|Yes| B["JsonFileSessionStore"]
A -->|No| C{"Single process, local?"}
C -->|Yes| D["SQLiteSessionStore"]
C -->|No| E{"On Supabase / Postgres?"}
E -->|Yes| F["SupabaseSessionStore"]
E -->|No| G["RedisSessionStore"]
Best Practices
1. Use Meaningful Session IDs
# Good -- traceable, unique per conversation
session_id = f"user-{user_id}-{conversation_id}"
# Bad -- opaque, hard to debug
session_id = str(uuid.uuid4())
2. Set TTL for Production
# Expire idle sessions after 7 days
store = SQLiteSessionStore(db_path="sessions.db", ttl_seconds=604800)
3. Handle Missing Sessions Gracefully
data = store.load("nonexistent-session")
if data is None:
# Start fresh -- agent does this automatically
pass
4. List and Clean Up Sessions
# List all active sessions
for sid in store.list_sessions():
print(sid)
# Delete a specific session
store.delete("user-alice-001")
5. Separate Stores by Environment
if ENV == "development":
store = JsonFileSessionStore(directory="./dev-sessions")
elif ENV == "production":
store = RedisSessionStore(url=REDIS_URL, ttl_seconds=86400)
Testing
def test_session_roundtrip():
store = JsonFileSessionStore(directory="/tmp/test-sessions")
store.save("s1", {
"messages": [{"role": "user", "content": "Hello"}],
"metadata": {"user": "test"},
})
assert store.exists("s1")
data = store.load("s1")
assert data is not None
assert len(data["messages"]) == 1
assert data["messages"][0]["content"] == "Hello"
store.delete("s1")
assert not store.exists("s1")
def test_session_ttl_expiry():
store = JsonFileSessionStore(
directory="/tmp/test-sessions",
ttl_seconds=1, # 1-second TTL for testing
)
store.save("s1", {"messages": []})
assert store.exists("s1")
import time
time.sleep(2)
assert not store.exists("s1")
assert store.load("s1") is None
def test_agent_with_sessions():
store = JsonFileSessionStore(directory="/tmp/test-sessions")
memory = ConversationMemory(max_messages=20)
agent = Agent(
tools=[],
provider=LocalProvider(),
memory=memory,
config=AgentConfig(
session_store=store,
session_id="test-session",
),
)
agent.run([Message(role=Role.USER, content="Hello")])
assert store.exists("test-session")
# New agent with same session ID loads history
agent2 = Agent(
tools=[],
provider=LocalProvider(),
memory=ConversationMemory(max_messages=20),
config=AgentConfig(
session_store=store,
session_id="test-session",
),
)
history = agent2.memory.get_history()
assert len(history) > 0
Namespace Isolation
All session stores (JsonFileSessionStore, SQLiteSessionStore,
RedisSessionStore, SupabaseSessionStore) accept an optional
namespace parameter on save, load, delete, and exists. Use it
to isolate session data when multiple agents share the same
session_id.
from selectools.sessions import JsonFileSessionStore
store = JsonFileSessionStore(directory="./sessions")
# Two agents can use the same session_id without collision
store.save("session_123", agent_a_memory, namespace="agent_a")
store.save("session_123", agent_b_memory, namespace="agent_b")
mem_a = store.load("session_123", namespace="agent_a")
mem_b = store.load("session_123", namespace="agent_b")
When namespace is None (the default), the bare session_id is used
as the storage key — preserving backward compatibility with sessions
saved before this feature.
Cross-Session Search
Stability: beta
All six backends implement search(query, namespace=None, limit=5) —
free-text search over the content of user and assistant messages
across every stored session. This is how an agent "remembers what we
discussed last Tuesday."
from selectools.sessions import SQLiteSessionStore
store = SQLiteSessionStore("sessions.db")
results = store.search("billing discrepancy", namespace="user:alice", limit=5)
for r in results:
print(r.session_id, r.score, r.matched_messages)
memory = store.load(r.session_id, namespace="user:alice")
Each result is a frozen SessionSearchResult dataclass:
| Field | Type | Description |
|---|---|---|
session_id | str | Matched session. With a namespace argument, the bare id (pass straight back to load(id, namespace=...)). Without one, follows the same convention as list() for the backend |
score | float | Relevance, higher is better. Only comparable within one result set |
matched_messages | List[str] | Length-capped snippets of matched user/assistant messages, most relevant first (max 5 per session) |
The query is split on whitespace; a session matches when any term occurs (case-insensitive) in any user/assistant message. Tool messages and metadata are never searched. Expired sessions are skipped.
Per-Backend Semantics
| Backend | Mechanism | Scoring | Cost |
|---|---|---|---|
SQLiteSessionStore | FTS5 virtual table over message content, built on save() | Each matching message scores 1.0 plus a bounded bm25 tiebreak in [0, 1) — hit count strictly dominates, bm25 breaks ties | Index lookup — fast even for many sessions |
JsonFileSessionStore | Linear scan over session files | Case-insensitive term frequency | O(sessions), reads every file |
RedisSessionStore | SCAN over the key prefix + in-process matching (RediSearch not required) | Case-insensitive term frequency | O(sessions) round-trips, transfers every payload |
SupabaseSessionStore | PostgREST ilike on the JSON text projection memory_json->>messages (one request per term, terms JSON-escaped to match the projection) + in-process scoring | Case-insensitive term frequency | One round-trip per term, transfers matched payloads, no server-side ranking |
Supabase note: each per-term candidate select carries an explicit
limit(1000). PostgREST deployments can enforce a server-side
max-rows setting that silently truncates unbounded selects, so the
explicit cap keeps behavior deterministic instead of
server-config-dependent. If a single term can match more than 1000
rows in your table, use a proper tsvector index plus a dedicated RPC
instead of search().
SQLite Index Migration
The FTS5 index tables (session_messages_fts, session_search_index)
are created automatically and are purely additive — the sessions
table and its rows are never modified. Databases created before this
feature are upgraded transparently: sessions saved by older versions
are indexed lazily on search(), as are rows later rewritten by a
search-unaware older library version sharing the database (detected
via indexed_at < updated_at). FTS5 availability is probed directly
on open with a temp-schema virtual table, so a database created on an
FTS5-enabled build and reopened on a build without FTS5 degrades
cleanly instead of erroring. If the SQLite build lacks FTS5 (rare),
search() emits a RuntimeWarning and degrades to a LIKE scan with
term-frequency scoring.
Third-Party Stores
search is a @beta addition to the SessionStore protocol. Stores
written before it existed do not implement it: explicit subclasses of
SessionStore inherit a default that raises NotImplementedError;
purely structural implementations raise AttributeError.
Feature-detect with:
if callable(getattr(store, "search", None)):
results = store.search("invoice")
API Reference
| Class | Description |
|---|---|
SessionStore | Protocol defining save/load/list/delete/exists/search interface |
SessionSearchResult | Frozen result of search(): session_id, score, matched_messages |
JsonFileSessionStore(directory, ttl_seconds) | File-based backend, one JSON file per session |
SQLiteSessionStore(db_path, ttl_seconds) | SQLite-backed backend, single database file |
RedisSessionStore(url, prefix, ttl_seconds) | Redis-backed backend for distributed deployments |
SupabaseSessionStore(client, table_name) | Postgres-backed backend via Supabase PostgREST |
| AgentConfig Field | Type | Description |
|---|---|---|
session_store | Optional[SessionStore] | Backend for session persistence |
session_id | Optional[str] | ID to save/load this session |
session_metadata | Optional[Dict[str, Any]] | Arbitrary metadata stored with the session |
Further Reading
- Memory Module - Conversation memory that sessions persist
- Agent Module - How agents integrate with session storage
- Entity Memory Module - Entity tracking across sessions
- Knowledge Module - Cross-session knowledge memory
Next Steps: Learn about entity tracking in the Entity Memory Module.
Related Examples
| # | Script | Description |
|---|---|---|
| 105 | 105_session_search.py | Cross-session search with SQLite FTS5 and JSON file backends |
| 33 | 33_persistent_sessions.py | Persistent sessions with JSON and SQLite |
| 20 | 20_customer_support_bot.py | Production bot with session persistence |
| 04 | 04_conversation_memory.py | Conversation memory (sessions persist this) |