Skip to content

Message Threading

RoomKit supports flat, two-level threads (Slack / Teams style): a root message and its replies. A reply carries parent_event_id pointing at its thread root; a root or non-threaded message has parent_event_id = None.

Threading is transport-agnostic — the parent is applied centrally in the inbound pipeline, so it works for WebSocket, SMS, email, and any other channel without per-channel wiring. It is distinct from ChannelData.thread_id, which is a provider-native thread reference.

Quick start

Set parent_event_id on the inbound message (or on send_event) to reply inside a thread:

from roomkit import InboundMessage, RoomKit, TextContent

kit = RoomKit()
# ... register/attach channels, create room "r1" ...

root = await kit.process_inbound(
    InboundMessage(channel_id="ws-alice", sender_id="alice",
                   content=TextContent(body="Can we move the sync?"))
)
root_id = root.event.id

# A reply in that thread
await kit.process_inbound(
    InboundMessage(channel_id="ws-bob", sender_id="bob",
                   content=TextContent(body="Sure — which day?"),
                   parent_event_id=root_id)
)

# Or via direct injection
await kit.send_event("r1", "ws-bob", TextContent(body="Tuesday?"), parent_event_id=root_id)

The flat two-level invariant

parent_event_id always points at a thread root. If you reply to a message that is itself a reply, RoomKit normalises the pointer to that reply's root — so a thread never nests beyond one level:

reply = ...        # parent_event_id == root_id
nested = await kit.send_event("r1", "ws-alice", TextContent(body="Tuesday?"),
                              parent_event_id=reply.id)
assert nested.parent_event_id == root_id   # collapsed to the root, not reply.id

Normalisation happens once, inside the locked pipeline, for every entry point (process_inbound and send_event alike). A parent_event_id that does not exist or belongs to another room drops to top level (None) with a warning — a stale reference never loses the sender's message.

AI replies stay in the thread

When an intelligence channel is triggered by a threaded message, its response inherits the trigger's thread root — so an @-mention inside a thread is answered inside that thread. A mention at top level is answered at top level. This holds on both the streaming and non-streaming generation paths, and no configuration is required.

Reading threads

Two EventFilter fields drive thread-aware reads:

from roomkit.models.store_filter import EventFilter

# Main timeline: roots + standalone messages, replies excluded
timeline = await kit.store.list_events("r1", event_filter=EventFilter(top_level_only=True))

# One thread: the replies of a given root
thread = await kit.store.list_events("r1", event_filter=EventFilter(parent_event_id=root_id))

top_level_only and parent_event_id are mutually exclusive.

Reply counts

get_thread_summaries returns per-root aggregates so you can render a "N replies · last reply" affordance without fetching every reply:

summaries = await kit.store.get_thread_summaries("r1", [root_id])
summary = summaries[root_id]         # roots with no replies are absent
summary.reply_count                  # e.g. 4
summary.last_reply_at                # datetime of the latest reply

Capability

Channels that support threading advertise it via ChannelCapabilities.supports_threading (true for the in-app WebSocket channel, Email, Teams, and Discord). The value is informational — it tells the AI what the target channel supports; it does not gate the storage behaviour above.

Storage

Replies are ordinary events with parent_event_id set — no separate table. The PostgreSQL store keeps a partial index on events(parent_event_id) for efficient thread reads. Because a thread is fixed at creation, parent_event_id is set on insert and never mutated.

Relationship to ChannelData.thread_id

parent_event_id is RoomKit's in-app threading key. ChannelData.thread_id is a provider-native reference (Slack thread_ts, Discord message id, Teams replyToId) passed straight through to/from the provider. They are independent; bridging a provider's native threads to in-app threads is a separate concern.

Example

See examples/message_threading.py for a complete, runnable walkthrough.