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#

TypeScript
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#

TSX
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.

TypeScript
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:

TypeScript
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.

Next#