# Iblai Vibe Agent Chat

> Add the in-process Chat SDK component (full agent surface — message stream, canvas, file attach, voice, prompts) to a Next.js app

- Skill: `iblai/iblai-vibe-agent-chat` (Agent Skill, multi-file: 8 files)
- Install (CLI): `npx skillmds@latest add iblai/iblai-vibe-agent-chat`
- Raw SKILL.md: https://api.skillmd.com/api/skills/iblai/iblai-vibe-agent-chat/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: iblai (https://skillmd.com/u/iblai)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/iblai/iblai-vibe-agent-chat

---


# /iblai-vibe-agent-chat

> **First time here?** If `iblai.env` has no `ARCHITECTURE=`, run `/iblai-vibe-start` first (four questions; two minutes) — it decides single-org / multi-org / headless and who signs in, and every skill reads the answer.

Add the full ibl.ai agent chat surface — message stream, conversation
starters, canvas, file attach, voice input, voice call, screen sharing,
prompt gallery — to your Next.js app. Uses the `Chat` React component
from `@iblai/iblai-js/web-containers/next`, which renders **in-process**
(not in an iframe) and shares the host app's Redux store, providers, and
auth session.

![Welcome state](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/skills/agents/iblai-vibe-agent-chat/iblai-vibe-agent-chat-1-welcome.png)
![Message sent](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/skills/agents/iblai-vibe-agent-chat/iblai-vibe-agent-chat-2-message-sent.png)

> **Template (legacy `<mentor-ai>` widget):** a full-screen `<mentor-ai>`
> web-component ChatWidget (distinct from the in-process `Chat` component
> this skill documents) — bundled as
> [`assets/chat-widget.tsx.j2`](assets/chat-widget.tsx.j2) +
> [`assets/iblai-web-mentor.d.ts`](assets/iblai-web-mentor.d.ts). See
> [`/iblai-vibe-scaffold`](../../start/iblai-vibe-scaffold/SKILL.md) for the `{{ }}` contract.

> **What you get:** the SDK's `Chat` component wired directly into your
> app (in-process, not an iframe). Full feature surface, intercept
> actions, shared auth/store with the rest of your app, themeable with
> your Tailwind config.

Do NOT add custom styles, colors, or CSS overrides to the SDK `Chat`
component. It ships with its own styling. Keep the component as-is.
Do NOT implement dark mode unless the user explicitly asks for it.

> **This is a revised skill.** It folds in everything learned from a
> real brownfield integration (Next.js App Router, React 19, RTK, pnpm,
> sub-path `basePath`). Greenfield apps can mostly follow the happy
> path; brownfield apps must read the **Brownfield** and **Known
> issues** notes — most of the integration cost lives there, not in the
> happy path.

> **Common setup (brand, conventions, env files, verification):** see
> [docs/skill-setup.md](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/docs/skill-setup.md).

## Prerequisites

- Auth must be set up first (`/iblai-vibe-auth`) — the `Chat` component reads
  the axd token, username, and organizations from the providers tree.
- A working store, providers, and SSO callback route — i.e. an app that
  already passes `/iblai-vibe-auth` verification.
- An agent ID (a UUID) — the last path segment of an
  `https://os.ibl.ai/platform/<tenant>/<agent-uuid>` URL the user gave, or
  create an agent on [os.ibl.ai](https://os.ibl.ai) (or with
  `/iblai-api-agent-create`).
- **Minimum SDK versions.** The `Chat` component and its required hooks
  are only present in recent SDKs. Older-but-recent installs silently
  lack them. Require at least:

  | package | min version |
  |---|---|
  | `@iblai/iblai-js` | `^2.9.8` |
  | `@iblai/agent-ai` | `^2.9.3` |

  (v2 bundles `web-containers`, `web-utils`, and `data-layer` as
  `@iblai/iblai-js/*` subpaths — do not install the separate v1 packages.)

