/iblai-vibe-analytics
Mirrored from
iblai/vibe·skills/analytics/iblai-vibe-analytics/SKILL.md. This page is generated — edit it in the repository, not here.
First time here? If
iblai.envhas noARCHITECTURE=, run/iblai-vibe-startfirst (four questions; two minutes) — it decides single-org / multi-org / headless and who signs in, and every skill reads the answer.
Scope: per organization, optionally narrowed to one agent and to
user groups. One AnalyticsLayout renders the tab strip (Overview ·
Users · Courses · Programs · Topics · Transcripts · Memory · Cost · Audit,
with Data Reports on the right), plus a single control bar: agent picker,
groups filter, and a Today / 7D / 30D / 90D / Custom range that every chart
follows. Each tab is a Next.js route under basePath.

Template: the Overview page is bundled as
assets/analytics-page.tsx.
Do NOT add custom styles to ibl.ai SDK components. Do NOT implement dark
mode unless asked. Follow the component hierarchy: ibl.ai SDK
(@iblai/iblai-js) first, then shadcn/ui (npx shadcn@latest add).
Prerequisites
- Auth set up (
/iblai-vibe-auth) - MCP server + skills configured (
@iblai/mcpin.mcp.json) @iblai/iblai-js≥ 2.12 resolving@iblai/web-containers≥ 1.21 (the analytics revamp). Check withpnpm why @iblai/web-containers. Older versions have no Memory tab, agent picker, control bar or Cost sub-tabs, and the code below will not typecheck.iblai.envpopulated withPLATFORM,DOMAIN,TOKEN. If missing:curl -o iblai.env https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/iblai.env- API companion (recommended):
npx skills add iblai/vibe --all. Installs the/iblai-api-analyticsREST reference and a filtered snapshot of the live OpenAPI schema. This skill (frontend wiring) and that one (API contract) stay in lockstep.
On vibe-starter? The dashboard already lives at
/admin/analytics(oneAnalyticsPageswitch incomponents/admin/analytics-page.tsx;?agent=<uuid>scopes it). One thing to fix: it only has Overview, Users, Topics, Transcripts, Financial and Reports routes, but the SDK strip also lists Courses, Programs, Audit and Memory, so those tabs 404. Add thememoryandauditroutes below, and passexcludeTabs={["courses", "programs"]}to its layout.Read
/analyticsas/admin/analyticsthroughout.
Layout
app/(app)/analytics/layout.tsx owns everything shared: tabs, picker,
groups filter, date range, and the tab guard for watchers. The SDK layout
paints bg-[#f5f7fb], so wrap it in a white card.
"use client";
import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import {
AnalyticsLayout,
AnalyticsSettingsProvider,
GroupsFilterDropdown,
type GroupOption,
} from "@iblai/iblai-js/web-containers";
import {
useGetMemsearchStatusQuery,
usePlatformUserGroupsQuery,
} from "@iblai/iblai-js/data-layer";
import { getUserName } from "@iblai/iblai-js/web-utils";
import { resolveAppTenant } from "@/lib/iblai/tenant";
const basePath = "/analytics";
// Course/program analytics need the ibl.ai catalog — drop them in agent-only apps.
const CATALOG_TABS = ["courses", "programs"];
export default function AnalyticsLayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname() ?? basePath;
const router = useRouter();
const [tenantKey, setTenantKey] = useState("");
const [userId, setUserId] = useState("");
const [groupIds, setGroupIds] = useState<number[]>([]);
useEffect(() => {
setTenantKey(resolveAppTenant());
setUserId(getUserName() ?? "");
}, []);
// Groups the viewer may filter by; Data Reports checks a different action.
const { data: groupsPage, isLoading: groupsLoading } = usePlatformUserGroupsQuery(
{
platformKey: tenantKey,
requiredAction: pathname.startsWith(`${basePath}/reports`)
? "Ibl.Analytics/Reports/read"
: "Ibl.Analytics/Core/read",
},
{ skip: !tenantKey },
);
const groups = useMemo<GroupOption[]>(
() =>
(groupsPage?.results ?? []).flatMap((g: { id: number; name?: string | null }) =>
g.name ? [{ id: g.id, name: g.name }] : [],
),
[groupsPage],
);
// Hide Memory when memsearch is off for the org.
const { data: memsearch } = useGetMemsearchStatusQuery(
{ org: tenantKey, userId },
{ skip: !tenantKey || !userId },
);
const excludeTabs =
memsearch?.enable_memsearch === false ? [...CATALOG_TABS, "memory"] : CATALOG_TABS;
const settings = useMemo(
() => ({ usergroupIds: groupIds.length ? groupIds : undefined }),
[groupIds],
);
return (
<div className="mx-auto w-full flex-1 overflow-auto px-4 py-8 md:w-[75vw] md:px-0">
<div className="overflow-hidden rounded-lg border border-[var(--border-color)] bg-white">
<AnalyticsSettingsProvider value={settings}>
<AnalyticsLayout
currentPath={pathname}
basePath={basePath}
onTabChange={(tab) => router.push(tab ? `${basePath}/${tab}` : basePath)}
excludeTabs={excludeTabs}
showPicker
tenantKey={tenantKey}
beforeDataReports={
<GroupsFilterDropdown
groups={groups}
selectedGroupIds={groupIds}
onSelectionChange={setGroupIds}
isLoading={groupsLoading}
placeholder="Filter by Groups"
/>
}
>
{children}
</AnalyticsLayout>
</AnalyticsSettingsProvider>
</div>
</div>
);
}
What the layout does for you:
- Date range: one shared range (default 30D). Cards follow it; a card changed on its own shows an override chip. Don't add per-page date pickers.
- Agent picker (
showPicker): "All Agents" or one agent, kept in layout state (it resets on reload). To keep it in the URL, control it withscopedMentor+onScopedMentorChange. - Groups filter:
beforeDataReportsrenders into the control bar; the selection reaches pages throughAnalyticsSettingsProvider. Pages must pass it on asusergroupIds— the tabs don't read the context. - Watchers (non-admins holding
/watchedgroups/#list) only see Courses, Programs, Memory, Transcripts and Data Reports; a deep link to any other tab redirects to the first allowed tab that isn't inexcludeTabs.
Pages
Copy assets/analytics-page.tsx into
app/(app)/analytics/page.tsx (Overview). Every other tab is a small client
page. Tabs that take the groups filter (Users, Topics, Transcripts) follow
this shape, as in app/(app)/analytics/users/page.tsx:
"use client";
import { useEffect, useState } from "react";
import { AnalyticsUsersStats, useAnalyticsSettings } from "@iblai/iblai-js/web-containers";
import { resolveAppTenant } from "@/lib/iblai/tenant";
export default function UsersPage() {
const { usergroupIds } = useAnalyticsSettings();
const [tenantKey, setTenantKey] = useState("");
useEffect(() => setTenantKey(resolveAppTenant()), []);
if (!tenantKey) return null;
return <AnalyticsUsersStats tenantKey={tenantKey} mentorId="" usergroupIds={usergroupIds} />;
}
Memory, Cost and Audit also need the signed-in username (userId) —
their endpoints are /orgs/{org}/users/{username}/… paths. For example,
app/(app)/analytics/financial/page.tsx:
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { AnalyticsFinancialStats } from "@iblai/iblai-js/web-containers";
import { getUserName } from "@iblai/iblai-js/web-utils";
import { resolveAppTenant } from "@/lib/iblai/tenant";
export default function CostPage() {
const router = useRouter();
const [tenantKey, setTenantKey] = useState("");
const [userId, setUserId] = useState("");
useEffect(() => {
setTenantKey(resolveAppTenant());
setUserId(getUserName() ?? "");
}, []);
if (!tenantKey || !userId) return null;
return (
<AnalyticsFinancialStats
tenantKey={tenantKey}
mentorId=""
userId={userId}
// The trace panel links a session to its transcript; the Transcripts tab reads ?session_id=.
onOpenTranscript={(sessionId) =>
router.push(`/analytics/transcripts?session_id=${encodeURIComponent(sessionId)}`)
}
/>
);
}
memory/page.tsx and audit/page.tsx have the same shape and render:
<AnalyticsMemoryStats tenantKey={tenantKey} mentorId="" userId={userId} />
<AnalyticsAuditLogStats tenantKey={tenantKey} mentorId="" userId={userId} />
| Route | Component | Props beyond tenantKey + mentorId="" |
|---|---|---|
/analytics | AnalyticsOverview | usergroupIds, onViewAllTopics (adds "All topics →") |
/analytics/users | AnalyticsUsersStats | usergroupIds |
/analytics/courses | AnalyticsCourses | basePath="/analytics" → opens courses/[courseId] |
/analytics/courses/[courseId] | AnalyticsCourseDetail | courseId (from useParams), onBack |
/analytics/programs | AnalyticsPrograms | basePath="/analytics" → opens programs/[programId] |
/analytics/programs/[programId] | AnalyticsProgramDetail | programId, onBack |
/analytics/topics | AnalyticsTopicsStats | usergroupIds |
/analytics/transcripts | AnalyticsTranscriptsStats | usergroupIds, currentSPA, turnDetails (slots); reads ?session_id= |
/analytics/memory | AnalyticsMemoryStats | userId (required), myMemory (own memories only) |
/analytics/financial (label Cost) | AnalyticsFinancialStats | userId, onOpenTranscript. No basePath; usergroupIds is deprecated and ignored |
/analytics/audit | AnalyticsAuditLogStats | userId (required), defaultScope, timezone, canViewPlatformAudit |
/analytics/reports | AnalyticsReports | no mentorId: selectedMentorId="" (required), usergroupIds, disabledReports |
Courses/Programs pages only matter if you removed them from CATALOG_TABS.
Use MCP for the full props: get_component_info("AnalyticsMemoryStats"), etc.
Agent scope rule. Every tab resolves its agent as
selectedMentorId || mentorId || picker. On org-wide pages pass
mentorId="" and leave selectedMentorId unset, or the picker is
silently ignored. "" means org-wide; never pass undefined for mentorId.
What each tab shows
| Tab | What's in it | Backed by |
|---|---|---|
| Overview | Messages, Active Users, Topics, Conversations, LLM spend KPIs with sparklines; Sessions chart; top Topics | chat analytics + llm-usage (spend card) |
| Users | Logged in now / past 30 days / registered; Active Users; Access Times heatmap; User Details table | /api/analytics/users/…, time/ |
| Topics | Topics / Conversations / Messages KPIs; Conversations over time; Topics Details | /api/analytics/topics/…, conversations/ |
| Transcripts | Avg messages / cost / rating; conversation list + transcript panel with attachments, retrieved documents, tool calls and per-turn details (model, temperature, request context) | /api/analytics/messages/…, messages/details/ |
| Memory | Memories per user; scope Agent / All agents / Global, agent search, user, content and date filters | /api/ai-mentor/orgs/{org}/users/{username}/…memories…/ |
| Cost → Spend | Weekly / Monthly / Total costs, Cost per Day, Cost by Provider / LLM, Cost per User (→ Traces) | ClickHouse: /api/ai-analytics/orgs/{org}/users/{username}/{tenant-cost, agents/{id}/cost, costs/model, costs/peruser}/ |
| Cost → Usage & latency | LLM spend, Tokens, LLM calls, Latency p95; spend over time by service; spend and p50/p95 latency by model | /api/analytics/llm-usage/?resource=metrics |
| Cost → Traces | Per-call list (service, user, latency, cost) with a detail panel of observations | llm-usage resource=traces / observations |
| Audit → Agent | Agent configuration changes (who, what, when); user, date and action filters | /api/ai-mentor/orgs/{org}/users/{username}/mentors/audit-logs/ |
| Audit → Platform | Org-wide changes and security events (logins, model changes), with IP and a detail sheet | /api/core/platforms/{org}/audit-logs/ |
| Data Reports | Async CSV/JSON exports (generate → poll → download) | /api/reports/platforms/{org}/… |











