Open Kortyx on GitHub

SSE for API Routes

Updated 10 minutes ago • May 22, 2026

Use this page when you expose a chat endpoint and want live chunk streaming in the browser.

import { parseChatRequestBody, toSSE } from "kortyx"; import { agent } from "@/lib/kortyx-client"; export async function POST(request: Request): Promise<Response> { const body = parseChatRequestBody(await request.json()); const stream = await agent.streamChat(body.messages, { sessionId: body.sessionId, workflowId: body.workflowId, context: body.context, }); return toSSE(stream); }

Good to know: toSSE(...) is the route-level helper you usually want. It sets the SSE headers and writes the stream in SSE format. Authenticate and rate-limit this route before calling agent.streamChat(...); do not trust client-sent context for authorization.

If you are building a React client, start with @kortyx/react.

import { createRouteChatTransport, useChat } from "@kortyx/react"; export function ChatPage() { const chat = useChat({ transport: createRouteChatTransport({ endpoint: "/api/chat", }), }); return <ChatWindow chat={chat} />; }

Use chat.messages for finalized history and chat.streamContentPieces for the current in-flight assistant response.

Good to know: useChat(...) already separates assistant text, structured streams, interrupts, and storage. Most React apps should start here instead of manually reducing raw chunks.

Custom React UI with structured streams

If you want your own UI but not the full chat abstraction, use useStructuredStreams() and only wire the parts you care about.

import { useStructuredStreams } from "@kortyx/react"; import { readStream } from "kortyx/browser"; export function StructuredPanel() { const structured = useStructuredStreams<Record<string, unknown>>(); let text = ""; async function load() { const response = await fetch("/api/chat", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ messages }), }); for await (const chunk of readStream(response.body)) { if (chunk.type === "text-delta") { text += chunk.delta; } structured.applyStreamChunk(chunk); if (chunk.type === "done") { break; } } } return null; }

This keeps the structured-stream reducer logic framework-owned while leaving text rendering and layout up to you.

Example: render a streamed email draft

If a node uses useReason({ structured: { fields: { body: "text-delta", bullets: "append" } } }), the client can render the draft while the model is still writing:

const email = structured["some-stream-id"]; if (email?.dataType === "email.compose") { const draft = email.data as { subject?: string; body?: string; bullets?: string[]; }; renderSubject(draft.subject ?? ""); renderBody(draft.body ?? ""); renderBullets(draft.bullets ?? []); setLoading(email.status === "streaming"); }

kind semantics

Structured chunks use one of four kinds:

  • set: replace a value at a path
  • append: append items to an array at a path
  • text-delta: append text to a string at a path
  • final: replace the whole object and mark the stream complete

You usually do not need to implement these rules yourself. useStructuredStreams() and useChat() already apply them for you.

Path behavior

  • path is a dot-separated location inside the object being built
  • raw structured chunks, manual useStructuredData(...) calls, and useReason({ structured: { fields } }) can use dotted paths such as draft.body
  • numeric path segments target array indexes, such as sections.0.body
  • useReason({ structured: { fields } }) can use * as a single-segment wildcard, such as assessment_points.*.criteria_label; emitted chunks always contain concrete paths
  • applyStructuredChunk(...) throws on malformed paths, impossible container-shape conflicts, append on non-arrays, text-delta on non-strings, and any chunk that arrives after final
  • final replaces the whole accumulated object and should be treated as the source of truth

Good to know: If a single object streams multiple fields over time, keep one streamId for that whole object. Use different streamId values only for independent objects.

Good to know: streamId is the update identity for structured streams. If a node emits multiple related useStructuredData(...) calls, keep the same streamId so the client updates one object instead of creating many.

Advanced: manual reducer path

If you are not using React, or you want the raw reducer directly, use applyStructuredChunk(...) from kortyx/browser.

import { applyStructuredChunk, readStream, type StructuredStreamState, } from "kortyx/browser"; let text = ""; const structured: Record< string, StructuredStreamState<Record<string, unknown>> > = {}; const response = await fetch("/api/chat", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ messages }), }); for await (const chunk of readStream(response.body)) { if (chunk.type === "text-delta") { text += chunk.delta; } if (chunk.type === "structured-data") { structured[chunk.streamId] = applyStructuredChunk( structured[chunk.streamId], chunk, ); } if (chunk.type === "done") { break; } }

consumeStream(...)

If you prefer the callback helper:

import { applyStructuredChunk, consumeStream, streamChatFromRoute, } from "kortyx/browser"; const structured = {}; const stream = streamChatFromRoute({ endpoint: "/api/chat", messages, }); await consumeStream(stream, { onChunk: (chunk) => { if (chunk.type === "structured-data") { structured[chunk.streamId] = applyStructuredChunk( structured[chunk.streamId], chunk, ); } }, });

Infrastructure notes

  • toSSE(...) and createStreamResponse(...) set content-type: text/event-stream
  • they also set cache-control: no-cache, connection: keep-alive, and x-accel-buffering: no
  • keep this route on a runtime that supports streaming responses
  • if you run behind a proxy or CDN, make sure response buffering is disabled

Good to know: If your client needs a single buffered result instead of live chunks, expose a non-stream mode and return collectBufferedStream(...) from your route.

Low-level helper

Use createStreamResponse(...) only when you already have your own AsyncIterable<StreamChunk> and want to convert it to SSE directly.

For chunk types and protocol details, see Stream Protocol.