- **Pre-flight export check (do this before writing any code):**

  ```bash
  grep -qE "declare function Chat\b|type ChatConfig" \
    node_modules/@iblai/web-containers/dist/next/index.d.ts \
    && echo "Chat present" \
    || echo "MISSING — upgrade the SDK packages (see Step 3)"
  ```

  `@iblai/iblai-js/web-containers/next` re-exports `@iblai/web-containers/next`;
  the component physically lives in `@iblai/web-containers`. If the
  check fails, the rest of this skill cannot work — upgrade first.

> **Caveat: the SDK `.d.ts` is incomplete and sometimes misleading.**
> Several runtime props/args (e.g. `getChatHistory`'s `userId` path
> param) are absent from the type defs, and some documented slot props
> are typed but not implemented. When something doesn't match, verify
> against `node_modules/@iblai/web-containers/dist/next/index.esm.js`
> (the actual bundle), not just the types.

## What Gets Wired

| File | Change |
|------|--------|
| `package.json` | Adds SDK packages + peers (Step 3) |
| `providers/index.tsx` | Wraps tree in `<ServiceWorkerProvider>`; `skip={isSsoLoginRoute}` on `AuthProvider` / `TenantProvider` (Step 4) |
| `public/sw.js` | The SDK offline service worker — required by `ServiceWorkerProvider` (Step 4) |
| `next.config.*` | `Service-Worker-Allowed` header for sub-path mounts; `reactStrictMode:false` (Step 4, Known issues) |
| `store/index.ts` | Registers `chat`, `chatInput`, `chatSliceShared`, `files`, `rbac`, `subscription`, `topBanner` reducers (Step 5) |
| `app/agents/[mentorId]/chat-new/page.tsx` | New route rendering `<Chat>` with resume/new-chat handling (Step 6) |
| `components/radix-pointer-events-guard.tsx` | Host recovery for the SDK prompt-gallery teardown bug (Known issues) |

## Step 1: Check Environment

The API URLs default to hosted `iblai.app` in `lib/iblai/config.ts`, so
`.env.local` only needs:

```bash
NEXT_PUBLIC_MAIN_TENANT_KEY=your-platform
NEXT_PUBLIC_DEFAULT_AGENT_ID=<agent uuid>
```

Note: some host `config` helpers don't expose `supportEmail()`. If
yours doesn't, read `process.env.NEXT_PUBLIC_SUPPORT_EMAIL` directly in
`ChatConfig` (Step 6) — don't assume the helper has every field.

## Step 2: Get the Agent ID

If the user already gave an os.ibl.ai URL
(`https://os.ibl.ai/platform/<tenant>/<agent-uuid>`), the UUID is its last
path segment — use it, don’t ask. Otherwise ask the user for their
agent UUID. Write it directly to `.env.local` using the Edit tool —
do NOT echo it back in shell commands:

```
NEXT_PUBLIC_DEFAULT_AGENT_ID=<the-uuid>
```

If the user doesn't have one, direct them to create an agent on
https://os.ibl.ai (or run `/iblai-api-agent-create`).

## Step 3: Install Dependencies

```bash
pnpm add @iblai/data-layer @iblai/web-containers @iblai/web-utils \
         @iblai/agent-ai \
         react-paginate livekit-client \
         @livekit/components-react @livekit/components-styles \
         @tauri-apps/plugin-os @tauri-apps/api
```

Why each one:

| Package | Used by |
|---------|---------|
| `@iblai/data-layer`, `@iblai/web-containers`, `@iblai/web-utils` | Hoist SDK packages to top-level `node_modules` so the bundler resolves them from the host |
| `@iblai/agent-ai` | Voice/agent web component — **dynamically imported by `<Chat>`** (`import('@iblai/agent-ai')`). Easy to miss; its absence is a hard build error |
| `react-paginate` | Peer of `@iblai/web-containers` (agent search pagination) |
| `livekit-client`, `@livekit/components-react`, `@livekit/components-styles` | Voice call / screen-sharing |
| `@tauri-apps/plugin-os`, `@tauri-apps/api` | OS detection (Tauri desktop wrapper) |

> **All of these are build-time-required, even the runtime-guarded
> ones.** `<Chat>` statically/dynamically imports them; a missing
> package is a hard `Module not found` from Turbopack/Webpack, **not** a
> soft runtime fallback. Don't defer any of them "until needed".

If on a fresh upgrade you hit `Module not found: Can't resolve
'@iblai/...'` for a name you *did* install, the export may have moved
between subpaths — e.g. `AgentSearchResult` moved from
`@iblai/iblai-js/web-containers` to `@iblai/iblai-js/web-containers/next`.
Expect to fix a few import paths after an SDK bump; the
`/web-containers` vs `/web-containers/next` split is real (the chat
component lives under `/next`).

## Step 4: Wire the Providers + Service Worker

The `Chat` component uses `useAuthContext`, `useTenantContext`,
`useEmbedMode`, `useServiceWorker`, and several Redux hooks. The host
needs all of these.

### 4a. Providers

In `providers/index.tsx`:

1. Import `ServiceWorkerProvider` from `@iblai/iblai-js/web-utils`.
2. Wrap the existing `<AuthProvider>` tree in `<ServiceWorkerProvider>`.
3. Pass `skip={isSsoLoginRoute}` to **both** `<AuthProvider>` and
   `<TenantProvider>` so the `/sso-login-complete` callback page doesn't
   crash on missing context.
4. **Pass `basePath` if the app is mounted under a sub-path** (e.g.
   `NEXT_PUBLIC_BASE_PATH=/hq`):

```tsx
import {
  AuthProvider,
  TenantProvider,
  MentorProvider,
  ServiceWorkerProvider,
} from "@iblai/iblai-js/web-utils";

return (
  <ServiceWorkerProvider basePath={config.basePath() /* "" or "/hq" */}>
    <AuthProvider /* …, */ skip={isSsoLoginRoute}>
      <TenantProvider /* …, */ skip={isSsoLoginRoute}>
        <MentorProvider {...mentorProps}>{children}</MentorProvider>
      </TenantProvider>
    </AuthProvider>
  </ServiceWorkerProvider>
);
```

### 4b. Ship the service worker (required)

`ServiceWorkerProvider` calls
`navigator.serviceWorker.register(`${basePath}/sw.js`, { scope: basePath || "/" })`.
**The host must provide `public/sw.js`** — the SDK does not ship a
copyable one. Without it, registration 404s (non-fatal but noisy, and
offline is disabled). Use the SDK's standard offline worker (generic;
cache-in-background, serve-from-cache only when Tauri+offline). A known
working `sw.js` is bundled with this skill — copy it to `public/sw.js`.

### 4c. `Service-Worker-Allowed` header (sub-path mounts only)

If the app is under a `basePath`, the script lives at `/hq/sw.js`,
whose default max scope is `/hq/`, but the provider registers with
`scope: "/hq"` → `SecurityError: scope ('/hq') is not under the max
scope allowed ('/hq/')`. Fix in `next.config`:

```js
async headers() {
  return [
    {
      source: '/sw.js', // Next prefixes basePath → matches /hq/sw.js
      headers: [{ key: 'Service-Worker-Allowed', value: basePath || '/' }],
    },
  ]
}
```

