Architecture

AI

Kit ships an AI chat powered by the Vercel AI SDK and AI Gateway. Streaming responses, organization-scoped access, prebuilt chat UI.

Overview

The AI system provides:

  • Real-time streaming - Responses stream token by token
  • Organization-scoped access - The chat endpoint verifies org membership
  • Prebuilt UI - ai-elements components in packages/ui
  • Gateway routing - Bare model ids resolve through the Vercel AI Gateway

Architecture

graph LR
    A[Chat UI] --> B[useChat Hook]
    B --> C[/api/chat]
    C --> D[Org Membership Check]
    D --> E[AI Gateway]
    E --> F[Streaming Response]
    F --> A

Core Components

1. API Route (/api/chat)

// apps/web/src/app/api/chat/route.ts
import { getOrganization } from "@repo/api/auth/auth";
import { convertToModelMessages, smoothStream, streamText, validateUIMessages } from "ai";
import { APIError } from "better-auth/api";
import { z } from "zod";

export const maxDuration = 30;

const chatRequestSchema = z.object({
  messages: z.array(z.unknown()),
  slug: z.string(),
});

export async function POST(request: Request) {
  const parsed = chatRequestSchema.safeParse(await request.json());
  if (!parsed.success) {
    return new Response("Invalid request body", { status: 400 });
  }
  const { slug } = parsed.data;

  // Throws UNAUTHORIZED without a session, FORBIDDEN for non-members
  const organization = await getOrganization({ organizationSlug: slug });
  if (!organization) {
    return new Response("Organization not found", { status: 404 });
  }

  const messages = await validateUIMessages({ messages: parsed.data.messages });

  const result = streamText({
    // Bare model ids resolve through the Vercel AI Gateway
    model: "openai/gpt-4o-mini",
    messages: await convertToModelMessages(messages),
    experimental_transform: smoothStream({ chunking: "word" }),
  });

  return result.toUIMessageStreamResponse();
}

Access control rides on better-auth: getOrganization throws for anonymous users and non-members, so unauthorized chat requests never reach the model.

2. Chat UI

The chat form uses useChat from @ai-sdk/react with a DefaultChatTransport that injects the org slug:

// apps/web/src/app/(dashboard)/dashboard/[slug]/(home)/_components/ai-chat-form.tsx
export const AIChatForm = ({ slug }: AIFormProps) => {
  const { messages, sendMessage, status, stop } = useChat({
    transport: new DefaultChatTransport({
      api: "/api/chat",
      prepareSendMessagesRequest({ messages, id, body }) {
        return { body: { id, messages, slug, ...body } };
      },
    }),
    onError: (error) => {
      toast.error(`An error occurred, ${error.message}`);
    },
  });
  // ...renders Conversation / Message / PromptInput
};

Messages are UI-message parts - render by part.type, not a flat content string.

3. UI Components

Chat primitives come from the shared ai-elements set in packages/ui/src/components/ai-elements/:

  • Conversation, ConversationContent, ConversationScrollButton
  • Message, MessageContent, MessageResponse (markdown rendering)
  • PromptInput, PromptInputTextarea, PromptInputSubmit, PromptInputTools

Import via @repo/ui/components/ai-elements/<name>.

Environment Setup

One key. The Gateway routes to providers:

# Vercel AI Gateway key (https://vercel.com/docs/ai-gateway)
AI_GATEWAY_API_KEY=your-gateway-key

Customization

Switching Models

Model ids are creator-prefixed Gateway ids:

model: "anthropic/claude-sonnet-4.5",
// or
model: "openai/gpt-5",

System Prompts

const result = streamText({
  model: "openai/gpt-4o-mini",
  system: `You are a helpful assistant for the ${organization.name} team.`,
  messages: await convertToModelMessages(messages),
});

Tools

The AI SDK supports tool calling out of the box:

import { tool } from "ai";
import { z } from "zod";

const result = streamText({
  model: "openai/gpt-4o-mini",
  messages: await convertToModelMessages(messages),
  tools: {
    getOrganizationInfo: tool({
      description: "Get information about the current organization",
      inputSchema: z.object({}),
      execute: async () => organization,
    }),
  },
});

Render tool parts in the UI by adding cases to the part.type switch in ai-chat-form.tsx.

Persistence

Chat history is not persisted - the starter keeps chats in memory. To persist, add a chatMessage table scoped to organizationId in packages/db/src/drizzle-schema.ts and save in the route's onFinish callback.

Best Practices

  • Validate the body - The route Zod-parses the request and validateUIMessages checks message shape
  • Check access first - Membership check runs before any model call
  • Stream everything - toUIMessageStreamResponse plus smoothStream for even rendering
  • Cap duration - maxDuration = 30 bounds serverless execution

Next Steps

  1. Deployment - Production deployment
  2. API patterns - tRPC architecture
  3. Persistence - Add a chat history table via the database guide

On this page