Open Kortyx on GitHub

Rendering Streamed Chat

Updated 5 hours ago • May 22, 2026

useChat(...) separates completed history from the current in-flight assistant response.

  • Render messages for finalized chat history.
  • Render streamContentPieces for the active assistant response.
  • Expect text, structured data, interrupts, and errors to appear before finalization.
  • When the stream finishes, useChat(...) builds and appends the final assistant message.

Basic Pattern

import type { UseChatValue } from "@kortyx/react"; export function ChatWindow({ chat }: { chat: UseChatValue }) { return ( <section> {chat.messages.map((message) => ( <MessageBubble key={message.id} message={message} /> ))} {chat.streamContentPieces.length > 0 ? ( <AssistantLiveMessage pieces={chat.streamContentPieces} chat={chat} /> ) : null} {chat.error ? ( <button type="button" onClick={() => chat.clearError()}> Retry after error </button> ) : null} </section> ); }

Render Piece Types

Handle each piece type explicitly.

import type { ContentPiece, UseChatValue } from "@kortyx/react"; export function AssistantLiveMessage({ pieces, chat, }: { pieces: ContentPiece[]; chat: UseChatValue; }) { return ( <div> {pieces.map((piece) => { if (piece.type === "text") { return <p key={piece.id}>{piece.content}</p>; } if (piece.type === "structured") { return <StructuredPreview key={piece.id} data={piece.data} />; } if (piece.type === "interrupt") { return <InterruptForm key={piece.id} piece={piece} chat={chat} />; } return ( <p key={piece.id} role="alert"> {piece.content} </p> ); })} </div> ); }

Interrupt Controls

For interrupt pieces, call chat.respondToInterrupt(piece, response) with the same piece you received. This preserves the resumeToken and requestId.

If your workflow emits multiple interrupt types, route custom controls with piece.schemaId. The piece also preserves schemaVersion, interruptId, and public meta from the server interrupt request.

import type { HumanInputPiece, UseChatValue } from "@kortyx/react"; export function InterruptForm({ piece, chat, }: { piece: HumanInputPiece; chat: UseChatValue; }) { if (piece.kind === "text") { return ( <form onSubmit={(event) => { event.preventDefault(); const form = new FormData(event.currentTarget); void chat.respondToInterrupt(piece, { text: String(form.get("answer") ?? ""), }); }} > <label> {piece.question} <input name="answer" /> </label> <button type="submit">Send</button> </form> ); } return ( <div> <p>{piece.question}</p> {piece.options.map((option) => ( <button key={option.id} type="button" onClick={() => void chat.respondToInterrupt(piece, { selected: [option.id] }) } > {option.label} </button> ))} </div> ); }

Abort and Errors

Show an abort control while a stream is active.

import type { UseChatValue } from "@kortyx/react"; export function ChatControls({ chat }: { chat: UseChatValue }) { return ( <div> {chat.canAbort ? ( <button type="button" onClick={() => chat.abort()}> Stop </button> ) : null} {chat.error ? ( <button type="button" onClick={() => chat.clearError()}> Clear error </button> ) : null} </div> ); }

Common Mistakes

  • Rendering live assistant text from both streamContentPieces and the latest messages entry.
  • Returning ui.message with the same text already streamed by useReason({ emit: true }).
  • Treating messages as token-by-token state.
  • Ignoring structured pieces and wondering why cards or previews do not update.
  • Rendering interrupt UI without preserving the original piece's resumeToken and requestId.
  • Building a custom transport but not forwarding AbortSignal.