一键导入
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
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Add the in-process Chat SDK component (full agent surface — message stream, canvas, file attach, voice, prompts) to a Next.js app
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
Build and run your ibl.ai app on desktop and mobile (iOS, Android, macOS, Surface)
Add the agent Sandbox tab (OpenClaw instance management, agent prompt configuration, and agent skills) to your Next.js app
Add the agent Access tab (role-based access control for editor and chat roles) to your Next.js app
Add the agent Audit tab (audit log of who changed what and when, with user/date/action filters) to your Next.js app
Wrap the Chat surface with the SDK's AppSidebar — projects dropdown, pinned/recent messages, and host-supplied content/footer menu items
Add the agent MCP tab (Model Context Protocol connector management with featured connectors, custom connectors, OAuth, and add/edit dialogs) to your Next.js app
| 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 | null |
| alwaysApply | false |
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-processChatcomponent this skill documents) — bundled asassets/chat-widget.tsx.j2+assets/iblai-web-mentor.d.ts. See/iblai-vibe-scaffoldfor the{{ }}contract.
What you get: the SDK's
Chatcomponent 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.
Auth must be set up first (/iblai-vibe-auth) — the Chat component reads
the axd token, username, and tenants 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/mentor ID (a UUID) — get one at https://mentorai.iblai.app.
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 | ^1.10.3 |
@iblai/web-containers | 1.6.14 |
@iblai/web-utils | 1.6.9 |
@iblai/data-layer | 1.5.7 |
Pre-flight export check (do this before writing any code):
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.tsis incomplete and sometimes misleading. Several runtime props/args (e.g.getChatHistory'suserIdpath param) are absent from the type defs, and some documented slot props are typed but not implemented. When something doesn't match, verify againstnode_modules/@iblai/web-containers/dist/next/index.esm.js(the actual bundle), not just the types.
| File | Change |
|---|---|
package.json | Adds SDK packages + peers (Step 3) |
providers/index.tsx | Wraps tree in <ServiceWorkerProvider>; skip={isSsoLoginRoute} on Auth/Tenant providers (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) |
Confirm .env.local has the env vars the SDK reads at runtime:
NEXT_PUBLIC_BASE_WS_URL=wss://asgi.data.iblai.org
NEXT_PUBLIC_AUTH_URL=https://auth.iblai.org
NEXT_PUBLIC_DM_URL=https://api.iblai.org/dm
NEXT_PUBLIC_LMS_URL=https://api.iblai.org/lms
NEXT_PUBLIC_AXD_URL=https://api.iblai.org/dm
NEXT_PUBLIC_AGENT_URL=http://localhost:3000
NEXT_PUBLIC_MAIN_TENANT_KEY=<your-platform-key>
NEXT_PUBLIC_SUPPORT_EMAIL=support@ibl.ai
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.
Ask the user for their agent/mentor 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 https://mentorai.iblai.app to create an agent.
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 hardModule not foundfrom 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).
The Chat component uses useAuthContext, useTenantContext,
useEmbedMode, useServiceWorker, and several Redux hooks. The host
needs all of these.
In providers/index.tsx:
ServiceWorkerProvider from @iblai/iblai-js/web-utils.<AuthProvider> tree in <ServiceWorkerProvider>.skip={isSsoLoginRoute} to both <AuthProvider> and
<TenantProvider> so the /sso-login-complete callback page doesn't
crash on missing context.basePath if the app is mounted under a sub-path (e.g.
NEXT_PUBLIC_BASE_PATH=/hq):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>
);
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.
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:
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 /.)
In store/index.ts, register these reducers. The SDK's selectors
hard-code these exact keys (state.chat, state.rbac, …) — they are
non-negotiable:
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(orrbac/subscription/…) slices with different shapes. The SDK's keys are fixed, so rename the host's slices, not the SDK's. Find collisions first:grep -rn "state\.\(chat\|files\|rbac\|subscription\|topBanner\|chatInput\)\b" \ --include="*.ts" --include="*.tsx" app components lib hooksRename each host slice (e.g.
chat → v0Chat) and migrate every selector that reads it (slicenameand action creators are key-agnostic — onlyuseSelector(s => s.<key>)accesses change).mentorReduceris RTK Query API slices only; it does not shadow plain feature slices likechat/files.
The slice keys matter — don't rename them. mentorMiddleware is
still concatenated as before.
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):
"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/utils";
import { config } from "@/lib/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 |
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:
<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.key change (Step 6).const el = (<Chat .../>) reused in the same JSX position is fine
(React reconciles — not a remount).<Chat> has no sessionId prop and does not read a session
URL param. Sessions live in localStorage via useCachedSessionId()
→ { [mentorId]: sessionId }, read once at mount.
?session=<id>): seed
cachedSessionId[mentorId] = id before mount, gate render, key
<Chat> on the id (Step 6).delete cachedSessionId[mentorId] + remount via a
changing key nonce (?new=<ts>). Just navigating does not start a
new chat — the SDK resumes the persisted session.isLoadingChats is internal to useAdvancedChat; chatSliceShared
is not reset between sessions, so selectSessionId /
selectNumberOfActiveChatMessages read stale on first render.
For a "loading" overlay during resume, use a guaranteed-minimum +
hard-capped timer, not a slice-derived condition.getChatHistory)Endpoint: /api/ai-analytics/orgs/{org}/users/{user_id}/chat-history/.
userId → URL path {user_id} (required to form the URL; absent
from the d.ts arg type but real).filterUserId → filter_user_id query — what actually restricts
results. For an org admin the path alone returns org-wide; pass
both userId and filterUserId (= signed-in username) to scope
to the current user.| Prop | Type | Required | Description |
|---|---|---|---|
mentorId | string | yes | Agent/mentor UUID |
tenantKey | string | yes | Platform/tenant 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 tenant'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 tenant 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 |
These are SDK-side defects/limitations confirmed against
web-containers@1.6.14 / web-utils@1.6.9. Fix upstream if you can;
otherwise apply the host workaround.
useVoiceChat's isMounted ref is only ever reset to false (effect
cleanup), never back to true on setup. React StrictMode (Next default)
double-invokes effects in dev (mount→cleanup→remount) → the ref stays
false → audio-to-text resolves but the isMounted-guarded
setProcessing(false) is skipped → stuck "Processing…". Any <Chat>
remount reproduces it.
reactStrictMode: false in next.config (dev-only
behavior; prod runs effects once) and follow "Mounting
discipline" so <Chat> doesn't remount.isMounted.current = true; to the
effect setup in useVoiceChat.The bundled PromptGalleryModal is a Radix dialog whose teardown leaves
document.body { pointer-events: none } and sibling
inert/aria-hidden after close → the entire app becomes unclickable.
The promptGalleryModal slot override does not exist in this SDK
(see Props), so it can't be replaced via API.
"use client";
import { useEffect } from "react";
const OPEN = '[role="dialog"][data-state="open"],[role="alertdialog"][data-state="open"],[data-radix-popper-content-wrapper]';
function recover() {
if (document.querySelector(OPEN)) return;
if (document.body.style.pointerEvents === "none") document.body.style.pointerEvents = "";
for (const el of Array.from(document.body.children)) {
if (el.hasAttribute("inert")) el.removeAttribute("inert");
if (el.getAttribute("aria-hidden") === "true") el.removeAttribute("aria-hidden");
}
}
export function RadixPointerEventsGuard() {
useEffect(() => {
recover();
const mo = new MutationObserver(recover);
mo.observe(document.body, { attributes: true, attributeFilter: ["style","inert","aria-hidden"], childList: true, subtree: true });
const onDown = () => recover();
document.addEventListener("pointerdown", onDown, true);
return () => { mo.disconnect(); document.removeEventListener("pointerdown", onDown, true); };
}, []);
return null;
}
Mount it once next to your store/providers. This is recovery, not a root-cause fix — the gallery still tears down wrong, but the app stays usable.
promptGalleryModal slot so hosts can supply a
cleanly-unmounting modal.pnpm build — must pass with zero errors. (Watch for
Module not found on the Step 3 peers, esp. @iblai/agent-ai.)pnpm test — host unit suite still green (esp. any slices you
renamed in the Brownfield step).pnpm dev — open the route, sign in, and walk:
<Chat> is
remounting);Automating this skill would: install the Step 3 deps
(incl. @iblai/agent-ai); patch providers (wrap ServiceWorkerProvider
skip, pass basePath); copy public/sw.js; add the
Service-Worker-Allowed header + reactStrictMode:false; register the
Step 5 reducers (detecting + renaming host key collisions); scaffold the
Step 6 route; drop in RadixPointerEventsGuard; and no-op if already
added.Brand guidelines: BRAND.md