Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

std::rpc reference

Transports, the generated service surface, errors, and connection state. Concepts and usage: the services guide. Most apps touch only the generated client, RpcError, and ConnectionState; everything else here is the machinery those sit on.

The generated surface ([service])

For [service(FooClient)] struct Foo with [rpc] methods and [expose] signal fields, the macro generates:

// client side
FooClient::connect(url: str, codec: Codec): Result<FooClient<SocketTransport>, RpcError>
client.some_rpc(args…): Result<T, RpcError>     // per [rpc] method; implicitly awaited
client.some_handle(args…): RemoteSource<T>      // per [rpc] method RETURNING a source (SignalCell or MemoCell); sync, unleased
client.some_keyed(args…): KeyedSource<K, V>     // per [rpc] method returning a KeyedCell; same, keyed
client.some_signal: RemoteSource<T>             // per [expose] field; a typed mirror (below)
client.some_map: KeyedSource<K, V>              // per [expose(keyed)] field; a patched mirror (below)
client.transport: SocketTransport               // connection state lives here

// server side
foo.dispatcher(): Dispatcher                    // the method table
dispatcher.into_protocol(codec: Codec): RpcProtocol   // what Service::new takes

A handle stub is idempotent per origin: a second call with the same arguments on the same client answers the mirror the first one minted (one call, one Subscribe), until that mirror’s last lease closes. The table behind it is generated beside the client — one MirrorTable per handle method — so a stub is safe inside a cold select (and_then, switch) that runs on every read.

connect accepts a relative url ("/") in the browser; it dials the same host over WebSocket, waits for the server’s announcement, and verifies the contract hash: a drifted server fails the connect with RpcError::Contract. The hash covers the surface’s RESOLVED types, so a respelling is not a drift: HashMap<str, i32> and a renaming import of it hash alike. It also covers each of your Wire types’ SHAPE — a struct’s fields in declaration order (name and type, recursively), an enum’s variants in order — because a codec reads the type itself: a field added, removed, renamed or reordered, or a variant moved, is a different contract under the same name, and a server redeployed with one refuses its old clients at connect instead of being mis-decoded by them. (std’s own types stand for themselves: they change only with the toolchain both sides are built by.)

Mirrors: RemoteSource<T>

struct RemoteSource<T> { … }

impl RemoteSource<type T> with Source<Option<T>> {
	fun get(self): Option<T>                              // passive: the cache, `None` before the first update
	[must_use]
	fun on_settle(self, subscriber: Subscriber): Subscription       // counted: attach, then lease
	[must_use]
	fun attach_observer(self, observer: |Option<T>| void, immediately: bool): Subscription
	                                                      // counted; eager = lease first, one immediate call
}
// Through `Flow` (every source is one): `on_change` (counted, lazy), `sub`
// (counted, eager), `effect` (counted, eager, owner-scoped), `derive`, …

impl RemoteSource<type T> {
	fun status(self): SignalCell<Status>                      // passive: what the mirror was last told
	fun or(self, initial: T): Derive<RemoteSource<T>, Option<T>, T>   // a pipe: `initial` until the first update
	[must_use]
	fun sub(self, observer: (|T| void) context tracking): Subscription  // counted, manual: present values; dispose to release
}

// A mirror is a transient source (std::reactive::transient): Status mapped arm for arm.
impl RemoteSource<type T> with TransientSource<T, RpcError> {
	fun state(self): MemoCell<TransientState<T, RpcError>>   // passive, like `status`: leases nothing
	fun states(self): dyn Pipe<TransientState<T, RpcError>>  // leases while bound; the whole state
	fun latest(self): dyn Pipe<Option<T>>                    // leases while bound; keeps the stale value
	fun is_pending(self): dyn Pipe<bool>                     // leases while bound
}

[derive(PartialEq, Debug)]
enum Status {
	Waiting,             // nothing has arrived — including "nothing has been asked"
	Ready,               // the cache holds a value
	Absent,              // a handle's call answered `None`: no such source
	Failed(RpcError),    // a handle's call failed, and this is what it said
}

Absent and Failed are a handle mirror’s arms (the stub of an [rpc] method that returns a source): they say what the minting call was told, and a field mirror never reads either. Both are answers about now — the mirror keeps whatever it last held, and the next 0→1 lease asks again — and both are cleared by the rebind that a successful ask produces.

