Skip to content

Stream a complete turn

A streamed message has one envelope in messages and an ordered set of durable rows in messageParts. Parts may be text, reasoning, tool, or a host-defined data payload. Tool parents let one message preserve an entire supervisor/subagent execution tree.

Write lifecycle

ts
const writer = await chat.stream(channelId)
try {
  writer.push(
    { type: 'part_start', key: 'answer', partType: 'text' },
    { type: 'delta', key: 'answer', text: 'Hello ' },
    { type: 'delta', key: 'answer', text: 'world' },
    { type: 'part_end', key: 'answer' },
  )
  await writer.finalize()
} catch (error) {
  await writer.abort(String(error)).catch(() => {})
  throw error
}

Client writers queue events and micro-batch them onto the wire on an ~80 ms cadence — a plain run → finalize loop streams smoothly with no manual flushing, and queued appends always land before a settle (strict per-message order). flush() forces the queue out early and surfaces any wire or server error. Server writers from chatKit.messages.stream({ channelId, authorId }) use the same vocabulary but apply events immediately.

EventMeaning
part_startOpen a text, reasoning, tool, or typed data part.
deltaAppend text to a text/reasoning part.
tool_patchReplace tool args, result, error flag, or state.
data_patchReplace the host-typed data payload.
part_endClose the part; optional final text heals missed deltas.

Tool state is monotonic: input-streaming → running → done.

Durable and live reads

Message envelopes and detailed parts are intentionally separate:

ts
const recent = chat.messages(channelId, { limit: 200 })
const page = await chat.history(channelId, { before: cursor, limit: 50 })
const parts = chat.messageParts(channelId, messageId)
  • messages() is the bounded live recent window.
  • history() is a one-shot keyset page of older envelopes.
  • messageParts() is complete for one message, tree-ordered, and live. It overlays ephemeral text deltas on the last durable checkpoint.

This split keeps channel feeds cheap without ever truncating a selected message's execution history. After reload, mounting messageParts() reconstructs every supervisor and subagent part from rows.

React exposes useMessages, useChatHistory, and useMessageParts with the same responsibilities.

Typed data parts

Declare the host payload at contract time:

ts
const app = defineContract({
  plugins: [
    authContract(),
    chatContract({ data: z.object({ kind: z.literal('progress'), value: z.number() }) }),
  ],
})

Then writers and readers are typed:

ts
writer.push({
  type: 'part_start',
  key: 'progress',
  partType: 'data',
  data: { kind: 'progress', value: 10 },
})
writer.push({ type: 'data_patch', key: 'progress', data: { kind: 'progress', value: 100 } })

Structured payloads replace atomically and are limited by maxStructuredBytes.

Supervisor trees

The parent field names an existing tool call:

ts
writer.push({ type: 'part_start', key: 'call-42', partType: 'tool', toolName: 'delegate' })
writer.push({ type: 'part_start', key: 'worker-text', partType: 'text', parent: 'call-42' })
writer.push({ type: 'delta', key: 'worker-text', text: 'Worker result' })
writer.push({ type: 'part_end', key: 'worker-text' })
writer.push({ type: 'tool_patch', key: 'call-42', result: { content: 'Worker result' } })
writer.push({ type: 'part_end', key: 'call-42' })

messageParts() returns tree order. buildPartTree(parts) returns an explicit nested structure; partsText(parts, parent) extracts text from one lane.

Stream adapters

ts
import { pipeUIMessageStream } from '@super-line/plugin-chat/ai-sdk'
import { pipeMastraStream } from '@super-line/plugin-chat/mastra'

Both map provider chunks into plugin events and never open or settle messages. Use createUIMessageStreamAdapter() or createChunkAdapter() when the host owns the iteration loop. mapDataPart converts provider-specific file/source/data/usage chunks into the contract's typed data payload; onUnsupported lets the host observe anything intentionally not persisted. Framing chunks the adapters would otherwise drop (finish, step-finish, message-metadata, …) are offered to mapDataPart first — map a finish chunk's usage into a data part to persist per-run (and, in subagent lanes, per-agent) token usage. Unmapped framing drops silently without onUnsupported.

Cancellation

ts
await chat.cancelMessage(messageId, 'user stopped generation')
writer.signal.addEventListener('abort', () => modelAbort.abort())

Only the author or a channel owner may cancel. The server settles the message aborted, preserves partial parts, and signals the producer. Cancellation is control flow, not a fake transcript message.

The settle is server-side: once a cancel lands, the row is already aborted — the producer must NOT call finalize() afterwards (at best a CONFLICT no-op). Stop pushing and let writer.signal unwind the model call.

Deletion follows the same invariant: a streamed message always settles before it vanishes. Deleting a still-streaming message settles it aborted (reason deleted) first, then removes the rows — consumers always observe a terminal status before the row disappears, and the producer's stream handle is released automatically, whoever deleted it and from whichever node. Deleting a channel mid-stream releases producer handles the same way (reason channel deleted).

Limits

Configure with chat({ streaming: { … } }):

OptionDefault
checkpointMs1000
maxParts512
maxTextBytes256 KiB
maxStructuredBytes256 KiB
maxEventsPerAppend256

project(parts) derives the settled envelope content; by default root text parts are joined with blank lines. Hosts with structured content schemas should provide a matching projection.

Disconnect, shutdown, explicit abort, and stale-stream sweeping preserve partial durable parts and settle envelopes honestly.

Released under the MIT License.