Chat guide
Embed the HETIBANK chat surface in a website, an internal tool, or beside an MCP client.
Install
pnpm add @hetibank/chat @hetibank/ui @hetibank/theme @hetibank/contracts
import "@hetibank/theme/styles.css";
import "@hetibank/ui/styles.css";
import "@hetibank/chat/styles.css";
The transport is yours
The kit never owns a socket, a model or a key. You implement ChatTransport:
import type { ChatTransport, ChatMessage } from "@hetibank/contracts";
const transport: ChatTransport = {
async send({ messages, signal }) {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages }),
signal,
});
if (!response.ok) {
const envelope = await response.json(); // { code, message, request_id }
throw new Error(envelope.message);
}
return response.json() as Promise<ChatMessage>;
},
};
If your backend streams, implement stream() as an AsyncIterable<ChatMessage>; useChat renders partial messages as they arrive.
Minimal surface
import { ChatProvider, ChatSurface } from "@hetibank/chat";
import { BuyerUiComponentView } from "@hetibank/websites";
import { ThemeProvider } from "@hetibank/ui";
import { createBrandTheme } from "@hetibank/theme";
const theme = createBrandTheme({ brand: { name: "Lakeshore Properties", primary: "#0b3954", accent: "#087e8b" } });
export function Assistant() {
return (
<ThemeProvider theme={theme} as="section">
<ChatProvider transport={transport} voice={voiceAdapter}>
<ChatSurface
title="Ask about a home"
placeholder="Find assumable homes in Henderson under $3,200/mo"
renderComponent={(component) => <BuyerUiComponentView component={component} />}
onAction={(action) => console.log(action)}
/>
</ChatProvider>
</ThemeProvider>
);
}
renderComponent keeps the chat package independent of the websites package: pass the websites dispatcher (or your own), or omit it and the component part falls back to the descriptor's text.
Parts
A reply is an ordered list of parts, so one message can carry text, a card, evidence and actions:
| Part | Use |
|---|---|
text | plain sentences, the floor |
markdown | explanation, short comparisons — rendered by SafeMarkdown |
component | a canonical buyer-UI envelope (card, financing, cash, market snapshot) |
form | structured input with validation labels |
evidence | definition ids, source and as-of for the figures above |
actions | suggested replies, tool calls and links |
error | typed failure with request_id |
Safe Markdown
SafeMarkdown supports headings, bold/italic, inline code, lists, links and line breaks. It escapes HTML, never uses dangerouslySetInnerHTML, and opens links with rel="noopener noreferrer". Raw HTML in model output renders as text. This is tested with XSS payloads in packages/chat/test/safe-markdown.test.mjs.
Voice
Push-to-talk is an adapter:
import type { VoiceAdapter } from "@hetibank/contracts";
const voice: VoiceAdapter = {
async start() { await recorder.start(); },
async stop() { await recorder.stop(); },
async *levels() { for await (const level of recorder.levels()) yield level; },
onTranscript(handler) { return recorder.onTranscript(handler); },
};
VoiceButton and usePushToTalk handle press/release semantics, the level visualizer and reduced-motion. Speech-to-text, permissions and provider choice stay in your app.
Handoff
Use HandoffLink for "open the full page" transitions. It carries identifiers only — never a bearer token or a document URL — and the target page must reauthorize. Preserve conversation state in your own store if the dedicated page should continue the thread.
Error states
useChat maps a thrown HetibankApiError into an error part and keeps request_id. Render it with ChatErrorState; offer retry only when error.retryable is true.
What the package deliberately does not do
- No network, model calls or API keys.
- No conversation persistence, analytics or moderation.
- No microphone/speech implementation.
- No raw HTML rendering.