Skip to content

useChatStream ​

The high-level hook: composes useTokenStream + useToolCallState into a multi-turn, chat-shaped reducer. Append a user message, stream the assistant's reply (text interleaved with tool calls), execute any tool calls that come ready, and track the whole conversation as a StreamMessage[] history.

Signature ​

typescript
function useChatStream(
  options: UseChatStreamOptions
): UseChatStreamResult

Options ​

PropTypeDefaultDescription
getAssistantStream(history: StreamMessage[]) => StreamSourceFactory—Given the full history (incl. the just-sent user message), return a factory for the assistant's reply. This is where you call an adapter.
toolsToolRegistry{}Tool implementations, forwarded to the underlying useToolCallState.
onAssistantMessageDone(message: StreamMessage) => void—Called once per assistant message when its stream completes successfully.
onError(error: Error, messageId: string) => void—Called if the assistant's stream errors.

Return value ​

FieldTypeDescription
messagesStreamMessage[]Full conversation, with the active assistant message reflecting live streaming text/status.
isStreamingbooleanWhether the assistant's current turn is still streaming.
errorError | nullLast turn's error, if any.
sendMessage(text: string) => voidAppend a user message and start the assistant reply.
abort() => voidAbort the in-flight reply.
reset() => voidClear all messages and tool state.

Key design decisions ​

  • Interleaved, not "text then tool call." A model's streamed response interleaves text and tool-call chunks within one response — the model emits some text, starts a tool call, and the next text depends on its result. A single useTokenStream run captures the raw interleaved sequence rather than assuming a single trailing tool call.
  • Shared tool state across messages. One useToolCallState instance backs the whole chat (not one per message), so tool calls from different historical messages keep independent lifecycles even after the conversation moves on.
  • streamKey-driven turns. Each sendMessage bumps an internal turn counter that becomes useTokenStream's streamKey, triggering a fresh run per turn instead of reusing stale closures. (See useTokenStream for why streamKey exists instead of relying on factory identity.)

Example ​

tsx
import { useChatStream, StreamingMarkdown, StreamStatus } from "streamkit-ui";
import { fromAnthropic } from "streamkit-ui/adapters/anthropic";

function Chat() {
  const { messages, isStreaming, sendMessage, abort } = useChatStream({
    getAssistantStream: (history) => (signal) =>
      fromAnthropic({ stream: callAnthropic(history, signal) }),
    tools: {
      search: async ({ query }: { query: string }) => fetchSearch(query),
    },
  });

  return (
    <>
      <StreamStatus status={isStreaming ? "streaming" : "idle"} />
      {messages.map((m) =>
        m.role === "user" ? (
          <p key={m.id}>{m.text}</p>
        ) : (
          <StreamingMarkdown
            key={m.id}
            text={m.text}
            isStreaming={isStreaming && m.status === "streaming"}
          />
        ),
      )}
      {isStreaming && <button onClick={abort}>Stop</button>}
    </>
  );
}

MIT License