Wire protocol
What's on the WebSocket — @effect/rpc over JSON, the snapshot/delta subscription envelope, and the POST /rpc one-shot path.
Voltro's client and api speak @effect/rpc over a WebSocket. Every primitive — queries, mutations, actions, agents, subscriptions — rides the one bidirectional connection, multiplexed by the rpc layer. The serialization is JSON (RpcSerialization.layerJson), not a bespoke binary format.
You usually don't think about the wire — the typed client + @voltro/protocol handle it end-to-end. This page is for when you DO need to: debugging a mystery, proxying through a gateway, or understanding what the inspect tooling shows you.
The transport
The api boots two rpc server instances on the same HttpLayerRouter:
- WebSocket —
RpcServer.layerProtocolWebsocketRouter({ path }), the primary transport. Long-lived; carries streaming queries (subscriptions), unary mutations/actions, and agent runs. - HTTP one-shot —
RpcServer.layerProtocolHttpRouter({ path: '/rpc' }), registered atPOST /rpc. Non-streaming; one request → one batched response.
Both are provided RpcSerialization.layerJson and the SAME rpc group (options.group), so the SAME per-rpc handlers + the group-level AuthMiddleware / ConnectionInfoMiddleware run on either path. A forwarded session cookie resolves the same Subject + tenant whether the call arrives over WS or HTTP.
Because @effect/rpc owns the framing, there's no app-level frame taxonomy to learn — the rpc client encodes a request, the server decodes it, runs the handler, and streams back the result. What's app-specific is the payload schema of each rpc (your defineQuery / defineMutation input + output) and, for streaming queries, the subscription-event envelope below.
Subscription events — snapshot / delta
A streaming query (what useSubscription opens) emits a sequence of subscription events. The envelope is defined in @voltro/protocol's subscriptionEvent(output) — a Schema.Union of three variants, parameterised on the query's declared output schema so the rpc layer enforces the row shape end-to-end:
// snapshot — always the FIRST event; the full initial query result
{ _tag: 'snapshot', revision: number, data: <output> }
// delta — every subsequent event; an id-keyed JSON-patch, NOT full data
{
_tag: 'delta'
revision: number
emittedAt: number
patch: {
ops: Array<
| { op: 'add'; path: '/<id>'; value: <row> }
| { op: 'replace'; path: '/<id>'; value: <row> }
| { op: 'remove'; path: '/<id>' }
>
order: Array<id> // the full id sequence of the next set, in order
}
}
// error — this ONE subscription's handler failed; surfaced in-band on its own
// stream (never a defect that would stall siblings on the shared connection)
{ _tag: 'error', error: { _tag?: string, message: string, ...fields }, revision?: number }revision— monotonically increasing; lets the client order events and detect gaps.emittedAt— epoch milliseconds, present ondeltaonly.data(snapshot) — the full payload, typed by the query'soutputschema (a row, an array of rows, a computed value — whatever the handler returns).patch(delta) — an id-keyed RFC-6902-style patch against the row set the client last held.error— a JSON-safe shape of a typed error (its_tag+ fields +messagepreserved, so the client can pattern-matcherror._tag).useSubscriptionexposes it as.errorfor THAT query key.
The first event is always a snapshot carrying the full initial result — the client materialises it as its base. Subsequent events are deltas carrying only the rows that changed, plus the next id order. The server still re-runs the query on a change (the patch saves wire egress, not the re-query); it then diffs the previous row set against the new one into the patch.
Per-subscription errors — isolated, not connection-wide
Many subscriptions multiplex over ONE WebSocket. If a single subscription's
handler fails (e.g. a live single-row getter that throws for a stale/foreign
id), the server emits an error event on THAT subscription's own stream and
completes it — it does not let the failure become a defect, which would
propagate to the shared connection and stall every other subscription on it
(the classic "one not-found and the whole dashboard hangs on loading"). The
client surfaces it as useSubscription(...).error for that one query key;
siblings keep delivering their snapshots and deltas.
Author a live-subscribed getter to return, not throw. A subscription is a
long-lived stream, so a getter that throws on every re-evaluation is a broken
stream. For an expected-absent row, make the query output: Schema.NullOr(...)
and return null — that's a normal snapshot the widget renders as "empty",
cleaner than an error banner. Reserve throwing for genuinely exceptional cases;
even then it's now contained to the one subscription.
How the patch is keyed — by row id, not array index
The diff keys on each row's id, addressing it as path: '/<id>', rather than by array index. Index-based paths are brittle the moment a row moves — and reordering is the common case for the live result sets this targets (leaderboards, collaborative lists, game state). Id-keying makes a reshuffle cost just the changed rows plus the id list:
replace— a row present in both prev and next whose content changed;valueis the full new row.add— a row new in next;valueis it.remove— a row gone from next.- reorder — carried entirely by
order(the next id sequence); a pure reorder emits zero ops and just a neworder.
The client keeps the last materialised row set, applies the ops to an id→row map, then materialises the result strictly in order. The round-trip is exact: applying the computed patch to the previous set reproduces the new set for every case — add, remove, replace, reorder, and combinations.
No-op suppression still applies. When a change re-runs the query but the resolved row set is unchanged, no event is emitted at all — the patch path covers "a small part of a big result changed", the suppression covers "nothing changed".
Fallbacks. A result set whose rows aren't id-keyed (a custom projection that drops id) can't be diffed by id, so its update ships as a full snapshot instead. Computed queries (a handler that returns a scalar or aggregate, not an id-keyed row set) likewise ship every update as a snapshot — there's no row set to patch.
HTTP one-shot rpc (POST /rpc)
The WebSocket is the primary transport, but the api ALSO exposes POST /rpc for non-streaming invokes. It speaks the same JSON envelope and runs through the SAME per-rpc handlers + the same auth middleware — a forwarded session cookie resolves the same Subject + tenant as the WS path.
This is what a server-side web-router loader's ctx.query uses: a loader runs inside the request handler with no WS connection, so it calls the backend over HTTP for SSR first-paint + meta. The HTTP protocol drains a streaming query handler's stream to completion and returns it, so a streaming query yields its FIRST (initial) snapshot in the response batch — exactly what first-paint needs. See Loaders & meta.
For live, after-hydration data, subscribe over the WebSocket with useSubscription instead — POST /rpc is one-shot and never streams deltas.
Authentication
The session cookie travels in the WebSocket upgrade headers (and in the POST /rpc request headers). The api's AuthMiddleware resolver decodes it and binds the resolved Subject to the connection; subsequent rpc calls on that connection inherit it.
For API-key authentication, send Authorization: Bearer <key> in the upgrade (or request) headers — the same resolver path maps it to a subject.type === 'apiKey'.
Debugging
Use the framework's own tooling rather than reading raw frames:
voltro traces— the per-request span waterfall, including each subscriptionsnapshot/deltadelivery with its produce→push latency.voltro traces --errorsfilters to failed hops.voltro logs --trace <id>— every log line of one request across hops, in order.- The inspect dashboard's stream firehose (
GET /_voltro/inspect/stream) surfaces rpc / cdc / log events live.
Anti-patterns
- Hand-crafting rpc frames. Use
@voltro/client—@effect/rpcowns the framing and the protocol can change. - Long-polling fallbacks. None — Voltro is WS-or-bust for live data. Browsers without WebSocket support don't get reactive updates (one-shot reads still work over
POST /rpc). - Proxying through a CDN without WebSocket support. Cloudflare / Fastly / Vercel Edge all support it; configure the upgrade headers.