In a chat, a reply is usually some text and a UI. Use the inline prompt: the model then writes chat text and puts the program in gistui code fences.
const system = prompt({ mode: "inline" }).text;There are three ways to build the client, from most to least control.
1. Your own chat state
splitReply separates a reply into text parts and UI parts. replyProgram joins all the fences of one reply into one program.
import { replyProgram, splitReply } from "@gistui/chat";
const parts = splitReply(text, { streaming }); // [{ kind: "text", text }, { kind: "ui", source, open }]
const program = replyProgram(parts);
// Render the text parts as chat text, then the UI once:
<GistUI library={ui} source={program} streaming={streaming} onAction={handle} />;Other code blocks (ts, json) stay chat text.
2. The AI SDK’s useChat
The same two functions work on the text of a useChat message. The full example is in Vercel AI SDK.
3. @gistui/chat
A headless store with model adapters and thread storage, plus React layouts.
npm i @gistui/chat @gistui/react @gistui/stylesyarn add @gistui/chat @gistui/react @gistui/stylespnpm add @gistui/chat @gistui/react @gistui/stylesbun add @gistui/chat @gistui/react @gistui/stylesimport { aiSdk, createChat, localThread } from "@gistui/chat";
import { Chat } from "@gistui/chat/react";
import "@gistui/chat/chat.css";
import "@gistui/styles/styles.css";
import { ui } from "@gistui/react/ui";
const store = createChat({
adapter: aiSdk({ url: "/api/chat" }),
storage: localThread("support"),
});
<Chat store={store} library={ui} layout="sidebar" title="Assistant" suggestions={["Show last month's sales"]} />;Adapters
| Adapter | Talks to |
|---|---|
aiSdk({ url }) |
A Vercel AI SDK route returning toUIMessageStreamResponse() |
openai({ model, url? }) |
OpenAI Chat Completions and every compatible API |
anthropic({ model, url? }) |
Anthropic Messages |
agui({ url }) |
An AG-UI agent endpoint |
textStream({ url }) |
Any endpoint that streams plain text |
Call models from your server. apiKey on an adapter sends the key from the browser, where every visitor can read it: it is for local prototypes only.
The store
createChat({ adapter, system?, initialMessages?, storage?, onError? }) returns a store with send(text), regenerate(), stop(), clear() and a subscribable snapshot of the messages. It has no framework dependency, so it works under Vue, Svelte or Solid with your own markup.
Layouts
<Chat layout> is "full" (a page), "sidebar" or "tray" (a bottom sheet). It takes the same tools, mutations, onToolCall, theme, color, tokens, darkTokens and lockUntil as <GistUI>.
What the chat package handles for you
- UI actions become messages. A button’s label or a form’s summary goes back as the next message with
origin: "ui". The thread labels it “Sent from the UI”, and the model receives it wrapped, so text chosen by a generated program is never mistaken for typed text. Send one yourself withstore.send(text, { origin: "ui" }). - Only the latest answer refreshes. Earlier answers still load and respond, but their timed queries are paused.
- History is clean.
requestHistory(messages)is what the model receives: failed, empty and still-streaming replies are left out. - Threads persist. The thread is saved after each sent message, about once a second while a reply streams, and at the end. A saved error keeps its HTTP status but not the provider’s text.
Editing an earlier UI
To let the model change a UI it already showed instead of writing it again, see Edits and multi-turn.