Chat
A conversation with a model, whole — the transcript, markdown that streams, the model's reasoning and tool calls as they happen, and a composer that sends, stops and retries. The host brings useChat and draws its own tools' results.
Ask the quartermaster about the board.
Usage
import { Chat, useChat } from "@kanzo-tech/ai";
import { createGateway, DirectChatTransport, ToolLoopAgent } from "@kanzo-tech/llm";
const gateway = createGateway({ baseURL: "/api/ai" });
const agent = new ToolLoopAgent({ model: gateway("chat"), tools: { query } });
const transport = new DirectChatTransport({ agent });function Ask() {
const chat = useChat({ transport });
return <Chat chat={chat} suggestions={["How many contracts are late?"]} />;
}Chat takes useChat(...)'s return and nothing about where it came from. DirectChatTransport
runs the agent in the page, against a model behind the host's own route — the set-up
@kanzo-tech/llm describes. useChat() with no transport posts
to the host's /api/chat instead, for an agent that runs on a server. Chat draws either the same,
because both stream the AI SDK's UIMessage.
useChat is @ai-sdk/react's, re-exported, so a host imports the hook and the component from one
place. Chat reads only the helpers — messages, status, error, sendMessage, stop,
regenerate — so any chat state of that shape drives it.
Chat is flex-1 inside a column, so it wants a bounded parent: a panel with a height, or a
flex column that has one. Given an unbounded one it grows with its content and there is nothing
left to scroll.
What it draws
Each part is drawn from the AI SDK's own message parts, so nothing is translated on the way in:
| Part | Drawn as |
|---|---|
text, from the person | the words as typed |
text, from the model | markdown, streamed: an emphasis or a fence still open mid-stream is closed until it arrives |
reasoning | folded away — it opens while the model thinks, lingers a second, and folds to Thought for N seconds |
| a tool call | a frame with the tool's name and the SDK's state, its input as a JSON tree, its output under it |
A reader's own toggle ends the automatic behaviour. A reasoning fold opens itself while it streams and a tool frame opens itself when the call settles — a result is what a reader came for — unless the reader has already opened or closed it, in which case their choice stands. A panel that reopens what somebody just closed is the version of this everyone has met.
The tool's state is the SDK's, drawn — not mapped onto a vocabulary of ours. Four tones are all a
reader needs to tell apart (waiting, working, done, failed), and the label says which of the SDK's
seven states it is: Preparing, Running, Awaiting approval, Done, Failed, Denied. A
denied or failed call shows why, from the part's own errorText.
The transcript follows a stream only while the reader is at the tail. An effect that scrolls to
the bottom on every message follows unconditionally, so a reader who scrolled up to re-read
something is dragged back down by every token. Here, scrolling up releases the pin and a button
takes it back. It needs two observers: a stream produces growth, not scrolling, and the browser
fires no scroll event for content getting taller under a still scrollTop.
The composer is one button that says what pressing it does. Enter sends and
Shift+Enter is a newline. While an answer streams the button is Stop; after
an error, with nothing typed, it is Retry and asks again. The error itself is announced under the
transcript with role="alert".
Held by packages/ai/src/chat.test.tsx, "asks, shows the tool call the model made, and streams the
answer as markdown"; packages/ai/src/conversation.test.tsx, "releases the pin the moment the reader
scrolls up, and stays released while it grows"; packages/ai/src/reasoning.test.tsx, "stops opening
itself once the reader has said otherwise".
Drawing a tool's result
A tool's output is drawn as a JSON tree unless the host says what it is. tools maps a tool's name
to a function of the call's part, and once the call has a result, what it returns is drawn inside
the frame in place of its input and output — the host's drawing is the whole result, and a
result card that carries its own SQL does not want the JSON of that SQL above it. Until then the frame
shows the input, so the reader sees what is running:
<Chat
chat={chat}
tools={{ query: (part) => <ResultTable result={part.output as ResultSet} /> }}
/>JSON is right for a chatbot that cannot know what its tool was. A host always knows: a query's
result is a table, and rendering it as a blob of JSON discards that. The part is a ToolPart — the
SDK's ToolUIPart or DynamicToolUIPart, whole — so the renderer has the call's input and state
too. Its output is unknown there: the map is keyed by a string, so the host, which wrote the
tool, says what it returns.
For data, QueryResult is that drawing, whole.
Held by packages/ai/src/chat.test.tsx, "lets the host draw its own tool's result".
Before the first question
empty is what the panel says before anything is asked, and suggestions are questions to start
from, drawn as @kanzo-tech/ui's Suggestions under it. Pressing one asks it. Both go once the
conversation starts.
Suggested questions come from one door, suggest(). It asks a model for questions over some
material — a schema, a domain, the files — with the host's instructions, and streams each as soon as
it is whole, a SuggestedQuestion of { question, rationale } (the AI SDK's
Output.array). There is no template fallback: a list of questions written from column names
beside a list a model wrote is two paths to one strip, and the templates were the worse one.
for await (const { question } of suggest({ model: gateway("chat"), instructions, prompt: schema, abortSignal })) {
offered.push(question);
}SuggestOptions | |
|---|---|
model | The host's — gateway("chat") |
instructions | What the questions are for and what makes a good one: the system prompt |
prompt | The material they are asked over |
abortSignal | Stops the stream |
While it streams, pass suggesting: the strip draws pills in skeleton beside the ones that have
arrived, so it keeps its height as they land. A failure throws when the stream ends — the AI SDK
otherwise ends a refused stream as if the model had nothing to suggest — and on a failure the host
passes no pills: the conversation works the same without them. A data space's questions are
dataSuggestions, which is suggest() with a data space's instructions.
A pill never outgrows the strip. A question longer than the panel is as wide as the strip and cut with an ellipsis, and the whole question is in a tooltip that opens only when it is cut. The accessible name is the whole question either way.
Held by packages/ai/src/chat.test.tsx, "holds room for suggestions still arriving, beside the
ones that have"; packages/ai/src/suggest.test.ts, "throws what stopped the model, rather than ending
as if it had nothing to suggest"; packages/ui/src/simples/suggestions.test.tsx, "shows the whole
label in a tooltip when the label is cut".
Before it can be drawn
ChatSkeleton is Chat's layout while what it needs is still loading — a schema, an agent: the
empty state, the pills in skeleton and the composer, inert. Give it the same empty the chat will
have and nothing moves when the chat replaces it. The composer is the real one, disabled, rather than
a block of its size, so the two cannot drift apart.
{schema ? <Chat chat={chat} empty={empty} /> : <ChatSkeleton empty={empty} />}ChatSkeletonProps | ||
|---|---|---|
empty | ReactNode | The chat's own; a placeholder of its size when omitted |
suggestions | number | How many pills to hold room for. Default 3 |
translations | { placeholder } | The composer's placeholder |
Held by packages/ai/src/chat.test.tsx, "draws Chat's layout before it can be drawn: the empty
state, pills in skeleton, an inert composer".
The parts
Chat is the one door, and the parts it draws with are exported beside it for a host whose
transcript is not Chat's — a review pane, a log of an agent's run:
| Part | |
|---|---|
MessageList, Message, MessageContent, MessageAvatar, MessageActions | A turn: role declared once, a bubble for the person and the page for the model |
Tool, ToolHeader, ToolContent, ToolInput, ToolOutput | One call, bound to its AI SDK part: its name, its state, its input and output |
Reasoning, ReasoningTrigger, ReasoningContent | A model's thinking, folded away once it ends |
Their props are MessageProps, MessageAvatarProps, MessageRole and ReasoningProps.
API
| Prop | Type | |
|---|---|---|
chat | useChat(...)'s return | The conversation |
tools | ChatToolRenderers | Record<string, (part: ToolPart) => ReactNode> — how the host draws its tools' results |
empty | ReactNode | What the panel says before the first question |
suggestions | readonly string[] | Questions to start from |
suggesting | boolean | More are on their way: pills in skeleton beside them |
translations | Partial<ChatTranslations> | placeholder for the composer, and failed, read before an error |
className | string | On the root |
The types are exported as ChatProps, ChatSkeletonProps, ChatToolRenderers, ChatTranslations,
ToolPart, SuggestOptions and SuggestedQuestion.
AI Elements is a source, not a reference
The behaviours above — the pin, the fold that opens while it streams, the tool frame that opens when it settles — come from Vercel's AI Elements; the names, and the decision to ship one door rather than the parts, are this house's. AI Elements is not installable here, so nothing goes red when this drifts from it. The references has what was taken, what was re-pointed and what was refused.
Assist
A field the model helps fill. One proposal for the field's value, drawn the way the control calls for — ghost text at the caret of a Textarea, whole values under an Input, items for a TagsInput.
Ask your data
An agent that answers questions about data by querying DuckDB on the page's coordinator, the schema it is given, the one door from its SQL to the engine, the questions to start from, and the card each answer is drawn in — @kanzo-tech/ai/data.