Conversation

Production-ready chatbot UI built on the AI SDK v6 + ai-elements, with Streamdown markdown, reasoning, tool calls, and a shimmer "Thinking..." state

Working Chatbot

Uses the AI SDK v6 (ai@^6, @ai-sdk/react@^3) with ai-elements for the conversation shell + Streamdown for streaming markdown. Tool calls render as collapsible cards, reasoning appears in a "Thinking..." panel that auto-closes when done, and a shimmer indicates inflight requests.

Installation

Install the SDK + provider packages

npm install ai @ai-sdk/react @ai-sdk/groq streamdown sonner zod
pnpm add ai @ai-sdk/react @ai-sdk/groq streamdown sonner zod
yarn add ai @ai-sdk/react @ai-sdk/groq streamdown sonner zod
bun add ai @ai-sdk/react @ai-sdk/groq streamdown sonner zod

Install the ai-elements components

Conversation pulls in everything you need: message bubbles, response (Streamdown), reasoning panel, tool-call cards, and the shimmer indicator.

npx ai-elements@latest add conversation message response reasoning tool shimmer prompt-input

Create the API route

Make an app/api/chat/route.ts (App Router):

app/api/chat/route.ts
import { groq } from '@ai-sdk/groq';
import {
  convertToModelMessages,
  smoothStream,
  stepCountIs,
  streamText,
  type UIMessage,
} from 'ai';

export const maxDuration = 30;

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: groq('openai/gpt-oss-120b'), // good function-calling on Groq
    messages: await convertToModelMessages(messages),
    stopWhen: stepCountIs(6),
    maxOutputTokens: 8192,
    experimental_transform: smoothStream({ chunking: 'word' }),
  });

  return result.toUIMessageStreamResponse({
    sendReasoning: true, // enables <Reasoning /> rendering on the client
  });
}

v6 changes from v5:

  • convertToModelMessages is async — always await it.
  • maxSteps → stopWhen: stepCountIs(n)
  • maxTokens → maxOutputTokens
  • toDataStreamResponse() → toUIMessageStreamResponse()
  • Tool definitions use inputSchema (not parameters) and the tool({...}) helper.

Add your API key

.env.local
GROQ_API_KEY=your-groq-api-key

Hook it up on the client

useChat in v6 no longer manages input state — own it with useState and submit via sendMessage({ parts: [{ type: 'text', text }] }):

components/working-chatbot.tsx
'use client';

import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
import { isToolUIPart, getToolName } from 'ai';

