diff --git a/docs/superpowers/plans/2026-07-17-messaging-ui-foundation.md b/docs/superpowers/plans/2026-07-17-messaging-ui-foundation.md
new file mode 100644
index 0000000..5c14ff3
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-17-messaging-ui-foundation.md
@@ -0,0 +1,1547 @@
+# Messaging UI SDK — Foundation (Plan 1 of 3) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build the transport-free core of `@insignia/iios-messaging-ui` — types, the `MessagingAdapter` contract, a shared conformance suite, a mock adapter, and the provider + hooks — proving the seam works with zero network.
+
+**Architecture:** The core knows nothing about any transport. Hosts inject a `MessagingAdapter`; the provider holds it in context; hooks (`useConversations`, `useMessages`) call it. `iios-kernel-client` is reachable only from the `./adapters/kernel` subpath (Plan 3), never from core — enforced by a test, not convention. A conformance suite defines "a correct adapter" once, so the mock, kernel, and CRM adapters are all verified against one definition.
+
+**Tech Stack:** TypeScript, React 19 (peer), tsup (ESM), vitest 3 + jsdom + @testing-library/react, pnpm workspaces.
+
+**Spec:** `lynkeduppro-crm/docs/superpowers/specs/2026-07-17-messaging-sdk-design.md`
+
+**Scope of this plan:** Package scaffold, test infra, types, adapter contract + conformance suite, mock adapter, provider, hooks, boundary enforcement. **Out of scope:** UI components and CSS (Plan 2); kernel adapter, CRM adapter, attachments, and the CRM migration (Plan 3).
+
+---
+
+## Context an engineer needs
+
+**Read these first:**
+- `packages/iios-message-web/` — the existing headless package. **This is the anti-pattern**, not the model: 109 lines, narrowed `send` API, no consumers. We are deliberately building the layer it refused to build.
+- `lynkeduppro-crm/src/lib/messenger-api.ts` — the source of the adapter contract. Its `MessengerData`/`ThreadData` interfaces already survived two implementations (live + mock).
+- `scripts/check-import-boundary.mjs` — the repo's dependency law.
+
+**Repo facts that will bite you:**
+- Root `vitest.config.ts` includes only `packages/**/src/**/*.{test,spec}.ts` — **`.tsx` is not matched**, and its `globalSetup` provisions a Postgres DB on port 5434 for every run. This package therefore gets its **own** `vitest.config.ts` with `environment: 'jsdom'` and **no** `globalSetup`. Never add a DB dependency to a pure-UI package.
+- `tsconfig.base.json` sets `"module": "commonjs"` — every web package overrides this to `ESNext` + `moduleResolution: bundler` + `jsx: react-jsx`. Copy that override; don't fight the base.
+- Packages are ESM-only (`"type": "module"`).
+
+**The bug we are fixing (do not port it):** the CRM infers the current actor id by scanning for a message you sent (`messenger-api.ts:200-203`). Until you have sent in a thread, `myActorId` is `null`, so the REST poll fallback computes `mine: false` for **every** message. Root cause: the kernel's receipt event carries no `threadId`. Our fix is `adapter.currentActorId()` — identity is the adapter's job, stated explicitly. Task 8 has the regression test.
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|---|---|
+| `packages/iios-messaging-ui/package.json` | Package manifest, exports map, scripts |
+| `packages/iios-messaging-ui/tsup.config.ts` | ESM build, 3 entries (core, adapters/mock, adapters/kernel) |
+| `packages/iios-messaging-ui/tsconfig.json` | ESNext + jsx override over base |
+| `packages/iios-messaging-ui/vitest.config.ts` | jsdom, includes `.tsx`, **no** globalSetup |
+| `src/types.ts` | Domain types only. Zero imports. |
+| `src/adapter.ts` | The `MessagingAdapter` interface |
+| `src/conformance.ts` | Shared suite defining a correct adapter (shipped, not test-only) |
+| `src/conformance.test.ts` | Runs the suite against the mock adapter |
+| `src/adapters/mock.ts` | In-memory adapter for demos/tests |
+| `src/provider.tsx` | `MessagingProvider` + `useAdapter` |
+| `src/hooks/use-conversations.ts` | Conversation list state |
+| `src/hooks/use-messages.ts` | Thread state, optimistic send, subscribe reconciliation |
+| `src/index.ts` | Public barrel |
+| `src/boundary.test.ts` | Asserts core never imports a transport |
+
+Files split by responsibility, not layer. `conformance.ts` ships in `src/` (not a test file) because Plan 3's adapters import it to verify themselves.
+
+---
+
+### Task 1: Package scaffold + test infrastructure
+
+**Files:**
+- Create: `packages/iios-messaging-ui/package.json`
+- Create: `packages/iios-messaging-ui/tsconfig.json`
+- Create: `packages/iios-messaging-ui/tsup.config.ts`
+- Create: `packages/iios-messaging-ui/vitest.config.ts`
+- Create: `packages/iios-messaging-ui/src/index.ts`
+- Test: `packages/iios-messaging-ui/src/smoke.test.tsx`
+
+- [ ] **Step 1: Create the package manifest**
+
+Create `packages/iios-messaging-ui/package.json`:
+
+```json
+{
+ "name": "@insignia/iios-messaging-ui",
+ "version": "0.1.0",
+ "type": "module",
+ "main": "dist/index.js",
+ "module": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./adapters/mock": {
+ "types": "./dist/adapters/mock.d.ts",
+ "import": "./dist/adapters/mock.js"
+ },
+ "./conformance": {
+ "types": "./dist/conformance.d.ts",
+ "import": "./dist/conformance.js"
+ },
+ "./styles.css": "./dist/styles.css"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run"
+ },
+ "peerDependencies": {
+ "react": ">=18"
+ },
+ "devDependencies": {
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/react": "^16.1.0",
+ "@types/react": "^19.0.0",
+ "jsdom": "^26.0.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "tsup": "^8.3.5",
+ "typescript": "^5.7.3",
+ "vitest": "^3.0.5"
+ },
+ "publishConfig": {
+ "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
+ }
+}
+```
+
+Note: **no `dependencies`.** The core must stay transport-free. `./adapters/kernel` and its `@insignia/iios-kernel-client` dependency arrive in Plan 3. `./styles.css` is declared now but produced in Plan 2.
+
+- [ ] **Step 2: Create tsconfig**
+
+Create `packages/iios-messaging-ui/tsconfig.json`:
+
+```json
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM"],
+ "jsx": "react-jsx",
+ "types": ["react"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
+ "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
+}
+```
+
+- [ ] **Step 3: Create the build config**
+
+Create `packages/iios-messaging-ui/tsup.config.ts`:
+
+```ts
+import { defineConfig } from 'tsup';
+
+export default defineConfig({
+ // `conformance` is its own entry, never reachable from `index`. It imports vitest,
+ // and bundling that into the main barrel would drag a test runner into every
+ // consumer's production build.
+ entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/conformance.ts'],
+ format: ['esm'],
+ dts: true,
+ clean: true,
+ external: ['react', 'react-dom', 'vitest'],
+});
+```
+
+- [ ] **Step 4: Create the package's own vitest config**
+
+Create `packages/iios-messaging-ui/vitest.config.ts`:
+
+```ts
+import { defineConfig } from 'vitest/config';
+
+// This package owns its vitest config on purpose. The ROOT config includes only
+// `.ts` (never `.tsx`) and provisions a Postgres DB via globalSetup for every run —
+// a pure-UI package must not drag a database along, and its tests are .tsx.
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ include: ['src/**/*.{test,spec}.{ts,tsx}'],
+ globals: false,
+ },
+});
+```
+
+- [ ] **Step 5: Create a placeholder barrel**
+
+Create `packages/iios-messaging-ui/src/index.ts`:
+
+```ts
+export {};
+```
+
+- [ ] **Step 6: Write the failing smoke test**
+
+Create `packages/iios-messaging-ui/src/smoke.test.tsx`:
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+describe('test infrastructure', () => {
+ it('renders React components in jsdom', () => {
+ render(
messaging-ui
);
+ expect(screen.getByText('messaging-ui')).toBeDefined();
+ });
+});
+```
+
+- [ ] **Step 7: Install dependencies**
+
+Run from the repo root:
+
+```bash
+pnpm install
+```
+
+Expected: pnpm links the new workspace package; `@testing-library/react`, `jsdom`, `react-dom` resolve.
+
+- [ ] **Step 8: Run the test to verify it passes**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui test`
+Expected: PASS, 1 test. If it fails with "document is not defined", `environment: 'jsdom'` did not take effect — check you created `vitest.config.ts` in the package dir, not the root.
+
+- [ ] **Step 9: Verify typecheck and build**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui typecheck && pnpm --filter @insignia/iios-messaging-ui build`
+Expected: no type errors; `dist/index.js` + `dist/index.d.ts` produced.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add packages/iios-messaging-ui
+git commit -m "chore: scaffold @insignia/iios-messaging-ui with jsdom test setup"
+```
+
+---
+
+### Task 2: Domain types
+
+**Files:**
+- Create: `packages/iios-messaging-ui/src/types.ts`
+- Test: `packages/iios-messaging-ui/src/types.test.ts`
+
+These are the SDK's own types — deliberately **not** re-exported from `iios-kernel-client`, because core must not depend on it. Adapters map kernel/CRM DTOs onto these. Shape is taken from the CRM's `UiConversation`/`UiMessage`/`UiPerson`, which are already proven.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `packages/iios-messaging-ui/src/types.test.ts`:
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { isOwnMessage } from './types';
+import type { Message } from './types';
+
+const base: Message = {
+ id: 'm1',
+ actorId: 'actor_a',
+ text: 'hello',
+ at: '2026-07-17T10:00:00.000Z',
+};
+
+describe('isOwnMessage', () => {
+ it('is true when the message actor matches the current actor', () => {
+ expect(isOwnMessage(base, 'actor_a')).toBe(true);
+ });
+
+ it('is false when the actors differ', () => {
+ expect(isOwnMessage(base, 'actor_b')).toBe(false);
+ });
+
+ it('is false when the current actor is unknown', () => {
+ expect(isOwnMessage(base, null)).toBe(false);
+ });
+
+ it('is false when the message has no actor', () => {
+ expect(isOwnMessage({ ...base, actorId: null }, 'actor_a')).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui test`
+Expected: FAIL — "Failed to resolve import ./types" or "isOwnMessage is not a function".
+
+- [ ] **Step 3: Write the implementation**
+
+Create `packages/iios-messaging-ui/src/types.ts`:
+
+```ts
+// Domain types for the messaging UI. Zero imports on purpose: this file must never
+// reach for a transport package. Adapters map their own DTOs onto these.
+
+export type Membership = 'dm' | 'group';
+
+export interface Person {
+ id: string;
+ name: string;
+ kind: 'staff' | 'customer';
+}
+
+export interface Conversation {
+ threadId: string;
+ title: string;
+ subject: string | null;
+ membership: Membership | null;
+ participants: string[];
+ unread: number;
+ lastMessage?: string;
+ lastAt?: string;
+}
+
+export interface Reaction {
+ emoji: string;
+ count: number;
+ mine: boolean;
+}
+
+export interface Attachment {
+ url: string;
+ mime: string;
+ name: string;
+}
+
+export interface Message {
+ id: string;
+ actorId: string | null;
+ text: string;
+ at: string;
+ parentInteractionId?: string | null;
+ reactions?: Reaction[];
+ attachment?: Attachment;
+ /** True only when the transport is optimistic-local and not yet acknowledged. */
+ pending?: boolean;
+}
+
+export interface SendOpts {
+ parentInteractionId?: string;
+ attachment?: Attachment;
+}
+
+export type MessageEvent =
+ | { kind: 'message'; message: Message }
+ | { kind: 'typing'; userId: string }
+ | { kind: 'receipt'; messageId: string; actorId: string }
+ | { kind: 'reaction'; messageId: string; reactions: Reaction[] };
+
+export type Unsubscribe = () => void;
+
+/** Ownership is a pure function of explicit identity — never inferred from history.
+ * See the spec: inferring it is the bug this SDK exists partly to kill. */
+export function isOwnMessage(message: Message, currentActorId: string | null): boolean {
+ return currentActorId !== null && message.actorId !== null && message.actorId === currentActorId;
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui test`
+Expected: PASS, 5 tests (4 new + smoke).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/iios-messaging-ui/src/types.ts packages/iios-messaging-ui/src/types.test.ts
+git commit -m "feat: add messaging-ui domain types"
+```
+
+---
+
+### Task 3: The MessagingAdapter contract
+
+**Files:**
+- Create: `packages/iios-messaging-ui/src/adapter.ts`
+
+An interface has no runtime behaviour to test directly; Task 4's conformance suite is its test. This task is type-only, so it is verified by `typecheck`.
+
+- [ ] **Step 1: Write the interface**
+
+Create `packages/iios-messaging-ui/src/adapter.ts`:
+
+```ts
+import type {
+ Attachment,
+ Conversation,
+ Membership,
+ Message,
+ MessageEvent,
+ SendOpts,
+ Unsubscribe,
+} from './types';
+
+/**
+ * The one seam of this SDK. Hosts implement this; the SDK renders it.
+ *
+ * Lifted from lynkeduppro-crm's MessengerData/ThreadData, which already survived
+ * two implementations (live data-door + mock) — the minimum real evidence that a
+ * seam is genuine rather than imagined.
+ *
+ * Optional methods degrade gracefully: the UI hides the reaction picker when
+ * `react` is absent, and the attach button when `upload` is absent. That is how one
+ * component set serves both a full CRM messenger and a stripped-down widget with no
+ * `mode` prop.
+ */
+export interface MessagingAdapter {
+ listConversations(): Promise;
+
+ openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }>;
+
+ history(threadId: string): Promise;
+
+ send(threadId: string, content: string, opts?: SendOpts): Promise;
+
+ /** Returns an unsubscribe fn. Implementations MUST be idempotent on repeat unsubscribe. */
+ subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe;
+
+ sendTyping(threadId: string): void;
+
+ markRead(threadId: string, messageId: string): Promise;
+
+ /**
+ * The current user's actor id, or null if not yet known.
+ *
+ * MUST NOT be inferred from message history. The CRM's bug was exactly that:
+ * scanning for a sent message meant every message read as not-yours until you
+ * had spoken. Adapters derive this from auth/session.
+ */
+ currentActorId(): string | null;
+
+ /** Absent => the UI hides reactions entirely. */
+ react?(threadId: string, messageId: string, emoji: string): Promise;
+
+ /** Absent => the UI hides attachments. Storage/auth/limits are the host's concern. */
+ upload?(file: File): Promise;
+
+ /** Absent => treated as always connected (e.g. a pure-REST adapter). */
+ isConnected?(): boolean;
+}
+```
+
+- [ ] **Step 2: Verify it typechecks**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui typecheck`
+Expected: no errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add packages/iios-messaging-ui/src/adapter.ts
+git commit -m "feat: define MessagingAdapter contract"
+```
+
+---
+
+### Task 4: Adapter conformance suite
+
+**Files:**
+- Create: `packages/iios-messaging-ui/src/conformance.ts`
+
+This ships in `src/` (not as a `.test.ts`) because Plan 3's kernel and CRM adapters import it to verify themselves. One definition of "correct adapter", three implementations checked against it.
+
+- [ ] **Step 1: Write the suite**
+
+Create `packages/iios-messaging-ui/src/conformance.ts`:
+
+```ts
+import { describe, it, expect } from 'vitest';
+import type { MessagingAdapter } from './adapter';
+import type { MessageEvent } from './types';
+
+export interface ConformanceOptions {
+ /** Build a fresh, isolated adapter per test. */
+ makeAdapter: () => Promise | MessagingAdapter;
+ /** A thread id that exists in the fixture. */
+ seededThreadId: string;
+ /** Participant ids openThread can legally be called with. */
+ openWith: string[];
+}
+
+/**
+ * The definition of a correct MessagingAdapter. Every adapter runs this.
+ *
+ * Imports vitest, so it ships from the './conformance' subpath ONLY and is never
+ * reachable from the main barrel. Consumers supply their own vitest.
+ *
+ * Usage from another package:
+ * import { runAdapterConformance } from '@insignia/iios-messaging-ui/conformance';
+ * runAdapterConformance({ makeAdapter: () => new MockAdapter(), seededThreadId: 'th_1', openWith: ['pp_a'] });
+ */
+export function runAdapterConformance(opts: ConformanceOptions): void {
+ const make = async () => await opts.makeAdapter();
+
+ describe('MessagingAdapter conformance', () => {
+ it('lists conversations', async () => {
+ const a = await make();
+ const list = await a.listConversations();
+ expect(Array.isArray(list)).toBe(true);
+ });
+
+ it('returns history for a seeded thread', async () => {
+ const a = await make();
+ const msgs = await a.history(opts.seededThreadId);
+ expect(Array.isArray(msgs)).toBe(true);
+ });
+
+ it('send resolves with a message carrying the sent text and a stable id', async () => {
+ const a = await make();
+ const m = await a.send(opts.seededThreadId, 'conformance hello');
+ expect(m.text).toBe('conformance hello');
+ expect(typeof m.id).toBe('string');
+ expect(m.id.length).toBeGreaterThan(0);
+ });
+
+ it('a sent message is attributed to the current actor', async () => {
+ const a = await make();
+ const m = await a.send(opts.seededThreadId, 'whose is this');
+ expect(m.actorId).toBe(a.currentActorId());
+ });
+
+ it('currentActorId is known BEFORE any message is sent', async () => {
+ // The bug this SDK exists to kill: identity must come from auth, never be
+ // inferred from history. A fresh adapter already knows who you are.
+ const a = await make();
+ expect(a.currentActorId()).not.toBeNull();
+ });
+
+ it('a sent message appears in history', async () => {
+ const a = await make();
+ await a.send(opts.seededThreadId, 'persist me');
+ const msgs = await a.history(opts.seededThreadId);
+ expect(msgs.some((m) => m.text === 'persist me')).toBe(true);
+ });
+
+ it('subscribe delivers a message event on send', async () => {
+ const a = await make();
+ const seen: MessageEvent[] = [];
+ const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e));
+ await a.send(opts.seededThreadId, 'live one');
+ off();
+ const msgs = seen.filter((e) => e.kind === 'message');
+ expect(msgs.length).toBeGreaterThan(0);
+ });
+
+ it('unsubscribe stops delivery', async () => {
+ const a = await make();
+ const seen: MessageEvent[] = [];
+ const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e));
+ off();
+ await a.send(opts.seededThreadId, 'should not be heard');
+ expect(seen).toHaveLength(0);
+ });
+
+ it('unsubscribe is idempotent', async () => {
+ const a = await make();
+ const off = a.subscribe(opts.seededThreadId, () => {});
+ off();
+ expect(() => off()).not.toThrow();
+ });
+
+ it('openThread returns a thread id', async () => {
+ const a = await make();
+ const { threadId } = await a.openThread({ participantIds: opts.openWith });
+ expect(typeof threadId).toBe('string');
+ expect(threadId.length).toBeGreaterThan(0);
+ });
+
+ it('markRead resolves', async () => {
+ const a = await make();
+ const m = await a.send(opts.seededThreadId, 'read me');
+ await expect(a.markRead(opts.seededThreadId, m.id)).resolves.toBeUndefined();
+ });
+
+ it('sendTyping does not throw', async () => {
+ const a = await make();
+ expect(() => a.sendTyping(opts.seededThreadId)).not.toThrow();
+ });
+ });
+}
+```
+
+- [ ] **Step 2: Verify it typechecks**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui typecheck`
+Expected: no errors. (The suite has no adapter to run against yet — that is Task 5.)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add packages/iios-messaging-ui/src/conformance.ts
+git commit -m "feat: add adapter conformance suite"
+```
+
+---
+
+### Task 5: Mock adapter
+
+**Files:**
+- Create: `packages/iios-messaging-ui/src/adapters/mock.ts`
+- Test: `packages/iios-messaging-ui/src/conformance.test.ts`
+
+Ported from the CRM's `MOCK_STORE`, with one deliberate change: **the store is per-instance, not module-level.** The CRM's module-level `Map` leaks state between tests. Fixtures match the CRM's so demo mode looks identical after migration.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `packages/iios-messaging-ui/src/conformance.test.ts`:
+
+```ts
+import { runAdapterConformance } from './conformance';
+import { MockAdapter } from './adapters/mock';
+
+runAdapterConformance({
+ makeAdapter: () => new MockAdapter(),
+ seededThreadId: 'th_mock_1',
+ openWith: ['pp_sofia'],
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui test`
+Expected: FAIL — "Failed to resolve import ./adapters/mock".
+
+- [ ] **Step 3: Write the implementation**
+
+Create `packages/iios-messaging-ui/src/adapters/mock.ts`:
+
+```ts
+import type { MessagingAdapter } from '../adapter';
+import type {
+ Attachment,
+ Conversation,
+ Membership,
+ Message,
+ MessageEvent,
+ Person,
+ SendOpts,
+ Unsubscribe,
+} from '../types';
+
+const ME = 'me';
+
+export const MOCK_PEOPLE: Person[] = [
+ { id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
+ { id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
+ { id: 'pp_priya', name: 'Priya Nair', kind: 'staff' },
+ { id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
+ { id: 'cust_globex', name: 'Globex Homes (Client)', kind: 'customer' },
+];
+
+interface MockThread {
+ threadId: string;
+ membership: Membership;
+ subject: string | null;
+ participants: string[];
+ messages: Message[];
+}
+
+const nameById = new Map(MOCK_PEOPLE.map((p) => [p.id, p.name]));
+
+/**
+ * In-memory adapter for demos and tests. State is PER INSTANCE — the CRM's version
+ * used a module-level Map, which leaks between tests. Each `new MockAdapter()` is
+ * fully isolated.
+ */
+export class MockAdapter implements MessagingAdapter {
+ private seq = 100;
+ private threads = new Map();
+ private listeners = new Map void>>();
+
+ constructor(private readonly now: () => string = () => new Date().toISOString()) {
+ this.threads.set('th_mock_1', {
+ threadId: 'th_mock_1',
+ membership: 'dm',
+ subject: null,
+ participants: [ME, 'pp_sofia'],
+ messages: [
+ {
+ id: 'm1',
+ actorId: 'pp_sofia',
+ text: 'Can you review the Henderson estimate?',
+ at: this.now(),
+ reactions: [],
+ },
+ ],
+ });
+ this.threads.set('th_mock_2', {
+ threadId: 'th_mock_2',
+ membership: 'group',
+ subject: 'Storm response — East side',
+ participants: [ME, 'pp_dan', 'pp_priya'],
+ messages: [
+ { id: 'm2', actorId: 'pp_dan', text: 'Crew is rolling out at 7.', at: this.now(), reactions: [] },
+ ],
+ });
+ }
+
+ currentActorId(): string {
+ return ME;
+ }
+
+ async listConversations(): Promise {
+ return [...this.threads.values()].map((t) => {
+ const last = t.messages[t.messages.length - 1];
+ const others = t.participants.filter((p) => p !== ME);
+ return {
+ threadId: t.threadId,
+ title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation',
+ subject: t.subject,
+ membership: t.membership,
+ participants: t.participants,
+ unread: 0,
+ ...(last ? { lastMessage: last.text, lastAt: last.at } : {}),
+ };
+ });
+ }
+
+ async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
+ const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
+ const threadId = `th_mock_${this.seq++}`;
+ this.threads.set(threadId, {
+ threadId,
+ membership,
+ subject: p.subject ?? null,
+ participants: [ME, ...p.participantIds],
+ messages: [],
+ });
+ return { threadId };
+ }
+
+ async history(threadId: string): Promise {
+ return this.threads.get(threadId)?.messages ?? [];
+ }
+
+ async send(threadId: string, content: string, opts?: SendOpts): Promise {
+ const t = this.threads.get(threadId);
+ if (!t) throw new Error(`Unknown thread: ${threadId}`);
+ const message: Message = {
+ id: `m_${this.seq++}`,
+ actorId: ME,
+ text: content,
+ at: this.now(),
+ reactions: [],
+ ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
+ ...(opts?.attachment ? { attachment: opts.attachment } : {}),
+ };
+ t.messages = [...t.messages, message];
+ this.emit(threadId, { kind: 'message', message });
+ return message;
+ }
+
+ async react(threadId: string, messageId: string, emoji: string): Promise {
+ const t = this.threads.get(threadId);
+ if (!t) return;
+ let next: Message | undefined;
+ t.messages = t.messages.map((m) => {
+ if (m.id !== messageId) return m;
+ const existing = (m.reactions ?? []).find((r) => r.emoji === emoji);
+ const reactions = existing
+ ? (m.reactions ?? []).filter((r) => r.emoji !== emoji)
+ : [...(m.reactions ?? []), { emoji, count: 1, mine: true }];
+ next = { ...m, reactions };
+ return next;
+ });
+ if (next) this.emit(threadId, { kind: 'reaction', messageId, reactions: next.reactions ?? [] });
+ }
+
+ async upload(file: File): Promise {
+ return { url: `mock://uploads/${file.name}`, mime: file.type, name: file.name };
+ }
+
+ subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
+ if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
+ this.listeners.get(threadId)!.add(cb);
+ return () => {
+ this.listeners.get(threadId)?.delete(cb);
+ };
+ }
+
+ sendTyping(): void {
+ // No-op: nobody is typing back in a mock.
+ }
+
+ async markRead(): Promise {
+ // No-op: the mock has no second party to report a read.
+ }
+
+ isConnected(): boolean {
+ return true;
+ }
+
+ private emit(threadId: string, e: MessageEvent): void {
+ this.listeners.get(threadId)?.forEach((cb) => cb(e));
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui test`
+Expected: PASS — 12 conformance tests green.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/iios-messaging-ui/src/adapters/mock.ts packages/iios-messaging-ui/src/conformance.test.ts
+git commit -m "feat: add MockAdapter passing the conformance suite"
+```
+
+---
+
+### Task 6: MessagingProvider
+
+**Files:**
+- Create: `packages/iios-messaging-ui/src/provider.tsx`
+- Test: `packages/iios-messaging-ui/src/provider.test.tsx`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `packages/iios-messaging-ui/src/provider.test.tsx`:
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MessagingProvider, useAdapter } from './provider';
+import { MockAdapter } from './adapters/mock';
+
+function ShowActor() {
+ const adapter = useAdapter();
+ return {adapter.currentActorId()};
+}
+
+describe('MessagingProvider', () => {
+ it('supplies the injected adapter to descendants', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('me')).toBeDefined();
+ });
+
+ it('throws a helpful error when a hook is used outside the provider', () => {
+ // React logs the error boundary trace; that noise is expected.
+ expect(() => render()).toThrow(/useAdapter must be used within a /);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui test`
+Expected: FAIL — "Failed to resolve import ./provider".
+
+- [ ] **Step 3: Write the implementation**
+
+Create `packages/iios-messaging-ui/src/provider.tsx`:
+
+```tsx
+import { createContext, useContext, type ReactNode } from 'react';
+import type { MessagingAdapter } from './adapter';
+
+const AdapterContext = createContext(null);
+
+export function MessagingProvider({
+ adapter,
+ children,
+}: {
+ adapter: MessagingAdapter;
+ children: ReactNode;
+}) {
+ return {children};
+}
+
+/** Access the host-injected adapter. Throws outside a provider — a missing provider is
+ * a wiring bug, and failing loudly beats a confusing null-deref three layers down. */
+export function useAdapter(): MessagingAdapter {
+ const adapter = useContext(AdapterContext);
+ if (!adapter) throw new Error('useAdapter must be used within a ');
+ return adapter;
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui test`
+Expected: PASS, 2 new tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/iios-messaging-ui/src/provider.tsx packages/iios-messaging-ui/src/provider.test.tsx
+git commit -m "feat: add MessagingProvider and useAdapter"
+```
+
+---
+
+### Task 7: useConversations
+
+**Files:**
+- Create: `packages/iios-messaging-ui/src/hooks/use-conversations.ts`
+- Test: `packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx`:
+
+```tsx
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook, waitFor, act } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { MessagingProvider } from '../provider';
+import { MockAdapter } from '../adapters/mock';
+import { useConversations } from './use-conversations';
+import type { MessagingAdapter } from '../adapter';
+
+const wrap = (adapter: MessagingAdapter) =>
+ function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ };
+
+describe('useConversations', () => {
+ it('starts loading, then resolves the adapter list', async () => {
+ const { result } = renderHook(() => useConversations(), { wrapper: wrap(new MockAdapter()) });
+ expect(result.current.loading).toBe(true);
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.conversations).toHaveLength(2);
+ expect(result.current.conversations[0]!.threadId).toBe('th_mock_1');
+ expect(result.current.error).toBeNull();
+ });
+
+ it('surfaces adapter failure as error state and never throws', async () => {
+ const adapter = new MockAdapter();
+ vi.spyOn(adapter, 'listConversations').mockRejectedValue(new Error('data door down'));
+
+ const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.error).toBe('data door down');
+ expect(result.current.conversations).toEqual([]);
+ });
+
+ it('refetch picks up newly opened threads', async () => {
+ const adapter = new MockAdapter();
+ const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
+ await waitFor(() => expect(result.current.conversations).toHaveLength(2));
+
+ await act(async () => {
+ await adapter.openThread({ participantIds: ['pp_dan'] });
+ });
+ await act(async () => {
+ result.current.refetch();
+ });
+
+ await waitFor(() => expect(result.current.conversations).toHaveLength(3));
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui test`
+Expected: FAIL — "Failed to resolve import ./use-conversations".
+
+- [ ] **Step 3: Write the implementation**
+
+Create `packages/iios-messaging-ui/src/hooks/use-conversations.ts`:
+
+```ts
+import { useCallback, useEffect, useState } from 'react';
+import { useAdapter } from '../provider';
+import type { Conversation } from '../types';
+
+export interface ConversationsState {
+ conversations: Conversation[];
+ loading: boolean;
+ error: string | null;
+ refetch: () => void;
+}
+
+export function useConversations(): ConversationsState {
+ const adapter = useAdapter();
+ const [conversations, setConversations] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [nonce, setNonce] = useState(0);
+
+ useEffect(() => {
+ let alive = true;
+ setLoading(true);
+ adapter
+ .listConversations()
+ .then((list) => {
+ if (!alive) return;
+ setConversations(list);
+ setError(null);
+ })
+ .catch((e: unknown) => {
+ if (!alive) return;
+ setError(e instanceof Error ? e.message : String(e));
+ setConversations([]);
+ })
+ .finally(() => {
+ if (alive) setLoading(false);
+ });
+ return () => {
+ alive = false;
+ };
+ }, [adapter, nonce]);
+
+ const refetch = useCallback(() => setNonce((n) => n + 1), []);
+
+ return { conversations, loading, error, refetch };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui test`
+Expected: PASS, 3 new tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/iios-messaging-ui/src/hooks
+git commit -m "feat: add useConversations hook"
+```
+
+---
+
+### Task 8: useMessages
+
+**Files:**
+- Create: `packages/iios-messaging-ui/src/hooks/use-messages.ts`
+- Test: `packages/iios-messaging-ui/src/hooks/use-messages.test.tsx`
+
+The heart of the SDK. Three behaviours matter most: **ownership before you speak** (the CRM bug), **optimistic send with rollback**, and **event reconciliation by id** (no duplicates when the echo arrives).
+
+- [ ] **Step 1: Write the failing test**
+
+Create `packages/iios-messaging-ui/src/hooks/use-messages.test.tsx`:
+
+```tsx
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook, waitFor, act } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { MessagingProvider } from '../provider';
+import { MockAdapter } from '../adapters/mock';
+import { useMessages } from './use-messages';
+import type { MessagingAdapter } from '../adapter';
+
+const wrap = (adapter: MessagingAdapter) =>
+ function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ };
+
+describe('useMessages', () => {
+ it('loads history for the thread', async () => {
+ const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(new MockAdapter()) });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.messages).toHaveLength(1);
+ expect(result.current.messages[0]!.text).toBe('Can you review the Henderson estimate?');
+ });
+
+ // REGRESSION: the CRM inferred actor identity by scanning for a sent message, so
+ // before you had spoken in a thread EVERY message rendered as not-yours.
+ it('marks ownership correctly before the user has sent anything', async () => {
+ const adapter = new MockAdapter();
+ await adapter.send('th_mock_1', 'an earlier message of mine');
+
+ const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
+ await waitFor(() => expect(result.current.messages).toHaveLength(2));
+
+ // Never sent anything via the hook — ownership still resolves from currentActorId().
+ expect(result.current.messages[0]!.mine).toBe(false); // from pp_sofia
+ expect(result.current.messages[1]!.mine).toBe(true); // from me
+ });
+
+ it('appends an optimistic message immediately on send', async () => {
+ const adapter = new MockAdapter();
+ let release!: () => void;
+ vi.spyOn(adapter, 'send').mockImplementation(
+ () => new Promise((res) => { release = () => res({ id: 'srv_1', actorId: 'me', text: 'hi', at: '2026-07-17T10:00:00.000Z' }); }),
+ );
+
+ const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ act(() => { void result.current.send('hi'); });
+
+ await waitFor(() => expect(result.current.messages).toHaveLength(2));
+ expect(result.current.messages[1]!.pending).toBe(true);
+ expect(result.current.messages[1]!.mine).toBe(true);
+
+ await act(async () => { release(); });
+ await waitFor(() => expect(result.current.messages[1]!.pending).toBeFalsy());
+ });
+
+ it('rolls back the optimistic message and reports error when send fails', async () => {
+ const adapter = new MockAdapter();
+ vi.spyOn(adapter, 'send').mockRejectedValue(new Error('offline'));
+
+ const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ await expect(result.current.send('doomed')).rejects.toThrow('offline');
+ });
+
+ expect(result.current.messages).toHaveLength(1);
+ expect(result.current.messages.some((m) => m.text === 'doomed')).toBe(false);
+ expect(result.current.error).toBe('offline');
+ });
+
+ it('does not duplicate a message when the transport echoes it back', async () => {
+ const adapter = new MockAdapter();
+ const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ // MockAdapter.send emits a 'message' event AND resolves with the same message.
+ await act(async () => { await result.current.send('echo once'); });
+
+ expect(result.current.messages.filter((m) => m.text === 'echo once')).toHaveLength(1);
+ });
+
+ it('collects typing user ids from subscribe events', async () => {
+ const adapter = new MockAdapter();
+ let emit!: (userId: string) => void;
+ vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
+ emit = (userId) => cb({ kind: 'typing', userId });
+ return () => {};
+ });
+
+ const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ act(() => emit('pp_sofia'));
+ expect(result.current.typingUserIds).toEqual(['pp_sofia']);
+ });
+
+ it('unsubscribes on unmount', async () => {
+ const adapter = new MockAdapter();
+ const off = vi.fn();
+ vi.spyOn(adapter, 'subscribe').mockReturnValue(off);
+
+ const { unmount, result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ unmount();
+
+ expect(off).toHaveBeenCalled();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui test`
+Expected: FAIL — "Failed to resolve import ./use-messages".
+
+- [ ] **Step 3: Write the implementation**
+
+Create `packages/iios-messaging-ui/src/hooks/use-messages.ts`:
+
+```ts
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAdapter } from '../provider';
+import { isOwnMessage } from '../types';
+import type { Message, SendOpts } from '../types';
+
+const TYPING_TTL_MS = 3500;
+
+export interface UiMessage extends Message {
+ mine: boolean;
+}
+
+export interface MessagesState {
+ messages: UiMessage[];
+ loading: boolean;
+ error: string | null;
+ send: (content: string, opts?: SendOpts) => Promise;
+ react: (messageId: string, emoji: string) => Promise;
+ typingUserIds: string[];
+ seenIds: Set;
+ sendTyping: () => void;
+ canReact: boolean;
+ canUpload: boolean;
+}
+
+let optimisticSeq = 0;
+
+export function useMessages(threadId: string | null): MessagesState {
+ const adapter = useAdapter();
+ const [raw, setRaw] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [typing, setTyping] = useState>({});
+ const [seenIds, setSeenIds] = useState>(new Set());
+ const [, forceTick] = useState(0);
+
+ const currentActorId = adapter.currentActorId();
+ const actorRef = useRef(currentActorId);
+ actorRef.current = currentActorId;
+
+ // Load history, then subscribe. Reconciliation is by message id, so an echoed
+ // send never duplicates the optimistic row.
+ useEffect(() => {
+ if (!threadId) {
+ setRaw([]);
+ setLoading(false);
+ return;
+ }
+ let alive = true;
+ setLoading(true);
+ setRaw([]);
+ setSeenIds(new Set());
+ setTyping({});
+
+ adapter
+ .history(threadId)
+ .then((h) => {
+ if (alive) {
+ setRaw(h);
+ setError(null);
+ }
+ })
+ .catch((e: unknown) => {
+ if (alive) setError(e instanceof Error ? e.message : String(e));
+ })
+ .finally(() => {
+ if (alive) setLoading(false);
+ });
+
+ const off = adapter.subscribe(threadId, (e) => {
+ if (!alive) return;
+ switch (e.kind) {
+ case 'message':
+ setRaw((l) => (l.some((m) => m.id === e.message.id) ? l : [...l, e.message]));
+ break;
+ case 'typing':
+ if (e.userId !== actorRef.current) {
+ setTyping((t) => ({ ...t, [e.userId]: Date.now() + TYPING_TTL_MS }));
+ }
+ break;
+ case 'receipt':
+ // Only the OTHER side reading my message counts as "seen".
+ if (e.actorId !== actorRef.current) {
+ setSeenIds((s) => (s.has(e.messageId) ? s : new Set(s).add(e.messageId)));
+ }
+ break;
+ case 'reaction':
+ setRaw((l) => l.map((m) => (m.id === e.messageId ? { ...m, reactions: e.reactions } : m)));
+ break;
+ }
+ });
+
+ return () => {
+ alive = false;
+ off();
+ };
+ }, [adapter, threadId]);
+
+ const messages: UiMessage[] = useMemo(
+ () => raw.map((m) => ({ ...m, mine: isOwnMessage(m, currentActorId) })),
+ [raw, currentActorId],
+ );
+
+ const send = useCallback(
+ async (content: string, opts?: SendOpts) => {
+ if (!threadId) return;
+ const tempId = `optimistic_${optimisticSeq++}`;
+ const optimistic: Message = {
+ id: tempId,
+ actorId: actorRef.current,
+ text: content,
+ at: new Date().toISOString(),
+ pending: true,
+ reactions: [],
+ ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
+ ...(opts?.attachment ? { attachment: opts.attachment } : {}),
+ };
+ setRaw((l) => [...l, optimistic]);
+
+ try {
+ const saved = await adapter.send(threadId, content, opts);
+ setError(null);
+ // Replace the optimistic row with the server's. If the subscribe echo already
+ // added the real message, just drop the optimistic one.
+ setRaw((l) => {
+ const withoutTemp = l.filter((m) => m.id !== tempId);
+ return withoutTemp.some((m) => m.id === saved.id) ? withoutTemp : [...withoutTemp, saved];
+ });
+ } catch (e: unknown) {
+ setRaw((l) => l.filter((m) => m.id !== tempId));
+ setError(e instanceof Error ? e.message : String(e));
+ throw e;
+ }
+ },
+ [adapter, threadId],
+ );
+
+ const react = useCallback(
+ async (messageId: string, emoji: string) => {
+ if (!threadId || !adapter.react) return;
+ await adapter.react(threadId, messageId, emoji);
+ },
+ [adapter, threadId],
+ );
+
+ const sendTyping = useCallback(() => {
+ if (threadId) adapter.sendTyping(threadId);
+ }, [adapter, threadId]);
+
+ // Report my read of the newest message (drives the other side's "seen" tick).
+ useEffect(() => {
+ if (!threadId || raw.length === 0) return;
+ const last = raw[raw.length - 1]!;
+ if (last.pending) return;
+ void adapter.markRead(threadId, last.id).catch(() => {});
+ }, [adapter, threadId, raw]);
+
+ const typingUserIds = useMemo(() => {
+ const now = Date.now();
+ return Object.entries(typing)
+ .filter(([, exp]) => exp > now)
+ .map(([u]) => u);
+ }, [typing]);
+
+ // Expire stale typing entries by re-evaluating after the TTL.
+ useEffect(() => {
+ if (typingUserIds.length === 0) return;
+ const t = setTimeout(() => forceTick((n) => n + 1), TYPING_TTL_MS);
+ return () => clearTimeout(t);
+ }, [typingUserIds.length, typing]);
+
+ // Only my messages that the other side has read.
+ const seenMine = useMemo(() => {
+ const out = new Set();
+ for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id);
+ return out;
+ }, [seenIds, messages]);
+
+ return {
+ messages,
+ loading,
+ error,
+ send,
+ react,
+ typingUserIds,
+ seenIds: seenMine,
+ sendTyping,
+ canReact: typeof adapter.react === 'function',
+ canUpload: typeof adapter.upload === 'function',
+ };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui test`
+Expected: PASS, 7 new tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/iios-messaging-ui/src/hooks/use-messages.ts packages/iios-messaging-ui/src/hooks/use-messages.test.tsx
+git commit -m "feat: add useMessages hook with explicit ownership and optimistic send"
+```
+
+---
+
+### Task 9: Public barrel + transport-freedom enforcement
+
+**Files:**
+- Modify: `packages/iios-messaging-ui/src/index.ts`
+- Create: `packages/iios-messaging-ui/src/boundary.test.ts`
+- Modify: `scripts/check-import-boundary.mjs:22` (add the new package to `ALLOWED`)
+
+The repo's boundary script is **package-level**, so it cannot express "core must not import kernel-client, but `adapters/kernel` may". That distinction is this SDK's core premise, so it gets its own test.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `packages/iios-messaging-ui/src/boundary.test.ts`:
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { join } from 'node:path';
+
+// This package is ESM ("type": "module") — __dirname does not exist here.
+const SRC = fileURLToPath(new URL('.', import.meta.url));
+const ADAPTERS = join(SRC, 'adapters');
+
+function tsFilesIn(dir: string): string[] {
+ const out: string[] = [];
+ for (const entry of readdirSync(dir)) {
+ const full = join(dir, entry);
+ if (statSync(full).isDirectory()) out.push(...tsFilesIn(full));
+ else if (/\.tsx?$/.test(full)) out.push(full);
+ }
+ return out;
+}
+
+// The whole premise of this package: core renders, adapters transport. If core ever
+// imports a transport, an app on a different backend pays for socket code it cannot
+// use — which is exactly how iios-message-web welded itself to MessageSocket.
+describe('transport boundary', () => {
+ const coreFiles = tsFilesIn(SRC).filter((f) => !f.startsWith(ADAPTERS) && !/\.test\.tsx?$/.test(f));
+
+ it('has core files to check', () => {
+ expect(coreFiles.length).toBeGreaterThan(0);
+ });
+
+ it('core never imports a transport package', () => {
+ const offenders: string[] = [];
+ for (const file of coreFiles) {
+ const content = readFileSync(file, 'utf8');
+ if (/@insignia\/iios-kernel-client|socket\.io|@abe-kap\/appshell-sdk/.test(content)) {
+ offenders.push(file.replace(SRC, 'src'));
+ }
+ }
+ expect(offenders).toEqual([]);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it passes**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui test`
+Expected: PASS. (This test guards a property that already holds — it fails loudly in Plan 3 if someone imports the kernel from core.)
+
+- [ ] **Step 3: Write the public barrel**
+
+Replace `packages/iios-messaging-ui/src/index.ts`:
+
+```ts
+// Public API. Components land in Plan 2; adapters/kernel in Plan 3.
+//
+// `runAdapterConformance` is deliberately NOT exported here — it imports vitest and
+// ships from the './conformance' subpath so consumers never pull a test runner into
+// their production bundle.
+export { MessagingProvider, useAdapter } from './provider';
+export { useConversations } from './hooks/use-conversations';
+export { useMessages } from './hooks/use-messages';
+export { isOwnMessage } from './types';
+
+export type { MessagingAdapter } from './adapter';
+export type { ConversationsState } from './hooks/use-conversations';
+export type { MessagesState, UiMessage } from './hooks/use-messages';
+export type {
+ Attachment,
+ Conversation,
+ Membership,
+ Message,
+ MessageEvent,
+ Person,
+ Reaction,
+ SendOpts,
+ Unsubscribe,
+} from './types';
+```
+
+- [ ] **Step 4: Register the package in the repo's import-boundary law**
+
+In `scripts/check-import-boundary.mjs`, add this entry to the `ALLOWED` map (after the `'@insignia/iios-message-web'` line):
+
+```js
+ // Core is transport-free; only ./adapters/kernel may import the kernel client.
+ // This map is package-level and cannot express that subpath rule — the finer
+ // constraint is enforced by packages/iios-messaging-ui/src/boundary.test.ts.
+ '@insignia/iios-messaging-ui': ['@insignia/iios-kernel-client'],
+```
+
+- [ ] **Step 5: Verify the boundary law still passes**
+
+Run: `pnpm boundary`
+Expected: `✓ import-boundary: OK`
+
+- [ ] **Step 6: Verify the full package**
+
+Run: `pnpm --filter @insignia/iios-messaging-ui test && pnpm --filter @insignia/iios-messaging-ui typecheck && pnpm --filter @insignia/iios-messaging-ui build`
+Expected: all tests PASS; no type errors; `dist/index.js`, `dist/index.d.ts`, `dist/adapters/mock.js`, `dist/conformance.js` produced.
+
+- [ ] **Step 6b: Verify the main entry does not drag in vitest**
+
+Run: `grep -c vitest packages/iios-messaging-ui/dist/index.js || echo "clean"`
+Expected: `clean` (grep finds nothing and exits 1). If this prints a number, `conformance.ts` leaked into the main barrel — remove the re-export from `src/index.ts`.
+
+Run: `grep -rE "iios-kernel-client|socket\.io" packages/iios-messaging-ui/dist/index.js || echo "transport-free"`
+Expected: `transport-free`.
+
+- [ ] **Step 7: Verify the root test suite is unaffected**
+
+Run: `pnpm test`
+Expected: the root suite passes exactly as before. Its `include` matches only `.ts`, so this package's `.tsx` tests are not picked up there — intentional, since root provisions Postgres. The package's tests run via its own `test` script.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add packages/iios-messaging-ui/src/index.ts packages/iios-messaging-ui/src/boundary.test.ts scripts/check-import-boundary.mjs
+git commit -m "feat: export messaging-ui public API and enforce transport boundary"
+```
+
+---
+
+## Done criteria
+
+- `pnpm --filter @insignia/iios-messaging-ui test` — all green (33 tests as shipped).
+- `pnpm boundary` — OK.
+- `pnpm test` at root — unchanged.
+- The package builds to ESM with types and has **zero runtime dependencies**.
+- A host can already drive messaging headlessly: `MessagingProvider` + `MockAdapter` + hooks.
+
+## Post-implementation corrections (2026-07-18)
+
+Executed via subagent-driven development on branch `feat/inbox-owner-interest`. Code review during Tasks 5 and 8 caught defects in the code blocks **above** — the shipped code diverges from the plan text in these ways, and Plan 3 should treat the committed source (not the Task 8 listing above) as authoritative:
+
+- **`useMessages` typing expiry** — the plan used `forceTick((n) => n + 1)` to expire stale typing entries. That is a no-op: the `typingUserIds` memo is keyed on `[typing]`, so an unrelated state bump returns the cached array and the indicator sticks forever. Shipped code uses `setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS)` (a new `typing` reference forces the memo to recompute with a fresh `now`). `forceTick` state removed entirely.
+- **`useMessages` history race** — the plan's `.then((h) => setRaw(h))` clobbers live messages that arrive via `subscribe` while `history()` is still in flight (silent data loss on any async transport; the sync MockAdapter can't show it). Shipped code merges by id: `setRaw((live) => { const histIds = new Set(h.map(m => m.id)); const extras = live.filter(m => !histIds.has(m.id)); return extras.length ? [...h, ...extras] : h; })`. Guarded by the test "keeps a live message that arrives before history resolves" (verified to fail if the merge is reverted).
+- **`useMessages` markRead** — re-keyed from `[adapter, threadId, raw]` (fired on every array churn, incl. reactions) to a derived `lastReadableId` (newest non-pending id), so it fires only when the read frontier actually moves.
+- **`useMessages` error reset** — `setError(null)` added to the thread-switch reset block (plan left a stale error banner across thread switches).
+- **`MockAdapter`** — `history()` and `listConversations().participants` now return copies (plan leaked the live internal arrays); constructor calls `now()` once for both seed messages.
+- **`boundary.test.ts`** — carries a `// @vitest-environment node` directive on line 1. jsdom overrides the global `URL` constructor, which breaks `fileURLToPath(new URL('.', import.meta.url))`; a filesystem test must run in node.
+
+**Known limitation deferred to Plan 3 (issue I2):** `useMessages` reads `adapter.currentActorId()` per-render but nothing makes it reactive. If a real adapter's identity resolves from `null` to a value with no intervening React state change, ownership stays wrong until an incidental re-render — and an optimistic message stamped while identity is `null` renders as not-mine. The MockAdapter never has null identity, so this does not manifest in the foundation. Plan 3 must decide how the real adapter exposes identity reactively (e.g. an `onActorIdChange` subscription on the interface, or the host re-rendering the provider when auth resolves) rather than relying on incidental re-renders.
+
+## What Plan 2 adds
+
+Components (`ConversationList`, `ThreadView`, `Composer`, `MessageBubble`, `Messenger`), the SDK's own internal primitives (the CRM's `Avatar`/`Btn`/`Icon` cannot be imported — they live in `lynkeduppro-crm/src/components/dashboard/ui.tsx`), `styles.css` with the `--msg-*` token contract, and the `classNames` slot map.
+
+## What Plan 3 adds
+
+`./adapters/kernel` (wrapping `iios-kernel-client`, running the conformance suite), `CrmMessagingAdapter` in the CRM repo (data door + socket + 4s poll fallback, also running conformance), attachments end-to-end, and the CRM migration that deletes `messenger.tsx`.
+
+Both adapters verify themselves with `import { runAdapterConformance } from '@insignia/iios-messaging-ui/conformance'`. Plan 3 must add `@insignia/iios-kernel-client` to this package's `dependencies` and to the `tsup` `entry` + `external` lists — and `src/boundary.test.ts` will fail if that import reaches core rather than staying inside `src/adapters/`.
+
+**Strengthen the safety nets before writing the kernel adapter** (from the final foundation review — the current versions are adequate for the mock but too weak to catch a broken real adapter):
+
+- **Expand `runAdapterConformance`.** Today it only exercises the happy `message` path. It does NOT test the optional methods (`react`/`upload`/`isConnected`), **subscribe thread-isolation** (an adapter that leaks every thread's events to every subscriber would pass yet break the UI), or the `receipt`/`reaction`/`typing` event kinds. Since conformance IS the safety net for the real kernel adapter, add these cases first.
+- **Convert `boundary.test.ts` from a denylist to an allowlist.** It currently greps core for three transport strings (`iios-kernel-client|socket.io|@abe-kap/appshell-sdk`); a core file importing a *different* transport (`ws`, `pusher`, raw WebSocket) would slip through, and the root `pnpm boundary` allowlist only governs at package granularity. Once `adapters/kernel` exists, assert core imports only `react` + relative paths.
+- **Also address the deferred I2 (reactive identity)** noted under Post-implementation corrections above, and resolve the two Plan-2 notes: emit `dist/styles.css` so the exports entry resolves, and decide whether `react()` failures should populate `error` (today only `send()` does).
+
+## CI note for whoever wires it
+
+Root `pnpm test` does not run this package's tests. CI must also run:
+
+```bash
+pnpm --filter @insignia/iios-messaging-ui test
+```
+
+The alternative — folding `.tsx` + jsdom into the root config — would make every UI test wait on Postgres provisioning. Not worth it.
diff --git a/iios-0.0.0.tgz b/iios-0.0.0.tgz
new file mode 100644
index 0000000..970695d
Binary files /dev/null and b/iios-0.0.0.tgz differ
diff --git a/packages/iios-kernel-client/src/rest.ts b/packages/iios-kernel-client/src/rest.ts
index 5b04608..dd1234d 100644
--- a/packages/iios-kernel-client/src/rest.ts
+++ b/packages/iios-kernel-client/src/rest.ts
@@ -1,5 +1,5 @@
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
-import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types';
+import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, DiscoveredThread, SavedItem, LoginResult } from './types';
export interface RestConfig {
serviceUrl: string;
@@ -103,6 +103,27 @@ export class RestClient {
return this.post<{ threadId: string }>('/v1/threads', opts);
}
+ /**
+ * Discover threads across your scope matching an opaque metadata filter (e.g. browse public
+ * channels: `{ membership: 'channel', visibility: 'public' }`). Returns ones you have NOT joined
+ * too, each flagged `joined`.
+ */
+ async discoverThreads(filter?: { metadata?: Record }): Promise {
+ const qs = filter?.metadata
+ ? '?' + Object.entries(filter.metadata).map(([k, v]) => `metadata[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`).join('&')
+ : '';
+ const r = await fetch(this.url(`/v1/threads/discover${qs}`), { headers: this.headers() });
+ if (!r.ok) throw new Error(`discoverThreads ${r.status}`);
+ return (await r.json()) as DiscoveredThread[];
+ }
+
+ /** Self-leave a thread (e.g. leave a channel). */
+ async leaveThread(threadId: string): Promise<{ threadId: string; participantCount: number }> {
+ const r = await fetch(this.url(`/v1/threads/${threadId}/me`), { method: 'DELETE', headers: this.headers() });
+ if (!r.ok) throw new Error(`leaveThread ${r.status}`);
+ return (await r.json()) as { threadId: string; participantCount: number };
+ }
+
/** My saved messages (personal bookmarks), newest first, with thread context. */
async listSaved(): Promise {
const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() });
diff --git a/packages/iios-kernel-client/src/types.ts b/packages/iios-kernel-client/src/types.ts
index 8b32d24..d1b74dd 100644
--- a/packages/iios-kernel-client/src/types.ts
+++ b/packages/iios-kernel-client/src/types.ts
@@ -64,6 +64,15 @@ export interface MessageEvents {
annotation: (e: AnnotationEvent) => void;
}
+/** A discoverable thread from GET /v1/threads/discover — includes ones you have NOT joined. */
+export interface DiscoveredThread {
+ threadId: string;
+ subject: string | null;
+ metadata: Record | null;
+ participantCount: number;
+ joined: boolean;
+}
+
/** A "my threads" entry from GET /v1/threads (server-authoritative). */
export interface ThreadSummary {
threadId: string;
diff --git a/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz b/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz
new file mode 100644
index 0000000..1958cab
Binary files /dev/null and b/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz differ
diff --git a/packages/iios-messaging-ui/package.json b/packages/iios-messaging-ui/package.json
new file mode 100644
index 0000000..6a9d110
--- /dev/null
+++ b/packages/iios-messaging-ui/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@insignia/iios-messaging-ui",
+ "version": "0.1.0",
+ "type": "module",
+ "main": "dist/index.js",
+ "module": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./adapters/mock": {
+ "types": "./dist/adapters/mock.d.ts",
+ "import": "./dist/adapters/mock.js"
+ },
+ "./adapters/kernel-client": {
+ "types": "./dist/adapters/kernel-client.d.ts",
+ "import": "./dist/adapters/kernel-client.js"
+ },
+ "./adapters/mock-inbox": {
+ "types": "./dist/adapters/mock-inbox.d.ts",
+ "import": "./dist/adapters/mock-inbox.js"
+ },
+ "./conformance": {
+ "types": "./dist/conformance.d.ts",
+ "import": "./dist/conformance.js"
+ },
+ "./styles.css": "./dist/styles.css"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run"
+ },
+ "peerDependencies": {
+ "@insignia/iios-kernel-client": "*",
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@insignia/iios-kernel-client": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@insignia/iios-kernel-client": "workspace:*",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/react": "^16.1.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "jsdom": "^26.0.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "tsup": "^8.3.5",
+ "typescript": "^5.7.3",
+ "vitest": "^3.0.5"
+ },
+ "publishConfig": {
+ "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
+ }
+}
diff --git a/packages/iios-messaging-ui/src/adapter.ts b/packages/iios-messaging-ui/src/adapter.ts
new file mode 100644
index 0000000..3ea31f5
--- /dev/null
+++ b/packages/iios-messaging-ui/src/adapter.ts
@@ -0,0 +1,83 @@
+import type {
+ Attachment,
+ ChannelSummary,
+ Conversation,
+ CreateChannelInput,
+ Membership,
+ Message,
+ MessageEvent,
+ Person,
+ SendOpts,
+ Unsubscribe,
+} from './types';
+
+/**
+ * The one seam of this SDK. Hosts implement this; the SDK renders it.
+ *
+ * Lifted from lynkeduppro-crm's MessengerData/ThreadData, which already survived
+ * two implementations (live data-door + mock) — the minimum real evidence that a
+ * seam is genuine rather than imagined.
+ *
+ * Optional methods degrade gracefully: the UI hides the reaction picker when
+ * `react` is absent, and the attach button when `upload` is absent. That is how one
+ * component set serves both a full CRM messenger and a stripped-down widget with no
+ * `mode` prop.
+ */
+export interface MessagingAdapter {
+ listConversations(): Promise;
+
+ openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }>;
+
+ history(threadId: string): Promise;
+
+ send(threadId: string, content: string, opts?: SendOpts): Promise;
+
+ /** Returns an unsubscribe fn. Implementations MUST be idempotent on repeat unsubscribe. */
+ subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe;
+
+ sendTyping(threadId: string): void;
+
+ markRead(threadId: string, messageId: string): Promise;
+
+ /**
+ * The current user's actor id, or null if not yet known.
+ *
+ * MUST NOT be inferred from message history. The CRM's bug was exactly that:
+ * scanning for a sent message meant every message read as not-yours until you
+ * had spoken. Adapters derive this from auth/session.
+ */
+ currentActorId(): string | null;
+
+ /** Absent => the UI hides reactions entirely. */
+ react?(threadId: string, messageId: string, emoji: string): Promise;
+
+ /** Absent => the UI hides attachments. Storage/auth/limits are the host's concern. */
+ upload?(file: File): Promise;
+
+ /** Absent => treated as always connected (e.g. a pure-REST adapter). */
+ isConnected?(): boolean;
+
+ /** A thread's members (id + display name), for @mention autocomplete + highlighting.
+ * Absent => the composer offers no autocomplete (you can still type @text). */
+ listMembers?(threadId: string): Promise;
+
+ /** The people you can start a conversation with (org directory). Drives the "New message"
+ * people picker. Absent => the UI hides DM/group creation (you can still open channels). */
+ directory?(): Promise;
+
+ // ── Channels (optional capability) ──────────────────────────────
+ // A channel is just a third membership beyond dm/group: a discoverable, joinable room.
+ // Implement all four to enable the channels UI; absent => the UI hides channels entirely.
+
+ /** Discoverable channels in the caller's scope, each flagged `joined`. */
+ browseChannels?(): Promise;
+
+ /** Create a channel; the creator joins as admin. Returns the new thread id. */
+ createChannel?(input: CreateChannelInput): Promise<{ threadId: string }>;
+
+ /** Join a (public) channel by id. */
+ joinChannel?(threadId: string): Promise;
+
+ /** Leave a channel by id. */
+ leaveChannel?(threadId: string): Promise;
+}
diff --git a/packages/iios-messaging-ui/src/adapters/kernel-client.test.ts b/packages/iios-messaging-ui/src/adapters/kernel-client.test.ts
new file mode 100644
index 0000000..0785ee5
--- /dev/null
+++ b/packages/iios-messaging-ui/src/adapters/kernel-client.test.ts
@@ -0,0 +1,153 @@
+// Runs the shared adapter conformance suite against KernelClientAdapter — proving it satisfies
+// the same contract as the mock, over the REAL MessageSocket facade. Only the lowest socket.io
+// layer is faked (via kernel-client's own SocketLike seam), so the facade's wire mapping is
+// exercised for real. No live IIOS required.
+
+import { describe, it, expect } from 'vitest';
+import { MessageSocket } from '@insignia/iios-kernel-client';
+import type { Message as KernelMessage, SocketLike } from '@insignia/iios-kernel-client';
+import { runAdapterConformance } from '../conformance';
+import { KernelClientAdapter, type RestPort } from './kernel-client';
+
+const ME = 'me';
+const SEEDED = 'th_seed';
+
+/** An in-memory stand-in for the /message socket.io namespace: stores messages, echoes 'message'. */
+function makeFakeSocket(): SocketLike {
+ const threads = new Map([
+ [
+ SEEDED,
+ [
+ {
+ id: 'seed_1',
+ threadId: SEEDED,
+ senderActorId: 'actor_other',
+ senderId: 'pp_other',
+ senderName: 'Other',
+ content: 'seeded message',
+ createdAt: new Date(0).toISOString(),
+ },
+ ],
+ ],
+ ]);
+ const handlers = new Map void>>();
+ let seq = 1;
+
+ const fire = (event: string, payload: unknown): void => handlers.get(event)?.forEach((h) => h(payload));
+
+ return {
+ on(event, handler) {
+ if (!handlers.has(event)) handlers.set(event, new Set());
+ handlers.get(event)!.add(handler);
+ return undefined;
+ },
+ off(event, handler) {
+ handlers.get(event)?.delete(handler);
+ return undefined;
+ },
+ emit() {
+ return undefined; // typing/focus — no echo needed for conformance
+ },
+ async emitWithAck(event, payload) {
+ const p = (payload ?? {}) as {
+ threadId: string;
+ content?: string;
+ parentInteractionId?: string;
+ interactionId?: string;
+ type?: string;
+ value?: string;
+ };
+ if (event === 'open_thread') {
+ if (!threads.has(p.threadId)) threads.set(p.threadId, []);
+ return { threadId: p.threadId, status: 'OPEN', history: [...threads.get(p.threadId)!] };
+ }
+ if (event === 'send_message') {
+ const msg: KernelMessage = {
+ id: `m_${seq++}`,
+ threadId: p.threadId,
+ senderActorId: `actor_${ME}`,
+ senderId: ME,
+ senderName: ME,
+ content: p.content ?? '',
+ createdAt: new Date().toISOString(),
+ ...(p.parentInteractionId ? { parentInteractionId: p.parentInteractionId } : {}),
+ };
+ if (!threads.has(p.threadId)) threads.set(p.threadId, []);
+ threads.get(p.threadId)!.push(msg);
+ fire('message', msg);
+ return msg;
+ }
+ if (event === 'read') return { ok: true };
+ if (event === 'annotate') {
+ fire('annotation', {
+ threadId: p.threadId,
+ interactionId: p.interactionId,
+ type: p.type,
+ value: p.value,
+ op: 'add',
+ users: [ME],
+ userId: ME,
+ });
+ return {};
+ }
+ return {};
+ },
+ connect() {
+ return undefined;
+ },
+ disconnect() {
+ return undefined;
+ },
+ connected: true,
+ };
+}
+
+function makeFakeRest(): RestPort {
+ let seq = 1;
+ return {
+ async listThreads() {
+ return [];
+ },
+ async createThread() {
+ return { threadId: `th_new_${seq++}` };
+ },
+ async addParticipant() {
+ /* governed server-side; a fake always allows */
+ },
+ async discoverThreads() {
+ return [
+ {
+ threadId: 'th_pub',
+ subject: 'general',
+ metadata: { membership: 'channel', visibility: 'public', topic: 'Company-wide' },
+ participantCount: 3,
+ joined: false,
+ },
+ ];
+ },
+ async leaveThread(threadId) {
+ return { threadId, participantCount: 0 };
+ },
+ };
+}
+
+function makeAdapter(): KernelClientAdapter {
+ const socket = new MessageSocket({ serviceUrl: 'http://iios.test', token: 'tok', autoConnect: false }, makeFakeSocket());
+ return new KernelClientAdapter({ currentUserId: ME, socket, rest: makeFakeRest() });
+}
+
+runAdapterConformance({ makeAdapter, seededThreadId: SEEDED, openWith: ['pp_a'] });
+
+describe('KernelClientAdapter channels', () => {
+ it('browse maps discovered public channels; create/join/leave delegate to the transport', async () => {
+ const adapter = makeAdapter();
+ const list = await adapter.browseChannels!();
+ expect(list[0]).toMatchObject({ threadId: 'th_pub', name: 'general', visibility: 'public', joined: false, memberCount: 3, topic: 'Company-wide' });
+
+ const { threadId } = await adapter.createChannel!({ name: 'design', topic: 'UI', visibility: 'public' });
+ expect(typeof threadId).toBe('string');
+
+ await expect(adapter.joinChannel!('th_pub')).resolves.toBeUndefined();
+ await expect(adapter.leaveChannel!('th_pub')).resolves.toBeUndefined();
+ });
+});
diff --git a/packages/iios-messaging-ui/src/adapters/kernel-client.ts b/packages/iios-messaging-ui/src/adapters/kernel-client.ts
new file mode 100644
index 0000000..178aafa
--- /dev/null
+++ b/packages/iios-messaging-ui/src/adapters/kernel-client.ts
@@ -0,0 +1,303 @@
+// Transport adapter: implements MessagingAdapter over @insignia/iios-kernel-client
+// (browser → IIOS directly, token-in). This is the "plug into any app with a token"
+// path — the same transport chat-web and support-sdk use.
+//
+// It lives in adapters/ (the ONLY layer allowed to import a transport) and ships from
+// its own subpath, so core UI never pulls socket code it can't use.
+
+import { MessageSocket, RestClient } from '@insignia/iios-kernel-client';
+import type {
+ AnnotationEvent,
+ DiscoveredThread,
+ Message as KernelMessage,
+ MessageEvents,
+ OpenThreadResult,
+ ReceiptEvent,
+ ThreadSummary,
+ TypingEvent,
+} from '@insignia/iios-kernel-client';
+import type { MessagingAdapter } from '../adapter';
+import type {
+ ChannelSummary,
+ ChannelVisibility,
+ Conversation,
+ CreateChannelInput,
+ Membership,
+ Message,
+ MessageEvent,
+ Reaction,
+ SendOpts,
+ Unsubscribe,
+} from '../types';
+
+/** The slice of MessageSocket the adapter needs — the real facade satisfies it; tests inject a fake. */
+export interface SocketPort {
+ openThread(threadId?: string, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise;
+ sendMessage(threadId: string, content: string, opts?: { parentInteractionId?: string; mentions?: string[] }): Promise;
+ react(threadId: string, interactionId: string, value: string): Promise;
+ markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }>;
+ typing(threadId: string): void;
+ on(event: E, handler: MessageEvents[E]): () => void;
+}
+
+/** The slice of RestClient the adapter needs. */
+export interface RestPort {
+ listThreads(filter?: { metadata?: Record }): Promise;
+ createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record }): Promise<{ threadId: string }>;
+ addParticipant(threadId: string, userId: string): Promise;
+ discoverThreads(filter?: { metadata?: Record }): Promise;
+ leaveThread(threadId: string): Promise<{ threadId: string; participantCount: number }>;
+}
+
+export interface KernelClientAdapterConfig {
+ /**
+ * The current user's id in IIOS's `senderId` space (email/username), from the host's auth —
+ * NEVER inferred from message history. This is exactly `currentActorId()`, and it's the bug the
+ * conformance suite kills: identity comes from the session, not from a message you happened to send.
+ */
+ currentUserId: string;
+ socket: SocketPort;
+ rest: RestPort;
+ /** Optional opaque metadata filter for the conversation list (e.g. { source: 'crm-messenger' }). */
+ threadFilter?: Record;
+}
+
+const REACTION = 'reaction';
+
+export class KernelClientAdapter implements MessagingAdapter {
+ private readonly me: string;
+ private readonly socket: SocketPort;
+ private readonly rest: RestPort;
+ private readonly threadFilter?: Record;
+
+ /** Per-thread UI subscribers. The socket fans server events in; these fan them out. */
+ private readonly listeners = new Map void>>();
+ /** Reaction users per message (messageId → emoji → userSet), so an annotation delta becomes a full set. */
+ private readonly reactions = new Map>>();
+ private readonly joined = new Set();
+ private readonly offs: Array<() => void> = [];
+
+ constructor(cfg: KernelClientAdapterConfig) {
+ this.me = cfg.currentUserId;
+ this.socket = cfg.socket;
+ this.rest = cfg.rest;
+ this.threadFilter = cfg.threadFilter;
+
+ this.offs.push(
+ this.socket.on('message', (m: KernelMessage) => {
+ this.ingestReactions(m);
+ this.emit(m.threadId, { kind: 'message', message: this.toMessage(m) });
+ }),
+ );
+ this.offs.push(
+ this.socket.on('typing', (e: TypingEvent) => this.emit(e.threadId, { kind: 'typing', userId: e.userId })),
+ );
+ this.offs.push(
+ // Receipts carry no threadId, so fan to every open thread; the UI filters by messageId.
+ this.socket.on('receipt', (e: ReceiptEvent) => this.broadcast({ kind: 'receipt', messageId: e.interactionId, actorId: e.actorId })),
+ );
+ this.offs.push(
+ this.socket.on('annotation', (e: AnnotationEvent) => {
+ if (e.type !== REACTION) return;
+ this.setReactionUsers(e.interactionId, e.value, e.users);
+ this.emit(e.threadId, { kind: 'reaction', messageId: e.interactionId, reactions: this.reactionsOf(e.interactionId) });
+ }),
+ );
+ }
+
+ currentActorId(): string {
+ return this.me;
+ }
+
+ async listConversations(): Promise {
+ const threads = await this.rest.listThreads(this.threadFilter ? { metadata: this.threadFilter } : undefined);
+ return threads.map((t) => this.toConversation(t));
+ }
+
+ async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
+ const membership: Membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
+ const { threadId } = await this.rest.createThread({
+ membership,
+ creatorRole: membership === 'group' ? 'ADMIN' : 'MEMBER',
+ ...(p.subject ? { subject: p.subject } : {}),
+ });
+ // Governance (DM cap, roles) is enforced server-side by IIOS/OPA; a rejected add surfaces up there.
+ for (const id of p.participantIds) await this.rest.addParticipant(threadId, id).catch(() => undefined);
+ return { threadId };
+ }
+
+ async history(threadId: string): Promise {
+ const res = await this.socket.openThread(threadId); // joins the thread, so live events start flowing
+ this.joined.add(threadId);
+ return res.history.map((m) => {
+ this.ingestReactions(m);
+ return this.toMessage(m);
+ });
+ }
+
+ async send(threadId: string, content: string, opts?: SendOpts): Promise {
+ // Attachments are intentionally not forwarded here: kernel-client exposes no media/presign yet,
+ // and the SDK Attachment carries a display `url`, not a storage `contentRef`. Media is a follow-up
+ // (kernel-client media methods + a contentRef on Attachment). Text + reply threading work today.
+ const sendOpts = {
+ ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
+ ...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}),
+ };
+ const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
+ return this.toMessage(m);
+ }
+
+ async react(threadId: string, messageId: string, emoji: string): Promise {
+ await this.socket.react(threadId, messageId, emoji);
+ }
+
+ subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
+ if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
+ this.listeners.get(threadId)!.add(cb);
+ if (!this.joined.has(threadId)) {
+ this.joined.add(threadId);
+ void this.socket.openThread(threadId).catch(() => this.joined.delete(threadId));
+ }
+ return () => {
+ this.listeners.get(threadId)?.delete(cb);
+ };
+ }
+
+ sendTyping(threadId: string): void {
+ this.socket.typing(threadId);
+ }
+
+ async markRead(threadId: string, messageId: string): Promise {
+ await this.socket.markRead(threadId, messageId);
+ }
+
+ // ── Channels ────────────────────────────────────────────────────
+ async browseChannels(): Promise {
+ const found = await this.rest.discoverThreads({ metadata: { membership: 'channel', visibility: 'public' } });
+ return found.map((d) => {
+ const bag = (d.metadata as { topic?: string; visibility?: string } | null) ?? {};
+ const visibility: ChannelVisibility = bag.visibility === 'private' ? 'private' : 'public';
+ return {
+ threadId: d.threadId,
+ name: d.subject ?? 'channel',
+ topic: bag.topic ?? null,
+ visibility,
+ memberCount: d.participantCount,
+ joined: d.joined,
+ };
+ });
+ }
+
+ async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> {
+ return this.rest.createThread({
+ membership: 'channel',
+ creatorRole: 'ADMIN',
+ subject: input.name,
+ metadata: { visibility: input.visibility, ...(input.topic ? { topic: input.topic } : {}) },
+ });
+ }
+
+ async joinChannel(threadId: string): Promise {
+ // Self-join is a governed open_thread; OPA allows it for a public channel.
+ await this.socket.openThread(threadId);
+ this.joined.add(threadId);
+ }
+
+ async leaveChannel(threadId: string): Promise {
+ await this.rest.leaveThread(threadId);
+ this.joined.delete(threadId);
+ }
+
+ /** Detach socket handlers. Not part of the contract — call on teardown to avoid leaks. */
+ close(): void {
+ for (const off of this.offs) off();
+ this.offs.length = 0;
+ this.listeners.clear();
+ }
+
+ // ── mapping ────────────────────────────────────────────────────
+ private toMessage(m: KernelMessage): Message {
+ return {
+ id: m.id,
+ // `senderId` (email/username), NOT the actor id — it matches currentActorId() and is the
+ // reliable "is this mine?" field. Never inferred from history.
+ actorId: m.senderId ?? null,
+ text: m.content ?? '',
+ at: m.createdAt,
+ parentInteractionId: m.parentInteractionId ?? null,
+ reactions: this.reactionsOf(m.id),
+ };
+ }
+
+ private toConversation(t: ThreadSummary): Conversation {
+ const others = t.participants.filter((p) => p !== this.me);
+ const membership: Membership | null =
+ t.membership === 'dm' || t.membership === 'group' || t.membership === 'channel' ? t.membership : null;
+ const topic = (t.metadata as { topic?: string } | null)?.topic;
+ return {
+ threadId: t.threadId,
+ title: t.subject?.trim() || others.join(', ') || 'Conversation',
+ subject: t.subject,
+ membership,
+ participants: [...t.participants],
+ unread: t.unread,
+ ...(topic != null ? { topic } : {}),
+ ...(t.lastMessage ? { lastMessage: t.lastMessage } : {}),
+ ...(t.lastAt ? { lastAt: t.lastAt } : {}),
+ };
+ }
+
+ // ── reaction state ─────────────────────────────────────────────
+ private ingestReactions(m: KernelMessage): void {
+ for (const a of m.annotations ?? []) {
+ if (a.type === REACTION) this.setReactionUsers(m.id, a.value, a.users);
+ }
+ }
+
+ private setReactionUsers(messageId: string, emoji: string, users: string[]): void {
+ let byEmoji = this.reactions.get(messageId);
+ if (!byEmoji) {
+ byEmoji = new Map();
+ this.reactions.set(messageId, byEmoji);
+ }
+ if (users.length === 0) byEmoji.delete(emoji);
+ else byEmoji.set(emoji, new Set(users));
+ }
+
+ private reactionsOf(messageId: string): Reaction[] {
+ const byEmoji = this.reactions.get(messageId);
+ if (!byEmoji) return [];
+ const out: Reaction[] = [];
+ for (const [emoji, users] of byEmoji) {
+ if (users.size > 0) out.push({ emoji, count: users.size, mine: users.has(this.me) });
+ }
+ return out;
+ }
+
+ // ── event fan-out ──────────────────────────────────────────────
+ private emit(threadId: string, e: MessageEvent): void {
+ this.listeners.get(threadId)?.forEach((cb) => cb(e));
+ }
+
+ private broadcast(e: MessageEvent): void {
+ for (const set of this.listeners.values()) set.forEach((cb) => cb(e));
+ }
+}
+
+/** Convenience: build an adapter that talks straight to IIOS with a token. */
+export function connectKernelAdapter(opts: {
+ serviceUrl: string;
+ token: string;
+ currentUserId: string;
+ threadFilter?: Record;
+ autoConnect?: boolean;
+}): KernelClientAdapter {
+ const rest = new RestClient({ serviceUrl: opts.serviceUrl, token: opts.token });
+ const socket = new MessageSocket({ serviceUrl: opts.serviceUrl, token: opts.token, autoConnect: opts.autoConnect ?? true });
+ return new KernelClientAdapter({
+ currentUserId: opts.currentUserId,
+ socket,
+ rest,
+ ...(opts.threadFilter ? { threadFilter: opts.threadFilter } : {}),
+ });
+}
diff --git a/packages/iios-messaging-ui/src/adapters/mock-inbox.ts b/packages/iios-messaging-ui/src/adapters/mock-inbox.ts
new file mode 100644
index 0000000..c2603e4
--- /dev/null
+++ b/packages/iios-messaging-ui/src/adapters/mock-inbox.ts
@@ -0,0 +1,109 @@
+import type { InboxAdapter } from '../inbox/adapter';
+import type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from '../inbox/types';
+
+const PEOPLE: MailPerson[] = [
+ { id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
+ { id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
+ { id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
+];
+
+interface MockMail {
+ subject: string;
+ messages: MailMessage[];
+}
+
+/**
+ * In-memory inbox for demos/tests. State is PER INSTANCE. Seeds a few work items (mention,
+ * needs-reply, alert) plus mail threads that fold into the Open view as MAIL rows.
+ */
+export class MockInboxAdapter implements InboxAdapter {
+ private seq = 100;
+ private readonly items: InboxItem[];
+ private readonly threads = new Map();
+ /** threadIds that surface as standalone MAIL rows (in addition to work items). */
+ private readonly mailFold = ['mt_welcome', 'mt_invoice'];
+
+ constructor(private readonly now: () => string = () => new Date().toISOString()) {
+ const at = this.now();
+ this.items = [
+ { id: 'in_1', kind: 'MENTION', state: 'OPEN', title: 'Sofia mentioned you', summary: '@you — can you confirm the Henderson scope?', priority: 'HIGH', threadId: 'mt_mention', createdAt: at },
+ { id: 'in_2', kind: 'NEEDS_REPLY', state: 'OPEN', title: 'Reply needed — Storm response', summary: 'Dan: crew rolling out at 7', priority: 'MEDIUM', threadId: 'mt_storm', createdAt: at },
+ { id: 'in_3', kind: 'SYSTEM_ALERT', state: 'OPEN', title: 'Ticket TK-204 updated', summary: 'Customer replied on the roof-leak case.', priority: 'LOW', createdAt: at },
+ ];
+ this.threads.set('mt_mention', {
+ subject: 'Henderson scope',
+ messages: [{ id: 'm1', actorId: 'pp_sofia', kind: 'EMAIL', at, html: '
Can you confirm the Henderson scope by EOD?
', text: 'Can you confirm the Henderson scope by EOD?', attachment: null }],
+ });
+ this.threads.set('mt_storm', {
+ subject: 'Storm response — East side',
+ messages: [{ id: 'm2', actorId: 'pp_dan', kind: 'EMAIL', at, html: '
Crew is rolling out at 7. Confirm the Henderson job?
', text: 'Crew rolling out at 7. Confirm the Henderson job?', attachment: null }],
+ });
+ this.threads.set('mt_welcome', {
+ subject: 'Welcome to the Founders Club',
+ messages: [{ id: 'm3', actorId: 'system', kind: 'EMAIL', at, html: '
Thanks for joining the Founders Club. Set up your account to get started.
+ );
+}
diff --git a/packages/iios-messaging-ui/src/components/modal-portal.tsx b/packages/iios-messaging-ui/src/components/modal-portal.tsx
new file mode 100644
index 0000000..9e79dbb
--- /dev/null
+++ b/packages/iios-messaging-ui/src/components/modal-portal.tsx
@@ -0,0 +1,35 @@
+import { useState, type ReactNode, type CSSProperties } from 'react';
+import { createPortal } from 'react-dom';
+
+// The SDK theme tokens. A body-portaled node is outside the `.miu-messenger`/`.miu-inbox`
+// subtree that defines these, so we copy their resolved values onto the portal root.
+const THEME_VARS = [
+ '--miu-bg', '--miu-panel', '--miu-panel-2', '--miu-border',
+ '--miu-text', '--miu-muted', '--miu-accent', '--miu-accent-text', '--miu-radius',
+] as const;
+
+function copyThemeVars(): Record {
+ if (typeof document === 'undefined') return {};
+ const src = document.querySelector('.miu-messenger, .miu-inbox');
+ if (!src) return {};
+ const cs = getComputedStyle(src);
+ const out: Record = {};
+ for (const v of THEME_VARS) {
+ const val = cs.getPropertyValue(v).trim();
+ if (val) out[v] = val;
+ }
+ return out;
+}
+
+/**
+ * Render an overlay into document.body so it escapes the host's stacking context and overflow.
+ * A host wrapper like `.view { position: relative; z-index: 1 }` traps a `position: fixed` overlay
+ * BELOW a sibling sticky header no matter how high its z-index — the only robust fix is to leave
+ * that stacking context entirely. Theme tokens are copied from the live surface (captured once on
+ * mount, synchronously, so there is no unstyled first paint) and re-applied on the portal root.
+ */
+export function ModalPortal({ children }: { children: ReactNode }) {
+ const [vars] = useState>(copyThemeVars);
+ if (typeof document === 'undefined') return null;
+ return createPortal(
{children}
, document.body);
+}
diff --git a/packages/iios-messaging-ui/src/components/new-conversation.test.tsx b/packages/iios-messaging-ui/src/components/new-conversation.test.tsx
new file mode 100644
index 0000000..ab26ccd
--- /dev/null
+++ b/packages/iios-messaging-ui/src/components/new-conversation.test.tsx
@@ -0,0 +1,66 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { MessagingProvider } from '../provider';
+import { MockAdapter } from '../adapters/mock';
+import { Messenger } from './messenger';
+
+function mount(adapter = new MockAdapter()) {
+ render(
+
+
+ ,
+ );
+ return adapter;
+}
+
+describe('MockAdapter directory + openThread', () => {
+ it('directory lists people you can message', async () => {
+ const a = new MockAdapter();
+ const people = await a.directory();
+ expect(people.some((p) => p.name === 'Dan Whitaker')).toBe(true);
+ });
+
+ it('one participant opens a dm; two+ open a group with a subject', async () => {
+ const a = new MockAdapter();
+ const dm = await a.openThread({ participantIds: ['pp_dan'], membership: 'dm' });
+ const list = await a.listConversations();
+ expect(list.find((c) => c.threadId === dm.threadId)?.membership).toBe('dm');
+
+ const group = await a.openThread({ participantIds: ['pp_dan', 'pp_priya'], membership: 'group', subject: 'Roof crew' });
+ const g = (await a.listConversations()).find((c) => c.threadId === group.threadId);
+ expect(g?.membership).toBe('group');
+ expect(g?.title).toBe('Roof crew');
+ });
+
+ it('opening a dm with the same person reuses the existing thread', async () => {
+ const a = new MockAdapter();
+ const first = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
+ const second = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
+ expect(second.threadId).toBe(first.threadId);
+ });
+});
+
+describe(' new conversation flow', () => {
+ it('opens the picker from the Direct messages +, and starting a chat leaves the picker', async () => {
+ mount();
+ // Open the "New message" picker.
+ fireEvent.click(await screen.findByTitle('New message'));
+ expect(await screen.findByText('New message')).toBeTruthy();
+
+ // Pick a person and start a DM.
+ fireEvent.click(await screen.findByText('Dan Whitaker'));
+ fireEvent.click(screen.getByText('Start chat'));
+
+ // The picker closes (we're back in a thread view — the picker heading is gone).
+ await waitFor(() => expect(screen.queryByText('Start chat')).toBeNull());
+ });
+
+ it('selecting two people switches the action to group create', async () => {
+ mount();
+ fireEvent.click(await screen.findByTitle('New message'));
+ fireEvent.click(await screen.findByText('Dan Whitaker'));
+ fireEvent.click(await screen.findByText('Priya Nair'));
+ expect(screen.getByText(/Create group \(2\)/)).toBeTruthy();
+ expect(screen.getByLabelText('Group name')).toBeTruthy();
+ });
+});
diff --git a/packages/iios-messaging-ui/src/components/new-conversation.tsx b/packages/iios-messaging-ui/src/components/new-conversation.tsx
new file mode 100644
index 0000000..2436c8a
--- /dev/null
+++ b/packages/iios-messaging-ui/src/components/new-conversation.tsx
@@ -0,0 +1,97 @@
+import { useEffect, useState } from 'react';
+import { useAdapter } from '../provider';
+import type { Person } from '../types';
+
+/**
+ * Start a direct message or a group — Slack-style. Pick people from the org directory: one selected
+ * opens a DM (deduped by the adapter), two or more create a group with an optional name. Shown in
+ * the main pane like the channel browser.
+ */
+export function NewConversation({ onCreated }: { onCreated?: (threadId: string) => void }) {
+ const adapter = useAdapter();
+ const [people, setPeople] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [q, setQ] = useState('');
+ const [selected, setSelected] = useState([]);
+ const [name, setName] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ useEffect(() => {
+ if (!adapter.directory) {
+ setLoading(false);
+ return;
+ }
+ let alive = true;
+ adapter
+ .directory()
+ .then((p) => {
+ if (alive) {
+ setPeople(p);
+ setError(null);
+ }
+ })
+ .catch((e: unknown) => {
+ if (alive) setError(e instanceof Error ? e.message : String(e));
+ })
+ .finally(() => {
+ if (alive) setLoading(false);
+ });
+ return () => {
+ alive = false;
+ };
+ }, [adapter]);
+
+ const filtered = people.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
+ const isGroup = selected.length > 1;
+ const canStart = selected.length >= 1 && !busy;
+
+ function toggle(id: string): void {
+ setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
+ }
+
+ async function start(): Promise {
+ if (!canStart) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const res = await adapter.openThread({
+ participantIds: selected,
+ membership: isGroup ? 'group' : 'dm',
+ ...(isGroup && name.trim() ? { subject: name.trim() } : {}),
+ });
+ onCreated?.(res.threadId);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+