← All posts

One conversation surface, and what streams through it

The agent rail is one surface on every page, fed by one typed event union over SSE. Here is the AgentEvent contract, the stream route, how a dropped connection resumes, and how a trace row gets drawn.

Vocion Teamvocion-v2.74.0

Status: shipped. vocion-core #332 and #329 are running. Merged onto the release branch release/2026-09-15-rail (7db4f7f6) and deployed to a deployment we operate; migrations 0094–0099 are applied. Both pull requests are still landing on main individually, at CI's pace, behind that release branch.

A dashboard that talks back has a choice to make early: how many conversation surfaces does a page get, and what is allowed to appear inside one. Vocion's answer is one surface, and a single typed union of events that anything upstream is allowed to send it.

The contract is a union, and it is frozen

AgentEvent is declared once in packages/core/src/services/agents/types.ts and mirrored in the standalone agent runtime at packages/agent-runtime/src/contract.ts, because that runtime is deployable on its own and has to speak the same wire.

export type AgentEvent
  = | { type: 'thinking_delta'; delta: string }
    | { type: 'tool_start'; tool: string; input: Record<string, unknown> }
    | { type: 'tool_end'; tool: string; input: Record<string, unknown>; output: string }
    | { type: 'response_delta'; delta: string }
    | { type: 'documents'; documents: SearchDocument[] }
    | { type: 'recommended_action'; recommendation: RecommendedActionPayload }
    | TraceNodeEvent
    | { type: 'hitl_gate'; gate: HitlGatePayload }
    | { type: 'tool_error'; tool: string; message: string; status?: number }
    | { type: 'done'; response: string; traceId?: string }
    | { type: 'error'; message: string }
    | { type: 'usage'; model: string; inputTokens?: number; outputTokens?: number };

Two of these are worth dwelling on.

usage never reaches the browser. It is the runtime charging tokens against a budget, and it stops at the route.

tool_error exists because of a specific failure mode. When a tool fails, the only signal a model gets is the tool's output text — and nothing obliges a model to relay it. An unreachable tool endpoint therefore reads, in the transcript, as a confident answer with no grounding. A typed error event makes the broken deployment visible instead of plausible.

recommended_action has the sharpest constraint on it: emitting one has no side effect. The gated review item is created only when a person taps the card.

export type RecommendedActionPayload = {
  actionId: string;        // 'gmail.send'
  input: Record<string, unknown>;
  label: string;
  rationale?: string;
  confidence?: number;
  agentSlug?: string;      // reconstructs the proposing principal on click
};

The route

POST /rpc/agent/stream (packages/core/src/app/[locale]/rpc/agent/stream/route.ts) is a real text/event-stream, one data: <json>\n\n per event, with a fifteen-second : keepalive comment to survive proxy idle drops.

return new Response(stream, {
  headers: {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',
  },
});

The server is authoritative about history. When the request names a conversation, the route uses the persisted message log and ignores whatever transcript the client sent up with it.

Resuming a stream nobody was listening to

The route already kept generating when a client dropped — writes into a closed controller are swallowed, and the finished turn is persisted either way. What was lost was the live stream: reload the tab mid-turn and you watched nothing until the answer landed.

The fix is small. The first frame the route writes is not an AgentEvent at all:

safeEnqueue(encoder.encode(`data: ${JSON.stringify({ type: 'stream_meta', streamId })}\n\n`));

Every event is also appended to an in-process ring in packages/core/src/libs/streams/buffer.ts, keyed by that id:

const streams = new Map<string, BufferedStream>();
const TTL_MS = 15 * 60_000;   // replayable for fifteen minutes
const MAX_EVENTS = 5000;      // runaway guard

The client stashes { streamId, agentSlug, count } in sessionStorage and increments count on every event it reduces. On reconnect it calls GET /rpc/agent/stream/resume?id=<streamId>&after=<count>, which replays from that index and then subscribes live. An unknown or expired id is a 404, and the client falls back to rehydrating the conversation from the database — which already worked.

This is deliberately a Map, not Redis: Vocion deploys as a single app container. The comment in the file says what to do if that stops being true — swap the implementation behind the same interface.

One bug in that path is instructive. Clearing the stash in the stream's finally block raced page unload: the fetch rejected during navigation, finally ran, and the resume handle was wiped before the new page could use it. The stash is now cleared on normal completion and on Stop, and nowhere else.

Drawing a row

Nothing but trace_node creates a row in the work timeline.

export type TraceNodeEvent = {
  type: 'trace_node';
  id: string;
  parentId?: string;     // delegated work nests here
  actor: TraceActor;     // { id, kind: 'lead' | 'specialist', name }
  kind: 'reason' | 'tool' | 'skill' | 'search' | 'delegate' | 'draft';
  status: 'start' | 'progress' | 'done' | 'error';
  label: string;
  delta?: string;
  citations?: TraceCitation[];
};

The fold is a pure function in packages/core/src/features/dashboard/chat/traceReducer.ts, which is why it is testable without a browser. delta appends; every other field is latest-wins with absent meaning keep. That rule matters: a done event carrying only status and result must not blank the detail the start event set. finalizeTrace closes anything still in progress when the turn lands, so a transcript never shows a spinner forever, and failToolNode closes a row from a tool_error, because a thrown tool does not reliably emit its own error node.

Events are merged into a ref and flushed on requestAnimationFrame alongside text deltas — reasoning tokens arrive as fast as response tokens, so both need batching.

The rail after a turn, with the work folded to a single line. While a turn runs, the timeline shows the first unfinished non-reasoning node as its headline. After it lands, the whole thing collapses to one line with a step count and a source count.

Where this lives in the repo

Read off release/2026-09-15-rail at 7db4f7f6, the commit that shipped. The two pull requests above are merging to main individually behind it, so a path may land on main a little after this post.

Related: One agent per workspace, routing by delegation and One conversation surface on every page, the user-facing half of the same change.