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 -> valueFor 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 chatThe 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.