A mirror is a Source<Option<T>> (tracker A52), so on_change, effect, effect_on_change and every generic S: Source<…> consumer — selector among them — take one. The trait argument is Option<T> because that is what a mirror holds, so a RemoteSource<List<Note>> is not a Source<List<Note>> and each takes mirror.or([]). sub has one spelling per view of the value: the inherent one hands the observer a present T, the trait’s hands it the Option<T>, and the observer’s own parameter type picks between them.

A mirror holds Option<T> — None until the first Update lands — and subscribes by demand: every subscribing leaf — a sub, an effect, a .cell(), a binding or an each — takes a counted lease on the channel, on the mirror itself or on any node over it (mirror.map(..), mirror.or(..)). The 0→1 lease sends Subscribe (the server answers with the current value at once); the 1→0 release sends Unsubscribe, deferred to the end of the ambient turn so a same-turn re-subscribe (a view re-rendering in place) sends nothing. A second watcher on an open channel sends no frame. On reconnect a watched mirror (count > 0) re-subscribes on its fresh channel; an unwatched one does not.

map (the blanket every source has) and or are cold nodes: building one opens nothing, anywhere — map is where the Option is confronted once, and or is map for the same-type fallback. The owner is asked at the subscribing leaf, where the lease is taken: effect and effect_on_change on the mirror or a node over it hand the lease to the ambient owner and are a compile error (context coverage) where none is — a network subscription must have a releaser; a .cell() of it takes one lease and ties it to the ambient owner (inside a view, the view’s); a binding or an each leases under the view that places it. sub and on_change are the manual form for code with no owner: you hold the Subscription and dispose it, which releases the lease.

get and status open nothing. A status observer alone never sees Waiting → Ready: status reports, it does not ask; until something that renders the value subscribes, the mirror stays Waiting, and that is correct — the channel was never opened.

A handle mirror takes that one step further: it is minted with no channel at all, and its first lease is what issues the call. So a handle nothing watches has not merely opened no channel — it has not asked, and costs nothing on either side. What the call answered is then status(): Absent for a None reply, Failed(error) for a call that failed, and the next 0→1 lease asks again in both cases.

A .cell() of or/map is a local derivative: writing it writes nothing back (the server owns the source) and the next update overwrites it. An empty-list initial needs no annotation — the [] takes its element type from the mirror (let notes = client.notes.or([]); is a source of List<Note>).

Keyed mirrors: KeyedSource<K, T>

The mirror an [expose(keyed)] / [expose(keyed = K)] field produces — and what an [rpc] method returning a KeyedCell<K, T> hands back, minted per call and unleased, exactly like a plain handle. On a minted one a per-key lease is a first demand too, and the channel is withdrawn when the last demand on it goes rather than the first. Where a RemoteSource<T> receives the whole value on every change, this one receives a Patch of Delta ops and applies them in order — and it can lease one key.

struct KeyedSource<K, T> { … }

impl KeyedSource<type K: Wire + Hashable, type T: Wire + Keyed<K>> with Source<Option<List<T>>> {
	fun get(self): Option<List<T>>                            // passive: what this client subscribed to
	[must_use]
	fun on_settle(self, subscriber: Subscriber): Subscription           // counted: attach, then lease
	[must_use]
	fun attach_observer(self, observer: |Option<List<T>>| void, immediately: bool): Subscription
}
// Through `Flow`: `on_change` (counted, lazy), `sub` (counted, eager), `effect`
// (counted, eager, owner-scoped), `derive`, …

impl KeyedSource<type K: Wire + Hashable, type T: Wire + Keyed<K>> {
	fun status(self): SignalCell<Status>                      // passive: `Waiting` until the first patch
	fun fault(self): Option<str>                              // passive: the first protocol fault, sticky
	fun or(self, initial: List<T>): Derive<KeyedSource<K, T>, Option<List<T>>, List<T>>   // a pipe: the whole collection
	[must_use]
	fun sub(self, observer: |List<T>| void): Subscription     // counted, manual: the whole collection
	fun of(self, key: K): SignalCell<Option<T>>               // counted per KEY, owner-scoped
	[must_use]
	fun sub_key(self, key: K, observer: |Option<T>| void): Subscription   // counted per KEY, manual
	fun rebind(self, channel: i32)                            // reconnect: re-subscribe every demand held
}

