Chat
Connect a streaming endpoint to the Honest AI chat shell, match the request and response protocols, and configure history.
GenerativeChat combines the composer, conversation, loading state, and tool activity components with local thread state. An assistant reply is Markdown, rendered with Honest AI Markdown. Where the reply fences an honestai block, honestChatLibrary renders that block as components.
Try the chat shell
Send a message, start a new conversation, then return to the first one through history.
This preview uses a local echo transport. It does not call a model, and its in-memory history is discarded when the preview unmounts.
Reply format
The model writes its reply in Markdown. When a component shows something better than prose, it adds a fenced honestai block holding one complete program:
Rent is the largest cost in this sample budget.
```honestai
root = Stack([chart])
chart = PieChart(["Rent", "Food", "Transport"], [1400, 520, 180], "donut")
```
Food is the easiest of the three to reduce.Text outside a block renders as Markdown. Each block renders in place, between the text around it, and streams as it arrives. A reply can hold several blocks or none.
A block's Stack root adds no frame, so its components read as part of the reply. The model adds a Card inside the block when a group should stand apart, such as a form. Card is a choice the model makes for each block, not a wrapper around every reply.
A reply that is only a program, with no Markdown around it, still renders.
Connect your endpoint
After installing generative-chat, create a client component:
"use client"
import {
GenerativeChat,
fetchLLM,
openAIAdapter,
openAIMessageFormat,
} from "@/lib/generative/chat"
const llm = fetchLLM({
url: "/api/chat",
streamAdapter: openAIAdapter(),
messageFormat: openAIMessageFormat,
})
export function Chat() {
return <GenerativeChat llm={llm} />
}You must implement /api/chat. Keep the model credentials there and use the chat library spec to instruct the model. That prompt asks for the reply format above. The component does not create a route or select a model for you.
Match your endpoint to the adapter
The example posts JSON with threadId, runId, messages, tools, and context. messages are converted to OpenAI Chat Completions format; tools and context are empty arrays. Extra fields, such as your model selection, can be supplied through fetchLLM's body option.
The endpoint must return a streaming response in the format selected by streamAdapter. The example expects OpenAI Chat Completions events, not a single JSON object or an unframed plain-text response. The server stream wrapper can produce the matching Server-Sent Events response.
For another provider, use its matching stream adapter and outgoing message format. The headless layer includes adapters for AG-UI, OpenAI Chat Completions and Responses, Vercel AI SDK, LangGraph, and Eve. These are protocol adapters; they do not supply the model service.
If the browser receives data but no reply appears, inspect the response format before changing the UI. Confirm that the selected adapter can parse the endpoint's events. If the text appears but a block shows as code, check that its fence is labeled honestai and that its program starts with the chat library's Stack root.
Sending, stopping, and failure
Sending updates the real transport state. Stop cancels through the provider's abort controller. A failed response shows an error, keeps the submitted message in the conversation, and restores the composer draft so the user can retry.
New-conversation and history controls operate on the configured storage. Without storage, history lasts while the provider remains mounted; it is not durable across a reload.
Persist conversations
Supply a ChatStorage implementation to connect your own persistence. For a backend using the built-in REST convention, use restStorage({ baseUrl: "/api/threads" }).
That adapter expects these routes:
restStorage implements thread requests only. Your backend must persist generated messages as well as the initial user message, and enforce access checks on every thread. Use ChatStorage.artifact for persisted artifact reads and edits. Installing an adapter does not create database tables or storage routes.
A separate createHonestAIStorage factory supports the local /v1 thread and artifact contract. It requires your own apiBaseUrl and token provider or token-mint endpoint. It has no default service URL.
Handle generated actions
The default chat handler continues the conversation for @ToAssistant and opens valid HTTP or HTTPS URL actions. Supply rendererProps.onAction to take control of action handling; your callback replaces the default handler, so it must handle the actions you want to support.
The renderer also accepts a custom library, tools, diagnostics, and state restoration through rendererProps. Use the same library contract in your model prompt.
Customize the shell
The headless hooks expose thread, detail-view, and artifact state. Honest AI's Artifact Panel can display your artifact content. Artifact renderer registrations are captured at provider mount; remount the provider when changing those registrations.
"use client";
import * as React from "react";
import { ChatThread, ChatMessage } from "@/components/ai/chat-thread";
import { PromptBar } from "@/components/ai/prompt-bar";
import { Markdown } from "@/components/ai/markdown";
import { ToolChips } from "@/components/ai/tool-chips";
import { LoadingState } from "@/components/ai/loading-state";
import { Button } from "@/components/ui/button";
import {
ChatProvider,
useThread,
useThreadList,
useToolActivities,
type ChatProviderProps,
type AssistantMessage,
type Message,
} from "./runtime/headless";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { safeUrl } from "./renderers/shared";
import { useChatStore } from "./runtime/headless/store/ChatContext";
import { Renderer, type RendererProps } from "./runtime/react";
import { honestChatLibrary } from "./index";
export * from "./runtime/headless";
export type GenerativeChatProps = Omit<ChatProviderProps, "children"> & {
label?: string;
rendererProps?: Omit<RendererProps, "response" | "isStreaming">;
renderMessage?: (message: Message) => React.ReactNode;
sidebar?: React.ReactNode;
detailPanel?: React.ReactNode;
};
type ReplyPart = {
kind: "markdown" | "block";
text: string;
/** False for a block whose closing fence has not arrived. */
closed: boolean;
};
const BLOCK_OPENING = /^\s*(`{3,})\s*honestai(?:-lang)?\s*$/;
const FENCE_OPENING = /^\s*(`{3,}|~{3,})/;
// The start of a line that may still become a fence, such as "``" or "```hon".
const UNFINISHED_FENCE = /^\s*`+[\w-]*$/;
function closesFence(line: string, fence: string) {
const marker = line.trim();
return (
marker.length >= fence.length && marker === fence[0].repeat(marker.length)
);
}
/** Splits a reply into its Markdown and the `honestai` blocks fenced inside it. */
function splitReply(content: string, streaming: boolean): ReplyPart[] {
// A stored message, or a reply that is only a program, has no Markdown around it.
if (content.includes("]]>honestai:") || /^\s*[A-Za-z_]\w*\s*=/.test(content))
return [{ kind: "block", text: content, closed: false }];
const lines = content.split("\n");
// Half a fence would flash as an empty code block, or reach the parser as a statement.
if (streaming && UNFINISHED_FENCE.test(lines.at(-1) ?? "")) lines.pop();
const parts: ReplyPart[] = [];
let markdown: string[] = [];
let program: string[] | null = null;
let programFence = "";
// Set inside an ordinary code block, where an `honestai` fence is sample text.
let codeFence: string | null = null;
const endMarkdown = () => {
const text = markdown.join("\n");
if (text.trim()) parts.push({ kind: "markdown", text, closed: true });
markdown = [];
};
for (const line of lines) {
if (program) {
if (closesFence(line, programFence)) {
parts.push({ kind: "block", text: program.join("\n"), closed: true });
program = null;
} else program.push(line);
} else if (codeFence) {
markdown.push(line);
if (closesFence(line, codeFence)) codeFence = null;
} else {
const opening = BLOCK_OPENING.exec(line);
if (opening) {
endMarkdown();
program = [];
programFence = opening[1];
} else {
codeFence = FENCE_OPENING.exec(line)?.[1] ?? null;
markdown.push(line);
}
}
}
if (program)
parts.push({ kind: "block", text: program.join("\n"), closed: false });
else endMarkdown();
return parts;
}
/** One generated block of a reply, with the errors from rendering it. */
function GeneratedBlock({
program,
streaming,
rendererProps,
}: {
program: string;
streaming: boolean;
rendererProps?: GenerativeChatProps["rendererProps"];
}) {
const thread = useThread();
const [errors, setErrors] = React.useState<string[]>([]);
return (
<>
<Renderer
{...rendererProps}
library={rendererProps?.library ?? honestChatLibrary}
response={program}
isStreaming={streaming}
onAction={(event) => {
if (rendererProps?.onAction) rendererProps.onAction(event);
else if (event.type === "continue_conversation")
void thread.processMessage({
role: "user",
content:
event.humanFriendlyMessage +
(event.formState && Object.keys(event.formState).length > 0
? "\n" + JSON.stringify(event.formState)
: ""),
});
else if (event.type === "open_url") {
const url = safeUrl(event.params.url);
if (url) window.open(url, "_blank", "noopener,noreferrer");
else
setErrors([
"The generated link is not a valid HTTP or HTTPS URL.",
]);
} else
setErrors([
`No handler is configured for the ${event.type} action.`,
]);
}}
onError={(next) => {
setErrors([
// One message each, and only for what is missing from view: a
// warning or a recovered value still renders.
...new Set(
next
.filter(
(error) => error.severity !== "warning" && !error.recovered,
)
.map((error) => error.message),
),
]);
rendererProps?.onError?.(next);
}}
/>
{errors.length > 0 && (
<p role="alert" className="text-sm text-destructive">
Part of the generated interface could not be shown:{" "}
{errors.join(" ")}
</p>
)}
</>
);
}
/**
* One assistant message: Markdown, with generated UI wherever the reply fences
* an `honestai` block, and its tool activity.
*/
export function AssistantResponse({
message,
messages,
streaming,
rendererProps,
}: {
message: AssistantMessage;
messages: Message[];
streaming: boolean;
rendererProps?: GenerativeChatProps["rendererProps"];
}) {
const activities = useToolActivities(message, messages);
const content = typeof message.content === "string" ? message.content : "";
const parts = splitReply(content, streaming);
return (
// The single minmax(0, 1fr) track lets wide content, such as a table, scroll instead of widening the message.
<div className="grid grid-cols-[minmax(0,1fr)] gap-3">
{parts.map((part, index) => {
const arriving = streaming && index === parts.length - 1;
// Parts only ever grow at the end, so the index keeps a block's state.
return part.kind === "block" ? (
<GeneratedBlock
key={index}
program={part.text}
streaming={arriving && !part.closed}
rendererProps={rendererProps}
/>
) : (
<Markdown key={index} isStreaming={arriving}>
{part.text}
</Markdown>
);
})}
{activities.length > 0 && (
<ToolChips
summary="Tool activity"
calls={activities.map((activity) => ({
id: activity.id,
label: activity.toolName,
status:
activity.status === "error"
? "error"
: activity.status === "complete"
? "done"
: "running",
output: activity.result ? [{ text: activity.result }] : undefined,
}))}
/>
)}
</div>
);
}
function Conversation({
label = "Conversation",
rendererProps,
renderMessage,
sidebar,
detailPanel,
}: Pick<
GenerativeChatProps,
"label" | "rendererProps" | "renderMessage" | "sidebar" | "detailPanel"
>) {
const thread = useThread();
const threads = useThreadList();
const store = useChatStore();
const canceled = React.useRef(false);
const [prompt, setPrompt] = React.useState("");
const lastAssistant = thread.messages.findLast(
(message) => message.role === "assistant",
)?.id;
return (
<div className="flex h-full min-h-0 min-w-0 gap-4">
{sidebar}
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
disabled={thread.isRunning}
onClick={threads.switchToNewThread}
>
New conversation
</Button>
<Select
items={Object.fromEntries([
["", "New conversation"],
...threads.threads.map((item) => [item.id, item.title]),
])}
value={threads.selectedThreadId ?? ""}
disabled={thread.isRunning || threads.isLoadingThreads}
onValueChange={(value) => {
if (value) threads.selectThread(value);
else threads.switchToNewThread();
}}
>
<SelectTrigger aria-label="Conversation history" className="w-56 min-w-0 max-w-full">
<SelectValue placeholder="New conversation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">New conversation</SelectItem>
{threads.threads.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
{threads.hasMoreThreads && (
<Button variant="ghost" onClick={threads.loadMoreThreads}>
Load more conversations
</Button>
)}
</div>
{threads.threadListError && (
<div role="alert">
<p>Conversation history could not be loaded.</p>
<Button variant="secondary" onClick={threads.loadThreads}>
Retry history
</Button>
</div>
)}
<ChatThread
label={label}
className="min-h-96"
emptyMessage={
thread.isLoadingMessages
? "Loading messages…"
: "Send a message to start."
}
footer={
<PromptBar
value={prompt}
onValueChange={setPrompt}
disabled={thread.isRunning || thread.isLoadingMessages}
onStop={
thread.isRunning
? () => {
canceled.current = true;
thread.cancelMessage();
}
: undefined
}
onSubmit={({ text }) => {
canceled.current = false;
void thread
.processMessage({ role: "user", content: text })
.then(() => {
setPrompt(
store.getState().threadError || canceled.current
? text
: "",
);
});
}}
/>
}
>
{thread.messages
.filter(
(message) =>
message.role === "user" || message.role === "assistant",
)
.map((message) => (
<ChatMessage
key={message.id}
role={message.role === "user" ? "user" : "assistant"}
author={message.role === "user" ? "You" : "Assistant"}
hideAuthor
>
{renderMessage ? (
renderMessage(message)
) : message.role === "assistant" ? (
<AssistantResponse
message={message}
messages={thread.messages}
streaming={thread.isRunning && message.id === lastAssistant}
rendererProps={rendererProps}
/>
) : (
<Markdown>
{typeof message.content === "string" ? message.content : ""}
</Markdown>
)}
</ChatMessage>
))}
</ChatThread>
{thread.isRunning && <LoadingState label="Generating response" />}
{thread.threadError && (
<p role="alert" className="text-sm text-destructive">
The response failed. Your message is saved. Try sending it again.
</p>
)}
</div>
{detailPanel}
</div>
);
}
/** HonestAI transport and state with Honest AI's conversation and composer. */
export function GenerativeChat({
llm,
storage,
artifactRenderers,
artifactCategories,
artifactAutoOpen,
...props
}: GenerativeChatProps) {
return (
<ChatProvider
llm={llm}
storage={storage}
artifactRenderers={artifactRenderers}
artifactCategories={artifactCategories}
artifactAutoOpen={artifactAutoOpen}
>
<Conversation {...props} />
</ChatProvider>
);
}