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

  1. Overview
  2. Quick Start
  3. SessionStore Protocol
  4. Store Backends
  5. TTL-Based Expiry
  6. Agent Integration
  7. Observer Events
  8. Choosing a Backend
  9. Best Practices
  10. Namespace Isolation
  11. 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 / namespace validation guards as RedisSessionStore (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 / namespace validation guards as the other backends (null bytes, 512-char cap)
  • Optional server-side expiry: when default_ttl is set, a TTL index is created on expires_at and each save stamps it
  • search fetches the (optionally namespace-filtered) documents and scores them in-process with term frequency — no $text index 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_item upsert saves
  • Namespace isolation and the same session_id / namespace validation guards as the other backends
  • Optional server-side TTL: when default_ttl is set, each save stamps an expires_at epoch-seconds attribute — enable TTL on that attribute in the table settings for the server to expire stale sessions
  • list / search paginate a full table scan and 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:

BackendTTL Mechanism
JsonFileSessionStoreChecks updated_at in file on load
SQLiteSessionStoreFilters by updated_at column on queries
RedisSessionStoreUses 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")
EventWhenParameters
on_session_loadAfter restoring a session during initrun_id, session_id, message_count
on_session_saveAfter persisting session state post-runrun_id, session_id, message_count

Choosing a Backend

Decision Matrix

FeatureJsonFileSQLiteRedis
DependenciesNoneNoneredis
PersistenceFile per sessionSingle DB fileRemote server
Multi-processNo (file locks)LimitedYes
TTLApplication-levelApplication-levelNative
ScalabilityThousandsTens of thousandsMillions
SetupDirectory pathDB pathRedis 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.


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:

FieldTypeDescription
session_idstrMatched 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
scorefloatRelevance, higher is better. Only comparable within one result set
matched_messagesList[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

BackendMechanismScoringCost
SQLiteSessionStoreFTS5 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 tiesIndex lookup — fast even for many sessions
JsonFileSessionStoreLinear scan over session filesCase-insensitive term frequencyO(sessions), reads every file
RedisSessionStoreSCAN over the key prefix + in-process matching (RediSearch not required)Case-insensitive term frequencyO(sessions) round-trips, transfers every payload
SupabaseSessionStorePostgREST ilike on the JSON text projection memory_json->>messages (one request per term, terms JSON-escaped to match the projection) + in-process scoringCase-insensitive term frequencyOne 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

ClassDescription
SessionStoreProtocol defining save/load/list/delete/exists/search interface
SessionSearchResultFrozen 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 FieldTypeDescription
session_storeOptional[SessionStore]Backend for session persistence
session_idOptional[str]ID to save/load this session
session_metadataOptional[Dict[str, Any]]Arbitrary metadata stored with the session

Further Reading


Next Steps: Learn about entity tracking in the Entity Memory Module.


#ScriptDescription
105105_session_search.pyCross-session search with SQLite FTS5 and JSON file backends
3333_persistent_sessions.pyPersistent sessions with JSON and SQLite
2020_customer_support_bot.pyProduction bot with session persistence
0404_conversation_memory.pyConversation memory (sessions persist this)