Skip to content
Build With SyntaxKit

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 read

On This Page

How A Chat Turn Flows

A chat turn: PromptInput sends through useChat to /rpc/chat/send, the handler runs billing and abuse checks, persists the user message, calls streamText with gateway(modelId), and the token stream rides back through toUIMessageStream and streamToEventIterator into the Conversation. onFinish persists the assistant message and an AiUsageEvent.

A turn goes: composer → useChatclient.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:

  1. The user message is saved before the model stream starts.
  2. The assistant message and an AiUsageEvent row are written in onFinish, once tokens stop.

Package Layout

What's Wired In

CapabilityHow it's enabled
Streaming chat with reasoning + sourcestoUIMessageStream({ sendReasoning: true, sendSources: true }) in chat.send
Image attachmentsBrowser: storage.presign → PUT → storage.finalize. Server: assertOwnedAttachmentParts (key prefix images/<userId>/)
Web searchPro-only. Switches the model to perplexity/sonar and disables image attachments for that turn
Multi-model selectionPro-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
Regeneratechat.regenerate deletes the trailing assistant turn(s) and streams a replacement
Auto-titled chatsFirst-turn onFinish runs generateText with openai/gpt-4o-mini and writes Chat.title once
Cursor-paginated chat list + searchchat.list (cursor) + chat.search (title and content)
Cursor-paginated message historychat.get returns the latest page plus nextCursor; chat.listMessages loads older pages
Per-plan monthly response capreserveAiUsageEvent counts and inserts an AiUsageEvent under a per-org advisory lock
Abuse protectionUpstash 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.

ChoiceWhy
Vercel AI SDKOne streaming API (streamText), one prompt format (ModelMessage), one tool protocol. The chat handler is not OpenAI-specific.
AI GatewayOne 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:

PatternWhere
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 / stopPass status to <PromptInputSubmit> so the button toggles submit, stop, and pending
RegenerateDrain 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 closuresmodelRef 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

FeatureFreePro
monthlyAiResponses100 / monthUnlimited (null)
multiModelAccess (model picker beyond default)NoYes
webSearch (Perplexity Sonar)NoYes

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.

ConstantValueWhat it bounds
CHAT_MAX_MESSAGES_PER_REQUEST1Only the latest user turn is sent on each call
CHAT_MAX_PARTS_PER_MESSAGE4Text + file parts per message
CHAT_MAX_TEXT_LENGTH_PER_PART4,000Characters in any single text part
CHAT_MAX_USER_MESSAGE_TEXT_LENGTH4,000Total characters across all text parts in one message
CHAT_MAX_CONTEXT_MESSAGES40History the server includes when calling the model
CHAT_MAX_CONTEXT_CHARACTERS20,000Total history characters across included messages
CHAT_DEFAULT_MESSAGES_PAGE_SIZE50Messages returned per page from chat.get and chat.listMessages
CHAT_MAX_MESSAGES_PAGE_SIZE100Hard 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 } from generateText for a single mutation result.

Wire the client

  • Stream: useChat with the same transport.sendMessages pattern, 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

Also useful: Storage for the attachment pipeline, Database for the AI models, and Customization for the shadcn / Tailwind layer the ai-elements primitives extend.

Was this page helpful?

On this page