Introduction

OverviewPhilosophyStructureUpdatesFAQ

Usage

Other

The standard
Examples

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

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 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

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 (
    <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

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 (
    <>
      <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 app

What changed since v4

If you are adapting an older tutorial:

v4v6
useChat({ api: '/api/chat' })useChat({ transport: new DefaultChatTransport({ api }) })
input, handleInputChange, handleSubmitYour own useState plus sendMessage({ text })
isLoadingstatussubmitted, streaming, ready, error
result.toDataStreamResponse()result.toUIMessageStreamResponse()
message.content — a stringmessage.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. 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.