Docs · Vercel AI SDK

Vercel AI SDK

Drop Prompt Area into a Vercel AI SDK chatbot. PromptArea owns the input; useChat owns the stream. They meet at one line: sendMessage({ text: segmentsToPlainText(segments) }).

The AI SDK gives you transport, streaming, and message state for free. Prompt Area gives you a rich composer — mentions, slash commands, tags, inline markdown, and attachments — that most chat starters skip. Because Prompt Area is just a controlled Segment[] value, it sits cleanly between the two: you keep the AI SDK's runtime and add a real input on top.

  • One integration seam. Flatten segments with segmentsToPlainText() and hand them to sendMessage. No bespoke serialization.
  • Status-aware controls. The AI SDK's status drives the action bar — the send button becomes a stop button while a response streams.
  • Structured metadata. Read typed chips with getChipsByTrigger() and ship them in the request body next to the prompt.
  • Provider-agnostic. Swap @ai-sdk/anthropic for any other provider package — the component code is identical.

Install

Add the AI SDK alongside Prompt Area. This example streams from Claude, so it uses the Anthropic provider — swap in another provider package (@ai-sdk/openai, @ai-sdk/google, …) if you prefer.

npm install ai @ai-sdk/react @ai-sdk/anthropic zod

The Anthropic provider reads ANTHROPIC_API_KEY from the environment. Set it in .env.local before calling the route.

Example

Streaming chat with useChat

Type a message and submit. The demo simulates the AI SDK stream so it runs without a backend; the Code tab shows the real useChat + Claude wiring. The send button becomes a stop button while a response streams.

Send a message to start streaming a response.
The live preview swaps the real useChat() for a small simulated hook so it can run in the browser with no API key. Everything else — the onSubmit wiring, segmentsToPlainText(), the status-aware send/stop button, and rendering message.parts — is identical to the production code in the Code tab.

How it works

On submit, flatten the segments to a string with segmentsToPlainText() and pass it to sendMessage. Mentions, commands, and tags resolve to their display text, so a model-friendly prompt falls out for free; useChat appends it to messages and POSTs to your route.

The AI SDK's status (ready, submitted, streaming, error) drives the action bar: while a response streams, the send button becomes a stop button wired to stop(). Guard handleSubmit on both isSegmentsEmpty(segments) and the streaming state so a press during generation is a no-op rather than a second request.

On the server, convertToModelMessages() turns the UI message history into the provider format, streamText() calls the model, and toUIMessageStreamResponse() streams it back in the shape useChat expects. Render each assistant reply by mapping over message.parts and picking the text parts.

Send structured context

Flattening to text is enough for a plain chatbot, but the whole point of chips is keeping structure. Read them back with getChipsByTrigger() and attach them to the request body — the AI SDK merges body into the POST, so your route receives them next to messages.

import { getChipsByTrigger, segmentsToPlainText } from '@/components/segment-helpers'

function handleSubmit() {
  const mentions = getChipsByTrigger(segments, '@')
  const commands = getChipsByTrigger(segments, '/')

  sendMessage(
    { text: segmentsToPlainText(segments) },
    {
      // Anything in `body` is merged into the POST to /api/chat,
      // so typed chips travel alongside the prompt — no string parsing.
      body: {
        mentions: mentions.map((c) => c.value),
        command: commands[0]?.value,
        model: 'claude-opus-4-8',
      },
    },
  )
  setSegments([])
}

Then validate the body with a Zod schema in your route handler and shape the call from the parsed, allowlisted values:

import { anthropic } from '@ai-sdk/anthropic'
import { convertToModelMessages, streamText, type UIMessage } from 'ai'
import { z } from 'zod'

// Allowlist everything you accept — the request body is untrusted input.
const bodySchema = z.object({
  messages: z.array(z.custom<UIMessage>()),
  model: z.enum(['claude-opus-4-8', 'claude-haiku-4-5']).default('claude-opus-4-8'),
  command: z.enum(['summarize', 'translate']).optional(),
  mentions: z.array(z.string().max(64)).max(10).default([]),
})

export async function POST(req: Request) {
  const parsed = bodySchema.safeParse(await req.json())
  if (!parsed.success) {
    return Response.json({ error: parsed.error.issues }, { status: 400 })
  }
  const { messages, model, command, mentions } = parsed.data

  const system = [
    command
      ? `The user invoked the "${command}" command. Follow its conventions.`
      : 'You are a helpful assistant.',
    mentions.length ? `People referenced: ${mentions.join(', ')}.` : '',
  ]
    .filter(Boolean)
    .join(' ')

  const result = streamText({
    model: anthropic(model),
    system,
    messages: convertToModelMessages(messages),
  })

  return result.toUIMessageStreamResponse()
}
Request body is untrusted input. Parse it with a Zod schema and safeParse on the server — allowlist model IDs and command values with z.enum() and bound array sizes rather than passing anything straight through to the model.

Attachments

Prompt Area handles the attachment UI — pasted screenshots and picked files render as thumbnails with loading and remove states. The AI SDK accepts files on the same call: hand the underlying File objects to sendMessage({ text, files }). See Attachments for the image and file props.

Next steps

  • Action Bar — the toolbar that hosts the send/stop button and any extra controls.
  • DX Helpers usePromptAreaState(), trigger presets, and getChipsByTrigger().
  • Chat Prompt Layout — a scrollable message list with a pinned composer to wrap this example.
  • Hooks & Helpers — full signatures for the helpers used here.