---
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) => (
))}
);
};
```
`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)