Inside a transcript
The Transcripts panel replays a conversation the way the chat rendered it, from the per-turn context the backend attaches on admin surfaces. Every piece is optional and renders only when the payload carries it:
- Attachments — files and images exchanged on a turn, as the chat's
file cards (the presigned
urlopens them). - Retrieved Documents — the chunks the answer cited, with source,
snippet and relevance score. Literally the chat's own control
(
RetrievedDocumentsButton, openingDocumentSidebaras a dialog): in the live composer it fetches the session's vector results, here it is handed the turn's — or the whole conversation's — documents instead. The agent History tab shows the same dialog (screenshot). - Used N tools — the turn's tool calls (name, input, output).
- Show Details — per AI turn: Metadata (model with the provider
logo, temperature, client) and Request Context (session, flow, org,
pathway, system prompt, file references …). Platform names are used, not
wire names:
enable_artifacts→ Canvas,is_proactive→ Proactive Prompt,grading_context→ Grader,document_filter→ Datasets; unknown keys are humanised automatically. - Row badges — per conversation, how many documents and tool calls it used.
- The user on a row is named by the shared
resolveUserIdentityrule (full name → email → username → anonymous) and links to the profile dialog; passcurrentSPAso'lms'/'skills'hosts get its Gradebook tab.
To render documents or tool calls with your own chat components
instead of the built-in ones, pass the turnDetails slots:
<AnalyticsTranscriptsStats
tenantKey={tenantKey}
mentorId=""
turnDetails={{
renderDocuments: (documents) => <MyCitations documents={documents} />,
renderToolCalls: (toolCalls) => <MyToolLog calls={toolCalls} />,
}}
/>
The same panel powers the agent History tab
(/iblai-vibe-agent-history),
and its components are exported from @iblai/iblai-js/web-containers
for custom transcript UI:
| Export | What it renders |
|---|---|
TranscriptTurnDetails, TranscriptRollupBadges | the Show Details panel and the list-row document/tool badges |
RetrievedDocumentsButton, DocumentSidebar | the Retrieved Documents dialog — pass sessionId for a live chat, documents for a transcript |
ToolCallIndicator (+ getFriendlyToolName, getQueryLabel, formatResult) | the tool-call record |
TranscriptFileCards | attachment cards (name, contentType, url) |
LlmModelBadge | the model chip with its provider logo |
resolveUserIdentity | the shared name-a-user rule |
summarizeTranscriptTurns, conversationDocuments, toRetrievedDocuments, toToolCallInfos, toRollupCount | payload → props helpers |
Permissions and data caveats
- Platform audit shows only to org admins or holders of
/auditlogs/#list. Everyone else gets the Agent trail with no switch. PasscanViewPlatformAuditif you already resolved it. The API returns 403 regardless of what the UI shows. - Usage & latency / Traces need org-admin analytics access; other users see a permission notice, not an error.
- Memory returns 403 → "no permission" card. Watchers only see the users they watch.
- Costs are platform-marked-up USD — show them as-is. An empty or unavailable cost block means the cost store was unreachable, not zero spend.
- Per-agent cost ranking is forward-only: only calls traced since agent tagging shipped carry an agent. The tab shows this notice itself.
- Date defaults differ server-side:
llm-usagedefaults to today, while the other endpoints default to 30 days. The layout pins 30D for both; any hand-rolled request must send its own range.
One agent instead of the org
For analytics on a single agent (e.g.
app/(app)/agents/[mentorId]/dashboards/; keep …/analytics/ free for the
agent Analytics tab's hub), pass the route's mentorId to
every tab and omit showPicker: the page already knows its agent. Also
drop the groups filter, and hide Audit unless the viewer holds
/mentors/{mentorDbId}/#view_audit_logs (/iblai-vibe-rbac):
const basePath = `/agents/${mentorId}/dashboards`;
<AnalyticsLayout
currentPath={pathname}
basePath={basePath}
onTabChange={(tab) => router.push(tab ? `${basePath}/${tab}` : basePath)}
excludeTabs={canViewAuditLogs ? ["courses", "programs"] : ["courses", "programs", "audit"]}
>
{children}
</AnalyticsLayout>
The Audit tab alone is also its own skill: /iblai-vibe-agent-audit. To
link an agent's settings to these pages, add the agent Analytics tab
(AgentAnalyticsTab, a hub of report cards): /iblai-vibe-agent-analytics.
Monetization tab. The strip also has a monetization tab, hidden until
you pass enableMonetization (the org's enable_monetization flag) and
the user holds can_sell_items on the org. It renders
AnalyticsMonetizationStats (revenue, sales, paywalls, subscribers). Setup:
/iblai-vibe-monetization-analytics.
Verify
pnpm build && pnpm test, then touch test /analytics in the browser as
/iblai-vibe-ops-test says: click every tab in the strip — none may 404; switch
the agent picker and the date range, and check that the numbers change; read
the console; check the phone width.
Notes
- Redux store must include
mentorReducerandmentorMiddleware. initializeDataLayer()takes 5 args (data-layer v1.2+).@reduxjs/toolkitdeduped via webpack aliases innext.config.ts.AnalyticsControlBar,AnalyticsAgentPicker,StatCard,ChartCardWrapperand theuseLlmUsage/useLlmTraceshooks are exported too — use them for a custom analytics page instead of rebuilding the pieces.- To override the SDK gray background to white, in
globals.css:.bg-\[\#f5f7fb\] { background-color: #fff !important; }
Analytics REST API — see /iblai-api-analytics (headless twin)
Every analytics endpoint and Data Report — URLs, required params, RBAC
role names, response shapes — lives in the companion skill
/iblai-api-analytics
in this repo (kind api). It is installed with the rest of the skills
and stays in sync with the backend:
npx skills add iblai/vibe --all
Then /iblai-api-analytics covers auth, the schema-first workflow, the
analyst-shaped endpoint groupings (Overview / Costs / Users & engagement
/ Topics & conversations / Transcripts / Sessions & ratings / Course /
Program / Pathway & skill / Per-user / Time-on-platform), the async
Data Reports lifecycle, and a local snapshot of the live OpenAPI schema
at references/analytics-schema.json.
The OpenAPI schema is the contract. Live at
{dm_url}/api/docs/schema/?format=json(browsable at{dm_url}/api/docs/;{dm_url}=https://api.$DOMAIN/dm,DOMAINfromiblai.env, defaultiblai.app). Consult it before writing any analytics request. The/iblai-api-analyticsskill'sreferences/schema.mdgives the fetch + drift-check routine.
In this app (frontend wiring)
- Auth header:
Authorization: Token <token>. The SDK attaches this automatically viaSERVICES.DM. - Anchor:
{dm_url}=https://api.$DOMAIN/dm(DOMAINfromiblai.env, defaultiblai.app) throughout the companion/iblai-api-analyticsskill. Endpoints there are written{dm_url}/api/analytics/…and{dm_url}/api/reports/…. In TypeScript,dmUrlis the first arg passed toinitializeDataLayer()(sourced fromNEXT_PUBLIC_API_BASE_URL), so`${dmUrl}/api/analytics/…`composes the request URL. - Prefer SDK RTK Query hooks over hand-rolled
fetch. Useget_api_query_info("<hookName>")in MCP to find the relevant hook before writing custom UI.