JB logo
CoffeeyOUTUBE
Blog
Next

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, RBAC- and tenant-scoped tool-calling that returns UI card envelopes, streaming chat with generative UI, one-click Explain-this-figure provenance, cached structured briefings, and full cost observability. 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:

  1. A model gateway abstraction (cheap by default, escalate only when needed).
  2. A per-tenant entitlement + credit wallet (AI is off by default, metered by real cost).
  3. A tool layer that is RBAC- and tenant-scoped, returning card envelopes.
  4. A streaming chat endpoint (tool-calling).
  5. A client chat panel that renders tool results as custom UI cards (generative UI).
  6. "Explain this figure" — one-click provenance for any number on screen.
  7. A structured daily briefing (generateObject + caching).
  8. Observability (an AI activity log) and super-admin controls.

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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."
  5. 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 cards

Key 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 accounting

The 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/haiku tiers 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 either a stats or list card, the client stays tiny regardless of how many tools you add — it switches on output.card, not on the tool name.


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} />;
  return null;
}

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-availableoutput-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/status that returns { enabled, features } so the button is invisible for tenants without AI — and still enforce the gate server-side (§4).


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.


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. 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.

13. 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.

14. AI SDK v7 gotchas (things that will bite you)

These differ from most v5-era tutorials you'll find online:

  • convertToModelMessages is async. await it before streamText, or TypeScript fails with Promise<ModelMessage[]> is missing … from ModelMessage[].
  • Usage fields are inputTokens / outputTokens / totalTokens (all number | undefined) — not promptTokens / completionTokens.
  • Models are plain provider/model strings; the gateway resolves them when AI_GATEWAY_API_KEY is set. No provider client to instantiate.
  • Dynamic tool sets: when building a ToolSet conditionally, type your helper's tool param as ToolSet[string], not ReturnType<typeof tool> (which infers Tool<never> and rejects your tools).
  • Client: useChat({ transport: new DefaultChatTransport({ api }) }), then sendMessage({ text }). There's no input/handleSubmit any more — you own the input state. Iterate message.parts and switch on part.type.
  • Tool UI parts are type: "tool-<name>" with state (input-streaminginput-availableoutput-available / output-error); read part.output at output-available.
  • generateObject returns { object, usage } and takes a Zod schema.

15. A pragmatic rollout plan

Architect the whole thing up front, but ship progressively — each phase is independently useful and safe (off by default):

  1. Foundation — gateway, entitlement + credit wallet, super-admin enable/grant, a hidden /api/ai/ping that proves the whole path (gate → real model call → charge).
  2. Ask — the tool layer + streaming chat + custom cards.
  3. Explain-thisexplainMetric + a breakdown card + a reusable Explain popover.
  4. BriefinggenerateObject + caching on the dashboard.
  5. Later — "explain this report", event-driven alerts, a business-context/baseline engine, draft actions (approval-gated), RAG over your docs, self-serve credit purchase.

16. Adapting this to your app — a checklist

  • Identify your tenant boundary and the session helper that yields { tenantId, userId, role, scope }. That helper feeds requireAiFeature.
  • 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 2–3 card shapes (stat grid, list/table, breakdown). Map every tool to one. Keep the client renderer a single switch.
  • Pick your credit unit (a money wallet is the most flexible) and a margin.
  • Wire chargeAi into onFinish for every AI route so nothing is unmetered.
  • Add the Explain affordance to your 3–4 most-questioned figures first.
  • 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/entitlement.ts    requireAiFeature (gate) + chargeAi (meter)          [server-only]
lib/ai/tools.ts          RBAC/tenant-scoped read tools → card envelopes
lib/ai/explain.ts        explainMetric → breakdown card
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
 
app/api/ai/chat/route.ts       streaming chat (tool-calling)
app/api/ai/explain/route.ts    focused provenance chat
app/api/ai/briefing/route.ts   cached daily briefing
app/api/ai/status/route.ts     { enabled, features } for the client
app/api/ai/ping/route.ts       end-to-end diagnostic
app/api/organizations/[id]/ai/route.ts          super-admin enable/disable/trial
app/api/organizations/[id]/ai/credits/route.ts  super-admin grant credits
 
components/ai/ask.tsx                chat launcher + slide-over
components/ai/explain-button.tsx     the "Explain this figure" popover
components/ai/executive-briefing.tsx dashboard briefing widget
components/ai/cards/tool-card.tsx    stats / list / breakdown card renderer

*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.*