How ChatSession and ChatMessage Models Store Conversation History and Relate to Notebooks in Open Notebook

Chat sessions in Open Notebook store metadata in SurrealDB via the ChatSession model while persisting actual message content in LangGraph's SQLite checkpoint file, using a refers_to graph edge to link conversations to notebooks.

Open Notebook implements a hybrid storage architecture that separates session metadata from conversational data. Understanding how ChatSession and ChatMessage interact with notebooks requires examining the division between SurrealDB relationship records and LangGraph's persistent state management.

The ChatSession Model: Metadata and Notebook Linkage

The ChatSession class serves as the authoritative record for conversation metadata and ownership. Defined in open_notebook/domain/notebook.py, this model inherits from ObjectModel and maps to the chat_session table in SurrealDB.

Key fields include:

  • title – Optional user-defined session identifier
  • model_override – Per-session LLM configuration that overrides global defaults
  • Automatic timestampscreated and updated fields managed by the base ObjectModel class

Creating Notebook Relationships

The model provides explicit methods to establish graph relationships:


# From open_notebook/domain/notebook.py

async def relate_to_notebook(self, notebook_id):
    await self.relate("refers_to", notebook_id)

When a client creates a new chat session via the POST endpoint in api/routers/chat.py, the handler executes:

session = ChatSession(title=title, model_override=model_override)
await session.save()
await session.relate_to_notebook(notebook_id)

This creates a refers_to edge connecting the ChatSession node to the Notebook node, enabling queries that list all conversations belonging to a specific notebook.

Where Conversation History Actually Lives: LangGraph Checkpoints

Contrary to typical CRUD applications, ChatSession does not store individual messages. The actual conversation history persists in a SQLite checkpoint file managed by LangGraph, configured via LANGGRAPH_CHECKPOINT_FILE in open_notebook/config.py.

The Message Flow

When users exchange messages, the chat graph defined in open_notebook/graphs/chat.py manages persistence through the call_model_with_messages node:

  1. Retrieves current thread state containing a messages list
  2. Appends the user's input as a HumanMessage
  3. Sends accumulated context to the LLM
  4. Appends the model's response as an AIMessage
  5. Commits the updated state to the SQLite checkpoint

This architecture decouples the conversation timeline from the database, allowing LangGraph to handle state serialization and history truncation natively.

The ChatMessage Model: API Serialization Layer

The ChatMessage class defined in api/routers/chat.py (and duplicated in api/routers/source_chat.py) is a Pydantic schema, not a database entity:

class ChatMessage(BaseModel):
    id: str          # Generated by LangGraph thread state

    type: str        # "human" or "ai"

    content: str     # Raw message text

    timestamp: Optional[str] = None

These objects are constructed on-the-fly when API endpoints request conversation history. The system never persists ChatMessage instances to SurrealDB; they serve purely as Data Transfer Objects (DTOs) for client communication.

Retrieving Conversation History

To reconstruct a conversation timeline, the API implements a two-stage retrieval process visible in the GET endpoint handlers:

  1. Load metadata – Fetch the ChatSession record from SurrealDB to obtain configuration and relationships
  2. Extract messages – Call graph.get_state(thread_id=session_id) to access the LangGraph checkpoint
  3. Map to DTOs – Iterate over thread_state.values["messages"] and instantiate ChatMessage objects

Because SQLite checkpoint access is synchronous, the codebase wraps these calls in asyncio.to_thread:


# Pattern found in api/routers/chat.py

thread_state = await asyncio.to_thread(graph.get_state, thread_id=session_id)
messages = thread_state.values["messages"]

Counting Messages Without Loading Content

For UI pagination or quota management, open_notebook/utils/graph_utils.py provides an efficient helper:

async def get_session_message_count(graph, session_id: str) -> int:
    # Reads thread state from checkpoint returns len(messages)

    state = await asyncio.to_thread(graph.get_state, thread_id=session_id)
    return len(state.values.get("messages", []))

This avoids deserializing full message content when only the count is required.

Summary

  • ChatSession persists in SurrealDB with metadata and refers_to edges linking to notebooks or sources, but contains no message content.
  • Actual messages live in LangGraph's SQLite checkpoint file, referenced by session_id as the thread identifier.
  • ChatMessage is a transient Pydantic model used exclusively for API serialization, constructed from LangGraph state at request time.
  • Relationship creation occurs through relate_to_notebook() in open_notebook/domain/notebook.py, establishing graph edges during session initialization.
  • History retrieval combines SurrealDB session lookups with synchronous SQLite checkpoint reads wrapped in asyncio.to_thread.

Frequently Asked Questions

Where are chat messages physically stored in Open Notebook?

Chat messages are stored in a SQLite checkpoint file specified by the LANGGRAPH_CHECKPOINT_FILE environment variable, not in SurrealDB. The ChatSession record only stores metadata and relationships, while the LangGraph runtime manages message persistence through its checkpointing system in open_notebook/graphs/chat.py.

First instantiate and save a ChatSession object, then call await session.relate_to_notebook(notebook_id) to create the refers_to edge. This pattern is implemented in the POST handler of api/routers/chat.py, which creates the edge immediately after persisting the session record.

What is the purpose of the ChatMessage model if it isn't stored in the database?

ChatMessage serves as a Pydantic serialization schema defined in api/routers/chat.py. It provides a standardized JSON structure for API responses, converting LangGraph's internal message objects (HumanMessage/AIMessage) into client-friendly dictionaries with fields for id, type, content, and timestamp.

How does the system count messages in a session without loading the full conversation?

The utility function get_session_message_count in open_notebook/utils/graph_utils.py accesses the LangGraph checkpoint state directly and returns the length of the messages list. This avoids the overhead of deserializing message content when only the count is needed for pagination or display purposes.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →