Before building UI or actions, define what must be persisted.

The chat module needs four concepts:

Model Why it exists
Conversation Owns one chat thread. It stores title, rolling summary, and last activity.
Message Stores user, assistant, system, task, and component timeline entries.
Attachment Links uploaded files to the message that introduced them and keeps extraction metadata.
ChatComponent Persists standalone A2UI artifacts generated by the agent, such as charts, tables, cards, and lists.

The important design choice is that generated UI components are not embedded inside assistant text messages. They are persisted as their own rows and later projected into the chat timeline as standalone component messages. This keeps text messages, tool progress, and UI artifacts independent.

Create modules/chat/models.py:

from __future__ import annotations

from sqlalchemy import DateTime, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column

from democrai.sdk.database import get_module_base


Base = get_module_base("chat")


class Conversation(Base):
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    title: Mapped[str] = mapped_column(String(255), nullable=False, default="")
    summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
    summary_until_sequence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    last_message_at = mapped_column(DateTime, nullable=True)

    @classmethod
    def filters_model(cls) -> list[dict]:
        return [
            {"key": "id", "column": "id", "operator": "eq"},
            {"key": "title", "column": "title", "operator": "ilike"},
        ]


class Message(Base):
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    conversation_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
    role: Mapped[str] = mapped_column(String(32), nullable=False)
    kind: Mapped[str] = mapped_column(String(32), nullable=False, default="text")
    status: Mapped[str] = mapped_column(String(32), nullable=False, default="completed")
    sequence: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
    content: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)

    @classmethod
    def filters_model(cls) -> list[dict]:
        return [
            {"key": "id", "column": "id", "operator": "eq"},
            {"key": "conversation_id", "column": "conversation_id", "operator": "eq"},
            {"key": "role", "column": "role", "operator": "eq"},
            {"key": "kind", "column": "kind", "operator": "eq"},
            {"key": "status", "column": "status", "operator": "eq"},
            {"key": "before_sequence", "column": "sequence", "operator": "lt"},
            {"key": "after_sequence", "column": "sequence", "operator": "gt"},
            {
                "key": "content_query",
                "column": "content",
                "operator": "ilike",
                "cast": "string",
                "escape": "\\",
            },
        ]


class Attachment(Base):
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    conversation_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
    message_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
    name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
    mime_type: Mapped[str] = mapped_column(String(255), nullable=False, default="")
    storage_path: Mapped[str] = mapped_column(Text, nullable=False, default="")
    file_id: Mapped[str] = mapped_column(String(255), nullable=False, default="")
    extraction_request_id: Mapped[str] = mapped_column(String(255), nullable=False, default="")
    metadata_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)

    @classmethod
    def filters_model(cls) -> list[dict]:
        return [
            {"key": "id", "column": "id", "operator": "eq"},
            {"key": "conversation_id", "column": "conversation_id", "operator": "eq"},
            {"key": "message_id", "column": "message_id", "operator": "eq"},
            {"key": "message_ids", "column": "message_id", "operator": "in"},
            {"key": "extraction_request_id", "column": "extraction_request_id", "operator": "eq"},
        ]


class ChatComponent(Base):
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    conversation_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
    message_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
    sequence: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
    component_kind: Mapped[str] = mapped_column(String(32), nullable=False)
    payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)

    @classmethod
    def filters_model(cls) -> list[dict]:
        return [
            {"key": "id", "column": "id", "operator": "eq"},
            {"key": "conversation_id", "column": "conversation_id", "operator": "eq"},
            {"key": "message_id", "column": "message_id", "operator": "eq"},
            {"key": "component_kind", "column": "component_kind", "operator": "eq"},
            {"key": "before_sequence", "column": "sequence", "operator": "lt"},
        ]

Why Use get_module_base

Module models inherit from:

Base = get_module_base("chat")

This gives every table the p_chat_ prefix and attaches the standard module scope columns. The generated migration includes:

  • user_id;
  • organization_id;
  • created_at;
  • updated_at.

The module does not add those columns manually. They come from the SDK module base, and the module-scoped datastore uses them when reading and writing rows.

The table names generated from these classes are:

Model Table
Conversation p_chat_conversation
Message p_chat_message
Attachment p_chat_attachment
ChatComponent p_chat_chat_component

Why Declare filters_model

Module models expose public CRUD helpers such as:

Message.list(
    filters={"conversation_id": conversation_id},
    sort={"field": "sequence", "direction": "desc"},
    page=0,
    page_size=30,
)

filters_model() defines which filters that public API accepts. A filter maps:

key -> column -> operator -> value

For example:

{"key": "before_sequence", "column": "sequence", "operator": "lt"}

This lets callers ask for older messages without exposing arbitrary SQL:

Message.list(
    filters={
        "conversation_id": conversation_id,
        "before_sequence": first_loaded_sequence,
    },
    sort={"field": "sequence", "direction": "desc"},
    page=0,
    page_size=30,
)

The same column can be exposed through multiple keys with different operators. before_sequence and after_sequence both target sequence, but one uses lt and the other uses gt.

Supported operators for module model filters are:

Operator Meaning
eq equal
ne not equal
lt less than
lte less than or equal
gt greater than
gte greater than or equal
in included in a list of values
ilike case-insensitive text search

Message.content_query casts the JSON content column to string and uses ilike. This is a fallback search path for chat messages when knowledge retrieval is unavailable.

Generate The Migration

After reviewing models.py, generate the migration with the module autogenerate command:

python main.py create-migration data -m "create chat tables" --autogenerate --module chat

The migration is generated by the core. The module author should review it, but the source of truth remains models.py.

Next we will add RBAC and translations, then use these models from the first page and action.