Guides
Custom chat UI
Consuming the stream directly — no framework, no SDK, about sixty lines.
The Next.js guide uses useChat, which hides the stream. This
one does not — useful if you are not on React, are on React Native, or simply
want to know exactly what is happening.
Everything here uses the SSE format.
A minimal client#
interface ChatEvents {
onText?: (chunk: string) => void;
onTool?: (name: string, input: unknown) => void;
onCanvas?: (canvas: Canvas) => void;
onDone?: (summary: Done) => void;
onError?: (message: string) => void;
}
export async function sendTurn(
{ sessionId, message, mode = "text" }: TurnInput,
events: ChatEvents,
signal?: AbortSignal,
) {
const response = await fetch(`/api/zeevaa/assistants/${ASSISTANT_ID}/chat`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-zeevaa-format": "sse",
},
body: JSON.stringify({ session_id: sessionId, message, mode }),
signal,
});
if (!response.ok || !response.body) {
const body = await response.json().catch(() => null);
events.onError?.(body?.error?.message ?? "Something went wrong.");
return;
}
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
// Events are separated by a blank line. The last piece is usually a
// partial event, so it stays in the buffer until the rest arrives —
// dropping it here is the classic way to lose every other token.
const chunks = buffer.split("\n\n");
buffer = chunks.pop() ?? "";
for (const chunk of chunks) {
const name = chunk.match(/^event: (.+)$/m)?.[1];
const raw = chunk.match(/^data: (.+)$/m)?.[1];
if (!name || !raw) continue;
const data = JSON.parse(raw);
if (name === "message.delta") events.onText?.(data.text);
else if (name === "tool.call") events.onTool?.(data.name, data.input);
else if (name === "canvas") events.onCanvas?.(data);
else if (name === "done") events.onDone?.(data);
else if (name === "error") events.onError?.(data.message);
}
}
}Sixty lines, no dependencies, works in any browser and in Node 18+.
Important
The buffer.split("\n\n") / buffer = chunks.pop() pattern is the part to
get right. A network chunk boundary lands mid-event regularly, and code that
parses whatever arrived and throws away the remainder loses tokens
intermittently — which looks like the model producing broken sentences rather
than a parsing bug.
Wiring it up#
function Chat({ sessionId }: { sessionId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
const [canvas, setCanvas] = useState<Canvas | null>(null);
const [busy, setBusy] = useState(false);
const abort = useRef<AbortController | null>(null);
const send = async (text: string) => {
setBusy(true);
setMessages((current) => [
...current,
{ role: "user", content: text },
{ role: "assistant", content: "" },
]);
abort.current = new AbortController();
await sendTurn(
{ sessionId, message: text },
{
onText: (chunk) =>
setMessages((current) => {
const next = [...current];
next[next.length - 1] = {
...next[next.length - 1],
content: next[next.length - 1].content + chunk,
};
return next;
}),
onCanvas: setCanvas,
onDone: () => setBusy(false),
onError: (message) => {
setMessages((current) => [...current, { role: "error", content: message }]);
setBusy(false);
},
},
abort.current.signal,
);
};
return (
<>
<Transcript messages={messages} />
{canvas && <ResultsPanel canvas={canvas} />}
<Composer onSend={send} busy={busy} onStop={() => abort.current?.abort()} />
</>
);
}The empty assistant message pushed before the request is what gives the stream somewhere to land. Without it the first chunk has no message to append to and the interface flickers.
Things worth doing#
Show tool activity. onTool fires before any text. "Searching properties…"
during a two-second catalogue query is the difference between a considered
answer and a frozen interface.
const LABELS: Record<string, string> = {
search_records: "Searching…",
search_knowledge: "Reading the documents…",
send_email: "Sending…",
};Paint skeletons from expected. The loading canvas event arrives before
the results and tells you how many to expect. Paint that many placeholders and
the layout will not jump.
Make stop work. Hold the AbortController and expose a stop button. Partial
replies are still stored, so stopping loses nothing.
Render errors as messages, not toasts. An error in a conversation is part of the conversation. A toast disappears while the person is still reading.
Keep the session id server-side if you can. A cookie or a database row is better than local storage — it survives a device change, and it cannot be tampered with.
Reconnecting#
If a stream dies mid-turn — a dropped connection, a closed laptop — nothing is lost. Whatever was generated is already stored.
Fetch the transcript and carry on:
const { turns } = await fetch(`/api/zeevaa/sessions/${sessionId}`).then((r) =>
r.json(),
);
setMessages(
turns.map((turn) => ({ role: turn.role, content: turn.content })),
);This is what makes server-side history worth having. There is no reconciliation step, no replaying of a local buffer, no risk of a duplicate turn — you ask what happened and you are told.
Other languages#
The same loop, minus the browser types. In Python, see
Streaming for a complete httpx example.
The rules are identical everywhere: split on a blank line, keep the remainder,
parse event: and data:, and set a read timeout of at least 120 seconds.