Streaming Architecture
Data part protocol, transport-agnostic StreamWriter, and resumable SSE that fuses a Redis Stream (durable history) with pub/sub (live push) — monotonic ids stitch history to live with no dedup table
The engine only writes to one stream; delivery is someone else’s job
Once the agent is running, output is continuous — text tokens, tool results, state transitions, notifications, metadata. All of it has to reach the client reliably and behave identically on web (SSE) and desktop (IPC) — yet the engine itself shouldn’t care how the network wobbles or which transport carries it. It writes to one transport-agnostic stream, and everything else is split across four layers, each owning one span:
- StreamWriter — the transport-agnostic event-write interface;
- Data Part Protocol — agent-specific events riding alongside the AI SDK content parts;
- Task Transport — SSE (default) or WebSocket, both carrying the same frame format;
- Resumable SSE — Redis-backed reconnect-and-resume, SSE path only.
StreamWriter Interface
The backend agent writes events through a transport-agnostic StreamWriter interface. The business layer injects the
concrete implementation:
export interface StreamWriter {
write(event: { type: `data-${string}`; data: unknown; transient?: boolean }): void;
}
| Platform | Implementation |
|---|---|
| Web — SSE (default) | Wraps UIMessageStreamWriter from createUIMessageStream({ execute }) |
| Web — WebSocket | Same UIMessageStreamWriter; frames are piped to wsHub.send(connectionId, ...) |
| Chat broadcast | Room-wide fan-out via wsHub.broadcast() (text-only, chat service only) |
| Desktop (IPC) | Wraps Electron IPC send |
Events are classified as persistent (stored in message history via context.write()) or transient (displayed
but not persisted via context.writeTransient()). State transitions and notifications are typically transient; tool
results and text content are persistent.
Data Part Protocol
The Vercel AI SDK defines a standard streaming protocol for text and tool-call chunks. Zapvol extends this with custom data part events that carry agent-specific metadata alongside the content stream:
| Event | Payload | Purpose |
|---|---|---|
agent-init | Agent config, sandbox metadata, model info | Bootstrap client-side agent state before first token |
agent-state | AgentStateKey enum value | Drive the UI progress indicator through state transitions |
notification | Message string + severity level | Surface agent-generated notifications as toast messages |
title-updated | Auto-generated title string | Update the task title without a separate API call |
tool-stream | stdout/stderr line chunks | Stream tool execution output in real-time (e.g., build logs) |
All events are multiplexed into the same SSE stream as content, ensuring a single connection carries all agent-to-client communication.
State Transition Events
The toUIMessageStream() function maps AI SDK stream parts to state events: start → generating, start-step →
executing, finish → completed, error → error, abort → aborted. Each transition is broadcast as a transient
agent-state event.
Message Metadata
The messageMetadata callback attaches timing and usage data to stream messages:
| Field | Set when | Purpose |
|---|---|---|
streamCreatedAt | Stream start | Latency measurement baseline |
firstTokenAt | First text/reasoning/tool-call | Time to first token metric |
completedAt | Stream finish | Total execution duration |
totalUsage | Stream finish | Token counts (input + output) |
finishReason | Stream finish | Why the agent stopped |
Task Transport — SSE (default) vs WebSocket (opt-in)
The core builds one stream (startTaskUiStream in task-runner.ts); two thin wrappers on task-orchestrator.ts frame it:
| Wrapper | Entry | Client reaches it via | Wire format |
|---|---|---|---|
execute | POST /api/tasks/:id/messages | DefaultChatTransport with standard fetch | SSE frames data: <UIMessageChunk JSON>\n\n |
executeWs | task:stream:start on /ws | DefaultChatTransport with createWsFetch() synthetic body | WS text frames { type: "task:stream:frame", taskId, frame: UIMessageChunk } |
Key property — format equivalence: toUIMessageStream() produces the same UIMessageChunk JSON objects in both
paths. SSE adds a data: prefix; WS wraps in an envelope. The agent engine, writer, and useChat consumer are
completely transport-agnostic.
When to pick which
| Factor | SSE | WebSocket |
|---|---|---|
| Default | On unless VITE_TASK_WS_TRANSPORT=1 | Opt-in |
| Resume after drop | Yes — the run produces into a Redis stream buffer a reconnect reads back (see next section) | None — reconnect refetches DB history; in-flight frames lost |
| Connection model | One HTTP request per task run | One long-lived socket shared with chat + any future features |
| Desktop | n/a — desktop uses IPC | n/a — customFetch short-circuits WS |
| Multi-process infra | Redis Streams for resume | Redis Pub/Sub already in wsHub for chat broadcast; point-to-point via wsHub.send(connectionId) bypasses it |
SSE remains the recommended default because of resume. WebSocket is useful for eventual bidirectional features (cheaper abort round-trip, live cursor hints, shared socket with chat/presence) and for environments where SSE is blocked.
Abort flow (identical on both transports)
The client’s Stop button always goes through POST /api/tasks/:id/abort. That handler calls taskOrchestrator.abort,
which runs two steps in order:
taskService.abort(userId, taskId)— ownership check +isActive=false. Throws on unauthorized access.abortManager.abort(taskId)— fires theAbortSignalpassed intorunAgentLoop, interrupting the LLM at its next await point.
The WS task:stream:abort message routes to the same orchestrator method, so the two transports have byte-identical
Stop semantics. (Client-side, useChat’s internal signal aborts only detach the local consumer — they do not fire an
abort to the server, matching SSE’s behavior when the HTTP connection drops.)
Resumable SSE (Redis Stream + pub/sub)
The run never streams straight to the client — it produces into a Redis-backed StreamBuffer
(redis-stream-buffer.ts), and every reader (the live execute response and a mid-run reconnect) reads that one
buffer. That is what lets a minutes-long run survive a dropped connection with no re-execution.
Durable history + live push
The buffer combines two Redis mechanisms per chunk:
XADDappends the chunk to a per-turn Redis Stream — the durable history a (re)connecting reader backfills from.PUBLISHpushes the same chunk on a channel the instant it isXADD’d — live delivery, event-driven (no polling, no blockingXREAD), so it keeps the model’s natural token cadence.
One shared subscriber connection multiplexes every channel via a dispatch map — so there is no per-stream dedicated
connection, which is precisely why live delivery is pub/sub + XRANGE rather than a blocking XREAD loop.
How a reader self-coordinates history ↔ live
A reader has no handshake with the writer — the producer may be a worker that starts after the reader connects — so it orders itself by Redis’s monotonic stream ids:
- SUBSCRIBE first (awaited), buffering any live pushes — nothing published after the coming snapshot can be missed.
- XRANGE-backfill the history; track the last id emitted.
- Flush the buffered pushes with
id > lastId(dedup), then stream live.
Because stream ids are monotonic, those three phases are id-contiguous — no gap, no overlap, no dedup table.
Correctness rests on two orderings: the producer XADDs before it PUBLISHes (the id comes from the XADD), and
the reader SUBSCRIBEs before it XRANGEs. Client-generated monotonic ids (<baseMs>-<seq>) let the XADD and
PUBLISH for a chunk share one pipeline. This replaces the old per-listener promise-chain dedup
(lib/resumable-stream.ts) with consumer-side id-contiguity — but pub/sub did not go away; it is the live-push
half, now paired with the Stream for durable history.
Lifecycle & memory
One stream = one assistant turn’s chunks — produce DELs the key first, so a stream never spans turns. Each write
refreshes a grace TTL (600 s) so a live long-horizon turn stays alive and a crashed producer (no terminal entry)
self-expires within it. The terminal entry (an end field via XADD) drops the key to a shorter retention window
(300 s) for late reconnects. A reader that subscribes before the producer ever appears gives up after a 120 s
initial-wait cap.
| Operation | Redis | Purpose |
|---|---|---|
| Append chunk | XADD + PUBLISH | Durable history entry + live push, sharing one pipeline |
| Live delivery | SUBSCRIBE on the channel | Event-driven push, no polling |
| Backfill | XRANGE over the stream | Replay history for a (re)connecting reader |
| Completion | terminal end entry (XADD) | Signal end of stream to readers |
| Cleanup | grace TTL (600 s) → retention (300 s) | Live turn stays alive; settles to a reconnect window |