launch-quickly
Patterns

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 buildingcalllives in
token-by-token chat UIstreamAiChat(ctx, { messages })a route handler (/api/ai/chat)
one-shot generation (draft, summarize)generateAiText(ctx, { prompt })a server action or a job
image generationgenerateAiImage(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 one ai_usage_events row 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 with safeValidateUIMessages (the SDK's own contract, not our approximation), maps AppError to its real status.
  • src/features/assistant/useChat rendering parts (a response can carry text, reasoning and tool calls; rendering only text is 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.

On this page