AI patterns
How AI features work here — the provider seam, metered entry points, streaming route vs one-shot action, and the usage budget wired into billing. Use when adding any model call, or when AI spend and entitlements are involved.
Generated from
.claude/skills/ai-patterns/SKILL.md, which ships in every project created from this template. Your agent loads it on demand; this page is the same text. Edit the skill, not this page.
Every model call goes through src/lib/ai/ — never the ai-sdk directly from a
feature. That single choke point is what makes "every call is gated and
metered" a property of the codebase instead of a habit.
The three entry points
| you are building | call | lives in |
|---|---|---|
| token-by-token chat UI | streamAiChat(ctx, { messages }) | a route handler (/api/ai/chat) |
| one-shot generation (draft, summarize) | generateAiText(ctx, { prompt }) | a server action or a job |
| image generation | generateAiImage(ctx, { prompt }) | a server action or a job; persist via the files layer |
Streaming belongs to route handlers, not actions. A server action resolves
once; the SDK's UI message stream is a Response the client consumes as it
arrives, and useChat speaks that wire format natively. Do not reach for RSC
streamable values to force streaming into an action — the route handler is the
supported, boring path.
Every entry point takes a TenantContext first, like a query does — an AI
call is spend, and spend belongs to an organization.
The provider seam
src/lib/ai/provider.ts is the only file that names a vendor. textModel()
is Anthropic, imageModel() is OpenAI (Anthropic has no image model), and the
model ids come from env (AI_TEXT_MODEL, AI_IMAGE_MODEL) so a deprecation
is a config change, not a deploy.
Unconfigured is a supported state: the functions throw an error naming the
missing variable, the assistant page shows an explanatory Alert instead of a
broken chat, and the route returns the sentence with a 503. Follow that
pattern for any new AI surface — check aiTextConfigured() /
aiImageConfigured() where you would otherwise render a dead UI.
The budget is an ordinary entitlement
aiTokensPerMonth and aiImagesPerMonth are limits in
src/config/billing.ts, like projects or seats. There is no second
billing system: a plan change re-prices AI the way it re-prices seats, and
no-plan-string-compare applies to AI code like everywhere else.
The mechanics, all inside the entry points already:
requireAiBudget(ctx, kind)runs BEFORE the call and throws PAYMENT_REQUIRED when the month's budget is spent — the caller's normal error path (ActionResult, route error JSON) carries the sentence.recordAiUsage(ctx, …)runs AFTER, writing oneai_usage_eventsrow per call with the provider's own token counts. Missing counts record as zero — an honest under-charge beats an invented number.- An organization can overshoot by at most one call. Deliberate: gating on a pre-call estimate refuses work the budget could actually cover.
The first thing to do with a real API key is read an ai_usage_events row.
Not "did a completion arrive" — that is obvious when it fails. The accounting
above is tested; what is NOT tested anywhere is whether a live provider's
response carries its counts where recordAiUsage looks for them. If ai-sdk
moves that field, counts land as zero and every test still passes, because
zero-because-absent and zero-because-cheap are the same row.
If you add a new kind of AI spend, add a limit to billing.ts, a column or
kind to ai_usage_events, and a requireAiBudget branch — all three, or the
meter lies.
Usage rows are tenant data
ai_usage_events spreads tenantColumns and has an RLS policy like any
tenant table. Usage is billing data: reading another org's spend is a
confidentiality bug, writing into their meter is a billing bug. Sum queries go
through withTenant like everything else.
The reference surface
src/app/api/ai/chat/route.ts— session-authenticated, rate-limited per organization (the budget is the org's; IPs should not multiply it), validates withsafeValidateUIMessages(the SDK's own contract, not our approximation), maps AppError to its real status.src/features/assistant/—useChatrenderingparts(a response can carry text, reasoning and tool calls; rendering onlytextis the floor).src/lib/ai/usage.integration.test.ts— the shape of a metering test, including the one that matters: events past the limit refusing the next call.
Data tables and pagination
The list convention — Paginated queries, URL-state filters and pagination with nuqs, tables, empty states. Use when building or changing any list of records, or when pagination/filtering behaves oddly.
Audit logging
How the audit trail works — the .audit() stage on the action client, what gets recorded and what must not, immutability via RLS, and the lint rule on destructive actions. Use when adding mutations, when the require-audit-on-destructive rule fires, or when compliance questions come up.