Messaging
The conversations, members, messages, threads and read markers that channels, direct messages and record threads share.
The messaging block is the core that chat features sit on: conversations,
their members, messages with one level of threads, edits, pins, mentions,
scheduled messages and read markers. It has no UI of its own. The
channels block builds Slack-style channels and direct
messages on it, and later phases move comments and the support inbox onto the
same tables (ADR 0062).
better-supabase add messaging # adds tenant, access and subjects as wellThe block's entities are message (the messages table) and conversation
(the conversations table). Both register as subjects, so reactions, share
links and search accept them. Rename them with blocks.messaging.entities;
see Typed blocks. messageInputSchema and
messageChangeSchema validate what send and edit take.
| Table | Holds |
|---|---|
conversations | kind, access, title, the linked record (subject_type, subject_id), last_message_at, archiving |
conversation_members | user_id, role, notify (all, mentions or none) and the member's last_read_at |
messages | body, document, mentions, parent_id, thread_id, reply counts, pins, resolution and scheduling |
message_edits | The earlier versions of an edited message |
thread_reads | Read markers per thread root, and per conversation for readers who aren't members |
| Permission | Lets a member | Default roles |
|---|---|---|
messages.read | read the conversations they can see in a tenant | member, admin |
messages.send | open conversations, send, edit their own messages | member, admin |
messages.moderate | delete, pin and archive in any conversation | admin (messages.*) |
Conversations
A conversation's kind says what it is for, and its access says who reads
it:
| Kind | Access | Who reads it |
|---|---|---|
channel | members, tenant | its members, or everyone with messages.read in the tenant |
dm | members | the two people in it; opening it again returns the same conversation |
group_dm | members | three to nine people, also opened once per set of people |
subject | subject | whoever can read the linked record, one conversation per record |
Members must hold messages.read in the tenant. Sending to a tenant
channel joins the sender to it. A subject conversation opens only in the
record's own tenant: when the record's type has a tenant column in
config.subjects and the row belongs to another tenant, open_conversation
fails with MESSAGING_NOT_FOUND, for the service role as well. A type with
tenant: false has no tenant to compare.
import {
createMessaging,
sqlTransport,
} from "better-supabase/blocks/messaging";
const messaging = createMessaging({
transport: sqlTransport(sql),
tenant: () => organizationId,
});
const launch = await messaging
.open({ kind: "channel", title: "launch", memberIds: [alice, bob] })
.orThrow();
const direct = await messaging.openDirect([alice]).orThrow();
const thread = await messaging
.openSubject({ type: "deal", id: dealId })
.orThrow();conversations() lists the caller's conversations, newest message first, with
their unread and mention counts. unreadCounts() returns the counts alone for
a sidebar badge, and markRead moves the caller's read marker.
Messages and threads
const message = await messaging
.send(launch.id, { body: `Ready @[Alice](${alice})` })
.orThrow();
await messaging.send(launch.id, { body: "On it", parentId: message.id });
const replies = await messaging.replies(message.id).orThrow();@[Name](<user id>) in the body mentions a user, and @channel or @here
mentions every member. Threads are one level deep by default: a reply to a
reply joins the root's thread. Set threadDepth: "unlimited" to nest them.
list returns the top-level messages (and replies sent with
broadcast: true), oldest first, in pages; replies returns a thread.
Only the author edits a message, within editWindow seconds when it is set,
and every edit keeps the earlier version (edits). Deleting is a soft delete
that clears the body and removes the message's files. pin and resolve
mark a message for the conversation.
With the attachments block, upload files to the
conversation (subject conversation) and pass their ids as attachmentIds;
send moves them onto the message. With the
reactions block, every message carries its reaction
summary.
File anchors
anchor on a top-level message says where it points. A file anchor points at
an attachment on the conversation's record or on one of its messages: a page,
a region of the page or video frame (fractions from 0 to 1), or a moment or
span in seconds. fileAnchor builds one and throws a TypeError when a value
is out of range, and send fails with MESSAGING_ANCHOR_INVALID when the
shape is wrong or the file is on another record. anchoredTo lists the
messages anchored to a file, oldest first:
import { fileAnchor } from "better-supabase/blocks/messaging";
await messaging.send(review.id, {
body: "The intro runs long",
anchor: fileAnchor(videoId, { start: 12.5, end: 20 }),
});
const notes = await messaging.anchoredTo(videoId).orThrow();Any other JSON anchor, such as a text range, is stored as it is.
isFileAnchor tells a file anchor from the rest when you render a message.
Record mentions
@[Name](subject:<type>/<id>) mentions a record registered in
config.subjects, such as a deal or a
customer, and renders as a chip. recordMention(name, subject) writes one,
mentionSegments(body) splits a body into text, user, record and scope
segments for rendering, and parseMentions(body) returns the mentioned
users, records and scope.
import { recordMention } from "better-supabase/blocks/messaging";
await messaging.send(launch.id, {
body: `Renewal is close on ${recordMention("Acme", { type: "deal", id: dealId })}`,
});
const backlinks = await messaging
.recordMentions({ type: "deal", id: dealId })
.orThrow();send and edit index the record mentions of a message; an edit that drops
one drops its backlink. recordMentions(subject, { before, limit }) lists the
messages that mention a record, newest first (default 50, at most 200), each
with its conversation's id, kind, title and subject. It returns
nothing to a caller who can't read the record, and only the messages in
conversations the caller reads. A mention of an unregistered type stays plain
text. message.sent carries the mentioned records as recordMentions.
Thread summaries
summarize(target, summarizer) writes a short summary of a conversation or
a thread for someone who missed it, and summary(target) reads the saved
one. The block never calls a model: the summarizer is a versioned
ThreadSummarizer (apiVersion: 1) that receives the messages and returns
the text, so any SDK can provide it. With the AI SDK, use
summarizeWith:
import { summarizeWith } from "better-supabase/ai-sdk";
const summarizer = summarizeWith("anthropic/claude-sonnet-4.5");
const summary = await messaging
.summarize({ threadId: rootId }, summarizer)
.orThrow();
// { summary, model, messageCount, throughAt, stale, ... }A target is { conversationId } for the conversation's top-level messages
or { threadId } for a root message and its replies. summarize reads the
messages as the caller, so a summary only covers what they can read, and
leaves out internal notes, deleted messages and unsent scheduled ones. It
sends at most maxMessages (default and maximum 200, the newest ones) and
the previous summary, so the model updates it instead of starting over.
A saved summary is stale once a message in its scope is sent after its
throughAt, edited or deleted. summarize returns a current summary without
calling the model again; { force: true } writes a new one. Only the service
role saves summaries, so pass the block the service transport
(createBlocks does this): a member can't store a summary that misstates the
thread. Every reader of the conversation reads it.
Notifications
With the notifications block, sending a message notifies:
| Type | Who |
|---|---|
message.mentioned | mentioned users who can read the conversation, unless their notify is none |
message.direct | the other people in a direct or group message whose notify is all |
message.replied | the thread's followers: its author and everyone who replied, besides the mentioned |
Each one carries a subject_label: the record's subject_title for a
record's conversation, else the conversation's title. message.replied is
sent only when the thread has a follower besides the replier and the
mentioned members. notify: false sends none of the three, and following
threads still works.
follow starts or stops following a conversation or a thread, and
setNotify sets a member's level for a conversation.
Scheduled messages
send with scheduledFor stores the message unsent: only its author sees it
until then. With the jobs block the module enqueues a
delayed message.publish job on scheduleQueue; without it, call
publishDue() from a cron with the service role.
Realtime
Each conversation has a private Realtime topic, <topic>:<conversation id>
(messages by default). A trigger broadcasts message, message_updated and
message_deleted with the ids, and typing(conversationId) broadcasts
typing. Only callers who read the conversation can join its topic.
conversationTopic(id) builds the topic name.
useMessages and useConversations from
better-supabase/blocks/messaging/react follow these topics for a chat
screen; see Channels.
Options
| Option | Default | What it does |
|---|---|---|
topic | "messages" | The Realtime topic prefix |
threadDepth | 1 | 1 keeps threads one level deep; "unlimited" nests them |
maxBodyLength | 40000 | The longest body, in characters |
editWindow | 0 | Seconds an author may edit a message; 0 for always |
scheduleQueue | "messaging" | The jobs queue for scheduled messages |
extraColumns | none | Extra columns on messages, set through fields in send |
notify | true | false sends no message notifications |
Events
The block writes conversation.created, conversation.archived,
conversation.member_added, conversation.member_removed, message.sent,
message.edited, message.deleted, message.pinned and message.resolved
to the outbox. Their payloads name the subject with
subjectType and subjectId; see Events.
The hooks before_message_send and after_message_send run inside send,
and before_message_edit and after_message_edit inside edit; see Extending blocks.
Last updated on