Studio previewSelf-host Studio locally.Run it

Stream Chunk Protocol

Updated 3 days ago • October 4, 2026

The stream protocol is defined in @kortyx/stream as StreamChunk.

Core chunk types

  • session
  • checkpoint
  • status
  • text-start
  • text-delta
  • text-end
  • tool-call-start
  • tool-call-result
  • tool-call-error
  • tool-result
  • message
  • structured-data
  • structured-data-invalidated
  • interrupt
  • transition
  • done
  • error

Typical client loop

import { readStream, type StreamChunk } from "kortyx/browser"; const chunks: StreamChunk[] = []; for await (const chunk of readStream(response.body)) { chunks.push(chunk); if (chunk.type === "text-delta") { // append to in-flight assistant text } if (chunk.type === "interrupt") { // render selection UI and resume with token/requestId } if (chunk.type === "checkpoint") { // map finalized assistant messages to checkpoint ids } if (chunk.type === "done") { break; } }

Structured Data

Simple mental model:

  • text-* is for text you show as text
  • structured-data is for objects you render as UI state
  • tool-call-* is for MCP/tool lifecycle updates when toolExecution.emit is enabled

For current useReason MCP tool execution, prefer the tool-call-* chunks. tool-call-result is the emitted lifecycle event for one concrete model-requested tool call and includes the provider/MCP toolCallId. tool-result is a legacy generic stream chunk shape retained in the public union for compatibility; it is not emitted by the MCP tool loop.

Examples:

  • email drafts
  • project tables
  • growing result lists
  • progress panels
  • validation results
  • MCP search or action progress

Tool Call Chunks

When useReason(...) runs MCP tools with toolExecution.emit: true, Kortyx emits a small lifecycle channel beside assistant text.

Example start chunk:

{ "type": "tool-call-start", "node": "support", "opId": "op_123", "tool": "search_issues", "toolCallId": "call_1", "input": { "query": "open payment bugs" } }

Example result chunk:

{ "type": "tool-call-result", "node": "support", "opId": "op_123", "tool": "search_issues", "toolCallId": "call_1", "content": "[{\"title\":\"Payment failure\"}]", "structuredContent": { "results": [{ "title": "Payment failure" }] } }

Example error chunk:

{ "type": "tool-call-error", "node": "support", "opId": "op_123", "tool": "search_issues", "toolCallId": "call_1", "message": "MCP request timed out" }

Tool chunks are emitted only when the call opts in with toolExecution.emit. Tool results are still fed back to the model when emission is disabled.

Structured chunk shape

Output contracts selected by useReason({ outputs }) use these same chunks. A contract with stream.fields emits provisional set, append, or text-delta chunks while its dedicated JSON model pass is streaming, followed by a validated final chunk. All chunks for one emission share a streamId; a later emission uses a different one. Text before and after the emission uses distinct segmentId values, so @kortyx/react preserves the order of text and structured pieces. The dedicated JSON pass consumes a model step. Contracts without stream.fields emit only the final chunk after their tool call finishes.

Example incremental chunk:

{ "type": "structured-data", "node": "planner", "streamId": "run_123", "dataType": "email.compose", "kind": "text-delta", "path": "body", "delta": "Hi team,", "schemaId": "email-compose", "schemaVersion": "1", "id": "compose-email" }

Example final chunk:

{ "type": "structured-data", "node": "planner", "streamId": "run_123", "dataType": "email.compose", "kind": "final", "data": { "subject": "Beta access is open", "body": "Hi team,\n\nBeta access is open.", "bullets": ["Faster setup", "Live streaming UI"] } }

Field meanings:

  • streamId: stable identity for one logical structured stream
  • dataType: app-defined routing label
  • kind: how the client should apply this update
  • schemaId and schemaVersion: optional schema metadata
  • id: optional app identifier
  • node: workflow node that emitted the payload

kind values

  • set: set one field at path
  • append: append items to an array at path
  • text-delta: append delta to a string at path
  • final: replace the whole object with data and mark the stream done

That is the full public mental model. Most clients should key by streamId and apply chunks in arrival order.

Path contract

  • path means a dot-separated location inside the object being built, such as draft.body
  • dotted paths are valid in raw structured-data chunks, manual useStructuredData(...) calls, and useReason({ structured: { fields } })
  • numeric path segments target array indexes, such as sections.0.body
  • useReason({ structured: { fields } }) also accepts * as a single-segment pattern and emits concrete paths, such as assessment_points.commercial_resilience.criteria_label
  • one streamId represents one logical object; if multiple fields in that object stream incrementally, they all use the same streamId
  • use separate streamId values only when you are building separate objects

Reducer guarantees

applyStructuredChunk(...) is the canonical reducer for this protocol.

  • set writes the value at path
  • append appends to an existing array at path, or creates that array when the path is unset
  • text-delta appends to an existing string at path, or creates that string when the path is unset
  • final replaces any previously accumulated partial object completely and becomes the source of truth
  • no chunk is allowed after final for the same streamId

Runtime enforcement:

  • invalid empty or malformed paths are rejected
  • impossible container-shape conflicts are rejected
  • append on a non-array target is rejected
  • text-delta on a non-string target is rejected
  • a chunk for a different streamId cannot be reduced into existing state for another stream

Checkpoint Chunks

When session checkpoints are enabled through a FrameworkAdapter, Kortyx emits a checkpoint boundary before the public done chunk:

{ "type": "checkpoint", "id": "cp-123", "sessionId": "session-123", "turnIndex": 4, "label": "before report generation" }

Clients should store the checkpoint id on the assistant message finalized by that stream. @kortyx/react does this automatically in useChat(...).

Field meanings:

  • id: checkpoint id used by rollback and fork APIs
  • sessionId: active session id
  • turnIndex: zero-based session checkpoint position
  • label: optional app-facing label

Good to know: The checkpoint chunk is emitted before done so transports that stop reading on done still receive the rollback boundary first.

Structured Data Invalidation

Rollback can discard structured streams that were produced after the restored checkpoint. Kortyx identifies those streams with structured-data-invalidated chunks or rollback API results:

{ "type": "structured-data-invalidated", "streamId": "report-1", "checkpointId": "cp-123" }

Clients that render structured data outside chat bubbles should remove or mark stale any local state keyed by streamId. useStructuredStreams().applyStreamChunk and useChat(...) handle invalidation automatically.

A streamed output contract also sends structured-data-invalidated without checkpointId if its provisional fields were shown but generation or final validation fails. Clients should remove that streamId immediately; a final validated chunk will not follow. The checkpointId field is present only for rollback invalidations.

Producer expectations:

  • emit chunks for a given streamId in order
  • use non-empty dot-separated paths or single-segment * patterns in output-contract stream.fields (the deprecated structured.fields path also supports them)
  • do not rely on partial chunks being validated against the final output schema

Good to know: useReason(...) validates the final object against its output contract (or deprecated outputSchema), but incremental structured chunks are enforced only at the path and operation level. Manual useStructuredData(...) calls can add optional schema checks for data, value, or appended items.

For React clients, prefer useStructuredStreams() from @kortyx/react.

import { useStructuredStreams } from "@kortyx/react"; import type { StreamChunk } from "kortyx/browser"; export function StructuredView() { const structured = useStructuredStreams<Record<string, unknown>>(); function onChunk(chunk: StreamChunk) { structured.applyStreamChunk(chunk); } return null; }

That gives you:

  • items: stable ordered structured pieces
  • byStreamId: map-style access for render logic
  • get(streamId): direct lookup
  • clear(): reset live state between runs

Good to know: useChat() in @kortyx/react builds on top of this and also handles assistant text, interrupts, and message history. Use useStructuredStreams() directly only when you want custom UI without the full chat abstraction.

Advanced: low-level reducer

If you are outside React or want the raw protocol reducer, use applyStructuredChunk(...) from kortyx/browser.

import { applyStructuredChunk, type StructuredDataChunk, type StructuredStreamState, } from "kortyx/browser"; const byStreamId: Record< string, StructuredStreamState<Record<string, unknown>> > = {}; function onStructuredChunk(chunk: StructuredDataChunk) { byStreamId[chunk.streamId] = applyStructuredChunk( byStreamId[chunk.streamId], chunk, ); }

applyStructuredChunk(...) returns:

  • data: current accumulated object
  • status: "streaming" | "done"
  • streamId
  • dataType

If you already have an array of structured chunks, you can also use reduceStructuredChunks(...).

How useReason({ structured }) uses this protocol

When you call useReason(...) with structured, there are two main modes.

Final-only mode

If you do not configure structured.fields, Kortyx emits one structured-data chunk with kind: "final" after the model output validates.

Incremental mode

If you set structured.fields, Kortyx can turn selected parts of the streamed JSON into deterministic structured updates before the final object arrives.

Example:

import { useReason } from "kortyx"; import { z } from "zod"; import { google } from "@/lib/providers"; const EmailDraftSchema = z.object({ subject: z.string(), body: z.string(), bullets: z.array(z.string()), }); const result = await useReason({ model: google("gemini-2.5-flash"), input: "Write an email draft as JSON.", outputSchema: EmailDraftSchema, structured: { dataType: "email.compose", stream: true, fields: { subject: "set", body: "text-delta", bullets: "append", }, }, });

In that mode:

  • the model still returns JSON for the full schema
  • Kortyx watches the stream and emits early structured updates for declared fields
  • all updates from that reasoning call share one streamId
  • the final validated object still arrives as kind: "final"

Current limits:

  • set field paths
  • string field paths as text-delta
  • array field paths as append
  • * wildcard patterns that match one object key or array index segment
  • non-interrupt flows only

The raw structured-data protocol and useReason(... structured.fields ...) both support dotted path values. useReason(...) extracts those paths conservatively from streamed model JSON, resolves wildcard matches to concrete paths in emitted chunks, and still treats the final validated object as the source of truth.

If a field should appear once and stay stable, it is often simpler to emit it from node logic with useStructuredData({ kind: "set", ... }).

Consuming text and structured data together

Many clients render both channels at once: text for conversation and structured data for UI state.

import { applyStructuredChunk, readStream, type StructuredStreamState, } from "kortyx/browser"; let text = ""; const structured: Record<string, StructuredStreamState<Record<string, unknown>>> = {}; 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, ); } }

Notes

  • done is terminal for a stream run
  • error may be followed by done
  • session helps clients persist conversation identity

Complete a response before execution finishes

See Background Continuation for completeResponse(), independent execution lifetime, and discovering human requests through agent.listInterrupts() without Studio. A done chunk ends client delivery; it does not prove successful execution.