# How Open Notebook Uses SqliteSaver to Persist Chat Message History

> Discover how Open Notebook uses SqliteSaver to store chat message history in local SQLite files, ensuring efficient persistence by integrating with LangGraph's state graphs and FastAPI.

- Repository: [Luis Novo/open-notebook](https://github.com/lfnovo/open-notebook)
- Tags: how-to-guide
- Published: 2026-06-27

---

**The Open Notebook chat service persists conversation history by wiring LangGraph's `SqliteSaver` as a checkpointer when compiling state graphs, storing each session's messages in a local SQLite file isolated by thread ID while wrapping synchronous operations for async FastAPI compatibility.**

Open Notebook is an open-source knowledge management system that implements conversational AI using LangGraph state machines. The chat service uses **SqliteSaver** to automatically checkpoint every message exchange to a local SQLite database, ensuring durable storage without external database dependencies. This implementation manages both generic chat flows and source-aware conversations through the same persistence mechanism.

## Configuring the SQLite Checkpoint File

The checkpoint database location is defined centrally in [`open_notebook/config.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/config.py). The configuration specifies `data/sqlite-db/checkpoints.sqlite` as the storage path through the `LANGGRAPH_CHECKPOINT_FILE` constant.

```python

# open_notebook/config.py

LANGGRAPH_CHECKPOINT_FILE = "data/sqlite-db/checkpoints.sqlite"

```

The application ensures this directory exists at startup, creating a dedicated SQLite file for all LangGraph checkpoints across the application.

## Initializing SqliteSaver in LangGraph Graphs

Both the standard chat graph ([`open_notebook/graphs/chat.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/graphs/chat.py)) and the source-aware variant ([`open_notebook/graphs/source_chat.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/graphs/source_chat.py)) initialize the checkpoint system identically. They establish a raw SQLite connection and wrap it with `SqliteSaver`.

```python

# open_notebook/graphs/chat.py

import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
from open_notebook.config import LANGGRAPH_CHECKPOINT_FILE

conn = sqlite3.connect(LANGGRAPH_CHECKPOINT_FILE, check_same_thread=False)
memory = SqliteSaver(conn)

```

The `check_same_thread=False` parameter is required because the connection may be accessed across different threads when wrapped in async executors later.

## Compiling the State Graph with SqliteSaver

After defining the graph structure using `StateGraph`, the saver is attached during compilation via the `checkpointer` parameter. This wires the SQLite persistence into every state transition.

```python

# open_notebook/graphs/chat.py

from langgraph.graph import StateGraph, START, END
from open_notebook.graphs.state import ThreadState

agent_state = StateGraph(ThreadState)
agent_state.add_node("agent", call_model_with_messages)
agent_state.add_edge(START, "agent")
agent_state.add_edge("agent", END)

graph = agent_state.compile(checkpointer=memory)

```

Once compiled, the `graph` object automatically writes checkpoint data—including the `messages` list—to SQLite after each node execution.

## Session Isolation via Thread IDs

Open Notebook isolates conversations by mapping each chat session to a unique thread ID. The session ID follows the format `chat_session:<uuid>`, which is passed through `RunnableConfig` when interacting with the graph.

```python
config = RunnableConfig(configurable={"thread_id": full_session_id})

```

`SqliteSaver` uses this `thread_id` as the primary key for checkpoint rows, ensuring that distinct chat sessions never collide in the database. Even when the same graph instance processes multiple conversations, the thread ID maintains separate state histories.

## Retrieving Stored Message History

The FastAPI endpoint `GET /chat/sessions/{session_id}` reads historical messages by querying the checkpoint store. Since `SqliteSaver` operates synchronously, the call is wrapped in `asyncio.to_thread` to prevent blocking the async event loop.

```python

# api/routers/chat.py

thread_state = await asyncio.to_thread(
    chat_graph.get_state,
    config=RunnableConfig(configurable={"thread_id": full_session_id}),
)

messages = []
if thread_state and thread_state.values and "messages" in thread_state.values:
    for msg in thread_state.values["messages"]:
        messages.append(
            ChatMessage(
                id=getattr(msg, "id", ""),
                type=msg.type,
                content=msg.content,
            )
        )

```

The returned `thread_state.values["messages"]` contains the full list of `HumanMessage` and `AIMessage` objects stored from previous interactions.

## Persisting New Messages Through the Graph

When processing new user input via `POST /chat/execute`, the service appends the incoming message to the existing state and invokes the graph. The `SqliteSaver` automatically persists the updated message list, including the AI's response.

```python

# api/routers/chat.py

# 1. Retrieve current state

current_state = await asyncio.to_thread(
    chat_graph.get_state,
    config=RunnableConfig(configurable={"thread_id": full_session_id}),
)

# 2. Prepare mutable state with new message

state_vals = current_state.values if current_state else {}
state_vals.setdefault("messages", []).append(HumanMessage(content=request.message))

# 3. Invoke graph (automatically checkpoints to SQLite)

result = await asyncio.to_thread(
    chat_graph.invoke,
    input=state_vals,
    config=RunnableConfig(
        configurable={"thread_id": full_session_id, "model_id": model_override}
    ),
)

```

The `invoke` call executes the `call_model_with_messages` node, generates an `AIMessage`, and writes the complete updated history to `checkpoints.sqlite` through the configured `SqliteSaver`.

## Handling Async Operations with Synchronous SqliteSaver

`SqliteSaver` is intentionally synchronous, while Open Notebook uses FastAPI's async architecture. The codebase bridges this gap by executing all checkpoint operations—`get_state`, `invoke`, and `update`—inside `asyncio.to_thread` or `ThreadPoolExecutor` pools.

This design pattern guarantees that:
- The FastAPI server remains fully asynchronous and non-blocking
- SQLite operations execute safely on background threads
- No complex async database drivers are required for checkpoint persistence

The utility function `get_session_message_count` in [`open_notebook/utils/graph_utils.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/utils/graph_utils.py) follows the same pattern, wrapping synchronous checkpoint reads for async consumption.

## Summary

- **SqliteSaver** persists chat history to `data/sqlite-db/checkpoints.sqlite` as defined in [`open_notebook/config.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/config.py)
- Both [`chat.py`](https://github.com/lfnovo/open-notebook/blob/main/chat.py) and [`source_chat.py`](https://github.com/lfnovo/open-notebook/blob/main/source_chat.py) initialize the saver by wrapping a `sqlite3` connection with `check_same_thread=False`
- Graphs compile with `checkpointer=memory` to enable automatic state checkpointing
- Thread IDs (`chat_session:<uuid>`) isolate sessions in the database via `RunnableConfig`
- The API wraps synchronous `get_state` and `invoke` calls in `asyncio.to_thread` for FastAPI compatibility
- Message history flows through `thread_state.values["messages"]` containing LangChain message objects

## Frequently Asked Questions

### Where is the chat history physically stored in Open Notebook?

The chat history is stored in a local SQLite file located at `data/sqlite-db/checkpoints.sqlite`, as configured by the `LANGGRAPH_CHECKPOINT_FILE` constant in [`open_notebook/config.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/config.py). This file contains serialized checkpoints for all LangGraph state transitions, including the message lists for every chat session.

### How does Open Notebook prevent different chat sessions from overwriting each other?

Each chat session is assigned a unique thread ID in the format `chat_session:<uuid>`, which is passed to the graph via `RunnableConfig(configurable={"thread_id": full_session_id})`. `SqliteSaver` uses this thread ID as the primary key for checkpoint rows, ensuring complete isolation between sessions in the same SQLite database.

### Why does the Open Notebook codebase use asyncio.to_thread with SqliteSaver operations?

`SqliteSaver` provides only synchronous APIs for reading and writing checkpoints, while Open Notebook runs on FastAPI's asynchronous framework. The developers wrap every `SqliteSaver` operation—such as `get_state` and `invoke`—in `asyncio.to_thread` to execute blocking database calls on background threads without stalling the async event loop.

### Can I replace SqliteSaver with a different checkpoint backend in Open Notebook?

While the current implementation hardcodes `SqliteSaver` in both [`open_notebook/graphs/chat.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/graphs/chat.py) and [`open_notebook/graphs/source_chat.py`](https://github.com/lfnovo/open-notebook/blob/main/open_notebook/graphs/source_chat.py), LangGraph supports alternative checkpointers like `PostgresSaver`. Replacing the backend would require modifying the connection initialization and imports in those graph definition files while maintaining the same checkpointer interface passed to `graph.compile()`.