Replace the bespoke in-CRM inbox with the SDK's <Inbox>, driven by a new CrmInboxAdapter over the be-crm data door (crm.inbox.* + crm.mail.*). Demo mode falls back to the SDK MockInboxAdapter. Maps --miu-* tokens for .miu-inbox to the CRM theme. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
10 KiB
Messaging UI SDK — Design
Date: 2026-07-17 Status: Approved, pending implementation plan
Problem
Messaging UI is rewritten from scratch in every app that needs it. lynkeduppro-crm
has a rich, working messenger — conversation list, thread view, bubbles, reactions,
reply threading, typing, read receipts — built as 352 lines in
src/components/dashboard/messenger.tsx over 371 lines in src/lib/messenger-api.ts.
None of it is reusable.
Why not just use @insignia/iios-message-web?
Because it does not solve this problem, and the CRM already rejected it.
iios-message-web is 109 lines across 2 files. It is fully headless: its only JSX is
the context provider element itself. It exports MessageProvider, useThread,
useMessages and a Message type — nothing more. It ships no components, no CSS, no
theming.
It also guessed its API wrong. useMessages.send narrows the options bag to
{ contentRef? }, while the underlying MessageSocket.sendMessage accepts
parentInteractionId, mentions, and attachment. Threading, mentions and
attachments are unreachable through its public API. The socket is held in a
module-private context with no escape hatch. It has zero consumers outside the iios
repo, and its own docs reference a useSendMessage hook that does not exist.
The CRM consequently bypassed it and depends on @insignia/iios-kernel-client
directly.
The lesson drives this design: the headless layer is already an SDK
(iios-kernel-client — sockets, threads, receipts, typing, published, consumed). A
second headless package saves no app any work. The unsolved part is the UI.
Why tower is not a consumer
Tower's messaging is a WhatsApp group ingest → moderate → forward pipeline, not chat.
There is no Conversation model; Message is a captured group post keyed by
senderJid + sourceGroupId with a moderation status enum
(RAW/PENDING/APPROVED/...) — no recipient, no delivery state. "Send" is a BullMQ job
rate-limited to 20 forwards/minute to avoid WhatsApp bans. There is no
socket.io/websocket/SSE in the browser anywhere in the repo. Its threads and
drafts mean different things than a chat SDK's would.
Tower would pay the abstraction cost for realtime machinery it never turns on. It is explicitly out of scope.
Constraint: one real consumer
lynkeduppro-crm is the only consumer. Genericity is not achievable by intent — it is
forced by a second consumer. This design therefore ports only what is already proven
in production and refuses to invent abstraction for imagined needs. iios-message-web
is the cautionary example of the opposite approach.
Architecture
One package, @insignia/messaging-ui, published to the existing Gitea registry
(https://git.lynkedup.cloud/api/packages/insignia/npm/). React as a peer dependency.
@insignia/messaging-ui
. → components + provider + hooks
./styles.css → structural CSS + token defaults
./adapters/kernel → optional iios-kernel-client adapter
./adapters/mock → in-memory adapter for demos/tests
The core has zero transport knowledge. iios-kernel-client is reachable only via
the optional ./adapters/kernel subpath, so an app on a different backend never pulls
socket code. This is the specific mistake iios-message-web made by welding itself to
MessageSocket.
This boundary is load-bearing for the actual consumer: the CRM does not talk to
iios directly. It routes messaging through be-crm's data door (crm.messenger.*) via
@abe-kap/appshell-sdk, socket-primary with a 4s REST poll fallback. An SDK that
hardcoded iios-kernel-client could not be adopted by the only app that wants it.
The adapter contract
Lifted from the existing MessengerData/ThreadData interfaces in
src/lib/messenger-api.ts, which already survived two implementations (live + mock).
Two implementations is the minimum real evidence that a seam is genuine rather than
imagined. This contract was not designed for an SDK — it earned its shape.
interface MessagingAdapter {
listConversations(): Promise<Conversation[]>;
openThread(p: { participantIds: string[]; subject?: string }): Promise<{ threadId: string }>;
history(threadId: string): Promise<Message[]>;
send(threadId: string, content: string, opts?: SendOpts): Promise<Message>;
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe;
sendTyping(threadId: string): void;
markRead(threadId: string, messageId: string): Promise<void>;
react?(messageId: string, emoji: string): Promise<void>;
upload?(file: File): Promise<{ url: string; mime: string; name: string }>;
currentActorId(): string | null;
}
interface SendOpts {
parentInteractionId?: string;
attachment?: { url: string; mime: string; name: string };
}
Graceful degradation
react and upload are optional. When an adapter omits them the UI hides the
reaction picker or the attach button respectively. This is how one component set
serves both a full CRM messenger and a stripped-down widget without a mode prop.
currentActorId fixes a live bug
Today the CRM infers the current actor id by scanning for a message you sent:
// src/lib/messenger-socket.tsx — current behaviour
const mine = socketMsgs.find((m) => m.mine && m.actorId);
if (mine?.actorId && mine.actorId !== myActorId) setMyActorId(mine.actorId);
Until you have sent a message in a thread, myActorId is null. Because the REST
poll fallback computes mine: !!myActorId && m.actorId === myActorId, every message
renders as not-yours in that state. The root cause is that the kernel's receipt
event carries no threadId, making it a global stream the CRM compensates for.
Making identity an explicit adapter responsibility eliminates this class of bug rather
than porting it. The two-tier socket/poll fallback stays in the adapter, not the SDK —
the CRM's adapter keeps its 4s poll; a socket-only app implements subscribe and
never polls.
Components
Composable primitives plus one all-in-one for drop-in use:
<MessagingProvider adapter={adapter}>
<Messenger onNewChat={openPicker} /> {/* all-in-one: list + thread */}
{/* ...or compose: */}
<ConversationList onSelect={setId} renderRow={custom} />
<ThreadView threadId={id} />
<Composer threadId={id} />
</MessagingProvider>
Hooks remain exported (useConversations, useThread, useMessages) so a host
wanting entirely custom UI can use the SDK headlessly. This makes iios-message-web's
use case a strict subset of this package rather than a competitor.
Explicitly out of scope
- Inbox. Coupled to iios semantics, not chat transport. Items are projected
server-side by iios from domain events (
MENTION,NEEDS_REPLY,SUPPORT_UPDATE,CRM_OWNER_INTEREST); authz is OPA policy. A chat SDK cannot own this. - People picker / directory. Fed by
crm.messenger.directory. "Who exists and who may I message" is host and tenant territory.<Messenger>takes anonNewChatcallback; the host renders its own picker. - Presence. No consumer needs it.
Theming
Structural CSS with token defaults, overridden by the host. No Tailwind, no CSS-in-JS,
no build coupling — the CRM has no shadcn and near-zero Tailwind (its real styling is
1142 lines of hand-rolled dashboard.css plus inline style objects), so a
Tailwind-based SDK would force a restyle of the only consumer.
:root {
--msg-font; --msg-radius; --msg-gap;
--msg-bubble-own-bg; --msg-bubble-other-bg;
--msg-accent; --msg-muted; --msg-surface; --msg-border;
}
Every component accepts className; <Messenger> accepts a classNames slot map for
per-part overrides. The CRM's existing #6366f1 → #8b5cf6 group-avatar gradient
becomes a token value rather than a hardcode.
Attachments
The SDK renders attachments (image thumbnail, file chip, download) and calls
adapter.upload(file), passing the result into send. Storage, auth, and
size/mime limits are host concerns — baking in an upload target would break the next
app. The attach button is hidden when upload is absent.
MessageSocket.sendMessage already accepts an attachment field, so this exercises
an existing wire contract rather than inventing one. No consumer has exercised it yet;
the CRM has no file upload anywhere today.
Data flow
- Host constructs an adapter (CRM: wrapping appshell data door + socket).
MessagingProviderholds the adapter in context.useConversationscallslistConversations;useMessages(threadId)callshistorythensubscribe.Composercallssendwith optimistic append; on rejection the optimistic message is rolled back and the input text restored (matching current CRM behaviour).subscribeevents reconcile against optimistic state by message id.
Error handling
- Adapter method rejection surfaces via hook
errorstate; components render an inline error affordance, never throw. - Optimistic send failure restores composer text — the CRM's current behaviour, kept.
subscribedisconnect is the adapter's problem, not the SDK's. The SDK renders aconnected: booleanfrom the adapter as a banner (the CRM's existing "Demo mode" banner generalises to this).
Validation
The migration is the validation. There is no second app, so the honest bar is:
- Rewrite the CRM's
messenger.tsxto consume the SDK; its data-door implementation becomesCrmMessagingAdapter. Success = identical behaviour with the 352-line component deleted, and the mock adapter preserving demo-mode fallback. - Then
support.tsx'sMessageCenter— currently puresetTimeouttheatre with no backend — becomes a zero-risk second surface.
Two surfaces in one app is not a true second consumer. It is the best honest test available of the adapter boundary, and it should be understood as such. The design should be revisited when a genuine second app appears rather than treated as settled.
Testing
- Component tests against the mock adapter — no network. This is the payoff of the injected seam.
CrmMessagingAdaptertested against the contract independently of UI.- A shared adapter conformance suite any adapter can run, so the kernel and CRM adapters are verified against one definition of correct.
- Explicit regression test for the
currentActorIdbug: messages render as own before the user has sent anything in the thread.
Open questions
- Package name:
@insignia/messaging-uiassumed, not confirmed. - Whether
CrmMessagingAdapterlives in the CRM repo or ships as./adapters/crm. Preference: the CRM repo — it depends on appshell-sdk, which the SDK must not.