Use the Vercel AI SDK UI and AI Elements with the Prompt API

Published: July 16, 2026

In Use the built-in Prompt API with the Vercel AI SDK, you saw the four core generation primitives, namely generateText(), streamText(), hybrid code, and structured output using Output.object(), all powered by @browser-ai/core. This time you build something more interactive: a full streaming chat UI that runs entirely in the browser, with automatic fallback to a cloud model when the Prompt API isn't available.

What you're building

A React chat interface that does the following:

  • Uses the Vercel AI SDK's useChat hook for streaming multi-turn conversation.
  • Runs the model loop in the browser with no backend server required.
  • Falls back to Gemini 2.5 Flash automatically when the Prompt API is unavailable.
  • Renders assistant replies as GitHub-flavored Markdown, handling incomplete tokens during streaming.
  • Shows a "Thinking…" shimmer while waiting for the first token.
  • Auto-scrolls to new messages, with a scroll-to-bottom button when you've scrolled up.

Additional dependencies

Along with ai, @browser-ai/core, and @ai-sdk/google, the chat UI needs React, the AI SDK's React bindings, and a few Markdown packages:

npm install react react-dom @ai-sdk/react
npm install react-markdown remark-gfm harden-react-markdown
npm install -D @types/react @types/react-dom

For the UI, also add Tailwind CSS, some component utilities, and Lucide for icons:

npm install -D tailwindcss postcss autoprefixer
npm install clsx tailwind-merge lucide-react

Vote config: multi-entry build

The project already has index.html. Add chat.html as a second entry point and configure Vite to build both:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: { alias: { '@': resolve(__dirname, './src') } },
  build: {
    rollupOptions: {
      input: {
        main: resolve(__dirname, 'index.html'),
        chat: resolve(__dirname, 'chat.html'),
      },
    },
  },
});

chat.html is minimal, just a <div id="root"> and a script tag:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Built-in AI Chatbot</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/chat.tsx"></script>
  </body>
</html>

Automatic model selection

In the chatbot, the app makes that choice automatically: it tries the built-in model first, and falls back to the cloud if the Prompt API is unavailable.

It does this at module load time, before React mounts, so the agent is ready by the time the user types their first message:

const agentPromise: Promise<ToolLoopAgent> = (async () => {
  const builtIn = browserAI();
  let model: any = builtIn;

  if (typeof builtIn.availability === 'function') {
    const availability = await builtIn.availability();
    if (availability === 'unavailable') {
      const { createGoogleGenerativeAI } = await import('@ai-sdk/google');
      model = createGoogleGenerativeAI({ apiKey })('gemini-2.5-flash');
    } else if (availability === 'downloadable') {
      await builtIn.createSessionWithProgress(() => {});
    }
  }

  return new ToolLoopAgent({ model, instructions: 'You are a helpful assistant.' });
})();

The new piece is ToolLoopAgent; the AI SDK abstraction that manages a multi-turn conversation loop on top of any model. It takes the model and a system prompt, and handles the back-and-forth internally.

Connect the agent to useChat

The @ai-sdk/react package's useChat hook normally talks to an HTTP endpoint. For browser-side inference, use DirectChatTransport instead. It runs the ToolLoopAgent loop entirely in the browser with no server involved:

const transport = useMemo(() => new DirectChatTransport({ agent }), [agent]);
const { messages, sendMessage, status, stop } = useChat({ transport });

useMemo is important as DirectChatTransport holds conversation state, so it must be a stable reference. Recreating it on every render resets the conversation.

useChat gives you:

  • Messages: the full conversation as UIMessage[], each with a role and a parts array
  • sendMessage({ text }): sends a new user turn and begins streaming the response
  • Status: 'idle' | 'submitted' | 'streaming' | 'error'
  • Stop: cancels an in-flight generation

Render messages

Each message has a parts array. For this chatbot, we only care about type: 'text' parts. User messages appear as a right-aligned bubble; assistant messages are left-aligned with an icon:

const ChatMessage = ({ message, isStreaming }: { message: UIMessage; isStreaming: boolean }) => {
  const isUser = message.role === 'user';

  const textParts = message.parts.map((part, i) => {
    if (part.type !== 'text') return null;
    if (isUser) return <span key={i}>{part.text}</span>;
    return <Response key={i} parseIncompleteMarkdown={isStreaming}>{part.text}</Response>;
  });

  if (isUser) {
    return (
      <div className="flex flex-col items-end gap-2 animate-fade-up">
        <MessageContent className="w-fit max-w-[min(80%,56ch)] ...">
          {textParts}
        </MessageContent>
      </div>
    );
  }

  return (
    <div className="flex items-start gap-3">
      <AIIcon />
      <MessageContent className="text-[13px] leading-[1.65]">{textParts}</MessageContent>
    </div>
  );
};

