Installs into .claude/skills of the current project.
Are you the author of Iblai Vibe Agent Chat?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/iblai-iblai-vibe-agent-chat)
---
name: iblai-vibe-agent-chat
description: Add the in-process Chat SDK component (full agent surface — message stream, canvas, file attach, voice, prompts) to a Next.js app
globs:
alwaysApply: false
metadata:
kind: ui
---
# /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.


> **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.18.1` |
| `@iblai/agent-ai` | `^2.10.0` |
(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)