AI
Org-scoped streaming chat at /dashboard/ai-chat. Built on the Vercel AI SDK and AI Gateway, with billing gates, abuse limits, and a reusable pattern for new AI features.
Last updated on
10 min readOn This Page
How A Chat Turn Flows
A turn goes: composer → useChat → client.chat.send → model stream → conversation UI.
The kit does not point useChat at a plain fetch URL. Instead, a custom transport.sendMessages calls the oRPC streaming procedure client.chat.send, then converts the result with eventIteratorToUnproxiedDataStream from @orpc/client. On the server, streamToEventIterator(result.toUIMessageStream({ sendReasoning: true, sendSources: true })) turns the model stream into UI message parts (including reasoning and sources).
Persistence is split on purpose:
- The user message is saved before the model stream starts.
- The assistant message and an
AiUsageEventrow are written inonFinish, once tokens stop.
Package Layout
What's Wired In
| Capability | How it's enabled |
|---|---|
| Streaming chat with reasoning + sources | toUIMessageStream({ sendReasoning: true, sendSources: true }) in chat.send |
| Image attachments | Browser: storage.presign → PUT → storage.finalize. Server: assertOwnedAttachmentParts (key prefix images/<userId>/) |
| Web search | Pro-only. Switches the model to perplexity/sonar and disables image attachments for that turn |
| Multi-model selection | Pro-only. Free plan stays on CHAT_DEFAULT_MODEL_ID |
| Voice transcription | <SpeechInput> uses the browser Web Speech API, or passes a recorded Blob to onAudioRecorded |
| Regenerate | chat.regenerate deletes the trailing assistant turn(s) and streams a replacement |
| Auto-titled chats | First-turn onFinish runs generateText with openai/gpt-4o-mini and writes Chat.title once |
| Cursor-paginated chat list + search | chat.list (cursor) + chat.search (title and content) |
| Cursor-paginated message history | chat.get returns the latest page plus nextCursor; chat.listMessages loads older pages |
| Per-plan monthly response cap | reserveAiUsageEvent counts and inserts an AiUsageEvent under a per-org advisory lock |
| Abuse protection | Upstash sliding-window limits keyed by userId + organizationId on chat.send and chat.regenerate |
The AI SDK And Gateway
Every model call goes through one adapter: gateway(modelId). Change providers by changing a string. One Vercel key (AI_GATEWAY_API_KEY) covers many model ids and one billing surface.
| Choice | Why |
|---|---|
| Vercel AI SDK | One streaming API (streamText), one prompt format (ModelMessage), one tool protocol. The chat handler is not OpenAI-specific. |
| AI Gateway | One key, model ids like openai/gpt-5.2, anthropic/claude-haiku-4.5, google/gemini-3-flash, perplexity/sonar. No per-provider SDK wiring. |
Default model. CHAT_DEFAULT_MODEL_ID lives in packages/shared/src/schemas/chat.ts. The picker list lives in apps/web/components/dashboard/ai-chat/chat-view/models.ts (each entry carries display metadata). To show a model in the picker, add it there. The server already accepts any string gateway() understands.
Env. Chat needs one AI-specific variable: AI_GATEWAY_API_KEY. It is listed in turbo.json for cache invalidation, but not yet in apps/web/.env.example.
Add AI_GATEWAY_API_KEY to your environment when you set up the kit. Without it, every AI request fails at the SDK boundary with an authentication error. Get a key from vercel.com/dashboard under "AI Gateway".
Supported providers and current model ids: models.dev. Any id the gateway accepts there works in gateway(modelId) here.
The Streaming Procedure
chat.send (POST /rpc/chat/send) runs five checks before it opens a model connection:
Validate attachment ownership
assertOwnedAttachmentParts checks every file part. Each url must sit under NEXT_PUBLIC_S3_PUBLIC_URL, and the key must start with images/<userId>/ for the session user. Attachments must already have gone through the kit's presign + finalize pipeline.
Resolve the model and check billing features
With webSearch on, resolveChatModel returns perplexity/sonar and rejects a non-default explicit model. Otherwise resolveRequestedModel calls assertBillingFeature for multiModelAccess when the request leaves the default model. webSearch itself is gated the same way.
Apply the abuse policy
enforceChatAbusePolicy runs the shared chat.send surface with userId and organizationId. Limits live in packages/shared/src/abuse.ts. Over the limit returns TOO_MANY_REQUESTS with retryAfter.
Build the model context
buildModelContextMessages walks history newest-first, capped at CHAT_MAX_CONTEXT_MESSAGES (40) and CHAT_MAX_CONTEXT_CHARACTERS (20,000). Image parts become { type: "image", image: URL, mediaType }; everything else is plain { role, content }. The system prompt is the inline string "You are a helpful assistant." in chat.ts.
Reserve the monthly response slot
reserveAiUsageEvent counts usage in the active billing window (subscription period, or calendar month on free) and inserts an AiUsageEvent in one transaction. A Postgres advisory lock per org keeps concurrent requests from overshooting the cap. Free plans are capped at monthlyAiResponses: 100; Pro is unlimited (null), which skips the lock and only inserts.
If the model call fails, streamText.onError (and a sync try/catch) best-effort deletes that row so a failed turn does not consume quota. assertWithinAiResponseLimit remains available for soft UI checks; the chat router does not use it for enforcement.
After the gates pass, the user Message is persisted, then:
const result = streamText({
model: gateway(modelId),
system: "You are a helpful assistant.",
messages: modelMessages,
onError: async () => {
// Refund the reservation row so a failed turn does not consume the cap.
},
onFinish: async ({ text }) => {
// Persist the assistant Message and (on the first turn)
// generateText({ model: gateway("openai/gpt-4o-mini") }) to set
// Chat.title and flip Chat.titleGenerated. The AiUsageEvent row has
// already been written by reserveAiUsageEvent before streaming began.
},
});
return streamToEventIterator(
result.toUIMessageStream({
sendReasoning: true,
sendSources: true,
})
);chat.regenerate uses the same gates and streamText shape. In onFinish it deletes the trailing assistant turn(s) before inserting the replacement, and writes AiUsageEvent with kind: "chat_regenerate" instead of "chat_send".
The Client: useChat With An oRPC Transport
useChat from @ai-sdk/react drives the UI. The kit bridges it to oRPC with one transport helper: call client.chat.send, then convert the event iterator into the data stream useChat expects.
import { useChat } from "@ai-sdk/react";
import { eventIteratorToUnproxiedDataStream } from "@orpc/client";
import { client } from "@/lib/orpc";
const { messages, sendMessage, status, stop } = useChat({
id: chatId,
messages: seedMessages,
transport: {
async sendMessages(options) {
const latestMessage = options.messages[options.messages.length - 1];
return eventIteratorToUnproxiedDataStream(
await client.chat.send(
{
chatId: options.chatId,
messages: buildSendPayloadMessages(latestMessage),
model: webSearchRef.current ? undefined : modelRef.current,
webSearch: webSearchRef.current,
},
{ signal: options.abortSignal }
)
);
},
reconnectToStream() {
throw new Error("Unsupported");
},
},
onFinish: () => {
// Invalidate the sidebar list and dashboard stats.
},
});Patterns used around that bridge:
| Pattern | Where |
|---|---|
seedMessages | [chatId]/page.tsx prefetches chat.get. ChatView maps DB rows to UIMessages once via dbMessagesToUIMessages. Older pages load through chat.listMessages and setMessages. |
status / stop | Pass status to <PromptInputSubmit> so the button toggles submit, stop, and pending |
| Regenerate | Drain client.chat.regenerate outside useChat (for await), then router.refresh() |
initialPrompt | ?prompt= from the new-chat hub auto-sends when status === "ready", then router.replace strips the query |
| Refs over closures | modelRef and webSearchRef keep the transport on the latest choice, because useChat captures the transport once per id |
AI Elements: Building Blocks
Reusable presentation primitives, grouped by job. Import from @syntaxkit/ui/components/ai-elements/<file>.
Message, PromptInput, Attachments, and PromptInputSubmit use ai package types (UIMessage, ChatStatus, FileUIPart, SourceDocumentUIPart). The rest are pure presentation and work outside useChat.
Billing And Limits
Two surfaces gate AI usage: plan features (what you may do) and per-request limits (what fits in one payload).
Per-plan features
| Feature | Free | Pro |
|---|---|---|
monthlyAiResponses | 100 / month | Unlimited (null) |
multiModelAccess (model picker beyond default) | No | Yes |
webSearch (Perplexity Sonar) | No | Yes |
The cap counts AiUsageEvent rows (kind: "chat_send" or "chat_regenerate") in the active billing window. See Billing for plan config, and Storage: How An Upload Flows for the attachment pipeline.
Per-request limits
Defined in packages/shared/src/schemas/chat.ts and enforced by Zod on the server.
| Constant | Value | What it bounds |
|---|---|---|
CHAT_MAX_MESSAGES_PER_REQUEST | 1 | Only the latest user turn is sent on each call |
CHAT_MAX_PARTS_PER_MESSAGE | 4 | Text + file parts per message |
CHAT_MAX_TEXT_LENGTH_PER_PART | 4,000 | Characters in any single text part |
CHAT_MAX_USER_MESSAGE_TEXT_LENGTH | 4,000 | Total characters across all text parts in one message |
CHAT_MAX_CONTEXT_MESSAGES | 40 | History the server includes when calling the model |
CHAT_MAX_CONTEXT_CHARACTERS | 20,000 | Total history characters across included messages |
CHAT_DEFAULT_MESSAGES_PAGE_SIZE | 50 | Messages returned per page from chat.get and chat.listMessages |
CHAT_MAX_MESSAGES_PAGE_SIZE | 100 | Hard cap for the per-page message limit |
History is trimmed newest-first in buildModelContextMessages, so long threads keep the recent tail.
Abuse Protection
chat.send and chat.regenerate are surfaces in the shared abuse policy. Each request is keyed by userId and organizationId. Both must be present; otherwise the surface fails closed.
When Upstash Redis is not configured, the chat handler logs a single warning per process and continues without rate limits. That is intentional for local development. Configure Upstash before going live. See Security: Abuse Protection (Upstash).
Tune limits in packages/shared/src/abuse.ts. Each surface declares its window and limit per characteristic, so you can lower the per-user cap without changing the per-org cap.
Adding A New AI Feature
Use the same shape as chat: schema, optional quota, procedure, return type, client, UI. Works for streaming or one-shot features (summarize, alt text, draft email, and similar).
Define the schema in packages/shared
Add packages/shared/src/schemas/<feature>.ts with Zod input/output schemas and any per-request constants. Re-export from packages/shared/src/schemas/index.ts.
export const summarizeInputSchema = z.object({
text: z.string().min(1).max(50_000),
style: z.enum(["bullet", "tldr", "executive"]).default("tldr"),
});Add usage accounting (optional)
If the feature should count toward a quota, extend AiUsageEvent.kind (or mirror that model) in packages/database/prisma/models/ai.prisma, then migrate. Skip this for free internal tools.
Write the oRPC procedure
Mirror chat middleware: org auth, billing assertion, abuse gate, then streamText (stream) or generateText (one-shot). Reserve a usage row before spending gateway tokens; refund it if the model errors.
import { CHAT_DEFAULT_MODEL_ID } from "@syntaxkit/shared";
export const summarize = authorized
.use(withActiveOrganization)
.route({ path: "/summarize", method: "POST" })
.input(summarizeInputSchema)
.handler(async ({ context, input }) => {
const billing = await getBillingState(context.organization.id);
const usage = await reserveAiUsageEvent(billing, context.organization.id, {
kind: "chat_send",
chatId: null,
createdByUserId: context.user.id,
});
try {
const result = streamText({
model: gateway(CHAT_DEFAULT_MODEL_ID),
system: "Summarize the input in the requested style.",
prompt: input.text,
onError: () =>
prisma.aiUsageEvent
.delete({ where: { id: usage.id } })
.catch(() => {}),
});
return streamToEventIterator(result.toUIMessageStream());
} catch (error) {
await prisma.aiUsageEvent
.delete({ where: { id: usage.id } })
.catch(() => {});
throw error;
}
});Pick the return shape
- Stream:
return streamToEventIterator(result.toUIMessageStream(...))for token-by-token UI. - One-shot:
return { text }fromgenerateTextfor a single mutation result.
Wire the client
- Stream:
useChatwith the sametransport.sendMessagespattern, pointed at your procedure. - One-shot:
useMutation(orpc.<feature>.run.mutationOptions()). Typed object in, typed object out. No stream bookkeeping.
Compose the UI from ai-elements
Use <PromptInput> for input and <Conversation> + <Message> + <MessageResponse> for output. Add <Reasoning> and <Sources> if the model returns them. These primitives are not chat-only; any UIMessage-shaped surface can use them.
Where To Go Next
API
oRPC patterns: middleware, streaming procedures, OpenAPI surface, calling from React.
Billing
How plans, entitlements, and feature flags drive AI gating.
Also useful: Storage for the attachment pipeline, Database for the AI models, and Customization for the shadcn / Tailwind layer the ai-elements primitives extend.
Billing
Org-scoped Stripe subscriptions with hosted Checkout and Customer Portal. One catalog file defines tiers, prices, features, and limits. Gate access from oRPC with BillingState phases and entitlements.
Storage
S3-compatible uploads in packages/storage. The browser PUTs to a presigned URL; the server validates with sharp, writes a final key, and deletes the temp. Any S3-compatible store works; only endpoint and credentials change.
