--- title: Create an AI Chatbot description: Build a streaming chatbot on the AI package. type: guide prerequisites: - /en/docs/setup/quickstart related: - /en/docs/packages/ai --- # Create an AI Chatbot `@kreogen/ai` wraps the [AI SDK](https://ai-sdk.dev) and ships the two components a chat surface needs. This walks through wiring them into the authenticated app. The package is on AI SDK **v6** (`ai@^6`, `@ai-sdk/react@^3`). Most tutorials you will find online are written against v4, where `useChat` owned the input state and the route returned `toDataStreamResponse()`. Neither exists any more — see [what changed](#what-changed-since-v4) at the end. ## 1. Set an API key `@kreogen/ai` reads `OPENAI_API_KEY`, which must start with `sk-`. Put it in `apps/app/.env.local`: ``` OPENAI_API_KEY="sk-..." ``` The default models are `gpt-4o-mini` for chat and `text-embedding-3-small` for embeddings, set in `packages/ai/lib/models.ts`. ## 2. Add the route handler ```ts title="apps/app/app/api/chat/route.ts" import { convertToModelMessages, streamText, type UIMessage } from '@kreogen/ai'; import { models } from '@kreogen/ai/lib/models'; import { requireSession } from '@kreogen/auth/session'; export const POST = async (request: Request) => { await requireSession(); if (!models) { return new Response('AI is not configured.', { status: 503 }); } const { messages }: { messages: UIMessage[] } = await request.json(); const result = streamText({ model: models.chat, system: 'You are a helpful assistant.', messages: convertToModelMessages(messages), }); return result.toUIMessageStreamResponse(); }; ``` Three things earn their place here. The `!models` guard is the repository-wide degrade convention, not boilerplate: `models` is `undefined` without `OPENAI_API_KEY`, so a project that never configured AI answers 503 rather than throwing on every message. `requireSession()` comes first. A route that calls a paid model on request is somebody else's bill if it is public, and route handlers are not covered by the page-level auth check — the middleware cookie check is optimistic and proves nothing. `convertToModelMessages` is not optional. The browser sends `UIMessage`s, which carry a `parts` array — text, tool calls, reasoning, files. The model wants `ModelMessage`s. Passing the UI shape through unconverted fails at the provider with an unhelpful error about message content. ## 3. Build the UI ```tsx title="apps/app/app/(authenticated)/components/chatbot.tsx" 'use client'; import { DefaultChatTransport } from '@kreogen/ai'; import { Message } from '@kreogen/ai/components/message'; import { Thread } from '@kreogen/ai/components/thread'; import { useChat } from '@kreogen/ai/lib/react'; import { Button } from '@kreogen/design-system/components/ui/button'; import { Input } from '@kreogen/design-system/components/ui/input'; import { handleError } from '@kreogen/design-system/lib/utils'; import { SendIcon } from 'lucide-react'; import { useState } from 'react'; export const Chatbot = () => { const [input, setInput] = useState(''); const { messages, sendMessage, status } = useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }), onError: handleError, }); const busy = status === 'submitted' || status === 'streaming'; return (
{messages.map((message) => ( ))}
{ event.preventDefault(); if (!input.trim()) { return; } sendMessage({ text: input }); setInput(''); }} > setInput(event.target.value)} placeholder="Ask a question!" value={input} />
); }; ``` `useChat` no longer manages the input. That is a deliberate change in the SDK — the hook owning a text field was the single most common source of "why does my input clear on re-render" — so `useState` is now yours. `onError: handleError` shows a toast through the design system. Without it a failed request updates `status` to `'error'` and nothing appears on screen. ## 4. Render it ```tsx title="apps/app/app/(authenticated)/page.tsx" import { requireOrganization } from '@kreogen/auth/session'; import { createMetadata } from '@kreogen/seo/metadata'; import type { Metadata } from 'next'; import { Chatbot } from './components/chatbot'; import { Header } from './components/header'; export const metadata: Metadata = createMetadata({ title: 'Chat', description: 'Ask a question.', }); const App = async () => { await requireOrganization(); return ( <>
); }; export default App; ``` `requireOrganization()` is the convention for an authenticated page — it redirects when there is no session and when the session has no active organization, so the page body never has to check either. ## 5. Run it ```sh bun dev --filter app ``` ## What changed since v4 If you are adapting an older tutorial: | v4 | v6 | | -------------------------------------------- | ----------------------------------------------------------- | | `useChat({ api: '/api/chat' })` | `useChat({ transport: new DefaultChatTransport({ api }) })` | | `input`, `handleInputChange`, `handleSubmit` | Your own `useState` plus `sendMessage({ text })` | | `isLoading` | `status` — `submitted`, `streaming`, `ready`, `error` | | `result.toDataStreamResponse()` | `result.toUIMessageStreamResponse()` | | `message.content` — a string | `message.parts` — a typed array | The last one is the one that bites. `message.content` is simply gone, so a component reading it renders empty rather than failing. `Message` already handles this: it filters `parts` to the text ones and joins them. ## Going further * **Rate limit the route.** A public-ish endpoint that spends money per call wants [a limiter](/en/docs/packages/rate-limit) keyed on the user id. * **Persist the conversation.** `messages` is client state and vanishes on reload. A tenant-scoped model and `requireOrganization()`'s scoped client are the natural home for it. * **Add tools.** `streamText` takes a `tools` map; `stopWhen: stepCountIs(n)` bounds how many round trips a single request may make. --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt)