Files
lynkeduppro-crm/docs/superpowers/specs/2026-07-17-messaging-sdk-design.md
T
maaz519 facc50e82f feat(inbox): migrate CRM Inbox to @insignia/iios-messaging-ui SDK
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>
2026-07-22 00:54:03 +05:30

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 an onNewChat callback; 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

  1. Host constructs an adapter (CRM: wrapping appshell data door + socket).
  2. MessagingProvider holds the adapter in context.
  3. useConversations calls listConversations; useMessages(threadId) calls history then subscribe.
  4. Composer calls send with optimistic append; on rejection the optimistic message is rolled back and the input text restored (matching current CRM behaviour).
  5. subscribe events reconcile against optimistic state by message id.

Error handling

  • Adapter method rejection surfaces via hook error state; components render an inline error affordance, never throw.
  • Optimistic send failure restores composer text — the CRM's current behaviour, kept.
  • subscribe disconnect is the adapter's problem, not the SDK's. The SDK renders a connected: boolean from 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:

  1. Rewrite the CRM's messenger.tsx to consume the SDK; its data-door implementation becomes CrmMessagingAdapter. Success = identical behaviour with the 352-line component deleted, and the mock adapter preserving demo-mode fallback.
  2. Then support.tsx's MessageCenter — currently pure setTimeout theatre 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.
  • CrmMessagingAdapter tested 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 currentActorId bug: messages render as own before the user has sent anything in the thread.

Open questions

  • Package name: @insignia/messaging-ui assumed, not confirmed.
  • Whether CrmMessagingAdapter lives in the CRM repo or ships as ./adapters/crm. Preference: the CRM repo — it depends on appshell-sdk, which the SDK must not.