Skip to content
kitn AI/UI

Use the chat app

<kai-chat> is the batteries-included element: a message thread with markdown, reasoning, and tool calls, a composer, optional header chrome, and a streaming-ready render loop. You feed it data and listen for events; it handles the UI. Send a message in the demo below — the reply streams in.

This guide assumes you’ve installed the package and registered the elements. If not, do the Getting Started quickstart first, then come back here to go deeper.

<kai-chat> works in two motions you’ll repeat all over this page: set rich data on a JavaScript property, and listen for kai-* events. Render the element, seed the thread, and handle a submit.

<kai-chat id="chat" style="display:block; height:100dvh;"></kai-chat>
<script type="module">
import '@kitn.ai/ui/elements';
await customElements.whenDefined('kai-chat');
const chat = document.getElementById('chat');
// Seed the thread. Arrays can't be attributes, so this lives in JavaScript.
chat.messages = [
{ id: '1', role: 'assistant', parts: [{ type: 'text', text: 'Hello! How can I help?' }] },
];
chat.addEventListener('kai-submit', (e) => {
const userMessage = { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: e.detail.value }] };
chat.messages = [...chat.messages, userMessage];
// Call your model here, then append an assistant message.
});
</script>

A few things that pay off everywhere:

  • messages is an array of { id, role, parts }. Every entry needs a stable id (it keys the render and message actions); role is 'user' or 'assistant'; parts is an ordered array of text / reasoning / tool / card / source / file parts. Optional fields — actions, avatar — light up the matching UI when present.
  • Assign a new array to re-render. Pushing into the existing one won’t trigger an update.
  • kai-submit carries the input on e.detail.value and any staged files on e.detail.attachments.
  • Give the element an explicit height. It fills its block and owns its own scrolling.

<kai-chat> is transport-agnostic: it renders whatever you hand it, so it doesn’t care whether tokens come from your own backend, a proxy, or a mock. You own the fetch; the kit parses what comes back.

import { createAssistantStream } from '@kitn.ai/ui/state';
import { readOpenAIStream, toOpenAIMessages } from '@kitn.ai/ui/wire';
chat.addEventListener('kai-submit', async (e) => {
const prompt = e.detail.value.trim();
if (!prompt) return;
// 1. Show the user's turn.
const history = [
...chat.messages,
{ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: prompt }] },
];
chat.messages = history;
chat.loading = true;
// 2. Stream from your endpoint. createAssistantStream appends the assistant
// message and hands readOpenAIStream a sink to fold deltas onto.
const stream = createAssistantStream((update) => {
chat.messages = update(chat.messages);
});
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: toOpenAIMessages(history) }),
});
await readOpenAIStream(res, stream);
} finally {
stream.done();
chat.loading = false;
}
});

loading toggles the streaming indicator and disables submit while the reply is in flight. Set it back to false when the stream ends. For the tool loop, Anthropic streams and the error model, see the wire adapter recipe; for kai-response-stream and the raw-frames path, the streaming recipe.

Pass a models array and <kai-chat> shows a switcher in its header. Track the selection with currentModel and update it from kai-model-change.

chat.models = [
{ id: 'gpt-4o', name: 'GPT-4o', provider: 'OpenAI' },
{ id: 'claude-sonnet', name: 'Claude Sonnet', provider: 'Anthropic' },
];
chat.currentModel = 'gpt-4o';
chat.addEventListener('kai-model-change', (e) => {
chat.currentModel = e.detail.modelId;
// Route your next request to e.detail.modelId.
});

Each model is { id, name, provider? }id is what you get back on kai-model-change, name is the label, and provider groups the menu. The switcher only appears when there’s more than one model.

An empty thread reads as a dead end. Hand the user a few suggestions and <kai-chat> renders them as starter chips in its own empty state, then hides them once the conversation begins.

chat.messages = [];
chat.suggestions = [
'What can you help me with?',
'Show me a streaming example',
'Theme the components to match my brand',
];

Clicking a chip submits it immediately by default, firing kai-submit like a typed message — so the streaming loop above just works. To prefill the input instead and let the user edit first, set suggestionMode = 'fill' and read the chosen text from kai-suggestion-click. For a custom greeting with chips of your own, see the empty-state pattern.

Every interaction surfaces as a non-bubbling kai-* CustomEvent. Listen on the element itself and read the payload off e.detail.

  • kai-submit — the user sent a message. detail: { value, attachments }. This is the one you always handle.
  • kai-model-change — the header switcher changed. detail: { modelId }.
  • kai-suggestion-click — a starter chip was chosen in 'fill' mode (in 'submit' mode you get kai-submit instead). detail: { value }.
  • kai-message-action — a message action button was clicked. detail: { messageId, action, state? }, where action is a built-in (copy, like, dislike, regenerate, edit) or your custom id; state is 'on'/'off' for the like/dislike toggles. Show actions by adding an actions array to a message.
  • kai-value-change — fires on every keystroke; useful for draft persistence or a controlled input.
  • kai-search / kai-voice — the toolbar Search and Mic buttons, shown when you set the search and voice props. Each fires with an empty detail; you own what happens next.
chat.addEventListener('kai-message-action', (e) => {
const { messageId, action } = e.detail;
if (action === 'regenerate') regenerate(messageId);
});

The element is identical under React; the wrapper just gives you typed props and on* handlers.

import { Chat } from '@kitn.ai/ui/react';
import type { ChatMessage } from '@kitn.ai/ui/react';
import { useState } from 'react';
export function App() {
const [messages, setMessages] = useState<ChatMessage[]>([
{ id: '1', role: 'assistant', parts: [{ type: 'text', text: 'Hello! How can I help?' }] },
]);
return (
<Chat
messages={messages}
style={{ display: 'block', height: '100dvh' }}
onSubmit={(e) => {
const turn: ChatMessage = { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: e.detail.value }] };
setMessages((prev) => [...prev, turn]);
// …call your model, then append an assistant message.
}}
/>
);
}

For state that’s tedious to thread by hand — the message list, the streaming loop, optimistic turns — the useKaiChat hook manages it for you.

  • Compose your own shell — when one element isn’t enough control, swap in slots and assemble the thread, header, and composer yourself.
  • State & hooksuseKaiChat / createKaiChat for managing the conversation without wiring messages by hand.
  • kai-chat reference — every prop, event, and slot in one place.