A keyed mirror is a Source<Option<List<T>>> (tracker A55), on the same counted lease and with the same reading as RemoteSource’s: the trait argument is the Option because a mirror that has been told nothing is not an empty collection, so a KeyedSource<K, T> is not a Source<List<T>> and each takes mirror.or([]). sub has one spelling per view of the value — the inherent one hands the observer a present List<T>, the trait’s hands it the Option<List<T>>, and the observer’s own parameter type picks between them.

The counted lease is RemoteSource’s, applied per demand rather than per channel: a per-key 0→1 sends Subscribe(channel, Some(key)) and the server forwards that key’s changes and nothing else; the 1→0 releases that key alone. Whole-collection demand subsumes per-key demand — while the whole collection is held, a key asks for nothing, and when the whole lease is released the keys still held take the wire back — so holding both never doubles a delivery.

The mirror holds exactly what this client asked for, which is why a per-key subscriber’s get() is a one-element list rather than the collection. fault() is Some(reason) if a patch ever named a key the mirror does not hold; that op is refused rather than applied.

Hand-wired exposures use ReactiveServer::expose_keyed(source, key_of) (for a Source<List<T>>) or expose_keyed_map(source, key_of) (for a Source<HashMap<K, V>>), with ReactiveClient::attached_keyed_source / keyed_source on the other end. key_of is a value parameter rather than a Keyed<K> bound alone because K appears nowhere else in the signature, and vilan infers a type parameter from a call’s types, not from its bounds.

The attribute generates whichever of the two the field’s collection calls for, and the key type comes from wherever it is written (tracker A51): a HashMap<K, V> element names it and takes the bare [expose(keyed)], and every other collection names it in the attribute — [expose(keyed = str)] items: SignalCell<List<Task>>. Naming it in both places is redundant rather than wrong, but the two spellings must AGREE: an argument that disagrees with the HashMap’s own key is refused at the attribute (tracker A56), because the expansion reads K from the annotation before any type resolves and cannot pick between them. The generated wiring is the hand-written call, frame for frame, and the two spellings are one contract: same Patch frames, same KeyedSource<K, T>, same contract hash.

Keyed cells: KeyedCell<K, T>

The SERVER-side twin of KeyedSource, and the cheap way to hold a keyed collection (tracker A54). Exposing a SignalCell<List<T>> keyed makes the server DIFF two snapshots on every change — it re-keys both and compares every retained element — so a keyed channel costs O(N) per change per subscribed connection, however small the change is. That is irreducible while the source is List-valued, because only the mutation knows what changed. A KeyedCell is the mutation saying so: each write appends the Delta<K, T> it is, and a subscriber’s forward sends the ops since it last looked.

struct KeyedCell<K, T> { … }

impl KeyedCell<type K: Hashable, type T: Keyed<K>> {
	fun new(initial: List<T>): KeyedCell<K, T>
	fun insert(self, value: T)                        // append, or replace what is held under its key
	fun remove(self, key: K)                          // a key it does not hold is a no-op
	fun update(self, key: K, mutate: sync |&mut T| void)   // in place, and the `Update` op it is
	fun set(self, value: List<T>)                     // the wholesale write: one `Reset`
	fun locate(self, key: K): Option<(usize, T)>      // the element and where it sits, by lookup
}

impl KeyedCell<type K: Hashable, type T: Keyed<K>> with Source<List<T>> {
	fun get(self): List<T>
	[must_use]
	fun on_settle(self, subscriber: Subscriber): Subscription
}

It is a Source<List<T>> with no Option in it — a cell always holds a collection, where a mirror may not have been told one yet — so each(cell, …) takes it directly and the or([]) a KeyedSource needs has nothing to say here.

[expose] items: KeyedCell<str, Task> is a keyed channel. The cell names both its key and its element in its own type, so keyed is redundant on it (writing [expose(keyed)] is the same thing) and there is nothing for keyed = K to add. The wire does not change: the same Reset seed and the same element-grained Patch per change, the same KeyedSource<K, T> on the client, and the same contract hash as [expose(keyed = str)] items: SignalCell<List<Task>> — a service may swap one field for the other without moving its contract. What changes is the cost: measured per change on one connection, 0.0078 ms at 1,000 rows and 0.0103 at 10,000 where the diffing exposure is 0.315 and 3.814 (children CPU, getrusage).