(No-op safe when there's no basePath: header value `/`.)

## Step 5: Register Store Reducers

In `store/index.ts`, register these reducers. **The SDK's selectors
hard-code these exact keys** (`state.chat`, `state.rbac`, …) — they are
non-negotiable:

```ts
import {
  coreApiSlice, mentorReducer, mentorMiddleware,
} from "@iblai/iblai-js/data-layer";
import {
  hostChatReducer,
  chatInputSliceReducer,
  chatSliceReducerShared,
  filesReducer,
  rbacReducer,
  subscriptionReducer,
  topBannerReducer,
} from "@iblai/iblai-js/web-utils";

reducer: {
  chat: hostChatReducer,
  chatInput: chatInputSliceReducer,
  chatSliceShared: chatSliceReducerShared, // used by useAdvancedChat; NOT in mentorReducer
  files: filesReducer,
  rbac: rbacReducer,
  subscription: subscriptionReducer,
  topBanner: topBannerReducer,
  [coreApiSlice.reducerPath]: coreApiSlice.reducer,
  ...mentorReducer,
}
```

> **Brownfield: store-key collisions.** A mature app very likely already
> has its own `chat` / `files` (or `rbac`/`subscription`/…) slices with
> different shapes. The SDK's keys are fixed, so **rename the host's
> slices**, not the SDK's. Find collisions first:
>
> ```bash
> grep -rn "state\.\(chat\|files\|rbac\|subscription\|topBanner\|chatInput\)\b" \
>   --include="*.ts" --include="*.tsx" app components lib hooks
> ```
>
> Rename each host slice (e.g. `chat → v0Chat`) and migrate **every
> selector** that reads it (slice `name` and action creators are
> key-agnostic — only `useSelector(s => s.<key>)` accesses change).
> `mentorReducer` is RTK Query API slices only; it does **not** shadow
> plain feature slices like `chat`/`files`.

The slice **keys matter** — don't rename them. `mentorMiddleware` is
still concatenated as before.

## Step 6: Create the Route

Create `app/agents/[mentorId]/chat-new/page.tsx`. This version handles
session resume, new-chat, the `useSearchParams` Suspense requirement,
and the mounting discipline (see notes after the code):

```tsx
"use client";

export const dynamic = "force-dynamic";

import { Suspense, useEffect, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { Chat, type ChatConfig } from "@iblai/iblai-js/web-containers/next";
import {
  useUsername, useAxdToken, useUserTenants, useVisitingTenant,
  useIsAdmin, useCachedSessionId,
} from "@iblai/iblai-js/web-utils";
import { redirectToAuthSpa } from "@/lib/iblai/auth-utils";
import config from "@/lib/iblai/config";

export default function AgentChatPageWrapper() {
  // `useSearchParams()` requires a Suspense boundary in the App Router
  // (the SDK <Chat> tree uses it too).
  return (
    <Suspense fallback={null}>
      <AgentChatPage />
    </Suspense>
  );
}

function AgentChatPage() {
  const { mentorId } = useParams<{ mentorId: string }>();
  const router = useRouter();
  const searchParams = useSearchParams();

  // Resume (`?session=<id>`) or new chat (`?new=<nonce>`). The SDK reads
  // its per-mentor cached-session map (`useCachedSessionId`,
  // localStorage `{ [mentorId]: sessionId }`) ONCE at <Chat> mount
  // (`isNewSession = cachedSessionId[mentorId] ? false : true`). So seed
  // (resume) or clear (new) BEFORE mount, gate the render until done,
  // and key <Chat> on session/new so switching remounts it.
  const restoreSessionId = searchParams.get("session") ?? undefined;
  const newParam = searchParams.get("new") ?? undefined;
  const [cachedSessionId, saveCachedSessionId] = useCachedSessionId();
  const [seededFor, setSeededFor] = useState<string | undefined>(
    restoreSessionId || newParam ? undefined : "none",
  );
  useEffect(() => {
    if (!mentorId) { setSeededFor("none"); return; }
    const map = { ...((cachedSessionId ?? {}) as Record<string, string>) };
    if (restoreSessionId) {
      if (map[mentorId] !== restoreSessionId) {
        saveCachedSessionId({ ...map, [mentorId]: restoreSessionId });
      }
      setSeededFor(restoreSessionId);
    } else if (newParam) {
      if (map[mentorId]) { delete map[mentorId]; saveCachedSessionId(map); }
      setSeededFor(`new:${newParam}`);
    } else {
      setSeededFor("none");
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [restoreSessionId, newParam, mentorId]);
  const sessionReady = restoreSessionId
    ? seededFor === restoreSessionId
    : newParam
      ? seededFor === `new:${newParam}`
      : true;

  const [tenantKey, setTenantKey] = useState("");
  useEffect(() => {
    const appTenant = localStorage.getItem("app_tenant");
    const tenant = localStorage.getItem("tenant");
    let currentTenant = "";
    try {
      currentTenant =
        JSON.parse(localStorage.getItem("current_tenant") ?? "{}")?.key ?? "";
    } catch {}
    setTenantKey(appTenant || currentTenant || tenant || config.mainTenantKey());
  }, []);

  const username = useUsername();
  const axdToken = useAxdToken();
  const { userTenants } = useUserTenants();
  const { visitingTenant } = useVisitingTenant();
  const isAdmin = useIsAdmin();

  const chatConfig: ChatConfig = {
    baseWsUrl: () => config.baseWsUrl(),
    // Host config may not expose supportEmail — fall back to env.
    supportEmail: () =>
      process.env.NEXT_PUBLIC_SUPPORT_EMAIL ?? "support@ibl.ai",
    authUrl: () => config.authUrl(),
    mainTenantKey: config.mainTenantKey(),
    navigateToAdminBilling: () =>
      router.push(`/agents/${mentorId}/settings?tab=billing`),
    navigateToExplore: () => router.push("/agents"),
    navigateToMentor: (id) => router.push(`/agents/${id}`),
  };

  if (!tenantKey || !sessionReady) return null;

  return (
    <div className="flex h-screen w-full flex-col">
      <Chat
        // Key on mentor+session+new so an intentional switch remounts
        // <Chat> and the SDK re-reads the cached session. Do NOT remount
        // it any other way (see "Mounting discipline").
        key={`${mentorId}:${restoreSessionId ?? ""}:${newParam ?? ""}`}
        isPreviewMode={false}
        mentorId={mentorId}
        tenantKey={tenantKey}
        config={chatConfig}
        // Wrap so the callback is `=> void` (host helpers are often
        // async / extra-arg; SDK type wants `(…) => void`).
        redirectToAuthSpa={(to, key, logout) =>
          void redirectToAuthSpa(to, key, logout)
        }
        username={username ?? null}
        userTenants={userTenants ?? []}
        visitingTenant={visitingTenant}
        axdToken={axdToken ?? ""}
        userIsStudent={!isAdmin}
      />
    </div>
  );
}
```

**Why each piece:**

| Item | Why |
|------|-----|
| `"use client"` + `dynamic = "force-dynamic"` | `Chat` is client-only; prevent static pre-render |
| `<Suspense>` wrapper | `useSearchParams()` requires a Suspense boundary in App Router |
| `useCachedSessionId` seeding + render gate | only way to resume / start-new (no `sessionId` prop, no URL handling — see "Resuming / starting sessions") |
| `key={mentor:session:new}` | the ONLY safe remount; switching session needs it |
| `void redirectToAuthSpa(...)` wrapper | SDK type is `(…) => void`; host helper is often `async` |
| env fallback for `supportEmail` | host `config` may not expose it |

## Mounting discipline

The SDK reads session/voice state **once at `<Chat>` mount** and has an
`isMounted` defect (see Known issues), so an unintended remount is
destructive (loses session, wedges voice). Rules:

- **Never** conditionally swap subtrees around `<Chat>`
  (`isMobile ? <A/> : <B/>` with `<Chat>` in both → remount on
  breakpoint resolve). Use one stable tree and change **layout via
  props** (e.g. `ResizablePanelGroup direction`), not subtree swaps.
- The **only** legitimate remount is an intentional session/new-chat
  `key` change (Step 6).
- `const el = (<Chat .../>)` reused in the same JSX position is fine
  (React reconciles — not a remount).

## Resuming / starting sessions

`<Chat>` has no `sessionId` prop and reads no URL param: sessions live in `localStorage` via `useCachedSessionId()` and are read once at mount — seed (resume) or clear (new) before mounting and key `<Chat>` on it, exactly as Step 6 does. Details, the missing "conversation loaded" signal, and the `getChatHistory` host list: [`references/sessions.md`](references/sessions.md).

## Props

| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `mentorId` | `string` | yes | Agent UUID |
| `tenantKey` | `string` | yes | Organization (platform) key |
| `config` | `ChatConfig` | yes | URLs + navigation callbacks |
| `redirectToAuthSpa` | `(redirectTo?, platformKey?, logout?) => void` | yes | Host-owned auth redirect (wrap async helpers to `=> void`) |
| `username` | `string \| null` | yes | Current user; `null` for anonymous |
| `userTenants` | `Tenant[]` | yes | From `useUserTenants` |
| `axdToken` | `string` | yes | AXD auth token |
| `userIsStudent` | `boolean` | yes | RBAC role hint |
| `visitingTenant` | `Tenant \| undefined` | no | Viewing another organization's agent |
| `isPreviewMode` | `boolean` | yes | `true` for admin preview |
| `mode` | `"default" \| "advanced"` | no | `"advanced"` enables builder UI |
| `isPublicRoute` | `boolean` | no | Unauthenticated share links |
| slot overrides (`canvasView`, `disclaimerModal`, `advancedChatHeader`, `advancedChatBuilder`, `liveKitChat`, `liveKitScreenSharing`, `welcomeChat`, `promptGalleryModal`) | `React.ComponentType<…>` | no | **Availability is SDK-version-dependent. As of `web-containers@1.6.14` these are NOT implemented** — `Chat` does not destructure them and they're absent from `index.d.ts`. Verify in `dist/next/index.d.ts` before relying on any of them; do not assume a slot override will take effect. |
| `onSubscriptionGate`, `on402Error`, `canPerformAction`, `renderDocumentSidebar`, `projectId`, `onExploreClick`, `onMentorClick`, `projectLandingPage` | mixed | no | As available in your SDK version |

### `ChatConfig`

| Field | Type | Description |
|-------|------|-------------|
| `baseWsUrl` | `() => string` | WebSocket origin |
| `supportEmail` | `() => string` | Footer / error-toast email (env fallback if host config lacks it) |
| `authUrl` | `() => string` | Auth SPA origin |
| `mainTenantKey` | `string` | Default organization key |
| `navigateToAdminBilling` | `() => void` | Open the billing tab |
| `navigateToExplore` | `() => void` | "Browse All" agents link |
| `navigateToMentor` | `(id: string) => void` | Open an individual agent |
| `appSyncBanner?` | `{ badge, text, link, linkText } \| null` | Optional sync banner |

## Known issues & host workarounds

Two SDK defects need host workarounds: voice "Processing…" hangs after any remount (set `reactStrictMode: false`, never remount `<Chat>` except through its key) and the prompt-gallery dialog can leave the app unclickable (mount `RadixPointerEventsGuard` once at the root — vibe-starter ships it as `components/radix-pointer-events-guard.tsx`). Full analysis and the guard's source: [`references/known-issues.md`](references/known-issues.md).

## Step 7: Verify

1. `pnpm build` — must pass with zero errors. (Watch for
   `Module not found` on the Step 3 peers, esp. `@iblai/agent-ai`.)
2. `pnpm test` — host unit suite still green (esp. any slices you
   renamed in the Brownfield step).
3. `pnpm dev` — open the route, sign in, and walk:
   - agent name + greeting, "Ask anything" textarea, action buttons
     (attach / canvas / screen share / voice input / voice call / send),
     conversation starters;
   - send a message → "I Accept" the User Agreement → bubble + "is
     generating…" indicator;
   - **voice**: record → transcription clears "Processing…" (if it
     hangs, the StrictMode workaround isn't applied or `<Chat>` is
     remounting);
   - **prompt gallery**: open then close → app still clickable (guard
     working);
   - **resume/new** (if wired): history link loads the conversation;
     "new chat" starts empty.

**Brand guidelines**: [BRAND.md](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/BRAND.md)

