@ai-matrx/messaging
Enterprise, AI-native in-app messaging for every Matrx client: direct, group, and org-scoped conversations, with agents as first-class participants — delivered as a drop-in package with the polish of iMessage and the power of Slack.
One provider, one component, under an hour to full production messaging including the AI.
pnpm add @ai-matrx/messaging
Built on @ai-matrx/data/realtime. There is zero hand-rolled .channel(
code in this package, and there must be none in yours.
Why this package exists
Messaging is the feature everyone assumes is easy and nobody ships correctly twice. The hard parts are not the bubbles; they are the eight ways a message can be lost, duplicated, reverted, or attributed to the wrong person. Every one of those has a name in this README and a regression test beside it.
We are deliberately inflexible. There is no provider abstraction, no transport adapter, no "bring your own store". The package does exactly what we want it to do, and that is the product.
The doctrine
1. A message can arrive FOUR ways. It renders once.
The optimistic bubble, the insert's own response, the broadcast, and the Postgres Changes row —
in any order. Identity is id or clientMessageId, and the confirmed row collapses onto
the optimistic one in place.
The tempting shortcut — matching on (sender, content) — silently merges two genuinely different
"ok" messages into one. The client-minted idempotency key exists so identity never has to be
guessed. It is minted once per queued message and reused by every send attempt, which is also
what makes a retry safe when the previous attempt actually reached the database.
2. Older never overwrites newer.
An out-of-order UPDATE carrying an older edited_at than the copy already held is dropped.
Without this, an edit visibly reverts itself a second later and then re-applies.
The one exception is deliberate: a persisted row always supersedes an optimistic one, even when the optimistic timestamp reads later. That timestamp is a guess from the sending device's clock, and a skewed clock must not strand a bubble as permanently "sending".
3. Reconnect means RE-READ. Realtime has no replay.
Every channel this package opens declares an onBackfill door, and those doors re-read from the
database — the conversation channel from its own high-water mark, so it fetches what it missed
rather than everything. A reconnect without a re-read leaves a permanently wrong thread that
looks perfectly healthy. That is the most expensive bug in messaging, and it is structurally
impossible here.
4. A message you typed is NEVER lost.
The outbox persists the draft before the network call, not after it succeeds. Guarantees:
- Durable across a reload. A tab closed mid-send comes back with the message still queued.
The default storage is
localStorage; when it is unavailable the package falls back to memory and says so rather than pretending to be durable. - In order. Message 2 never overtakes a retrying message 1.
- Bounded and honest. Attempts back off; after the last one the entry stays in the queue,
marked
failed, with a reason and a retry door. Never silently dropped, never retrying forever. - Visible. A queued message renders as an optimistic bubble immediately. An invisible queue is a lost message from the user's side.
5. Direct conversations are created ATOMICALLY, in the database.
Via dm_get_or_create_direct_conversation, which advisory-locks the unordered user pair. The
banned pattern is read-then-insert: two tabs, a double-click, or two batched system
notifications race it into two "direct" conversations for one pair, forever.
6. Authorization is the database's. Org is explicit.
RLS plus auth-checked SECURITY DEFINER RPCs. There is not one permission branch in this package —
an RPC that says no becomes a forbidden error, never a client-side re-decision. And every
conversation and message write carries an explicit organization_id; the package refuses the
write rather than letting an unscoped row reach the database. The organization is a write
DESTINATION only: organizationId={null} is a valid state in which every read works (the inbox,
a deep-linked conversation, replying — a reply is stamped with its conversation's organization),
and starting a NEW conversation is refused as organization-required so the host can ask the
person to choose one.
7. A missing session is a lifecycle moment, not an error.
A read that lands between sign-in and token arrival retries once after the host resolves a
session, then reports session-unavailable as a warning. A permission denial is never retried —
retrying one is how a request storm is built. (The incident: a reader mounted before the access
token existed and the app captured 909 errors in 0.6 seconds.) Profile and list reads also
share one in-flight promise per key, so N concurrent readers make one request — and a failure is
never cached, or one wobble leaves the surface broken until reload.
8. Pagination is keyset, terminated by a unique column.
(created_at, id), never created_at alone — which duplicates or skips rows whenever two share a
millisecond, i.e. every page in a large org. The package also asks for one row more than the page
so hasMore is a fact rather than a guess from "we got a full page".
9. An agent never wears a colleague's face.
sender_id is the audit principal and never changes. When an agent acted through a human's
session, the rendered author is the agent — and it names the human it acted for. An unnamed
agent is labeled "Agent"; falling back to the human's name and avatar would put an automated
message under a real person's byline.
10. Everything a message names must OPEN.
Matrx references arrive two ways — structured in metadata.references, or as a ```matrx
fence in the content. Both become live cards. A fence never reaches the reader as a code
block of JSON, and never reaches an inbox preview or a desktop notification as raw JSON either;
one collapse function serves both. A reference with no resolvable identity is dropped rather than
drawn as a card that cannot open.
11. An action this build cannot honor renders NOTHING.
Actionable messages go through a registry, not a switch. An unknown kind — or a known kind at
an unknown version — renders no chips and executes nothing, which is what lets a new sender ship
before every reader has caught up. Idempotence is enforced by the registry: concurrent
executions of (kind, messageId, actorId) share one call, and a settled action returns its stored
receipt without touching the server. The payload is not authorization — a handler must
re-resolve the durable, caller-authorized row before it writes.
12. Nothing structured rides user_input.
user_input is what a human typed. Transcripts, rosters, and cutoffs travel as named
variables. The server stores and frames user_input as the turn's human utterance, so
smuggling a transcript through it silently corrupts every conversation it touches. The one
user_input this package ever sends is the instruction a person typed into "draft a reply".
Using it
The one-hour integration
import { MessagingProvider, MessagingInbox } from "@ai-matrx/messaging/react";
import "@ai-matrx/messaging/tokens.css"; // defaults — override with your brand
import "@ai-matrx/messaging/styles.css"; // structural, required
export default function MessagesPage() {
return (
<MessagingProvider
client={supabase}
organizationId={org.id}
>
<MessagingInbox />
</MessagingProvider>
);
}
That is realtime, presence, typing, optimistic send, the offline outbox, references, actionable messages, keyset pagination, and the whole polished UI. There is nothing else to wire.
🚨 You did not tell it who you are, and you cannot (0.12.0, DD-241). The acting user is read
from client's own session — the same session that mints the JWT on every request — so pass the
browser Supabase client your app signs in with. A user id handed across this boundary is a COPY,
and a copy goes stale the moment the auth cookie rotates; in messaging it would land on the wire
as sender_id and created_by, writing rows attributed to somebody the tab cannot prove it is.
The provider is inert until the first session read lands, an account switch rebuilds it, and a
sign-out makes it inert again. Same rule on the framework-free path:
createMessagingRepository({ client, organizationId }) — no identity.
Adding the AI
<MessagingProvider
client={supabase}
organizationId={org.id}
transport={matrxTransport} // from @ai-matrx/api
agents={{
catchUp: { agentId: "…", configOverrides: { … } },
summarize: { agentId: "…" },
actionItems: { agentId: "…" },
draftReply: { agentId: "…" },
}}
maxTranscriptMessages={200} // your knob, not our taste
sourceApp="matrx-frontend" // YOUR registered producer slugs
sourceFeature="messages"
>
Agent definitions live in the database, never in this package — the injected identity is the only part a host supplies. A capability with no identity does not render at all; it is never a dead button that fails when pressed.
An identity is both halves. If your app resolves a capability's agent from a switching layer
that also decides settings for the job, pass configOverrides with the id — it is sent verbatim
as the turn's config_overrides. Passing the id alone silently drops the settings half. Omit it
and nothing is sent, so the agent's own settings stand.
A run the user watches streams. useMessagingAi reports partialText after every chunk and
ConversationView renders it; the skeleton appears only in the gap before the first chunk, and
names the job it is waiting on. If you render the output yourself, render partialText while
isRunning — a placeholder that sits until the run finishes is a spinner standing in for an
answer.
🚨 sourceApp / sourceFeature are not analytics garnish. The AI Matrx server keeps a register
of legal producers and answers an unregistered one with a 422 — so every AI call fails until you
pass slugs your server knows. This package's defaults are placeholders; only you know yours.
"Catch me up" means the unread tail, derived from the store's own unread count. Nothing unread, or a loaded window that does not reach back to the read mark, means there is no honest cutoff and the whole window applies — never a guessed timestamp.
Making references open
<MessagingProvider … onOpenReference={(ref) => router.push(`/${ref.entityType}/${ref.entityId}`)}>
Omit it and reference cards render as labeled, non-interactive cards with a title explaining why — honest, never a button that does nothing.
Mounting it in an app that already wires realtime
Nothing to do. <MessagingProvider> rides the host's <RealtimeProvider> when one is mounted
above it, and mounts its own only when there is none. That is not a convenience — an app has ONE
realtime manager on purpose (it owns the write ledger that makes an optimistic write's own echo
recognizable), and a second one under it makes each manager's writes look remote to the other.
<RealtimeProvider client={supabase} actorId={user.id}> {/* the app's ONE mount */}
<MessagingProvider client={supabase} organizationId={org.id}>
…
Registering an actionable message kind
<MessagingProvider … actions={[{
kind: "access_request",
versions: [1],
choices: () => [
{ id: "approve", label: "Approve", tone: "primary" },
{ id: "decline", label: "Decline", tone: "danger" },
],
execute: async (payload, ctx) => {
// Re-resolve the authorized request row here. The payload is the REQUEST,
// never the permission.
const outcome = await decideAccessRequest(payload.requestId, ctx.choice);
return { kind: "access_request", messageId: ctx.messageId, actorId: ctx.actorId,
outcome: outcome.alreadyAnswered ? "already" : "applied",
label: outcome.alreadyAnswered ? "Already answered" : "Approved",
settledAt: new Date().toISOString() };
},
}]}>
This is the seam @ai-matrx/meet uses: a call invitation is just a message with a structured
payload.
An action kind whose answer is a card, not a chip
Chips answer a question. A shared-resource card, a link out to a report, a reminder with an app icon are app-shaped surfaces only the host can draw — so the host supplies the surface and the package still owns the gate:
<MessagingProvider … actionRenderers={[{
kind: "resource_shared",
versions: [1],
render: ({ payload, isOwn }) => <ResourceSharedCard payload={payload} isOwn={isOwn} />,
}]}>
A kind with a renderer draws it instead of chips. Everything else is unchanged, including the
rule that matters: an unknown kind — or a known kind at a version this build does not list —
renders NOTHING. Use actions for decisions, actionRenderers for surfaces; a kind may have
both a handler (for summarize, used by previews) and a renderer.
Your app's chrome around our bubbles
A platform whose surfaces all answer a right-click, or that already renders references through its own registry, hands those in rather than rebuilding the thread:
<MessagingProvider …
wrapMessage={({ message, children }) => (
<MessageContextMenu message={message}>{children}</MessageContextMenu>
)}
wrapConversationRow={({ conversation, children }) => (
<ConversationContextMenu conversation={conversation}>{children}</ConversationContextMenu>
)}
renderReference={({ reference }) => <AppReferenceChip reference={reference} />}
/>
wrapMessage and wrapConversationRow WRAP; they never replace. renderReference replaces the
package's card — deliberately, because an app that draws references everywhere else must not
draw them two ways.
If your platform has its own ```matrx fence dialect, pass renderFence too. This package
parses one shape (an array of {entityType, entityId}); anything else reaches your renderer
verbatim as the fence body. Without it those fences draw an honest inert "Reference" card — they
are never deleted, and never shown to a reader as JSON.
Theming
Structural CSS ships in the package; the token contract is enforced; token values are
yours. Import tokens.css for a complete working default, then override the names you care
about:
:root {
--mx-msg-accent: #7c3aed;
--mx-msg-bubble-mine-bg: var(--mx-msg-accent);
}
Hardcoded colors inside package components are banned, and the tarball gate fails the build if a
hex value appears in styles.css. Dark values are defined for both prefers-color-scheme and an
explicit [data-theme="dark"], so a host toggle wins in either direction.
Composing your own layout
<MessagingInbox /> is the product. If you want the list in a sidebar and the thread on a route:
<ConversationList onSelect={(id) => router.push(`/messages/${id}`)} />
<ConversationView conversationId={id} />
Hooks — useConversations, useConversation, useComposer, useTypists, useOnlineUserIds,
useMessagingAi, useMessageAction — are exported for hosts that compose their own pages. They
are not an invitation to rebuild the product out of parts.
Outside React
The default entry is framework-free — safe from Redux middleware, a service module, a worker, or a node test:
import { createMessagingEngine, createMessagingRepository } from "@ai-matrx/messaging";
Archived conversations
THE ARCHIVED-ITEMS LAW (Arman, 2026-09-09 — common-docs/policies/archived-items.md): every
list over an entity that can be archived carries an archive filter, the default hides archived
rows, and revealing them is one or two clicks. Three states, and no more:
| value | what the list shows |
|---|---|
active | only un-archived conversations (the default) |
archived | only archived conversations |
all | every conversation |
The filter is a REQUEST to the reader, never a client-side sieve. It travels as
p_archived on get_dm_conversations_with_details, so hasMore, the pagination cursor and
every count describe the rows the caller actually asked for. A host that fetches everything and
partitions ConversationSummary.isArchived in its own component is reading rows the server
would not have sent, and its counts describe a different set from the one on screen.
ConversationList already carries the control — "Archived (N)", one click, and a way back.
The count comes from countArchivedConversations() and is CAPPED: exact: false renders "N+",
never a confident total. A host building its own list reads archiveFilter, setArchiveFilter
and archivedCount from useConversations().
The initial state is a KNOB, not this package's taste (root law 6, and clause 6 of the law):
<MessagingProvider archiveFilter={settings.lists.archivedDefault} … >
Omit it and you get the platform default, active.
The database contract
One canonical schema, in Matrx Main, under the platform's DB conventions:
| Table schema | communication (never public) |
| RPC schema | public — the SECURITY DEFINER functions stayed there through the schema reorg. The two DIFFER, deliberately; collapsing them fails every read with PGRST202 |
| Tables | dm_conversations, dm_conversation_participants, dm_messages |
| RPCs | dm_get_or_create_direct_conversation, get_dm_conversations_with_details, get_dm_user_info, get_dm_unread_count, is_dm_participant |
Every name lives in exactly one file — src/core/repository.ts — and the packed-tarball canary
asserts them, so a silent rename fails the build instead of the app.
matrx-dm grew a parallel schema in its own project (public.conversations / messages /
message_reactions). Per the work order's R8 exactly one canonical schema survives, and it is
this one — the one with explicit org, versioning, soft deletes, and auth-checked RPCs.
Entry points
| Entry | Contents | "use client" |
|---|---|---|
@ai-matrx/messaging | Framework-free core: repository, store, outbox, engine, action registry, references, AI, formatting | No — a stamped pure module becomes a client reference inside an RSC |
@ai-matrx/messaging/react | Provider, hooks, and the complete product UI. Re-exports the core, so a React app needs one specifier | Yes |
@ai-matrx/messaging/styles.css | Structural CSS. Required | — |
@ai-matrx/messaging/tokens.css | Default token values. Import, then override | — |
There is deliberately no /native entry. Per C25, platform bindings are built at the first
real consumer and always inside the package; a React Native UI that no Expo app exercises is
untested theory that rots. The core is already platform-agnostic (it is proven under a node
test environment with no DOM shim), so /native is a UI layer away when the mobile app adopts it.
License
MIT.