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 --> ACore 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,ConversationScrollButtonMessage,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-keyCustomization
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
validateUIMessageschecks message shape - Check access first - Membership check runs before any model call
- Stream everything -
toUIMessageStreamResponseplussmoothStreamfor even rendering - Cap duration -
maxDuration = 30bounds serverless execution
Next Steps
- Deployment - Production deployment
- API patterns - tRPC architecture
- Persistence - Add a chat history table via the database guide