msflib-conversation¶
Purpose¶
msflib-conversation is a chat-style conversation layer for host apps: channels, direct messages, threads, messages, membership, reactions, pins, read state and attached resources, with access control built in. It is also the identity substrate for the AI modules: a conversation's public_id is the conversation_id scope dimension that ai-core checkpoints and memory, and knowledge scoping, key off, and a thread's public_id is the sub_thread_id.
It has no UI or realtime transport. It gives you the tables, services, an optional HTTP router, and an optional bridge that lets an ai-api agent write its turns into a conversation.
Install¶
[tool.poetry.dependencies]
msflib = { git = "https://github.com/msflib/fastapi.git", subdirectory = "core", rev = "core-v0.2.1" }
msflib-conversation = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/conversation", rev = "conversation-v0.2.0" }
Dependencies are msflib, msflib-tenancy and python-ulid. There are no extras. The module does not import account, workspaces, ai_core or ai_api at module scope: it takes your account and workspace objects through small protocols that only need an id.
msflib.conversation lazily exports the settings, actions, contracts, services, errors and policy functions (ConversationScope, Participant, ChannelService, AccessService, can_read, ...). Models and schemas are in msflib.conversation.models, and the router and deps are imported from their submodules.
Wiring into a host app¶
- Add
ConversationSettingsto your settings class. - Import
msflib.conversation.modelsso the tables are created (and migrated). - Mount the router, passing your own session, account, workspace and tenant dependencies.
from msflib.conversation.router import router as create_conversation_router
app.include_router(
create_conversation_router(
get_session=get_session,
get_current_account=get_current_account,
get_current_workspace=get_current_workspace, # optional
get_current_tenant=get_current_tenant, # optional
settings=settings,
prefix="/conversation",
tags=["conversation"],
)
)
get_current_account only needs to return an object with an id. Without a workspace dependency, every request is in the workspace-less bucket (workspace_id=None). Without a tenant dependency, the tenant with slug default is looked up in the database and must be seeded, otherwise requests fail with 500.
Known issue (#288)
The default-tenant fallback ignores the host's TENANCY.DEFAULT_TENANT_SLUG. If you changed the slug, requests return 500 "Default tenant has not been seeded" even though your seeding used the same settings. Pass get_current_tenant built from get_tenant_dependencies(session_dep=get_session, settings=settings.scope("TENANCY")).get_current_tenant.
The router has no prefix of its own (prefix="" by default); its paths are:
| Path | Methods |
|---|---|
/conversations |
POST create (channel, dm, group dm or personal; for dms, participants lists the other people), GET list |
/conversations/{public_id} |
GET, PATCH |
/conversations/{public_id}/archive, /unarchive |
POST |
/conversations/{public_id}/members |
POST join yourself (public channels) or invite someone else |
/conversations/{public_id}/members/{member_id} |
DELETE remove or leave, PATCH change role |
/conversations/{public_id}/messages |
GET history (keyset paged), POST post |
/conversations/{public_id}/read |
PUT mark read |
/conversations/{public_id}/pinned |
GET |
/conversations/{public_id}/resources |
GET, POST link a resource |
/messages/{message_id} |
PATCH edit, DELETE |
/messages/{message_id}/thread |
POST reply in a thread |
/messages/{message_id}/reactions/{emoji} |
PUT, DELETE |
/messages/{message_id}/pin |
POST, DELETE |
/threads/{thread_public_id}/messages |
GET |
Access checks (does the conversation exist, is the caller allowed to see it) run once in the dependency layer, not in each endpoint. To override them, build a namespace with get_conversation_dependencies(...) from msflib.conversation.deps, replace the attributes you need, and pass it as dependencies= to the router. Its members are require_account, get_participant, get_conversation_scope, get_conversation_context, get_message_context, get_thread_context and access_service. A conversation that does not exist in the caller's scope returns 404, and one the caller may not read returns 403.
See Mounting routes for the general router pattern.
Configuration¶
Settings live in the CONVERSATION namespace, and the field names are lowercase.
| Key | Default | Notes |
|---|---|---|
default_visibility |
private |
Visibility for channels created without one: public, private or personal. |
max_message_chars |
8000 |
Longer messages and edits are rejected. |
history_page_size |
50 |
Default page size for history reads. |
history_page_size_max |
200 |
Upper bound on a requested page size. |
edit_window_minutes |
0 |
Minutes after posting during which a message can be edited; 0 means unlimited. |
allow_guest_posting |
False |
Whether members with the guest role may post. |
allow_private_to_public |
False |
Whether a private conversation's visibility may be changed to public. |
retention_days |
None |
Declared, currently has no effect (#278). Not read by any code in the module. |
purge_soft_deleted_after_days |
30 |
Declared, currently has no effect (#278). Not read by any code in the module, and no code emits the purge event conversation-messages-purged. |
Known issue (#277)
Because the fields are lowercase and environment matching is case-sensitive, CONVERSATION__MAX_MESSAGE_CHARS does not set anything. Use the flat aliases instead, which work whether your settings nest the namespace or mix it in: CONVERSATION_DEFAULT_VISIBILITY, CONVERSATION_MAX_MESSAGE_CHARS, CONVERSATION_HISTORY_PAGE_SIZE, CONVERSATION_HISTORY_PAGE_SIZE_MAX, CONVERSATION_EDIT_WINDOW_MINUTES, CONVERSATION_ALLOW_GUEST_POSTING, CONVERSATION_ALLOW_PRIVATE_TO_PUBLIC, CONVERSATION_RETENTION_DAYS and CONVERSATION_PURGE_SOFT_DELETED_AFTER_DAYS. With a nested CONVERSATION object, CONVERSATION__max_message_chars (lowercase field name) also works.
Key concepts¶
Conversation. The container. kind is channel, dm, group_dm or personal, and visibility is public, private or personal; the two axes are independent. public_id (a conv_-prefixed ULID) is the only identifier exposed outside the module and is what URLs and AI scopes use. Direct conversations are get-or-create keyed on their member set, so starting a dm with the same people returns the same conversation.
Thread. A first-class sub-conversation anchored to a root message, with its own thrd_-prefixed public_id. Replying in a thread through POST /messages/{id}/thread creates the thread on the first reply.
Message. Posted into a conversation or thread by a user, an agent or the system. Deletion is a soft delete (a tombstone). History is keyset-paginated on the message id: before_id and after_id rather than offsets. Content may carry blocks and a client-supplied client_msg_id. @mentions in the content are extracted when the message is posted.
Member and participant. Membership rows are polymorphic: (member_kind, member_id) with kind user or agent, a role (owner, admin, member, guest) and a status (invited, active, left, removed). Read state (last read message, notification level, mute and star) lives on the same row. A Participant(kind, id, tenant_id) is the caller projected into that shape; id is a string, so account ids are stringified.
Scope. ConversationScope(tenant_id, workspace_id, conversation_id, sub_thread_id) is the typed scope the services and actions take. workspace_id=None is the explicit workspace-less bucket. AccessService.resolve_conversation(session, scope=..., participant=...) is the single entry point that turns a caller-supplied conversation_id string into a verified row (exists, same tenant and workspace, caller allowed to read it) and returns it with the caller's membership. Anything downstream, such as a checkpointer config or a knowledge scope, should be built from a conversation resolved this way.
Policy. can_read, can_post, can_manage and can_change_visibility are pure functions over loaded rows. Who may join a public channel or be invited is delegated to a MemberEligibility object; the default, SameTenantEligibility, allows anyone in the conversation's tenant. Pass your own to AccessService(eligibility=...) to tie eligibility to, for example, workspace membership.
Services. Each service is a class taking its actions and settings at construction: ChannelService, MembershipService, MessagingService, ThreadService, HistoryService, ReadStateService, ReactionService and ResourceService. Channel and membership calls are closed units of work: each commits itself, writes a timeline system_event message for the change, and emits its domain event after commit.
Resources. A ResourceLink attaches an external thing (resource_kind plus resource_ref) to a conversation or message. Kinds are validated against RESOURCE_KIND_REGISTRY, which starts with document, url and image; add your own with RESOURCE_KIND_REGISTRY.register("note") at startup.
Events. Register listeners on the app emitter: app_emitter = bind_app_emitter(app) at app creation, then @app_emitter.on("conversation-message-posted"). Listeners fire for events emitted from the router. When you call the services yourself, for example ChannelService().create_channel(...) in a script, the app emitter is not active and nothing reaches app-bound listeners; wrap that code in with use_app_emitter(app): (both from msflib.eventbus). Domain events such as conversation-created, conversation-message-posted, conversation-member-joined and conversation-thread-started carry the conversation's public_id and scope values, never the integer primary key (messages are addressed by integer id). Row-level lifecycle events come from ModelAction. See Event bus.
Examples¶
Channels, messages and history with the services¶
This runs as written against in-memory SQLite. It seeds tenant 1 first: SQLite does not enforce the tenant_id foreign key, but Postgres does.
import msflib.conversation.models # noqa: F401 (registers the tables)
from msflib.conversation.contracts import ConversationScope, Participant
from msflib.conversation.models import ConversationCreate, MessageCreate
from msflib.conversation.models.enums import MemberKind
from msflib.conversation.services import (
AccessService,
ChannelService,
HistoryService,
MessagingService,
)
from msflib.tenancy.models.tenant import Tenant # noqa: F401
from msflib.tenancy.resolver import resolve_default_tenant_id
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
resolve_default_tenant_id(session) # seeds tenant 1
session.commit()
owner = Participant(kind=MemberKind.user, id="u1", tenant_id=1)
scope = ConversationScope(tenant_id=1, workspace_id=7)
with Session(engine) as session:
conversation, membership = ChannelService().create_channel(
session,
scope=scope,
data=ConversationCreate(name="general", slug="general"),
created_by=owner,
)
print(conversation.kind, conversation.visibility, membership.role) # channel private owner
resolved, member = AccessService().resolve_conversation(
session,
scope=ConversationScope(
tenant_id=1, workspace_id=7, conversation_id=conversation.public_id
),
participant=owner,
)
MessagingService().post_message(
session,
conversation=resolved,
sender=owner,
actor_membership=member,
data=MessageCreate(content="hello @team"),
)
page = HistoryService().list_conversation_history(session, conversation=resolved)
print([(m.content, m.sender_kind) for m in page]) # [('hello @team', 'user')]
create_channel returns the conversation with the creator's owner membership. post_message raises PermissionError if the membership may not post and ValueError for empty or too-long content. Services use ConversationSettings() defaults unless you pass conversation_settings= at construction. In your own code pass conversation_settings=settings.scope("CONVERSATION").unwrap(), or host overrides are ignored: the router applies them, but a bare MessagingService().post_message(...) does not.
Through HTTP¶
With the router mounted at /conversation and a seeded default tenant:
from fastapi.testclient import TestClient
client = TestClient(app)
created = client.post("/conversation/conversations", json={"name": "general", "slug": "general"})
public_id = created.json()["public_id"] # 201, "conv_..."
client.post(f"/conversation/conversations/{public_id}/messages", json={"content": "hello"})
client.get(f"/conversation/conversations/{public_id}/messages").json() # newest first
Request bodies are the module's own ConversationCreate, MessageCreate, MemberAddRequest, ResourceLinkCreate and so on. Tenant, workspace and the acting user always come from your dependencies, never from the body.
Persisting AI agent turns¶
msflib.conversation.integrations.ai_bridge wires an ai-api agent to a conversation without either module importing the other. The user's question and the agent's reply are written as ordinary messages (sender_kind user and agent), so the conversation is the readable transcript, separate from the LangGraph checkpoint state. This is how the repository's reference site mounts it:
from fastapi import Depends
from msflib.ai_api.router import router as create_ai_router
from msflib.conversation.integrations import ai_bridge
app.include_router(
create_ai_router(
settings=settings,
get_session=get_session,
get_current_account=get_current_account,
get_current_workspace=get_current_workspace,
get_current_tenant=get_current_tenant,
transcript_writer_factory=ai_bridge.build_transcript_writer_factory(
lambda: Session(engine), agent_id="assistant"
),
speaker_label_factory=ai_bridge.build_speaker_label_factory(resolve_display_name),
prefix="/ai",
),
dependencies=[
Depends(
ai_bridge.build_conversation_access_dependency(
get_session,
get_current_account,
get_current_workspace=get_current_workspace,
get_current_tenant=get_current_tenant,
)
)
],
)
build_conversation_access_dependency reads conversation_id (and sub_thread_id) from the request body and rejects callers who cannot read that conversation before the agent touches any checkpoint or memory state; requests without a conversation_id pass through. build_transcript_writer_factory returns a factory ai-api calls per request; it opens a short session per write, adds the agent as a member of the conversation if it is not an active member, and returns None when the request has no conversation_id. build_speaker_label_factory prefixes each user message with the sender's name and id so an agent in a multi-user conversation can tell people apart; resolve_display_name is your callable (account_id: str) -> str | None. This snippet was not run end to end because it needs a working LLM and a host account system; every name in it is checked against the source. engine and resolve_display_name are supplied by your host. Other helpers in the module are conversation_dimensions (the scope dimensions for a conversation and thread), add_agent_participant and register_memory_cleanup_listener.
Troubleshooting¶
| Symptom | Cause and fix |
|---|---|
| 500 "Default tenant has not been seeded" | The tenant dependency is missing and no tenant with slug default exists. Pass get_current_tenant or run your startup seeding. See the known issue under Wiring if you changed the slug. |
ValueError: A conversation with slug 'general' already exists on a fresh database |
Can be a missing tenant row: with foreign keys enforced the tenant failure is masked by this message. Seed the tenant. |
| 404 for a conversation that exists | AccessService matches tenant and workspace exactly, and workspace_id=None is its own bucket. Pass get_current_workspace consistently with how the conversation was created. |
| 403 on a channel you can find by id | The caller is not a member of a private conversation, or is not eligible under your MemberEligibility. |
CONVERSATION__MAX_MESSAGE_CHARS has no effect |
Field names are lowercase. Use CONVERSATION_MAX_MESSAGE_CHARS; see the Known issue notice under Configuration. |
PermissionError on post_message |
The membership is missing, not active, or a guest while allow_guest_posting is false. |
ValueError: Unregistered resource_kind |
Register it: RESOURCE_KIND_REGISTRY.register("your_kind"). |
ValueError: created_by.tenant_id must match scope.tenant_id |
The Participant and ConversationScope disagree on tenant. Build both from the same resolved tenant id. |
| Retention settings do nothing | retention_days and purge_soft_deleted_after_days are not implemented yet (#278), and no code emits conversation-messages-purged. |
API reference¶
See the generated API reference for msflib.conversation. modules/conversation/README.md has a layout overview, but its status section predates the router and AI bridge and is out of date; treat this page and the source as authoritative.
See also¶
- AI API, whose agent router accepts a transcript writer
- AI core, whose checkpoint and memory policies key off
conversation_id - Knowledge, where conversation-scoped facts use the same dimensions
- Scopes, tenancy and workspaces
- Event bus