import {
  Conversation,
  ConversationContent,
  ConversationEmptyState,
  ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import { Message, MessageContent } from '@/components/ai-elements/message';
import { Response } from '@/components/ai-elements/response';
import {
  Reasoning,
  ReasoningContent,
  ReasoningTrigger,
} from '@/components/ai-elements/reasoning';
import {
  Tool,
  ToolContent,
  ToolHeader,
  ToolInput,
  ToolOutput,
} from '@/components/ai-elements/tool';
import { Shimmer } from '@/components/ai-elements/shimmer';
import {
  PromptInput,
  PromptInputSubmit,
  PromptInputTextarea,
} from '@/components/ai-elements/prompt-input';

export default function WorkingChatbot() {
  const [input, setInput] = useState('');
  const { messages, status, sendMessage } = useChat();
  const isLoading = status === 'submitted' || status === 'streaming';

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim()) return;
    sendMessage({ parts: [{ type: 'text', text: input.trim() }] });
    setInput('');
  };

  return (
    <div className="relative size-full h-[600px] rounded-lg border">
      <Conversation>
        <ConversationContent>
          {messages.length === 0 ? (
            <ConversationEmptyState
              title="Start a conversation"
              description="Ask me anything below"
            />
          ) : (
            messages.map((message, i) => {
              const isLast = i === messages.length - 1;
              const reasoning = message.parts
                .filter((p) => p.type === 'reasoning')
                .map((p) => (p as { text: string }).text)
                .join('\n\n');
              const lastPart = message.parts.at(-1);
              const reasoningStreaming =
                isLast &&
                status === 'streaming' &&
                lastPart?.type === 'reasoning';

              return (
                <Message from={message.role} key={message.id}>
                  <MessageContent>
                    {reasoning && (
                      <Reasoning isStreaming={reasoningStreaming}>
                        <ReasoningTrigger />
                        <ReasoningContent>{reasoning}</ReasoningContent>
                      </Reasoning>
                    )}
                    {message.parts.map((part, idx) => {
                      if (part.type === 'text') {
                        return (
                          <Response key={`${message.id}-${idx}`}>
                            {part.text}
                          </Response>
                        );
                      }
                      if (isToolUIPart(part)) {
                        return (
                          <Tool key={part.toolCallId} defaultOpen>
                            <ToolHeader
                              type={`tool-${getToolName(part)}` as never}
                              state={part.state}
                            />
                            <ToolContent>
                              {(part.state === 'input-available' ||
                                part.state === 'output-available' ||
                                part.state === 'output-error') &&
                                part.input !== undefined && (
                                  <ToolInput input={part.input} />
                                )}
                              {part.state === 'output-available' && (
                                <ToolOutput
                                  output={
                                    <pre className="text-xs whitespace-pre-wrap p-3">
                                      {typeof part.output === 'string'
                                        ? part.output
                                        : JSON.stringify(part.output, null, 2)}
                                    </pre>
                                  }
                                  errorText={undefined}
                                />
                              )}
                              {part.state === 'output-error' && (
                                <ToolOutput
                                  output={null}
                                  errorText={part.errorText}
                                />
                              )}
                            </ToolContent>
                          </Tool>
                        );
                      }
                      return null;
                    })}
                  </MessageContent>
                </Message>
              );
            })
          )}
          {isLoading &&
            messages.at(-1)?.role === 'user' && (
              <Message from="assistant">
                <MessageContent>
                  <Shimmer duration={1.8}>
                    {status === 'submitted' ? 'Thinking...' : 'Generating...'}
                  </Shimmer>
                </MessageContent>
              </Message>
            )}
        </ConversationContent>
        <ConversationScrollButton />
      </Conversation>

      <PromptInput onSubmit={handleSubmit} className="mt-4">
        <PromptInputTextarea
          value={input}
          placeholder="Say something..."
          onChange={(e) => setInput(e.currentTarget.value)}
        />
        <PromptInputSubmit
          status={status === 'streaming' ? 'streaming' : 'ready'}
          disabled={!input.trim()}
        />
      </PromptInput>
    </div>
  );
}

Point the demo at your route

The bundled working-chatbot defaults to /api/demo-chat. Switch it to your route by passing a custom transport:

+ import { DefaultChatTransport } from 'ai';

  const { messages, status, sendMessage } = useChat({
+   transport: new DefaultChatTransport({ api: '/api/chat' }),
  });

What each piece does

PieceWhy it's here
Conversation / ConversationContentSticky-bottom scroll container + content slot. Auto-scrolls as new tokens stream in.
ConversationEmptyStateRenders icon/title/description placeholder when messages.length === 0.
ConversationScrollButtonFloating "scroll to bottom" pill that appears when the user scrolls up.
ConversationDownloadOptional. Floating button that exports the chat as a .md file via the bundled messagesToMarkdown helper.
Message / MessageContentPer-bubble role-aware styling (is-user / is-assistant).
ResponseThe Streamdown-powered markdown renderer. Handles incomplete code fences, GFM tables, math, and live re-flow during streaming.
Reasoning / ReasoningTrigger / ReasoningContentCollapsible "Thinking..." panel that auto-opens while the model streams reasoning parts and auto-closes 1s after they finish.
Tool / ToolHeader / ToolInput / ToolOutputCollapsible tool-call card showing input JSON + output + status badge (input-streaming / input-available / output-available / output-error).
ShimmerAnimated gradient text. Use for the "Thinking..." / "Generating..." indicator in the assistant bubble between the user's send and the first streamed token.
PromptInput familyTextarea + submit button wired for submitted/streaming status.

Conversation Example

Last updated on