MessageContent and Response are AI Elements. They are shadcn-style source components you copy into your project rather than install from npm. Response wraps react-markdown with remark-gfm for GitHub-flavored Markdown (tables, task lists, strikethrough) and harden-react-markdown to sanitize links and images in AI output.

The parseIncompleteMarkdown prop is true while the message is still streaming. During streaming, the model may write **bold but not yet close the **, leaving a dangling token that would render as literal asterisks. parseIncompleteMarkdown closes any open **, __, `, ~~, and truncates dangling [ link starts so the rendered output stays clean on every incremental chunk.

The "Thinking…" state

Between sending a message and receiving the first token, status is 'submitted'. During this window the app shows an animated shimmer:

{status === 'submitted' && messages.at(-1)?.role !== 'assistant' && (
  <ThinkingMessage />
)}

The condition messages.at(-1)?.role !== 'assistant' prevents the shimmer from reappearing once the assistant message has started streaming in.

ThinkingMessage uses a Shimmer component: a <span> with a moving gradient using background-clip: text that gives the "Thinking…" text a sweeping highlight effect.

Auto-scroll

When new content arrives, the app scrolls to the bottom, but only if the user is already at the bottom. Scrolling them away from something they're reading mid-conversation would be annoying.

const [isAtBottom, setIsAtBottom] = useState(true);

useEffect(() => {
  if (isAtBottom) endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, status, isAtBottom]);

const handleScroll = () => {
  const el = containerRef.current;
  if (!el) return;
  setIsAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 50);
};

A floating scroll-to-bottom button appears when isAtBottom is false, and fades out when the user is back at the bottom.

The input area

The textarea auto-resizes as you type by resetting its height to auto on every input event and then setting it to scrollHeight. It submits on Enter (not Shift+Enter), and while a response is streaming, the Send button is replaced by a Stop button that calls stop():

<textarea
  onInput={(e) => {
    const el = e.currentTarget;
    el.style.height = 'auto';
    el.style.height = `${el.scrollHeight}px`;
  }}
  onKeyDown={(e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      if (input.trim() && !isStreaming) {
        sendMessage({ text: input });
        setInput('');
      }
    }
  }}
/>;
{
  isStreaming ? (
    <Button variant="outline" onClick={stop}>
      Stop
    </Button>
  ) : (
    <Button type="submit" disabled={!input.trim()}>
      Send
    </Button>
  );
}

Mount with a loading state

Because agentPromise is async, wait for it before rendering Chat. An App wrapper resolves the promise and shows a spinner in the meantime:

function App() {
  const [agent, setAgent] = (useState < ToolLoopAgent) | (null > null);

  useEffect(() => {
    agentPromise.then(setAgent);
  }, []);

  if (!agent) {
    return (
      <div className="flex h-dvh items-center justify-center">
        <Loader size={20} />
      </div>
    );
  }

  return <Chat agent={agent} />;
}

Once the agent resolves, whether that's the built-in model booting instantly or waiting for a model download, the spinner disappears and the chat UI mounts.

Demo

The live demo is a fully functional chatbot running entirely in your browser. Type a message and press Enter. If the Prompt API is available, the reply streams directly from the on-device model with no network request. If your browser doesn't support the Prompt API, it falls back to Gemini 2.5 Flash automatically. Try asking it to explain something in a list, write a code snippet, or use Markdown formatting. The responses render with formatted code blocks, tables, and inline code out of the box.

A chat interface showing a conversation with an AI assistant, featuring a text input field and a streaming response area.

Conclusion

Over the course of these two articles you've seen the full range of what the Vercel AI SDK makes possible with the browser's built-in Prompt API, from raw generation primitives all the way to a polished streaming chat interface.

In Use the built-in Prompt API with the Vercel AI SDK, you learned how to use generateText() and streamText() for non-streaming and streaming text generation, how to request structured JSON output with Output.object(), and how to write hybrid code that picks between the built-in model and a cloud provider at runtime with no changes to the generation logic.

In this document, you took those same building blocks and wrapped them in a full React UI: a ToolLoopAgent to manage the conversation loop, useChat with DirectChatTransport to stream responses directly in the browser, and AI Elements components to render Markdown responses cleanly as they arrive; all with automatic cloud fallback when the Prompt API isn't available.

The result is two demos that work entirely in the browser, with no backend required: