Skip to content
BlocksChatbotMenu

Chatbot

A complete chat app surface: streaming Markdown, reasoning, tool calls with approvals, message actions, an empty state and a composer with attachments, model picker and reasoning effort. One install, a few lines to wire to AI SDK.

AI SDK useChat on a mock transport: reasoning, tool calls, an approval and a Markdown answer.

The demo runs real AI SDK useChat on a mock transport, so it needs no API key. Send any message to play the script again. Approve the edit to watch the tests run, or deny it to get the patch instead.

Installation

$ pnpm dlx shadcn@latest add https://chatcn.dev/r/chatbot.json

The block installs to components/chatcn/chatbot/chatbot.tsx together with every component it composes and the useChatcnChat hook. It is your code from here: change the header, drop the model picker, add a sidebar.

Wire it to AI SDK

Pass the whole useChatcnChat result as chat. It is useChat from AI SDK with chatcn messages and actions attached, and approvals resume the run on their own.

app/page.tsx
"use client"

import { DefaultChatTransport } from "ai"
import { Chatbot } from "@/components/chatcn/chatbot/chatbot"
import { useChatcnChat } from "@/hooks/use-chatcn-chat"

export default function Page() {
  const chat = useChatcnChat({
    transport: new DefaultChatTransport({ api: "/api/chat" }),
  })
  return (
    <Chatbot
      chat={chat}
      fullHeight
      suggestions={["Find why the runs page is slow", "Write tests for getRuns"]}
    />
  )
}

The route handler is plain AI SDK. Tools with needsApproval show Allow and Deny in the thread; the answer goes back through addToolApprovalResponse and the run continues.

app/api/chat/route.ts
import { anthropic } from "@ai-sdk/anthropic"
import { convertToModelMessages, isStepCount, streamText, tool, type UIMessage } from "ai"
import { z } from "zod"

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

  const result = streamText({
    model: anthropic("claude-sonnet-4-5"),
    messages: await convertToModelMessages(messages),
    stopWhen: isStepCount(8),
    tools: {
      read_file: tool({
        description: "Read a file from the repository",
        inputSchema: z.object({ path: z.string() }),
        execute: async ({ path }) => readFile(path),
      }),
      edit_file: tool({
        description: "Replace text in a file",
        inputSchema: z.object({ path: z.string(), old: z.string(), new: z.string() }),
        needsApproval: true,
        execute: async (input) => editFile(input),
      }),
    },
  })

  return result.toUIMessageStreamResponse({ sendReasoning: true })
}

Model and reasoning effort

The picker and the effort control are UI only. Keep their values in state and send them with each request through the transport body, which AI SDK reads at send time.

tsx
const [model, setModel] = useState("claude-sonnet-4-5")
const [effort, setEffort] = useState("medium")
const settings = useRef({ model, effort })
settings.current = { model, effort }

const chat = useChatcnChat({
  transport: new DefaultChatTransport({ api: "/api/chat", body: () => settings.current }),
})

<Chatbot
  chat={chat}
  models={models}
  model={model}
  onModelChange={setModel}
  reasoningEffort={effort}
  onReasoningEffortChange={setEffort}
/>

Without AI SDK

Any runtime works. Pass messages in the chatcn contract, a status and the actions you support; missing actions hide their controls (no regenerate, no Retry button).

tsx
<Chatbot
  messages={messages}
  status={status}
  actions={{ send, stop, approve, deny }}
  onNewChat={reset}
/>

Examples

Empty state

A heading, the composer and up to four starters. Picking one sends it. On the first send the composer glides from the middle to the bottom of the new thread, so it reads as one object moving. On phones the composer stays at the bottom and the starters scroll in one row above it.

Heading, composer and starters. The composer glides down on the first send.

What it handles

MomentWhat the block shows
EmptyEmptyState heading and description, the composer, Suggestions
Sent, no tokens yetThinkingIndicator: nothing for 300 ms, then Thinking for at least 400 ms
ReasoningReasoning: Thinking with a live summary, then Thought for 4 s
Tool callsEach tool's own renderer through renderToolPart, unknown tools fall back
ApprovalAllow, Always allow and Deny with a note, wired to approve and deny
AnswerStreaming Markdown with code blocks, tables and math
DoneCopy, thumbs up or down with reasons, and Retry response
StoppedUnfinished tools show Cancelled and the message says Stopped
ErrorMessageError matched to the cause (network, rate limit, context, filter) with Retry

Props

PropTypeNotes
chatChatbotChatThe useChatcnChat() result: messages, status, error, actions
messagesChatcnMessage[]Overrides chat. AI SDK messages work as is
statusChatbotStatusready, submitted, streaming or error, as in useChat
errorErrorShown under the last message with the fix
actionsChatcnActionssend, stop, regenerate, approve, deny. Missing ones hide
titleReactNodeDefaults to New chat, then the first line of the first message
headerReactNodeReplaces the header. null renders none
headerActionsReactNodeExtra controls before New chat
footerReactNodeUnder the composer: a note, usage or a hint
onNewChat() => voidDefaults to stopping and clearing chat messages
emptyTitleReactNodeEmpty state heading
emptyDescriptionReactNodeOne line under the heading
suggestionsChatbotSuggestion[]Strings or { label, prompt, icon }. Up to four
modelsModelOption[]No picker without models
providersModelProvider[]Group names and order
modelstringControlled model, with onModelChange
defaultModelstringUncontrolled starting model
reasoningEffortstringControlled effort, with onReasoningEffortChange
defaultReasoningEffortstringUncontrolled. Any effort prop shows the control
placeholderstringDefaults to Ask anything…
attachmentsbooleanPaste, drop or pick files. On by default
acceptstringSame syntax as <input accept>
maxFileSizenumberBytes. Defaults to 20 MB
uploadFilefunction(file, onProgress) => Promise<{ url }>. Otherwise files go inline as data URLs
toolRenderersToolRendererMapDefaults to every chatcn tool renderer
toolVariantinline or cardInline keeps long traces quiet
onFeedbackfunction(messageId, value, reasons?) after a thumbs up or down
fullHeightbooleanFill the viewport and stay above the keyboard
autoFocusbooleanFocus the composer on mount

Keyboard

KeyAction
EnterSend. Never while an IME is composing
Shift EnterNew line
Esc in the composerStop generating
⌘ Shift O (Ctrl Shift O)New chat
TabThrough the thread, message actions, approvals and the composer controls
Arrow keys in Low, Medium, HighChange reasoning effort, without animation

Accessibility

  • The thread is a focusable log that stays silent while tokens stream. Each finished answer is read once through a polite live region.
  • Thinking is a status, so screen readers hear it without it taking focus.
  • Approvals are real buttons with a reason above them; denial never relies on color.
  • Errors use role=alert and say how to fix the problem, with the raw detail behind a disclosure.
  • Every icon-only control has a label and a tooltip. Touch screens get 44 px targets.
  • Use fullHeight for a full-page chat: the composer stays above the on-screen keyboard and clear of the home indicator.

Motion

  • On the first send the composer moves from the empty state to the bottom over 280 ms with the default ease-out. Reduced motion skips it.
  • Starters stagger in 40 ms apart, once. Messages fade in; restored history appears at once.
  • Send turns into the stop square in the same footprint with a 150 ms blur crossfade.
  • Tokens append with no per-word effects. The thread sticks to the bottom only while you are there.