Create an AI Chatbot
Build a streaming chatbot on the AI package.
@kreogen/ai wraps the AI SDK 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 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
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 UIMessages, which
carry a parts array — text, tool calls, reasoning, files. The model wants
ModelMessages. Passing the UI shape through unconverted fails at the provider
with an unhelpful error about message content.
3. Build the UI
'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 (
<div className="flex h-[calc(100vh-64px-16px)] flex-col divide-y overflow-hidden">
<Thread>
{messages.map((message) => (
<Message data={message} key={message.id} />
))}
</Thread>
<form
className="flex shrink-0 items-center gap-2 px-8 py-4"
onSubmit={(event) => {
event.preventDefault();
if (!input.trim()) {
return;
}
sendMessage({ text: input });
setInput('');
}}
>
<Input
onChange={(event) => setInput(event.target.value)}
placeholder="Ask a question!"
value={input}
/>
<Button disabled={busy} size="icon" type="submit">
<SendIcon className="h-4 w-4" />
</Button>
</form>
</div>
);
};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
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 (
<>
<Header page="AI Chatbot" pages={[{ label: 'Workspace', href: '/' }]} />
<Chatbot />
</>
);
};
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
bun dev --filter appWhat 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 keyed on the user id.
- Persist the conversation.
messagesis client state and vanishes on reload. A tenant-scoped model andrequireOrganization()'s scoped client are the natural home for it. - Add tools.
streamTexttakes atoolsmap;stopWhen: stepCountIs(n)bounds how many round trips a single request may make.