Kanzo UI
AI

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:

PartDrawn as
text, from the personthe words as typed
text, from the modelmarkdown, streamed: an emphasis or a fence still open mid-stream is closed until it arrives
reasoningfolded away — it opens while the model thinks, lingers a second, and folds to Thought for N seconds
a tool calla 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
modelThe host's — gateway("chat")
instructionsWhat the questions are for and what makes a good one: the system prompt
promptThe material they are asked over
abortSignalStops 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
emptyReactNodeThe chat's own; a placeholder of its size when omitted
suggestionsnumberHow 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, MessageActionsA turn: role declared once, a bubble for the person and the page for the model
Tool, ToolHeader, ToolContent, ToolInput, ToolOutputOne call, bound to its AI SDK part: its name, its state, its input and output
Reasoning, ReasoningTrigger, ReasoningContentA model's thinking, folded away once it ends

Their props are MessageProps, MessageAvatarProps, MessageRole and ReasoningProps.

API

PropType
chatuseChat(...)'s returnThe conversation
toolsChatToolRenderersRecord<string, (part: ToolPart) => ReactNode> — how the host draws its tools' results
emptyReactNodeWhat the panel says before the first question
suggestionsreadonly string[]Questions to start from
suggestingbooleanMore are on their way: pills in skeleton beside them
translationsPartial<ChatTranslations>placeholder for the composer, and failed, read before an error
classNamestringOn 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.

On this page