@ai-matrx/messaging

中文文档
0.18.68发布于 2026/10/29MITTypeScriptESM + CJS近 30 天下载 6.6万

Enterprise, AI-native in-app messaging for Matrx clients: direct/group/org conversations with atomic direct creation, an outbox that never loses a typed message, optimistic send with echo-safe reconciliation, Matrx entity references, actionable messages w

原始使用文档
文档目录29 节
  1. Why this package exists
  2. The doctrine
  3. 1. A message can arrive FOUR ways. It renders once.
  4. 2. Older never overwrites newer.
  5. 3. Reconnect means RE-READ. Realtime has no replay.
  6. 4. A message you typed is NEVER lost.
  7. 5. Direct conversations are created ATOMICALLY, in the data…
  8. 6. Authorization is the database's. Org is explicit.
  9. 7. A missing session is a lifecycle moment, not an error.
  10. 8. Pagination is keyset, terminated by a unique column.
  11. 9. An agent never wears a colleague's face.
  12. 10. Everything a message names must OPEN.
  13. 11. An action this build cannot honor renders NOTHING.
  14. 12. Nothing structured rides user_input .
  15. Using it
  16. The one-hour integration
  17. Adding the AI
  18. Making references open
  19. Mounting it in an app that already wires realtime
  20. Registering an actionable message kind
  21. An action kind whose answer is a card, not a chip
  22. Your app's chrome around our bubbles
  23. Theming
  24. Composing your own layout
  25. Outside React
  26. Archived conversations
  27. The database contract
  28. Entry points
  29. License

@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:

valuewhat the list shows
activeonly un-archived conversations (the default)
archivedonly archived conversations
allevery 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 schemacommunication (never public)
RPC schemapublic — the SECURITY DEFINER functions stayed there through the schema reorg. The two DIFFER, deliberately; collapsing them fails every read with PGRST202
Tablesdm_conversations, dm_conversation_participants, dm_messages
RPCsdm_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

EntryContents"use client"
@ai-matrx/messagingFramework-free core: repository, store, outbox, engine, action registry, references, AI, formattingNo — a stamped pure module becomes a client reference inside an RSC
@ai-matrx/messaging/reactProvider, hooks, and the complete product UI. Re-exports the core, so a React app needs one specifierYes
@ai-matrx/messaging/styles.cssStructural CSS. Required—
@ai-matrx/messaging/tokens.cssDefault 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.

版本与发布记录

@ai-matrx/messaging 共发布 251 个版本,首次发布于 2026/08/30;过去 12 个月发布了 251 个版本。

版本发布日期距今
0.18.68latest2026/10/298 天前
0.18.672026/10/288 天前
0.18.662026/10/2413 天前
0.18.652026/10/2413 天前
0.18.642026/10/2413 天前
0.18.632026/10/2313 天前

关于 @ai-matrx/messaging 的常见问题

@ai-matrx/messaging 是什么?

Enterprise, AI-native in-app messaging for Matrx clients: direct/group/org conversations with atomic direct creation, an outbox that never loses a typed message, optimistic send with echo-safe reconciliation, Matrx entity references, actionable messages w

如何安装 @ai-matrx/messaging?

在项目目录运行 npm install @ai-matrx/messaging,或使用 pnpm add / yarn add / bun add。

@ai-matrx/messaging 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@ai-matrx/messaging 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@ai-matrx/messaging 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@ai-matrx/messaging 可以商用吗?

@ai-matrx/messaging 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@ai-matrx/messaging 的下载量有多少?

根据 npm 官方下载统计,@ai-matrx/messaging 在 2026/10/05 – 2026/11/03 这 30 天内被下载 65,529 次,日均约 2,184 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@ai-matrx/messaging 还在维护吗?

最新版本 0.18.68 发布于 2026/10/29(8 天前);至今共发布 251 个版本,其中过去 12 个月发布了 251 个;首次发布于 2026/08/30。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。