The hand-wired form is ReactiveServer::expose_keyed_cell(cell), which takes no key_of — KeyedCell<K, T> names K in its own type, so Keyed is a real bound there. It is a distinct name rather than an overload of expose_keyed because a cell is a Source<List<T>> and would otherwise satisfy that bound and take the diffing path silently. expose_keyed over a cell is still correct, just O(N).

T: PartialEq is a bound of the diffing exposure and not of the cell: a diff can only learn that an element changed by comparing it, and a cell whose writes are the ops never compares anything.

The op log itself is not the cell’s own any more: it is DeltaLog<O> in std::reactive (see Reactive), generic in the op rather than in the collection, and the cell records the POSITIONAL SeqOp every sequence cell records. So KeyedCell is a sequence cell plus a position index by key hash plus the wire translation, and it is a DeltaSource<List<T>, SeqOp<T>> as well as a Source<List<T>> — which is what lets a consumer that knows nothing about keys read what changed. cursor, drop_cursor and since are unchanged in name and in what they answer; the cursor type is DeltaCursor (it was KeyedCursor, which no longer exists).

Errors

[derive(Wire, Json, Debug, PartialEq)]
enum RpcError {
	Transport(str),   // couldn't reach / lost the server ("not connected", "connection lost")
	Decode(str),      // reply didn't parse
	Remote(str),      // the handler failed
	Contract(str),    // connect-time shape mismatch (old client vs new server)
	Unauthorized,     // 401/403 at the handshake: not with this credential
	Unavailable,      // 503 at the handshake: not now — the app's own refusal
}

Infrastructure failures only: an application “not found” belongs in the rpc’s own return type (Option<Task>), not here.

The POST {mount}rpc leg’s statuses

A vilan client reads the ENVELOPE and has never read the status. The status is the contract for everything that is not a vilan client — curl, a fetch in a page, a proxy, a load balancer, a monitoring probe — and the two never disagree:

outcomestatus
Success(..), including an application Err arm200
Failure(Decode(..))400
Failure(Unauthorized)401
Failure(Remote("unknown method: …"))404
Failure(Contract(..))409
Failure(Remote(..)) — a handler failed500
a Service::factory service on this leg501, as an envelope
Failure(Unavailable)503
a non-POST on {mount}rpc405, Allow: POST

An application error is a 200: Result<Token, str>’s Err arm is a value that crossed successfully, and a server answering 401 for “wrong password” would be claiming the CALL was unauthorized — a different fact, and the one authorize reports. The cost is worth stating: a dashboard reading statuses alone cannot see application errors, because they are not errors of the transport.

The leg requires Content-Type: application/json or application/octet-stream, and answers 400 otherwise. That one check is the CSRF posture: a cross-site HTML form can produce only text/plain, application/x-www-form-urlencoded and multipart/form-data, so it is structurally unable to reach any [service] — whether or not the service’s author thought about CSRF. HttpTransport sets the header; a hand-written caller must too. std does no CORS beyond this: on_request plus Response::builder().set_header is the whole mechanism, and an allowed-origin list is a decision std has no information for — the services guide carries the recipe.

Every envelope carries one of those two media types whatever its status, and that is what HttpTransport reads to tell a reply from a stranger’s document: an answer that is not an envelope is Err(RpcError::Transport(..)) naming the status, not a Decode blaming the codec. A status cannot serve — the leg’s 404 for an unknown method and an app’s 404 for an unclaimed path are the same number.

A Decode on the SERVER’s side of a call is what the generated route answers for a request it cannot read: an argument count the method does not declare (expects 2 argument(s), got 3 — extra arguments are a disagreement, not something to drop), or an argument of the wrong type. A vilan client cannot provoke either, since both sides are generated from one surface and the contract hash refuses a client that disagrees; an arbitrary HTTP caller can, which is what the check is for.

Connection state

enum ConnectionState { Connected, Reconnecting, Closed }

impl SocketTransport {
	fun connection_state(self): SignalCell<ConnectionState>
	fun on_reconnect(self, hook: async || void)
}

The reconnect lifecycle (automatic): on drop → Reconnecting, in-flight calls reject with Transport("connection lost"), new calls fail fast with Transport("not connected"); dial with backoff (250 ms doubling, 4 s cap, 10 attempts); on success → contract re-check, mirrors re-attach and resync, Connected. Nothing is ever silently retried; retry is the app’s decision.

