---
description: "Session persistence with JSON, SQLite, and Redis backends"
tags:
  - memory
  - persistence
---

# Sessions Module

**Import:** `from selectools.sessions import JsonFileSessionStore`
**Stability:** stable

```python title="sessions_quick.py"
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](MEMORY.md) - Conversation memory that sessions persist
    - [Entity Memory](ENTITY_MEMORY.md) - 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](#overview)
2. [Quick Start](#quick-start)
3. [SessionStore Protocol](#sessionstore-protocol)
4. [Store Backends](#store-backends)
5. [TTL-Based Expiry](#ttl-based-expiry)
6. [Agent Integration](#agent-integration)
7. [Observer Events](#observer-events)
8. [Choosing a Backend](#choosing-a-backend)
9. [Best Practices](#best-practices)
10. [Namespace Isolation](#namespace-isolation)
11. [Cross-Session Search](#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

```python
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:

```python
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:

```python
{
    "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:

```python
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:

```python
from selectools.sessions import SQLiteSessionStore

store = SQLiteSessionStore(
    db_path="./sessions.db",   # SQLite database path
    ttl_seconds=604800,        # expire after 7 days (optional)
)
```

**Schema:**

```sql
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

```python
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:**

```bash
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

```python
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):

```sql
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:**

```bash
pip install selectools[supabase]
```

**Example:** [`examples/96_supabase_session_store.py`](https://github.com/johnnichev/selectools/blob/main/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)

```python
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:**

```bash
pip install selectools[mongo]
```

### 6. DynamoDBSessionStore

**Best for:** AWS-native / serverless deployments already using DynamoDB.

**Since:** Unreleased (beta)

```python
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:**

```bash
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.

```python
# 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`:

```python
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:

```mermaid
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:

```mermaid
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:

```python
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:

```python
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

```mermaid
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

```python
# 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

```python
# Expire idle sessions after 7 days
store = SQLiteSessionStore(db_path="sessions.db", ttl_seconds=604800)
```

### 3. Handle Missing Sessions Gracefully

```python
data = store.load("nonexistent-session")
if data is None:
    # Start fresh -- agent does this automatically
    pass
```

### 4. List and Clean Up Sessions

```python
# 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

```python
if ENV == "development":
    store = JsonFileSessionStore(directory="./dev-sessions")
elif ENV == "production":
    store = RedisSessionStore(url=REDIS_URL, ttl_seconds=86400)
```

---

## Testing

```python
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`.

```python
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."

```python
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:

```python
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](MEMORY.md) - Conversation memory that sessions persist
- [Agent Module](AGENT.md) - How agents integrate with session storage
- [Entity Memory Module](ENTITY_MEMORY.md) - Entity tracking across sessions
- [Knowledge Module](KNOWLEDGE.md) - Cross-session knowledge memory

---

**Next Steps:** Learn about entity tracking in the [Entity Memory Module](ENTITY_MEMORY.md).

---

## Related Examples

| # | Script | Description |
|---|--------|-------------|
| 105 | [`105_session_search.py`](https://github.com/johnnichev/selectools/blob/main/examples/105_session_search.py) | Cross-session search with SQLite FTS5 and JSON file backends |
| 33 | [`33_persistent_sessions.py`](https://github.com/johnnichev/selectools/blob/main/examples/33_persistent_sessions.py) | Persistent sessions with JSON and SQLite |
| 20 | [`20_customer_support_bot.py`](https://github.com/johnnichev/selectools/blob/main/examples/20_customer_support_bot.py) | Production bot with session persistence |
| 04 | [`04_conversation_memory.py`](https://github.com/johnnichev/selectools/blob/main/examples/04_conversation_memory.py) | Conversation memory (sessions persist this) |
