Building a Production AI Layer for a Multi-Tenant App — Tool-Calling, Per-Tenant Credits & Generative UI
A production-tested blueprint for adding a genuinely useful AI layer to an existing data-intensive, multi-tenant app without rebuilding it: a tiered model gateway, a per-tenant credit wallet metered by real cost, 40+ reusable RBAC- and tenant-scoped tools behind one universal date resolver, streaming tool-calling chat with generative UI cards and charts, one-click Explain-this-figure and Explain-this-report provenance, cached structured briefings, an AI economics console that stops you overselling your gateway credit pool, and graceful degradation so users never see a red error. Built on Next.js, Prisma/Postgres and AI SDK v7 + the Vercel AI Gateway.
Building a Production AI Layer for a Multi-Tenant App
A practical, code-first guide — case study: Intellixio OPS
This guide shows how to add a genuinely useful AI layer to an existing data-intensive, multi-tenant application without rebuilding it. It is written from a real implementation (Intellixio OPS — a multi-tenant inventory/POS/sales platform on Next.js + Prisma + Postgres) and every pattern here ships in that product.
You will build, in order:
- A model gateway abstraction (cheap by default, escalate only when needed).
- A per-tenant entitlement + credit wallet (AI is off by default, metered by real cost).
- A broad, reusable tool layer — RBAC- and tenant-scoped, parameterised (not one tool per question), with universal date resolution, returning card envelopes.
- A streaming chat endpoint (tool-calling).
- A client chat panel that renders tool results as custom UI cards (generative UI), including charts.
- "Explain this figure" — one-click provenance for any number on screen, and "explain this report" — the same pattern scaled to a whole page, vs its prior period.
- A structured daily briefing (
generateObject+ caching). - Observability (an AI activity log) and super-admin controls.
- An AI economics console — read the real gateway balance, set the FX rate, and never oversell the credit pool.
- Graceful degradation — no error popups: when the AI can't answer, it offers a "request this capability" button that reaches the admin.
This guide grew with the product. By the end, Intellixio's assistant exposes 40+ reusable tools across sales, profit, inventory, stock-by-location, expiry, transfers, purchases, suppliers, customers, credit, payments, VAT, branches, cashiers, returns, quotations, serials, approvals and activity — built on exactly the small set of patterns below. The number of tools grows; the architecture doesn't.
The stack in the case study: Next.js 16 (App Router), React 19, Prisma 7 + Postgres, Tailwind, TanStack Query, Zod, AI SDK v7 (
ai+@ai-sdk/react), and the Vercel AI Gateway. Adapt freely — the ideas are framework-agnostic; only the glue is Next-specific.
0. The mental model (read this first)
Five rules drive every design decision below. Internalise them and the code writes itself.
- The database stays the source of truth. The AI must never compute or "remember" a business figure. It calls a tool, the tool queries your DB, the AI explains the result. This single rule kills hallucinated numbers.
- The AI can never see or do more than the current user. Bind
orgId, role and branch from the server session, never from the model or the client. Re-check permissions inside every tool. - AI is a paid capability, off by default. Gate it per tenant, meter it by real cost, and enforce that gate server-side on every request. Hiding a button is not security.
- Tools return data, the client renders UI. The model decides which tool to call; your UI decides how to render the result. This is "generative UI."
- Extend, don't rebuild. The AI layer consumes your existing services and DB and exposes new AI-only endpoints. Your app keeps working with the AI switched off.
Target evolution of the product experience:
Traditional → "Here are your numbers."
Intelligent → "Here is what is happening." (Ask + Explain)
Advanced → "Here is what you should do." (Briefing + recommendations)
Future → "I prepared the action — review & approve." (draft actions)1. Architecture
User (session: orgId, userId, role, branchScope)
│ AI request (+ requestId)
▼
Entitlement + credit gate ──► 402 NO_CREDITS / 403 NOT_ENABLED
│ (tenant.aiStatus ACTIVE|TRIAL, feature on, balance > 0)
▼
Orchestrator (streamText / generateObject, tiered model, system prompt)
│
├── Tool layer → each tool: bound orgId + permission check + scoped query → JSON "card"
├── Business rules (reuse your existing permissions / limits / approvals)
└── (later) memory / business context
│
▼
Application layer (your existing services / ORM) ──► Postgres (source of truth)
│
▼
onFinish: usage → cost → deduct credits + write an AI usage log
│
▼
Stream message.parts → client renders text + custom cardsKey seams you will add: lib/ai/gateway.ts, lib/ai/entitlement.ts,
lib/ai/tools.ts, and a few API routes + client components.
2. Install & configure
pnpm add ai @ai-sdk/react react-markdown # AI SDK v7 + a markdown renderer
# zod is almost certainly already a dependency# .env.local
AI_GATEWAY_API_KEY=vck_xxx # from the Vercel dashboard; the `ai` pkg reads it automatically
AI_MODEL_CHEAP=google/gemini-2.5-flash
AI_MODEL_STRONG=anthropic/claude-haiku-4.5
AI_USD_TO_GHS=16 # your currency conversion for cost accountingThe gateway takes provider/model strings and applies no markup — you pay list
prices, which is exactly why cheap tiers matter. You can swap providers by changing one string.
3. The model gateway abstraction
Never hard-code a provider. Map logical tiers to gateway model IDs, and keep the cost math pure (no DB, no framework) so it is trivially unit-testable.
// lib/ai/gateway.ts
export type ModelTier = "cheap" | "standard" | "strong";
export const MODELS: Record<ModelTier, string> = {
cheap: process.env.AI_MODEL_CHEAP ?? "google/gemini-2.5-flash",
standard: process.env.AI_MODEL_STANDARD ?? "openai/gpt-4o-mini",
strong: process.env.AI_MODEL_STRONG ?? "anthropic/claude-haiku-4.5",
};
const USD_TO_LOCAL = Number(process.env.AI_USD_TO_GHS ?? "16");
// List price per 1M tokens (input/output). Unknown models fall back to a
// non-zero default so a mis-set model id can't make AI look free.
const DEFAULT_COST = { inUsdPerM: 0.5, outUsdPerM: 1.5 };
const COST_TABLE: Record<string, { inUsdPerM: number; outUsdPerM: number }> = {
"google/gemini-2.5-flash": { inUsdPerM: 0.3, outUsdPerM: 2.5 },
"openai/gpt-4o-mini": { inUsdPerM: 0.15, outUsdPerM: 0.6 },
"anthropic/claude-haiku-4.5": { inUsdPerM: 1.0, outUsdPerM: 5.0 },
};
export type TokenUsage = { inputTokens?: number; outputTokens?: number };
export function computeRawCostLocal(usage: TokenUsage, model: string): number {
const { inUsdPerM, outUsdPerM } = COST_TABLE[model] ?? DEFAULT_COST;
const input = Math.max(0, usage.inputTokens ?? 0);
const output = Math.max(0, usage.outputTokens ?? 0);
const usd = (input / 1e6) * inUsdPerM + (output / 1e6) * outUsdPerM;
return usd * USD_TO_LOCAL;
}
export const computeChargedLocal = (raw: number, margin: number) =>
raw * Math.max(1, margin);
// Cheap by default; escalate only for genuinely hard reasoning.
export function pickModel(opts?: { hardReasoning?: boolean }) {
const tier: ModelTier = opts?.hardReasoning ? "strong" : "cheap";
return { tier, model: MODELS[tier] };
}Cost tip from the gateway guide: test your prompt on
nano/flash/haikutiers first; only upgrade to a frontier model if the cheap tier genuinely fails.
4. Entitlement + a credit wallet (the gate)
Make AI an explicit, per-tenant entitlement metered by real cost. One server-side function is the single chokepoint every AI request funnels through.
4.1 Data model (Prisma)
enum AiStatus { NOT_ENABLED TRIAL ACTIVE SUSPENDED DISABLED }
enum AiCreditReason { GRANT TRIAL_GRANT CONSUME ADJUST REFUND }
// on your tenant/Organization model:
aiStatus AiStatus @default(NOT_ENABLED) // OFF by default
aiFeatures Json? // { ask: true, explain: true, briefing: false, ... }
aiTrialEndsAt DateTime?
aiCreditBalance Decimal @default(0) @db.Decimal(12, 4) // a money wallet
aiMarginMultiplier Decimal @default(3) @db.Decimal(6, 3) // charged = rawCost × margin
// append-only ledger — never mutate/delete
model AiCreditLedger {
id String @id @default(cuid())
orgId String
deltaLocal Decimal @db.Decimal(12, 4) // + grant, − consume
balanceAfter Decimal @db.Decimal(12, 4)
reason AiCreditReason
reference String?
createdById String?
createdAt DateTime @default(now())
@@index([orgId, createdAt])
}
// one row per interaction — observability + the activity feed
model AiUsageLog {
id String @id @default(cuid())
requestId String @unique
orgId String
userId String?
feature String // "ask" | "explain" | "briefing" | ...
model String
toolsUsed String[]
inputTokens Int @default(0)
outputTokens Int @default(0)
rawCost Decimal @default(0) @db.Decimal(12, 6)
chargedCost Decimal @default(0) @db.Decimal(12, 6)
latencyMs Int @default(0)
status String // "ok" | "error"
prompt String? // what was asked (truncated)
reply String? // what the AI replied (truncated)
createdAt DateTime @default(now())
@@index([orgId, createdAt])
}4.2 The gate + the charge
// lib/ai/entitlement.ts
import "server-only";
import { db } from "@/lib/db";
import {
computeChargedLocal,
computeRawCostLocal,
type TokenUsage,
} from "@/lib/ai/gateway";
import { requireOrg } from "@/lib/tenant"; // resolves the authed session → { orgId, userId, role, branchId }
export type AiFeature = "ask" | "explain" | "briefing" | "ping";
const CORE = new Set<AiFeature>(["ping"]); // always allowed when active (no per-feature flag)
export type AiContext = {
orgId: string;
userId: string;
role: string;
branchId: string | null;
currency: string;
marginMultiplier: number;
balance: number;
};
export class AiError extends Error {
constructor(
public code: "AI_NOT_ENABLED" | "AI_FEATURE_OFF" | "AI_NO_CREDITS",
msg: string
) {
super(msg);
this.httpStatus = code === "AI_NO_CREDITS" ? 402 : 403;
}
httpStatus: number;
}
export async function requireAiFeature(feature: AiFeature): Promise<AiContext> {
const user = await requireOrg(); // ← tenant context from the SESSION
const org = await db.organization.findUnique({
where: { id: user.orgId },
select: {
aiStatus: true,
aiFeatures: true,
aiTrialEndsAt: true,
aiMarginMultiplier: true,
aiCreditBalance: true,
currency: true,
},
});
if (!org || (org.aiStatus !== "ACTIVE" && org.aiStatus !== "TRIAL"))
throw new AiError(
"AI_NOT_ENABLED",
"AI isn't enabled for your organisation."
);
if (
org.aiStatus === "TRIAL" &&
org.aiTrialEndsAt &&
org.aiTrialEndsAt < new Date()
)
throw new AiError("AI_NOT_ENABLED", "Your AI trial has ended.");
if (!CORE.has(feature) && (org.aiFeatures as any)?.[feature] !== true)
throw new AiError(
"AI_FEATURE_OFF",
`The "${feature}" capability isn't enabled.`
);
if (!(Number(org.aiCreditBalance) > 0))
throw new AiError(
"AI_NO_CREDITS",
"Out of AI credits. Ask your admin to top up."
);
return {
orgId: user.orgId,
userId: user.userId,
role: user.role,
branchId: user.branchId,
currency: org.currency,
marginMultiplier: Number(org.aiMarginMultiplier),
balance: Number(org.aiCreditBalance),
};
}
// Log one interaction + deduct its cost, atomically.
export async function chargeAi(a: {
ctx: AiContext;
requestId: string;
feature: AiFeature;
model: string;
usage: TokenUsage;
latencyMs: number;
toolsUsed?: string[];
prompt?: string;
reply?: string;
}) {
const raw = computeRawCostLocal(a.usage, a.model);
const charged = computeChargedLocal(raw, a.ctx.marginMultiplier);
const trim = (s: string | undefined, n: number) =>
s == null ? null : s.slice(0, n);
return db.$transaction(async (tx) => {
await tx.aiUsageLog.create({
data: {
requestId: a.requestId,
orgId: a.ctx.orgId,
userId: a.ctx.userId,
feature: a.feature,
model: a.model,
toolsUsed: a.toolsUsed ?? [],
inputTokens: a.usage.inputTokens ?? 0,
outputTokens: a.usage.outputTokens ?? 0,
rawCost: raw,
chargedCost: charged,
latencyMs: a.latencyMs,
status: "ok",
prompt: trim(a.prompt, 4000),
reply: trim(a.reply, 8000),
},
});
const updated = await tx.organization.update({
where: { id: a.ctx.orgId },
data: { aiCreditBalance: { decrement: charged } },
select: { aiCreditBalance: true },
});
const balanceAfter = Number(updated.aiCreditBalance);
await tx.aiCreditLedger.create({
data: {
orgId: a.ctx.orgId,
deltaLocal: -charged,
balanceAfter,
reason: "CONSUME",
reference: a.requestId,
},
});
return { raw, charged, balanceAfter };
});
}Why a cost-based wallet? It tracks true per-tenant cost, works with cheap models, and one balance covers chat + explanations + briefings. Users can see a friendly "credits remaining"; internally it is money. Top up manually (super-admin grant) first; add self-serve purchase later.
5. The tool layer (RBAC + tenant isolation + card envelopes)
This is the heart of a data-intensive AI. Three non-negotiables:
- The executor closes over the authed context. The model passes only business
arguments (a period, a product name) — never identity.
orgId/role/branch come from the closure. - Re-check permission + branch scope inside every tool, and omit tools the role can't use from the set so the model can't even call them.
- Return a "card envelope" — a consistent shape (
{ card: "stats" | "list" | ... }) that the client renders as a custom component. The model reads the same JSON to write a one-line summary.
// lib/ai/tools.ts
import { tool, type ToolSet } from "ai";
import { z } from "zod";
import type { AiContext } from "@/lib/ai/entitlement";
import { db } from "@/lib/db";
import { can, type Permission } from "@/lib/permissions"; // your existing RBAC helper
const ORG_WIDE = new Set(["ORG_ADMIN", "REGIONAL_MANAGER"]);
export function buildTools(ctx: AiContext): ToolSet {
const money = (n: number) => `${ctx.currency} ${Number(n).toFixed(2)}`;
// Branch scope: staff pinned to a branch only ever see their branch.
const orgWide = ORG_WIDE.has(ctx.role);
const branchWhere =
!orgWide && ctx.branchId ? { branchId: ctx.branchId } : {};
const tools: ToolSet = {};
// NB: type the param as ToolSet[string], NOT ReturnType<typeof tool> (which
// collapses to Tool<never> under AI SDK v7 and won't accept your tools).
const add = (perm: Permission, name: string, t: ToolSet[string]) => {
if (can(ctx.role, perm)) tools[name] = t; // ← omitted entirely if not permitted
};
add(
"sale.view",
"getSalesSummary",
tool({
description:
"Total revenue, transactions and average sale for a period. Use for 'how are sales', 'revenue today/this week'.",
inputSchema: z.object({
period: z.enum(["today", "7d", "30d"]).default("today"),
}),
execute: async ({ period }) => {
const start = rangeStart(period);
const agg = await db.sale.aggregate({
where: {
orgId: ctx.orgId,
status: "ACTIVE",
createdAt: { gte: start },
...branchWhere,
},
_sum: { total: true },
_count: true,
});
const revenue = Number(agg._sum.total ?? 0);
return {
card: "stats",
title: `Sales — ${period}`,
stats: [
{ label: "Revenue", value: money(revenue) },
{ label: "Transactions", value: String(agg._count) },
{
label: "Avg sale",
value: money(agg._count ? revenue / agg._count : 0),
},
],
};
},
})
);
add(
"sale.view",
"getOverdueCredit",
tool({
description:
"Customers whose credit is past due. Use for 'who owes me money'.",
inputSchema: z.object({}),
execute: async () => {
const sales = await db.sale.findMany({
where: {
orgId: ctx.orgId,
status: "ACTIVE",
balanceDue: { gt: 0 },
dueDate: { lt: new Date() },
...branchWhere,
},
select: { balanceDue: true, customer: { select: { name: true } } },
orderBy: { balanceDue: "desc" },
take: 25,
});
return {
card: "list",
title: "Overdue credit",
columns: [
{ key: "customer", label: "Customer" },
{ key: "amount", label: "Overdue", align: "right" },
],
rows: sales.map((s) => ({
customer: s.customer?.name ?? "Walk-in",
amount: money(Number(s.balanceDue)),
})),
note: sales.length
? undefined
: "No overdue credit — everyone is up to date.",
};
},
})
);
// …add getLowStock, getExpiringStock, getTopProducts, getDashboardSummary, etc.
return tools;
}Design win: because every tool returns a
stats/list/breakdown/chart/requestcard, the client stays tiny regardless of how many tools you add — it switches onoutput.card, not on the tool name.
5.1 One date resolver, not thirty
The moment you have more than a couple of tools, "today vs this week vs last month
vs between 1 Mar and 15 Mar" becomes the thing you re-implement in every executor —
inconsistently. Solve it once. A single resolveRange maps every phrase the
assistant understands (named periods and a custom from/to) to a { start, end, label }, and a reusable Zod fragment (rangeShape) drops the same three inputs into
any tool.
// lib/ai/period.ts
export const PERIODS = [
"today",
"yesterday",
"this_week",
"last_week",
"this_month",
"last_month",
"this_quarter",
"last_quarter",
"this_year",
"last_year",
"ytd",
"mtd",
"7d",
"14d",
"30d",
"60d",
"90d",
"all_time",
] as const;
export function resolveRange(
a: { period?: string; from?: string; to?: string },
fallback = "30d"
) {
// A custom range always wins ("between X and Y", "since [date]").
if (a.from || a.to) {
const start = a.from ? new Date(a.from) : new Date(0);
const end = a.to ? new Date(a.to) : new Date();
if (!isNaN(+start) && !isNaN(+end))
return {
start,
end,
label: `${start.toLocaleDateString()} – ${end.toLocaleDateString()}`,
};
}
// …switch on the named period → Monday-based weeks, calendar months/quarters,
// ytd/mtd, rolling N-day windows, all_time = epoch→now…
}
// The reusable input fragment every time-based tool spreads into its schema:
export const rangeShape = {
period: z
.enum(PERIODS)
.optional()
.describe("today, this_week, last_month, ytd, 30d, all_time, …"),
from: z.string().optional().describe("Custom range start YYYY-MM-DD"),
to: z.string().optional().describe("Custom range end YYYY-MM-DD"),
};Now every tool accepts natural-language time for free:
inputSchema: z.object({ ...rangeShape, /* tool-specific args */ }),
execute: async ({ period, from, to, ...rest }) => {
const { start, end, label } = resolveRange({ period, from, to }, "this_month");
// one query, consistent windows, a human label for the card title
},Put the full list of supported periods in the field's .describe() — that text is
what teaches the model which tokens are legal, so "month to date" reliably becomes
mtd instead of a hallucinated free-text date.
5.2 Reusable, parameterised tools — not one tool per question
The trap in a data-intensive assistant is writing getSalesToday, getSalesThisWeek,
getSalesForBranchX… — an endpoint per phrasing. Don't. Write one parameterised
tool per business concept and let arguments cover the variations. getSalesSummary
takes a period (and optional custom range); getTopProducts takes a period + limit;
getStockByLocation takes a product + location type; getBiggestDebtors takes a
minBalance. The model composes them.
That discipline is what lets the Intellixio assistant reach 40+ tools across every
module without the codebase (or the client) growing complexity — each is ~20 lines of
the same shape: add(perm, name, tool({ description, inputSchema, execute })).
| Domain | Representative tools |
|---|---|
| Sales & revenue | getSalesSummary, getSalesTrend (chart), getSalesByBranch, compareSalesPeriods, getPeriodComparison, getCancelledSales, getSalesReturns |
| Profit & value | getProfitSummary (net of VAT − COGS), getInventoryValue, getInventoryTurnover, getDashboardSummary |
| Inventory | getLowStock, getExpiringStock, getExpiredStock, getDeadStock, getProductStock, getStockByLocation, getTransfers, findSerial |
| Customers & credit | getOverdueCredit, getReceivables, getCreditAging, getBiggestDebtors, getTopCustomers, getNewCustomers, getInactiveCustomers, getCustomerStatement |
| Purchasing & suppliers | getPurchasesSummary, getTopSuppliers, getSupplierStatement |
| Money & tax | getPaymentsSummary, getPaymentsByMethod, getVatSummary |
| Ops & governance | getCashierPerformance, getApprovals, getActivity, getCatalogSummary, getProformaSummary |
| Meta (no gate) | getSalesTrend/renderChart (charts), requestDataAccess (graceful "I can't answer") |
Watch the money math per concept, not per phrasing. Because there's one
getProfitSummary, there's exactly one place to get the accounting right — profit issubtotal(net of VAT) − COGS, not the VAT-inclusivetotal. Centralising the concept centralises the correctness. (A throwaway-Postgres assertion caught our first version overstating profit by exactly the VAT.)
5.3 Charts are just another tool + another card
Two small tools turn the same architecture into a charting assistant — no new
plumbing, because a chart is just one more card envelope ({ card: "chart" }):
getSalesTrendfetches and shapes real data into time buckets (auto day/week/ month based on the span) and returns alinechart.renderChartis a meta-tool with no DB access and no permission gate — it turns figures the user already has on screen (from a previous tool result) into a bar/line/pie chart. Its description is the guardrail: "Pass the values EXACTLY as an earlier tool returned them — never invent numbers."
// A tool that fetches + buckets real data → a line chart:
add(
"sale.view",
"getSalesTrend",
tool({
description:
"Sales revenue over time as a CHART. 'sales trend', 'monthly sales chart', 'graph my sales'. Buckets by day/week/month automatically.",
inputSchema: z.object({
...rangeShape,
bucket: z.enum(["day", "week", "month"]).optional(),
}),
execute: async ({ period, from, to, bucket }) => {
const { start, end, label } = resolveRange(
{ period, from, to },
"this_year"
);
// …group ACTIVE sales into buckets → points:[{label,value}]…
return {
card: "chart",
chartType: "line",
title: `Sales trend — ${label}`,
points,
};
},
})
);
// A meta-tool that charts numbers the user ALREADY has — no gate, no DB:
tools.renderChart = tool({
description:
"Render figures the user ALREADY has (from a previous tool result) as a chart. Pass values EXACTLY as returned — never invent numbers. chartType: bar (rank), line (trend), pie (share).",
inputSchema: z.object({
chartType: z.enum(["bar", "line", "pie"]).default("bar"),
title: z.string(),
points: z
.array(z.object({ label: z.string(), value: z.number() }))
.min(1)
.max(60),
}),
execute: async ({ chartType, title, points }) => ({
card: "chart",
chartType,
title,
points,
}),
});The system prompt does the UX glue: "When a user asks for many rows of a metric, offer
'Would you like that as a table or a chart?'. If they say chart — or say 'chart this' —
call renderChart with the exact numbers already retrieved." The rendering side is in
§7.3.
5.4 Graceful "I can't answer that" — a request, not an error
Users should never see a red stack trace because they asked something you haven't
built a tool for. Give the model one more meta-tool — requestDataAccess — and instruct
it: "If no tool can answer, don't apologise vaguely and don't guess — call
requestDataAccess describing what data would answer it." It returns a request card,
which the client renders as a friendly "I don't have access to this yet — request this
capability" button that files the ask to the platform admin (§13).
tools.requestDataAccess = tool({
// no gate — always available
description:
"Call when NO other tool can answer the user's question. Describe the data that WOULD answer it. Renders a 'request access' button — do not apologise or invent an answer.",
inputSchema: z.object({
question: z.string().describe("What the user asked, in their words."),
dataDescription: z
.string()
.describe("The data/capability that would answer it."),
}),
execute: async ({ question, dataDescription }) => ({
card: "request",
question,
dataDescription,
}),
});This closes the loop: every unanswerable question becomes a product signal (see the missing tool the admin should build next) instead of a support ticket.
6. The streaming chat route (tool-calling)
// app/api/ai/chat/route.ts
import {
convertToModelMessages,
stepCountIs,
streamText,
type UIMessage,
} from "ai";
import { NextResponse } from "next/server";
import { AiError, chargeAi, requireAiFeature } from "@/lib/ai/entitlement";
import { pickModel } from "@/lib/ai/gateway";
import { buildTools } from "@/lib/ai/tools";
export async function POST(req: Request) {
let ctx;
try {
ctx = await requireAiFeature("ask");
} catch (e) {
if (e instanceof AiError)
return NextResponse.json(
{ error: e.message, code: e.code },
{ status: e.httpStatus }
);
throw e;
}
const { messages = [] } = (await req.json()) as { messages: UIMessage[] };
const promptText = lastUserText(messages); // for the activity log
const { model } = pickModel();
const requestId = crypto.randomUUID();
const startedAt = Date.now();
// ⚠️ AI SDK v7: convertToModelMessages is ASYNC — await it.
const modelMessages = await convertToModelMessages(messages);
const result = streamText({
model,
system: [
"You are an assistant embedded in a business platform.",
`The user's role is ${ctx.role}; currency ${ctx.currency}; today is ${new Date().toISOString().slice(0, 10)}.`,
"ALWAYS get business figures by calling a tool — never invent, estimate or recall numbers. If no tool can answer, say so.",
"A tool returns a card the UI shows; keep your text reply to ONE short sentence. Don't restate every number.",
].join("\n"),
messages: modelMessages,
tools: buildTools(ctx), // ← tools bound to the authed context
stopWhen: stepCountIs(6), // allow: call tool → read result → reply
onFinish: async (event) => {
const toolsUsed = [
...new Set((event.toolCalls ?? []).map((c) => c.toolName)),
];
await chargeAi({
ctx,
requestId,
feature: "ask",
model,
usage: {
inputTokens: event.usage.inputTokens,
outputTokens: event.usage.outputTokens,
},
latencyMs: Date.now() - startedAt,
toolsUsed,
prompt: promptText,
reply: event.text,
}).catch(() => {}); // never fail the response over billing bookkeeping
},
});
return result.toUIMessageStreamResponse();
}The system prompt is your safety rail: forbidding invented figures is what prevents the model from fabricating business data instead of calling a tool.
7. The client: chat panel + custom UI cards (generative UI)
7.1 The card renderer (one switch, any number of tools)
// components/ai/cards/tool-card.tsx ("use client")
export function AiToolCard({ output }: { output: unknown }) {
const d = output as any;
if (!d || typeof d !== "object" || !("card" in d)) return null;
if (d.card === "stats") return <StatsCard d={d} />;
if (d.card === "list") return <ListCard d={d} />;
if (d.card === "breakdown") return <BreakdownCard d={d} />;
if (d.card === "chart") return <ChartCardView d={d} />; // §7.3
if (d.card === "request") return <RequestAccessCard d={d} />; // §5.4 → §13
return null;
}Adding a whole new class of capability (charts, access requests) is one line here plus one small component — the chat panel, the route, and every existing tool are untouched. That is the payoff of the card-envelope contract.
7.2 The chat panel
// components/ai/ask.tsx ("use client")
import { DefaultChatTransport } from "ai";
import { useChat } from "@ai-sdk/react";
import ReactMarkdown from "react-markdown";
import { AiToolCard } from "@/components/ai/cards/tool-card";
export function AskPanel() {
const [input, setInput] = useState("");
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: "/api/ai/chat" }),
});
const busy = status === "submitted" || status === "streaming";
return (
<>
{messages.map((m) => (
<div key={m.id}>
{m.parts.map((part: any, i: number) => {
if (part.type === "text")
return <ReactMarkdown key={i}>{part.text}</ReactMarkdown>;
if (part.type.startsWith("tool-"))
// ← "tool-getSalesSummary" etc.
return part.state === "output-available" ? (
<AiToolCard key={i} output={part.output} /> // pass the tool JSON as props
) : (
<span key={i}>Looking that up…</span>
);
return null;
})}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
if (input.trim() && !busy) {
sendMessage({ text: input });
setInput("");
}
}}
>
<input value={input} onChange={(e) => setInput(e.target.value)} />
</form>
</>
);
}How the mechanism works: in AI SDK v5+/v7 a message is a list of parts. Text parts
render as text; each tool call becomes a part of type tool-<name> with a state
(input-available → output-available). When it reaches output-available, you hand
part.output straight to a React component as props. The model chooses the tool; you
choose the component.
Gate the launcher with a tiny
GET /api/ai/statusthat returns{ enabled, features }so the button is invisible for tenants without AI — and still enforce the gate server-side (§4).
7.3 Rendering charts (and giving wide data room)
The chart card carries { chartType, title, points:[{label,value}] }. Render it with
whatever chart library your app already ships (Intellixio reuses recharts, already a
dashboard dependency — so charts add zero new bundle weight). One component covers all
three shapes:
// inside tool-card.tsx — bar (rank), line (trend), pie (share)
function ChartCardView({ d }: { d: ChartCard }) {
const data = d.points.map((p) => ({
name: p.label,
value: Number(p.value) || 0,
}));
if (!data.length)
return (
<CardShell title={d.title}>
<Note text="No data to chart." />
</CardShell>
);
return (
<CardShell title={d.title}>
<div className="h-56 w-full p-2">
<ResponsiveContainer width="100%" height="100%">
{d.chartType === "line" ? (
<LineChart data={data}>
{/* Grid, X/Y, Tooltip */}
<Line
type="monotone"
dataKey="value"
stroke={CHART_COLORS[0]}
strokeWidth={2}
dot={false}
/>
</LineChart>
) : d.chartType === "pie" ? (
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="name"
innerRadius={38}
outerRadius={78}
>
{data.map((_, i) => (
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
) : (
<BarChart data={data}>
{/* Grid, X/Y, Tooltip */}
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{data.map((_, i) => (
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Bar>
</BarChart>
)}
</ResponsiveContainer>
</div>
</CardShell>
);
}Charts (and wide tables) need room, so give the slide-over an expand toggle — one piece of local state swapping the panel's max width:
const [expanded, setExpanded] = useState(false);
<aside className={cn("… transition-[max-width]", expanded ? "max-w-3xl" : "max-w-md")}>
…
<button onClick={() => setExpanded(v => !v)} aria-label={expanded ? "Collapse" : "Expand"}>
{expanded ? <Minimize2 /> : <Maximize2 />}
</button>Two nice touches worth copying from the case study:
- Streaming skeletons. A tool part passes through
input-availablebeforeoutput-available; render a<ToolSkeleton />in that window so the card's shape appears before its data — the UI streams in, it doesn't pop. - Never show a red error. Hide
output-errorparts entirely, and render any transporterroras neutral muted text ("I couldn't complete that just now — please try again"), not a red alert. Combined withrequestDataAccess(§5.4), the user's worst case is a calm "I can't do that yet" with a button — never a stack trace.
8. "Explain this figure" — one-click provenance
The single most valuable pattern for a data-intensive app: a user sees a number they don't trust, clicks a sparkle, and the AI re-derives it from source and shows the breakdown.
// lib/ai/explain.ts — one tool that re-computes a known metric from the DB
export function buildExplainTools(ctx: AiContext): ToolSet {
const explainMetric = tool({
description:
"Re-derive a business figure from source so the user can see where it comes from.",
inputSchema: z.object({
metric: z.enum([
"revenue",
"receivables",
"overdue_credit",
"inventory_value" /* … */,
]),
period: z.enum(["today", "7d", "30d"]).default("today"),
}),
execute: async ({ metric, period }) => {
if (!can(ctx.role, METRIC_PERM[metric]))
return {
card: "breakdown",
title: "Not permitted",
value: "—",
definition: "You don't have permission to see this figure.",
components: [],
};
// …switch on metric, run the exact query behind the on-screen number…
return {
card: "breakdown",
title: "Revenue — today",
value: money(revenue),
definition:
"Revenue = the sum of the total on every completed (non-cancelled) sale in the period.",
components: [
{ label: "Completed sales", value: String(count) },
{ label: "Sum of sale totals", value: money(revenue) },
],
};
},
});
return { explainMetric };
}A dedicated route (/api/ai/explain, feature "explain") receives { metric, period }
in the request body and biases the model to call explainMetric. The client is a small
popover that seeds one message and renders the breakdown card:
const [transport] = useState(
() =>
new DefaultChatTransport({
api: "/api/ai/explain",
body: { metric, period },
})
);
const { messages, sendMessage } = useChat({ transport });
// on open: sendMessage({ text: "Where does this figure come from?" })Drop <ExplainButton metric="overdue_credit" /> next to any figure. The magic:
the tool runs the same query that produced the number on screen, so the explanation
always reconciles.
8.1 "Explain this report" — the same pattern, scaled to a whole page
"Explain this figure" re-derives one number. The natural next step is a button on a full report — a P&L, a sales-by-product table, a stock-valuation — that says "explain what changed and why." Same architecture, three deliberate escalations:
- Reuse the report's own compute function — don't reimplement it. The tool calls the
exact
computeReport(type, orgId, range)the report page renders, so the AI's figures can never drift from the on-screen ones. (If your reports are computed in the page, extract that into a shared function first — it pays for itself here.) - Fetch the comparison period automatically. The tool computes the on-screen range and the immediately preceding range of equal length, so the model can talk about change ("gross margin fell 4pts vs the prior 30 days"), not just levels.
- Bind the report kind + range from the request, never the model. They come from the on-screen filters, so the explanation always matches exactly what the user is looking at. Permission is re-checked against that report's own RBAC.
// lib/ai/report-explain.ts — one tool, scoped to the report the user is viewing
export function buildReportExplainTools(
ctx: AiContext,
scope: { reportType: ReportType; from: Date; to: Date; branchId?: string }
): ToolSet {
const explainReport = tool({
description:
"Get the figures behind the report the user is viewing (this period AND the previous period) so you can explain what changed and why. Call once, then explain.",
inputSchema: z.object({}), // ← no model inputs; scope is bound from the request
execute: async () => {
if (!can(ctx.role, REPORT_PERMISSION[scope.reportType]))
return {
card: "stats",
title: "Not permitted",
stats: [],
note: "You don't have permission to view this report.",
};
// Branch staff are pinned to their branch, exactly as the report API does.
const orgWide = [
"ORG_ADMIN",
"SUB_ORG_ADMIN",
"REGIONAL_MANAGER",
].includes(ctx.role);
const branchId = orgWide ? scope.branchId : (ctx.branchId ?? undefined);
// The previous window = same length, immediately before.
const lenMs = Math.max(1, scope.to.getTime() - scope.from.getTime());
const prevTo = new Date(scope.from.getTime());
const prevFrom = new Date(scope.from.getTime() - lenMs);
const [current, previous] = await Promise.all([
computeReport(scope.reportType, ctx.orgId, {
from: scope.from,
to: scope.to,
branchId,
}),
computeReport(scope.reportType, ctx.orgId, {
from: prevFrom,
to: prevTo,
branchId,
}),
]);
const asFacts = (p) =>
p.summary.map(
(s) => `${s.label}: ${s.value}${s.sub ? ` (${s.sub})` : ""}`
);
return {
// What the CARD renders — this period's headline summary:
card: "stats",
title: current.title,
note: current.rangeLabel,
stats: current.summary.map((s) => ({ label: s.label, value: s.value })),
// Extra context ONLY the model reads (the card ignores these) to reason about change:
currentPeriod: { range: current.rangeLabel, summary: asFacts(current) },
previousPeriod: {
range: previous.rangeLabel,
summary: asFacts(previous),
},
topRows: current.rows.slice(0, 6),
columns: current.columns.map((c) => c.label),
};
},
});
return { explainReport };
}Notice the dual-audience return value: the same object feeds a stats card and
carries currentPeriod / previousPeriod / topRows that only the model reads. One
tool call, one render, plus exactly the context the model needs to explain the delta.
The route (POST /api/ai/report, feature "report") receives { report, from, to, branchId } from the on-screen filters and biases the model to call explainReport once,
then explain in a few sentences — headline, biggest changes vs the prior period (rough %),
anything unusual, and likely causes — using only the returned figures:
system: [
"The user is looking at a business report and clicked 'Explain with AI'.",
`The report is "${report}". Their role is ${ctx.role}; currency ${ctx.currency}.`,
"Call explainReport ONCE to get this period's and the previous period's figures, then explain in 2–4 short sentences: the headline result, the biggest changes vs the previous period (rough % where useful), anything unusual, and the most likely causes you can infer.",
"Use ONLY the figures the tool returns — never invent numbers. Be specific and practical, not generic.",
].join("\n"),
tools: buildReportExplainTools(ctx, { reportType: report, from, to, branchId }),
stopWhen: stepCountIs(3),The client is the same tiny popover as §8, pointed at a different route with the report
scope in the body — so a reusable <ExplainReportButton report="profit_and_loss" from={…} to={…} /> drops onto any report shell:
const [transport] = useState(
() =>
new DefaultChatTransport({
api: "/api/ai/report",
body: { report, from, to, branchId },
})
);
const { messages, sendMessage } = useChat({ transport });
// on open: sendMessage({ text: "Explain what changed in this report and why." })The reusable idea: bind the exact computation + its comparison window on the server, hand the model only real figures, and let it do the one thing it's good at — narrating the change. That's "explain this figure" scaled from a cell to a page, and it generalises to dashboards, cohorts, or any computed view you already ship.
9. Structured briefings (generateObject + caching)
For a "here's what needs your attention" dashboard, do not stream free text — get a typed object you can render as cards, and cache it so it costs one small charge/day.
// lib/ai/briefing.ts
import { generateObject } from "ai";
import { z } from "zod";
const BriefingSchema = z.object({
greeting: z.string(),
items: z
.array(
z.object({
severity: z.enum(["critical", "warning", "info"]),
title: z.string(),
detail: z.string(),
})
)
.max(6),
recommendations: z.array(z.string()).max(5),
});
export async function generateBriefing(ctx: AiContext) {
const key = cacheKey(
"dashboard",
ctx.orgId,
"ai-briefing",
ctx.branchId ?? "org",
todayIso()
);
return getCachedOrFetch(
key,
async () => {
const signals = await gatherSignals(ctx); // ← DETERMINISTIC numbers, from your DB
const facts = renderFacts(signals); // a plain-text summary of those numbers
const model = pickModel().model;
const { object, usage } = await generateObject({
model,
schema: BriefingSchema,
system:
"Use ONLY the figures provided — never invent numbers. Prioritise cash, then stock risk, then revenue.",
prompt: `Today's figures:\n${facts}\n\nWrite the briefing.`,
});
await chargeAi({
ctx,
requestId: crypto.randomUUID(),
feature: "briefing",
model,
usage: {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
},
latencyMs: 0,
prompt: facts,
reply: JSON.stringify(object),
}).catch(() => {});
return object;
},
60 * 60 * 12
); // 12h TTL
}The pattern that keeps briefings honest and cheap: compute the numbers yourself
(gatherSignals), and let the LLM only rank and phrase them. The model never
sources its own figures, and you pay one small charge per tenant per day.
10. Observability + an AI activity feed
Every interaction already writes an AiUsageLog row (§4.2, in chargeAi) with the
question, the reply, the tools used, tokens, cost and latency. Two payoffs:
- A user-facing "AI Activity" page — what was asked, what the AI replied, which tools it called — navigable by day. Great for trust and debugging.
- A super-admin cost view — usage and spend by tenant, so you know whether each tenant's AI revenue covers its AI cost.
// lib/ai/activity.ts — a day's interactions for one org (org-scoped; caller authorises orgId)
export async function getAiActivityForDay(orgId: string, date: string) {
const start = new Date(`${date}T00:00:00.000Z`);
const end = new Date(start.getTime() + 86_400_000);
const items = await db.aiUsageLog.findMany({
where: { orgId, createdAt: { gte: start, lt: end } },
orderBy: { createdAt: "desc" },
});
return {
date,
items,
summary: {
interactions: items.length,
toolsCalled: items.reduce((a, r) => a + r.toolsUsed.length, 0),
spent: items.reduce((a, r) => a + Number(r.chargedCost), 0),
},
};
}Store only what you need. Truncate prompts/replies; don't log full sensitive payloads.
11. Super-admin controls (enable per tenant, grant credits)
AI is a controlled entitlement. Give your platform admin the ability to enable /
trial / suspend / disable AI per tenant, toggle capabilities, and grant credits —
all audited. A tiny PATCH /api/organizations/[id]/ai (action = enable | start_trial |
suspend | disable | update) and a POST …/ai/credits (append a GRANT to the ledger +
bump the balance in one transaction) are all you need. Enabling defaults the capability
map to { ask: true, explain: true }.
Why per-tenant + server-enforced: it gives you cost control (you don't pay for every tenant), commercial flexibility (AI becomes a paid upgrade), and safety (the platform decides exactly who gets it).
12. AI economics: sell credits without overselling the pool
The wallet in §4 meters consumption. This section is about supply: you buy real capacity from the gateway in USD, you sell org credits in your currency, and you must never sell more real capacity than you actually hold. An AI economics console (super-admin only) makes that safe and visible.
12.1 The two-currency model
There are two ledgers and one exchange rate between them:
Vercel AI Gateway balance (USD) ──× FX (USD→local)──► the credit POOL (local)
│ real capacity you paid for what you're allowed to sell
▼
Per request: our cost rawLocal = gatewayUSD × FX
we charge chargedLocal = rawLocal × org.margin
profit = rawLocal × (margin − 1)So a grant of X local credits to an org at margin M commits X / M of real cost
(and yields X − X/M profit if fully consumed). That single identity drives the whole
console.
12.2 Read the real gateway balance
The gateway exposes your actual account balance — surface it so the admin isn't guessing.
Cache it briefly and degrade to null rather than throwing (the call fails when
there's no card on file or the API is down; that must not break the page):
// lib/ai/platform.ts [server-only]
import { gateway } from "ai";
export async function getVercelCredits() {
// cached ~60s; null on error
try {
const c = await gateway.getCredits(); // { balance, totalUsed } in USD
return { balanceUsd: Number(c.balance), totalUsedUsd: Number(c.totalUsed) };
} catch {
return null; // no card / API down → show "unavailable", not a crash
}
}There is no programmatic top-up endpoint — top up in the Vercel dashboard. The console links out to it; don't build a fake "pay" button that can't work.
12.3 A super-admin-editable FX rate
The USD→local rate is a business input, not a constant. Store it in a singleton config
row, cache it, and let the admin edit it. Every cost calculation reads getUsdToGhs()
so a rate change reprices everything consistently:
export async function getUsdToGhs(): Promise<number> {
// DB-backed, cached 5 min
const cached = await cache.get<string>("ai:usd-to-ghs");
if (cached != null) return Number(cached);
const row = await db.aiPlatformConfig.upsert({
where: { id: "singleton" },
create: { id: "singleton", usdToGhs: USD_TO_GHS },
update: {},
select: { usdToGhs: true },
});
await cache.set("ai:usd-to-ghs", String(row.usdToGhs), { ex: 300 });
return Number(row.usdToGhs);
}12.4 The pool view — committed vs available
Roll the whole platform up into one view. The key figures: the pool (gateway USD ×
FX), what's already committed to orgs (Σ balance / margin — real cost you owe if
they spend it all), and therefore what's still available to sell:
export async function getPlatformCreditView() {
const [rate, vercel, orgs, usage] = await Promise.all([
getUsdToGhs(),
getVercelCredits(),
db.organization.findMany({
where: { aiStatus: { in: ["ACTIVE", "TRIAL"] } },
select: { aiCreditBalanceGhs: true, aiMarginMultiplier: true },
}),
db.aiUsageLog.aggregate({ _sum: { chargedGhs: true, rawCostGhs: true } }),
]);
let committedRawGhs = 0,
outstandingChargedGhs = 0;
for (const o of orgs) {
const bal = Number(o.aiCreditBalanceGhs);
const margin = Math.max(1, Number(o.aiMarginMultiplier));
committedRawGhs += bal / margin; // real cost we're on the hook for
outstandingChargedGhs += bal; // face value we owe the orgs
}
const poolGhs = vercel ? vercel.balanceUsd * rate : null;
const availableRawGhs = poolGhs != null ? poolGhs - committedRawGhs : null;
const lifetimeChargedGhs = Number(usage._sum.chargedGhs ?? 0);
const lifetimeRawGhs = Number(usage._sum.rawCostGhs ?? 0);
return {
usdToGhs: rate,
vercel,
poolGhs,
committedRawGhs,
availableRawGhs,
outstandingChargedGhs,
orgsWithAi: orgs.length,
lifetimeProfitGhs: lifetimeChargedGhs - lifetimeRawGhs,
};
}lifetimeProfitGhs (everything ever charged − our real cost) is your running margin on
the whole AI line — the number that tells you the business is actually profitable, not
just busy.
12.5 The grant preview — see the profit, refuse the oversell
This is the payoff. When the admin grants credits to an org, show — before they
confirm — exactly what it costs you, what you'll make, and whether it exceeds the pool.
Because profit is amount × (margin − 1), the admin can drag the margin to hit a
target profit; because the guard is cost ≤ available, they cannot sell capacity
that doesn't exist:
export function previewGrant(
amountGhs: number,
margin: number,
availableRawGhs: number | null
) {
const m = Math.max(1, margin);
const costRawGhs = amountGhs / m; // real cost this grant commits
const profitGhs = amountGhs - costRawGhs; // profit if fully consumed
const exceedsPool =
availableRawGhs != null && costRawGhs > availableRawGhs + 1e-6;
return { costRawGhs, profitGhs, exceedsPool, availableRawGhs };
}Wire exceedsPool to disable the confirm button (and enforce it again server-side in the
grant route — the preview is UX, the block is a rule). The org-detail "AI" tab then reads
like a dealing desk: grant 200 at ×3 → costs you 66.67, profit 133.33, pool has room →
confirm.
The three questions this answers, all of which the plain wallet couldn't: "How much real money is this AI costing me?" (pool committed), "Am I making money on it?" (per-grant + lifetime profit), and "Can I safely promise this org more?" (available headroom / oversell guard).
13. Graceful degradation + a data-request queue
Two failure modes must never reach the user as a red error: the gateway is down/unpaid, and the question has no tool. Handle both as calm, actionable states.
Circuit breaker (gateway health). Classify gateway failures (a "customer verification
required" / no-card error looks nothing like "out of credits" — don't conflate them),
trip a short Redis-backed breaker on outage, and have GET /api/ai/status report
available: false. The launcher then shows "temporarily unavailable, try again shortly"
instead of letting requests hammer a dead gateway.
// lib/ai/errors.ts (sketch) — distinguish "gateway unavailable" from "no credits"
export function classifyAiError(e: unknown) {
const m = String((e as any)?.message ?? "").toLowerCase();
if (
m.includes("customer_verification") ||
m.includes("credit card") ||
m.includes("ai gateway") ||
m.includes("rate limit") ||
m.includes("quota")
)
return "gateway_unavailable";
return "unknown";
}
// markGatewayDown()/isGatewayDown() flip a 5-min key in Redis; the chat route checks it
// up front and the status route surfaces it, so the UI self-recovers.Data-request queue (missing tool). The requestDataAccess tool (§5.4) returns a
request card; its button POSTs to /api/ai/access-request, which appends an
AiDataRequest row (the user's question + the data they wanted). The super-admin console
lists these — a prioritised backlog of the exact tools to build next, ranked by real
demand. The unanswerable question becomes the roadmap.
// the request card's button (client)
await fetch("/api/ai/access-request", {
method: "POST",
body: JSON.stringify({
question: d.question,
dataDescription: d.dataDescription,
}),
});
// server: db.aiDataRequest.create({ data: { orgId, userId, question, dataDescription, status: "OPEN" } })14. Security checklist
- Tenant isolation:
orgId(and branch) bound from the session, never from the model/client. Verify a tool invoked under tenant A can never return tenant B rows. - RBAC inheritance: every tool re-checks
can(role, perm); tools the role lacks are omitted from the set. A cashier asking for company-wide profit is denied. - Server-side entitlement on every AI route (402/403), not just hidden UI.
- No hallucinated figures: system prompt forbids inventing numbers; all figures come from tools.
- Disable is safe: turning AI off never affects the rest of the app.
- Truncate + minimise what you log; never log secrets.
- Actions are drafts first: any write action the AI proposes should create a draft a human approves — never let the model execute financial/stock changes autonomously.
15. Cost-control patterns
- Intent → minimal tool → small structured retrieval. Never dump whole tables into the prompt; tools return only what's needed, capped/paginated.
- Tiered models: cheap by default, escalate only for hard reasoning.
- Cache expensive/repeated intelligence (daily briefing, rankings) per tenant.
- Meter everything through
chargeAi, and expose usage to the platform admin. - Per-tenant caps (optional): a monthly request ceiling in addition to the wallet.
16. AI SDK v7 gotchas (things that will bite you)
These differ from most v5-era tutorials you'll find online:
convertToModelMessagesis async.awaitit beforestreamText, or TypeScript fails withPromise<ModelMessage[]> is missing … from ModelMessage[].- Usage fields are
inputTokens/outputTokens/totalTokens(allnumber | undefined) — notpromptTokens/completionTokens. - Models are plain
provider/modelstrings; the gateway resolves them whenAI_GATEWAY_API_KEYis set. No provider client to instantiate. - Dynamic tool sets: when building a
ToolSetconditionally, type your helper's tool param asToolSet[string], notReturnType<typeof tool>(which infersTool<never>and rejects your tools). - Client:
useChat({ transport: new DefaultChatTransport({ api }) }), thensendMessage({ text }). There's noinput/handleSubmitany more — you own the input state. Iteratemessage.partsand switch onpart.type. - Tool UI parts are
type: "tool-<name>"withstate(input-streaming→input-available→output-available/output-error); readpart.outputatoutput-available. generateObjectreturns{ object, usage }and takes a Zodschema.- The gateway is importable:
import { gateway } from "ai"gives yougateway.getCredits()→{ balance, totalUsed }(USD) for the economics console (§12). It can throw (no card on file / API down) — wrap it and degrade tonull. - Charts are just data: there's no chart primitive in the SDK — a chart is a normal
tool return (
{ card: "chart", … }). All the "AI" is choosingrenderChart; your app draws it.
17. A pragmatic rollout plan
Architect the whole thing up front, but ship progressively — each phase is independently useful and safe (off by default):
- Foundation — gateway, entitlement + credit wallet, super-admin enable/grant, a
hidden
/api/ai/pingthat proves the whole path (gate → real model call → charge). - Ask — the tool layer + streaming chat + custom cards. Start with 5–10 tools; the
add(perm, name, tool)shape means growing to 40+ is additive, never structural. - Explain-this —
explainMetric+ a breakdown card + a reusable Explain popover. - Briefing —
generateObject+ caching on the dashboard. - Breadth + charts — one
resolveRange(§5.1), a parameterised tool per business concept (§5.2),getSalesTrend+renderChart+ thechartcard (§5.3, §7.3). - Economics + graceful degradation — the AI economics console (real balance, FX,
pool/oversell guard, per-grant profit — §12), the circuit breaker, and the
requestDataAccess→ data-request queue so nothing ever errors red (§13). - Later — "explain this report", event-driven alerts, a business-context/baseline engine, draft actions (approval-gated), RAG over your docs, self-serve credit purchase.
18. Adapting this to your app — a checklist
- Identify your tenant boundary and the session helper that yields
{ tenantId, userId, role, scope }. That helper feedsrequireAiFeature. - List the 10–15 questions your users actually ask ("who owes me", "what's low",
"why did X drop"). Each becomes one tool with a tight
description. - For every tool, decide the permission it requires and whether it's scope-limited (branch/region/team). Enforce both inside the executor.
- Standardise a small set of card shapes (stat grid, list/table, breakdown, chart, request). Map every tool to one. Keep the client renderer a single switch.
- Write one date resolver (
resolveRange+ a sharedrangeShape) and reuse it in every time-based tool — don't re-implement "this month" per tool. - Pick your credit unit (a money wallet is the most flexible) and a margin. If you buy capacity in another currency, add the economics console (§12): real balance, editable FX, pool/oversell guard, per-grant profit.
- Wire
chargeAiintoonFinishfor every AI route so nothing is unmetered. - Add the Explain affordance to your 3–4 most-questioned figures first.
- Decide your can't-answer path up front: a
requestDataAccesstool + a request queue, and hide/soften all error states. Users should never see red. - Keep the golden rule visible in your system prompts: the database is the source of truth; call a tool, never invent a number.
Appendix — file map (case study)
lib/ai/gateway.ts model tiers + cost math (pure, unit-tested)
lib/ai/period.ts universal date resolution (resolveRange + rangeShape)
lib/ai/entitlement.ts requireAiFeature (gate) + chargeAi (meter) [server-only]
lib/ai/tools.ts 40+ RBAC/tenant-scoped read tools → card envelopes
lib/ai/explain.ts explainMetric → breakdown card
lib/ai/report-explain.ts explainReport → re-derive a whole report vs prior period
lib/ai/signals.ts deterministic figures for the briefing (pure DB)
lib/ai/briefing.ts generateObject + daily cache [server-only]
lib/ai/activity.ts a day's interactions for the activity page
lib/ai/platform.ts AI economics: gateway balance, FX, pool + oversell [server-only]
lib/ai/errors.ts classify gateway errors + Redis circuit breaker [server-only]
app/api/ai/chat/route.ts streaming chat (tool-calling)
app/api/ai/explain/route.ts focused provenance chat
app/api/ai/report/route.ts "explain this report"
app/api/ai/briefing/route.ts cached daily briefing
app/api/ai/status/route.ts { enabled, features, available, hasCredits } for the client
app/api/ai/ping/route.ts end-to-end diagnostic
app/api/ai/access-request/route.ts file a missing-tool request (requestDataAccess)
app/api/super-admin/ai/route.ts economics view (GET) + set FX rate (PATCH)
app/api/super-admin/ai/requests/route.ts the data-request queue
app/api/organizations/[id]/ai/route.ts super-admin enable/disable/trial + margin/features
app/api/organizations/[id]/ai/credits/route.ts super-admin grant credits (oversell-guarded)
components/ai/ask-intellixio.tsx chat launcher + slide-over (expand toggle, skeletons)
components/ai/explain-button.tsx the "Explain this figure" popover
components/ai/explain-report-button.tsx "explain this report" popover
components/ai/executive-briefing.tsx dashboard briefing widget
components/ai/cards/tool-card.tsx stats / list / breakdown / chart / request renderer
components/super-admin/ai-console.tsx economics console (balance, FX, pool, requests)*Every pattern here ships in Intellixio OPS. The through-line: the model orchestrates and explains; your database decides the truth; your permissions decide what's visible; and your wallet decides what it costs.*