Closed is terminal, and three things reach it: the attempt budget runs out; the re-dialled server’s contract has drifted (it redeployed a different surface, so typed mirrors would decode against a shape they were not built for); or the re-attach itself is refused — the server answers, but will not hand back channel ids. The last two close the socket rather than staying Connected, because a mirror that cannot be rebound is pointed at the previous connection’s dead channels: it would never update again, and the socket would say nothing was wrong. Bind Closed and offer the restart; that decision is the app’s.

On every path that reaches Closed the client disposes itself: its update routes are emptied and its transport forgets it. The state is what decides, not the reason for it — a spent budget releases exactly as a drifted contract does. Closed is terminal, so nothing on that socket can ever reach those mirrors again and nothing keeps holding them: the mirrors read their last value forever, exactly as they did before, and the graph behind them is released instead of wedged open. What does not dispose is a connection that is merely slow — a redial in progress, or a server unreachable when the re-attach runs — because that socket is Reconnecting, not Closed: it keeps trying, and its mirrors resync when it succeeds.

on_reconnect is where that decision goes. Hooks run after each successful re-dial, awaited in order, and the generated client registers its own mirror re-attach when it connects — so a hook you register runs after the mirrors have resynced, which connection_state cannot tell you: the state flips to Connected one beat earlier, because the re-attach’s own rpc call needs a usable transport first. Bind the signal for a banner; use the hook for anything that needs current mirrors.

client.transport.on_reconnect(|| title.repush());

Keep a hook short — it runs inside the reconnect loop’s own extent, so a long round-trip inside one holds the reconnect open behind it.

Transports

trait Transport {
	fun call(self, request: Frame): Task<Result<Frame, str>>;
}
TransportWireUse
SocketTransportWebSocket (reconnecting)what connect gives you, the production client transport
HttpTransportone POST per callstateless calls, no mirrors, no handles
HeaderedHttpTransportone POST per call, with headersthe same, carrying a header credential
LocalTransportin-processtests: client and service in one process; handles need a stamped connection

A [service] an HTTP client can hold gets a constructor for this leg:

impl FooClient<HttpTransport> {
	fun over_http(mount: str, codec: Codec): FooClient<HttpTransport>
}
impl FooClient<HeaderedHttpTransport> {
	fun over_http_with(mount: str, codec: Codec, headers: List<(str, str)>): FooClient<HeaderedHttpTransport>
}

It takes the same mount string connect takes ("/", "/auth/") — the route is {mount}rpc — and it is SYNC and makes no call: over HTTP there is no connection, so there is no round trip to amortize, and an HTTP client is unversioned unless you call verify() yourself. There are no mirrors, no handles and no reverse direction, so it is generated only for a service that declares none: a service with an [expose]d field or a handle-returning method, or one declaring client = H, has no over_http and the call is refused by name.

over_http_with is the same constructor for a client that has to CARRY something: its headers are sent on every POST the client makes, after the leg’s own media type. That is how a page that keeps its credential in storage rather than in a cookie reaches a service gated by authorize_request — a cookie rides a same-origin POST on its own, a header token has to be written:

let client = AuthClient::over_http_with("/auth/", json_codec(), [("Authorization", i"Bearer {token}")]);

It builds a HeaderedHttpTransport { url, headers } rather than widening HttpTransport, so every HttpTransport { url } written today still means what it meant. The media type is the transport’s to set; a Content-Type does not belong among the headers.

[service(FooClient, http)] says the same thing at the DECLARATION: the http marker generates nothing and moves no contract hash, and it refuses each of those three shapes at the field, the method or the attribute that declared it — in the attribute’s own words, rather than as a missing over_http at a call site far away. It is opt-in, because which transports reach a service is a property of its mount and its methods; a service that wants both legs simply does not write it.

A transport FAILING is Err(reason), which call maps to RpcError::Transport(..): an unreachable host, a DNS failure, a connection dropped while the body was still arriving. HttpTransport answers that for every rejection of the host fetch, so a call to a server that is not there is an arm of your match and not the end of the process. It does not retry — dialling again is SocketTransport’s job.

