Streaming & event contracts
Kaizen streams a lot: chat turns, prompt-chain runs, task run traces, orchestration progress. They share one model.
The core idea: positions, not pushes
A stream is a cursor over a durable record, not a fire-and-forget push.
The conversation journal in PostgreSQL is the truth. Clients receive journal positions in strict journal order, remember the last position applied, and reconnect from there.
Valkey carries short-lived leases, "the room head changed" hints, and bounded artifact recovery material. Reconnect always re-authorizes membership and fills any durable gap from PostgreSQL. None of those transient records replaces the journal.
Client contract
- Remember the last applied position.
- Ignore exact duplicates. Redelivery is expected and safe.
- Reconnect from your position, not from the beginning.
- Treat a gap as a fill, not a loss — request the missing range.
- If the cursor is unusable, reload an authorized snapshot and resume live updates from there.
- Never treat a transient disconnect as a failed run. Committed messages, queue transitions, answers, and terminal outcomes catch up in order.
- Wake active work after hydration. A private or shared detail carrying an
active input wakes that exact input through its resume endpoint. The
initiating page follows this rule too: local
sendingstate is not runtime authority. Retry the idempotent wake on a bounded cadence while the input remains active; do not infer or mint a replacement run. - Honor
stream_reset. A slow consumer must reload its authorized room projection and reconnect; it must not keep applying an arbitrary delta suffix after its bounded subscriber queue overflowed.
A Reconnecting or Live updates are delayed state means the transport is degraded. It does not mean a message or a run was lost.
"Transient" includes an abort the client never asked for. A browser cancels
in-flight requests on its own — a navigation, a restored page, a renderer
under pressure — and that surfaces as an AbortError indistinguishable from a
deliberate close unless the client checks its own abort signal. Only the
client's own signal ends the loop; every other interruption reconnects from
the last applied position and is announced to the member. Ending the loop on a
foreign abort is how a live-looking conversation goes permanently silent, and
it is the one failure the member reads as the whole application freezing.
The reconnect is observable rather than implicit: the next attempt re-opens
GET /api/conversations/{id}/events with after=<last applied position>, the
same position in Last-Event-ID, and a fresh connection_id, after a bounded
backoff. The member-visible notice applies to private conversations as well as
shared rooms — both run on the same journal stream, so both say
Reconnecting while the transport is down.
What is projected
Only closed, member-safe facts enter the conversation journal and therefore the stream:
- messages and their attribution,
- queue transitions,
- answers and citations,
- bounded member-visible Thinking attached to a completed answer,
- artifacts and linked resources,
- membership changes,
- terminal outcomes.
Never projected: hidden model reasoning, raw tool arguments or results,
credentials, hidden prompts, presence, actor-only approvals. A failed tool
step's error_class is a word from a closed vocabulary, not content derived
from the result.
Internal runtime events are a separate, more detailed journal that does not reach the browser.
Visible Thinking is the reasoning text that the selected provider elects to
return through Pi's thinking_delta stream. It is not the provider's hidden
chain of thought. With Plain-speak disabled, Kaizen forwards those visible
fragments in arrival order without summarizing or rewriting them. Providers may
still return a detailed narrative, a short summary, coarse batches, or no
reasoning text at all. The selected effort is a reasoning budget, not a promise
of a particular transcript length or cadence.
The conversation event stream carries durable member-visible Thinking progress
and transient answer delta and artifact-content artifact_delta frames for
the active turn. Publishing a durable head flushes the transient
publisher first, so a terminal journal frame never overtakes fragments already
admitted from the same runtime stream. Fragments the runtime emits after the
terminal was committed fall outside that barrier, and a durable row can also
reach a client by journal catch-up while a transient frame cannot — so a late
fragment can still arrive after the terminal in the browser. A turn that has
recorded its terminal is therefore settled on the client, and later transient
frames for it are ignored rather than applied: applying them would reopen a
finished turn, restore its in-progress activity, re-append text the committed
answer already contains, and reopen its artifact session. A rerun reopens the
same turn deliberately, and its frames apply normally.
In a shared room, the same member-safe progress is fanned out to current
participants. The projector persists the first visible status, the
first meaningful fragment, and later 512-character checkpoints, then folds the
exact bounded visible Thinking stream into the terminal assistant message as
thinking_trace; hidden reasoning is still prohibited. If a process or
provider attempt stops after a durable checkpoint but before its terminal
answer, the next exact run projection rehydrates that bounded trace and appends
new visible Thinking before committing the terminal message. Thinking alone does
not prevent a bounded provider retry. With the turn engine enabled, a failed
attempt's Thinking never enters the retry request or later model conversation
history; the legacy compatibility rollback does not provide that isolation. New Thinking from
a retry is genuine new progress and remains visible even if its wording repeats.
The retained trace is presentation recovery only and is never returned to the
model as conversation context. The projector publishes a live Thinking fragment before
persisting its bounded checkpoint. That checkpoint carries the fragment's
cumulative character offset, allowing a current viewer to recognize bytes it
already rendered while a replaying viewer appends the same bytes once. The
checkpoint drains through a bounded ordered writer while later provider deltas
continue. A non-Thinking journal event, cancellation, failure, or terminal is a
hard barrier that waits for every earlier checkpoint, so removing the database
round trip from token cadence does not weaken journal order or recovery. The
client counts each received fragment once and accumulates every admitted
provider request, so the live turn estimate follows the same multi-call basis
as terminal usage across reasoning, prose, and artifact generation. The latest
request-local input remains available for context diagnostics. Pi attaches
provider-normalized usage to each completed assistant turn. The runtime emits
that cumulative prompt, output, cache, and call-count checkpoint immediately,
and the client replaces open-call estimates through that output-character
watermark before estimating the next call. Provider-reported checkpoints are
exact. If a provider omits usable usage, the gateway supplies a tokenizer-based
checkpoint that remains explicitly estimated; its provenance is not inferred
inside Pi. The live label remains approximate because the currently open
provider call has no terminal usage yet; the value can adjust at a checkpoint.
Each call transition also projects one bounded, non-model-visible
agent_progress.usage_progress journal checkpoint. This is deliberately not a
per-token write: it gives a participant who joins or refreshes mid-turn the same
cumulative call boundary as existing viewers without coupling provider token
cadence to PostgreSQL. Replaying the checkpoint through the normal context
projection is idempotent when the same viewer already received its transient
frame.
The terminal answer carries the same exact-or-estimated provenance. A provider
that does not emit incremental bytes still follows the same lifecycle, but the
UI can expose only the coarser frame cadence that provider supplies. For an
artifact update, it keeps the
open view's identity stable and applies the partial next content to that same
canvas or editor. The terminal tool result freezes the same checkpoint as the
completed immutable version. A rejected update leaves the previous version
untouched. An interrupted update retains the last streamed content in its
already-opened version, and a later semantic follow-up resumes that same
version rather than appending another one.
The live bus keeps the latest full artifact argument snapshot per active tool
call for five minutes. Every artifact fragment advances that cumulative
snapshot, including staged append_artifact calls whose runtime event carries
no preassembled arguments. The published live frame carries the same cumulative
value. Replay reads once during preparation and again after subscription,
closing the window in which a fragment could otherwise land between those
operations. Clients accept only an equal or longer snapshot, so the second read
and already-queued frames are idempotent and cannot roll the Workspace backward
or duplicate content. A tool terminal clears its call snapshot and a run
terminal clears remaining snapshots. These are transient recovery hints, not
journal events or completed artifact versions.
artifact_interaction frames carry a client-generated event id, exact artifact
id, deterministic element path, closed event kind, and bounded input fields.
The browser may submit up to 64 ordered interactions in one 16 ms
artifact_interactions batch. The server authorizes the member once, verifies
that every referenced exact artifact is active room content before publishing
anything, and preserves child order. Valkey expands the batch into the latest
256 individual replay entries for one hour so a reconnect can rebuild the
lightweight browser state. Durable application state remains outside this
protocol.
Live publication uses a bounded shared Valkey command pool and one pattern subscription per API process, which multiplexes room signals into bounded local viewer queues instead of allocating one Valkey connection per browser. Source fragments drain through independent conversation lanes: order is strict within a room, a slow room does not stall another room, and only a full lane applies bounded backpressure to its producer. Before a durable journal head is announced, its room lane is flushed so completion cannot overtake already admitted Thinking, answer, or artifact fragments.
The Pi-sidecar reconnect relay retains that same sequence in a bounded in-memory log. Ordinary token frames append in constant time; copying and compaction occur only when the configured byte bound is crossed. A detached backend can replay its missing suffix without making each live token progressively more expensive.
Durable head hints coalesce to the newest position. If transient traffic fills
a viewer's queue, the server replaces the incomplete suffix with a
stream_reset instruction; that client reloads its authorized PostgreSQL
projection and bounded artifact snapshots while other room viewers continue
without backpressure.
Keepalive and presence-heartbeat control frames mark initial subscription or an idle interval. A transient event or durable-head hint still triggers the authoritative journal catch-up, but active traffic does not manufacture an idle control frame between consecutive live events.
Clients initialize the active assistant as streaming at input
acceptance, not at the first prose delta. That keeps the flashing cursor and
provider-turn token indicator together through reasoning and tool-only intervals.
Native sandbox outputs use the terminal answer's run_artifacts projection;
the journal stores that bounded list on the terminal answer itself, so the live
frame and replay cannot disagree about which files belong to the turn. The
projector binds each saved file to the producing tool's room-safe call reference,
the same reference on durable tool progress. After a lost completion frame, the
client can finish a building pane only from a saved file attributed to that
exact call; a second call in the same run may produce the same filename.
Older resource-only files remain available through their authorized links,
but without a retained call reference they cannot settle a building pane.
The room's durable resource_linked event supplies the exact conversation and
reference identifiers used by preview and download. The collaboration reducer
binds that durable link to the exact active run immediately and retains it when
the terminal answer omits a redundant artifact list; replay projects the same
link onto the originating turn after reload.
Private turns use the same durable resource_linked projection, so reload
reconstructs the originating answer and Workspace output instead of depending
on transient state.
A delegated child has two answer projections with different consumers. Its
complete member-safe final answer is carried by the child card's terminal
frame. The parent model receives a separate readable, sentence-bounded digest
with a backend-only status indicating whether the projection was shortened.
The browser keeps the established Working, Completed, and Failed presentation;
it does not expose internal partial or truncated projection labels.
The adapter computes the trusted pre-execution approval decision before an
artifact fragment can cross the backend stream. Ordinary artifact_delta
frames for an existing artifact update pass through immediately after the
owner-resolved building version is committed. For a gated create or update,
the backend holds every fragment under the scoped tool-call identity and sends
none of its content to the browser. A successful durable decision marks that
exact call; denial drops the buffered fragments. The execution callback
independently rejects a matching pending approval, so transport ordering cannot
turn presentation progress into tool authority.
New create_artifact calls have a stricter checkpoint boundary. Every delta is
buffered until the complete arguments form one unique-key top-level JSON
object. The adapter rejects duplicates and the reserved renderable-artifact
pair before creating an owner-only row, then forwards the buffered ordinary
call with that durable reference. A stop, failure, or interruption before the
full object terminally fences the call, so late provider fragments or a late
callback cannot materialize it and there is no partial create to resume.
Existing-artifact updates stream after the backend creates their owner-only
building version. Persistence advances that call-correlated row in bounded
512-character checkpoints; the exact execution, failure, or interruption
boundary forces the latest in-memory content durable. The checkpoint reference
rides each transient frame so the pane uses the durable version identity from
the outset. Stop or transport failure marks the row interrupted or failed;
tool success marks that same row completed. Published and room-shared reads
continue to resolve the last completed version until then.
How an open row settles depends on whether the call ever reached execution and
on whether this call opened the row, and the cases are deliberately different.
A run that ends abnormally leaves its open rows interrupted: the call was in
flight, its partial content is real
work, and a follow-up resumes that same version. A run that ends normally can
still hold an open row, because the projection opens durably while the model is
writing the call and a model may abandon a call it has begun. That row records
work no tool ever performed, so normal completion discards it rather than
settling it. Settling it instead would leave a version the member cannot resume
— no later call carries its call id — and one no conversation-scoped read can
serve, since the room reference is only written at completion. A discard is
refused for any row that already reached a member-visible resting place: a
completed generation, a published version, one another version descends from or
was remixed out of, or one a conversation already references.
Two further conditions narrow it, because neither is visible from the row
itself. A discard applies only to a row this call opened: both checkpoint
paths can instead rebind an interrupted, failed, or superseded version onto a
later call, and a rebound row carries work the member may still resume. And a
terminal frame is not proof of a clean finish — a runtime-budget abort and a
length-truncated answer both arrive through the same done event as an ordinary
completion — so a turn that stopped short interrupts its open rows rather than
discarding them.
agent_run_completed and generation_status are independent facts.
A run completing says nothing about whether a given artifact completed, and a
non-completed artifact is not evidence that its run failed.
The conversation-scoped read GET /api/conversations/{id}/artifacts/{id}
resolves through conversation_resource_reference, which is written when the
generation completes. A non-completed artifact therefore has no reference and
the route answers 404 — by design, and not a status filter. The status is not
served with the row, because a version that is still being written has no stable
content for a conversation reader to cite.
New artifact imperatives use a transaction-shaped writer. begin_artifact
creates one owner-, turn-, call-, and execution-fenced draft;
append_artifact accepts an exact sequence, prior digest, and idempotent
receipt. Its per-run character bound is derived from the same trusted effective
model-output ceiling bound into the run manifest, after reserving answer space,
and never exceeds the 400,000-character artifact total. This lets a capable
model finish most artifacts in one complete append without weakening the
checkpoint boundary. finalize_artifact accepts only the current sequence and
content digest. discard_artifact terminalizes a draft
that cannot be validated. The browser accumulates append deltas against one
workspace identity, but append success remains building; only finalization
sets the stream and durable row to completed. A recovered attempt rotates the
private execution fence and may continue the interrupted cursor, so callbacks
from the expired attempt fail closed instead of racing the replacement.
The four staged operations are advertised to each provider step as one atomic capability: Kaizen either exposes the complete authorized protocol or defers all of it. If an older or stale step nevertheless requests a staged operation that was not in its captured tool binding, Pi records one typed failure and stops that attempt while retaining the private draft checkpoint. It does not spend the remaining model-call budget retrying an impossible protocol.
The client retains its exact-call buffer as rolling-version defense, but it is not the security boundary. A released replay stays transient until the durable tool terminal settles it; the answer's committed artifact reference is a recovery signal if transport ordering hides that terminal. Update replay subdivides provider-coalesced argument fragments into bounded presentation frames. Create replay remains buffered until the complete unique-key object is admitted, so it cannot expose a durable ordinary identity before the final kind and language are authoritative. Code replay inherits an already-open update's language so approval does not degrade syntax highlighting.
An explicit Stop or Cancel is itself an actor-bound conversation input. It
terminalizes the active accepted input before the producer loses authority.
The append-only journal retains the member request and interruption; a later
request reads that boundary and unfinished artifact metadata for semantic
continuity without relying on a client-side continue keyword.
Fencing also closes the detached producer's upstream async iterator. This must
happen even if cancellation interrupts orchestration-frame persistence: the
iterator's finally path marks open artifact checkpoints interrupted and
uses the server-authored cancellation signal to abort the sidecar run. Closing
the iterator because its host task or process is disappearing carries no such
signal: it detaches delivery, persists no cancellation terminal, and leaves the
accepted input to the fenced lease-recovery path. A subscriber disconnect is
likewise observer-only.
POST /api/conversations/{id}/inputs commits the member event and queue event
before returning 202 Accepted. The event stream is only an observer, so a
navigation or disconnect after acceptance cannot cancel or erase the turn.
Exactly one terminal event
A run produces exactly one terminal conversation event, even when callbacks, timeouts, cancellation, salvage, and process recovery race each other.
Clients can rely on this: seeing a terminal event means the run is over, and a second one will not arrive to contradict it.
Authority on reconnect
Reconnect is not a resumption of trust. Every reconnect re-authorizes active membership before replaying anything.
If a member left or was removed, the connection stops and retained room state is cleared from the client. An old cursor cannot regain access.
Any resumed stream, tool callback, timeout salvage, or terminal publication that presents a different member, conversation, audience, agent, or resource scope than the run was authorized with is rejected rather than reconciled.
For any chat, wake an accepted input with
POST /api/conversations/{id}/inputs/{input_id}/resume. That endpoint returns
acceptance, not transcript bytes; continue consuming the room journal. The
server checks current write access and exact actor identity on every wake.
Known limit: a reconnect does not replay the answer's prose
Only closed, member-safe facts enter the journal, and assistant text fragments
are not among them. Thinking checkpoints, tool steps and the terminal answer
are durable, so a client that reconnects mid-turn recovers them from the
journal; in-progress artifact content comes back from the short-lived artifact
snapshot described above, not from the journal. The transient delta frames that stream the answer a fragment at a time
are not durable, and there is no cursor that can bring them back.
The consequence is narrow but real. A member whose connection drops partway
through an answer reconnects, is served the turn from the journal, and receives
the terminal answer in one piece. Nothing is lost from the conversation — the
reply is complete and correct — but it materialises in a single jump, and the
progressive rendering that was running before the drop does not resume.
This is a deliberate trade, not an oversight. Journaling every text fragment
would put a PostgreSQL write on the provider's token cadence, which is exactly
the coupling the agent_progress.usage_progress checkpoint above is designed
to avoid. A bounded prose checkpoint on that same model would be the honest way
to close the gap if the behavior ever needs to change; per-fragment durability
would not.
Two things bound how often it can happen. Every long-lived stream carries a
keepalive, so a silent one does not read as idle to a proxy that would
otherwise close it — the conversation event stream on
SHARED_CHAT_PRESENCE_HEARTBEAT_SECONDS, and the agent-builder and sandbox
streams on MEMBER_CHAT_STREAM_HEARTBEAT_SECONDS; see Idle timeouts in front
of the API. And a
turn that has already recorded its terminal is settled, so a reconnect after
the answer lands replays it whole with nothing missing.
The agent-builder and sandbox keepalive spans the agent run itself, not the preparation before it. Assembling the spec, tightening capabilities and loading context all happen after the opening frame and before the first run event, and they emit nothing. That window is short in practice, but an upstream idle timeout set below it will still close the response there — so size a proxy timeout against the whole turn rather than against the keepalive interval.
If a member reports that streaming "sometimes stops working" while the answers themselves are fine, this is the first thing to check. Thinking and tool steps surviving while only the prose stops streaming is the signature of a dropped connection, not of a model or pipeline fault.
Streaming endpoints
| Stream | Endpoint |
|---|---|
| Conversation events | GET /api/conversations/{id}/events |
| Condense progress | GET /api/conversations/{id}/condense/{task_id}/events |
| Task run trace | GET /api/task-runs/{run_id}/events/stream |
| Prompt chain run | POST /api/prompts/{id}/run/stream |
| Prompt chain debug | POST /api/prompts/{id}/debug/stream |
The condense stream is an observer over a durable task. Its browser client reopens the same task URL after a transient disconnect; the endpoint re-authorizes the owner and replays current progress or the terminal result. Reconnect never creates another task or model call.
Prompt-chain POST streams create a fenced producer before returning the first
frame. The response is an observer over an owner-scoped orchestration journal;
disconnecting the response does not cancel the producer. Frames carry a
gap-free numeric SSE id. A client reconnects with
GET /api/prompts/{id}/runs/{run_id}/stream?after_sequence={id} and may also
send Last-Event-ID. The server re-authorizes prompt ownership on every
reconnect and sends a comment heartbeat while a step is otherwise silent.
Cancellation is explicit:
POST /api/prompts/{id}/runs/{run_id}/cancel terminalizes the journal before
fencing local execution. GET /api/prompts/{id}/runs projects interactive and
Task-launched executions into one audit history; Task records remain authoritative
in the Task ledger.
The upstream model stream
The streams above are Kaizen's own contract with a browser. Behind them sits a
different one: the OpenAI-compatible SSE stream the agent runtime consumes from
the model, proxied through /internal/pi/model/v1/chat/completions.
The proxy boundary always receives the selected reasoning level as
reasoning_effort, even when Pi used provider-specific catalog metadata to
identify the deployment. Reasoning capability still gates the field, so a
non-reasoning deployment receives no unsupported option. Provider-visible
reasoning deltas are forwarded in arrival order; private reasoning that the
provider does not emit is never synthesized or exposed.
That stream is normalized in one place before the runtime sees it, because
OpenAI-compatible deployments disagree on two points in practice. Servers that
resend the full accumulated content or reasoning_content in each chunk are
reduced to the unseen tail, so a delta-only consumer does not replay the
answer. Tool calls a chat template rendered into content rather than
tool_calls are withheld from the answer text and re-emitted as tool_calls
deltas. For the XML form of an artifact create or update, the normalizer emits
the content argument as soon as that parameter begins and continues emitting
only its newly arrived tail on each provider frame. Its incremental parser
holds split closing markers across chunk boundaries without rescanning the
accumulated artifact source. The arguments become valid JSON only when the
provider closes the call, so an interrupted or malformed call can be displayed
as partial progress but cannot be dispatched as a tool invocation.
Pi structural events such as thinking_start and toolcall_start do not count
as visible progress. The observable model-activity heartbeat continues until a
non-empty Thinking, answer, or artifact fragment is actually projected, so a
quiet provider cannot make the member-facing response state disappear early.
When a room snapshot reconciles during an active turn, a durable Thinking prefix
cannot replace a longer live prefix; the terminal snapshot then confirms the
same monotonic trace for reload and audit.
Tool-call identity is nevertheless retained as structural stream state. Some
Pi-supported providers repeat the call id and name on every delta, some supply
them only at start, and others resolve them only at toolcall_end. The shared
consumer binds by content index, buffers argument fragments until identity is
known, and flushes them in order. If a provider supplies only the completed
tool call, its finalized arguments produce one artifact frame. This
normalization is descriptor-driven; it contains no model-name or provider-name
branch.
Content blocks are reduced by their provider-supplied content index, so interleaved Thinking, answer, and tool-call deltas do not assume that one block will finish before another begins. A response that reaches its output limit cannot dispatch any tool calls from that response: their arguments may be truncated, so the whole batch becomes ordered error tool results and the model must issue complete calls on the next step. A structurally empty length stop receives one typed continuation at the same reasoning effort. That continuation is semantic recovery, not a transport retry, and a second empty terminal result fails honestly.
If you are adding a consumer of provider frames, consume them after this seam rather than re-deriving the corrections. Usage accounting deliberately runs on the raw frames the provider sent, so estimates describe upstream output rather than the corrected projection. See Model gateway.
Task run traces
A task's trace is an append-only, gap-free member timeline. A current worker lease allocates its sequence; authenticated member controls append through a separate audited path.
Durable event identity makes redelivery idempotent, and SSE replay uses the durable sequence — replaying creates no new event and no audit row.
Trace envelopes carry state, reason category, timing, usage, version, and opaque source references. Opening source detail re-authorizes it and returns one of:
| Status | Meaning |
|---|---|
authorized | A bounded, redacted projection with provenance |
redacted | Reveals no source existence or content |
payload_unavailable | Retained trace metadata outlived its source |
A running step emits step.progress (payload status: running and
step_index) at most every ten seconds while its runtime is active. Clients
should treat it as liveness for that step and attempt, not as a settled state.
The task's run list omits draft test runs; read a draft run's id from the task
detail's latest_test.
Input acceptance and the queue
POST /api/conversations/{id}/inputs accepts a message into the room's durable
FIFO lane and returns its position.
Acceptance is idempotent by key. A duplicate submission converges on the same logical input rather than running twice, and a worker or backend restart resumes the accepted turn without creating a second model request.
In a collaborative room, a normal message queues; steering targets the active run and is recorded separately.
Presence
Presence is a separate, disposable channel — short-lived viewing and typing leases in Valkey.
It is never journal content and never audit data. If the presence service is unavailable, the roster shows Presence unavailable while durable messages continue to replay normally. Build clients so presence failure degrades a badge, never the transcript.
A presence frame is delivered on the stream's idle keepalive, and a typing
change also wakes subscribers immediately, so a typing lease reaches the other
tabs promptly rather than waiting for the next keepalive. Joining and leaving
are carried by the keepalive frame, so a roster change can take up to one
heartbeat to appear. Each frame is projected for
the member receiving it: a member never appears in their own typing_members,
and only members currently present are listed. Treat every frame as the
complete current state rather than a delta.