Below SocketTransport sits SocketDuplex (the reconnect-surviving socket: pending-call registry, inbound dispatch, on_reconnect hooks) and the DuplexTransport machinery (duplex_pair, bridge, connect_split for the SSE/split fallback). App code doesn’t construct these; the generated connect does.

Disposing a reactive session (ReactiveServer/ReactiveClient) also clears its end’s inbound handler — DuplexEnd::clear_on_frame, the teardown half of on_frame. Without it the wire kept the closure that captured the session, so a closed connection stayed reachable from its transport; the server half runs per disconnect, through drop_session.

fun connect_socket(url: str): Result<SocketDuplex, str>   // dial + announcement (backoff); offers `vilan-rpc`
fun connect_socket_with(url: str, protocols: List<str>): Result<SocketDuplex, str>
fun dial_socket(url: str, protocols: List<str>): Result<SocketDuplex, DialFailure>
enum DialFailure { Unreachable(str), Refused(str) }   // Refused carries "401"/"403"/"503"
impl SocketDuplex {
	fun transport(self): SocketTransport
}

dial_socket is the typed dial the generated Client::connect reaches through: Refused is a server that upgraded this client and then declined it in one frame, which is the only way a refusal can be told from an unreachable server — no host WebSocket exposes a failed handshake’s HTTP status. A refusal ends the retry budget at the first attempt. connect_socket and connect_socket_with are this with the failure flattened to its sentence.

connect_socket offers vilan-rpc (tracker A52), because the refusal frame is only sent to a client that named the protocol — a bare connect that offered nothing could not tell a 401 from an outage and paid the whole backoff to learn nothing. connect_socket_with offers exactly the list it is given, which is the seam for a peer that speaks something else.

Server plumbing (std::rpc::server, process layer)

impl Service {
	fun new(protocol: RpcProtocol): Service   // mounted at "/"
	fun at(own self, prefix: str): Service    // mount elsewhere, e.g. "/admin/"
	fun on_connect(own self, handler: |i32, DuplexEnd| void): Service
	fun on_disconnect(own self, handler: |i32| void): Service
	// the handshake gate and its limits
	fun authorize(own self, check: async |Handshake| Result<Session, Reject>): Service
	fun authorize_timeout(own self, millis: i32): Service   // 429 if the hook does not answer — std's limit, never the app's 503
	fun authorize_request(own self, check: async |Request| Result<Session, Reject>): Service   // gates each POST {mount}rpc (A120 S4)
	fun max_connections(own self, limit: i32): Service      // upgraded sockets on this mount only
	fun handshake_rate(own self, attempts: i32, window_millis: f64): Service
	fun handshake_timeout(own self, millis: i32): Service   // bounds the greeting, not idleness
	fun trust_forwarded_for(own self, trusted: bool): Service   // key on X-Forwarded-For
}
impl ServerBuilder {
	fun with_service(own self, service: Service): ServerBuilder   // repeatable
}

A service is WebSocket upgrade + per-connection session registration (mirror attach/detach) + rpc dispatch. Each handler runs in a turn (AtEnd). ServerBuilder::with_service installs those routes and the handshake on a Server::builder() chain, answering before on_request, so a page and a service sit on one builder instead of one replacing the other. It is repeatable — a second service goes on its own mount (Service::new(protocol).at("/admin/")), picked by longest mount and independent of call order.

Service::new(protocol) wires the runtime session registry as the connection lifecycle — what Client::connect’s generated __attach answers from; on_connect/on_disconnect replace it for apps holding their own per-connection state (connection-scoped auth, an app-written attach). The {mount}rpc route is the server side of std::rpc’s HttpTransport (HttpTransport { url = "http://host:port/rpc" }), and on_request answers every path no service claims — the app shell, usually; serving the build’s own artifacts is ServerBuilder::serve_build’s job, on the same builder. Details: Services & RPC and the process reference.

One matching rule is worth knowing: a service claims a path segment — its route exactly, or its route followed by ? — so /rpc does not shadow an application’s /rpcs or /rpc-docs.

Envelope & codec layer

Frame is the codec-agnostic unit (std::wire); encode_request / open_request / encode_reply read and write the rpc envelope ({"method": …, "args": […]} on the json codec). Codec comes from json_codec() (std::json) or binary_codec() (std::binary); both ends must use the same one. You only meet this layer when implementing a custom transport or protocol bridge.