NATSEnvelope and the CodecRegistry: typed messages on a generic bus
How rysh keeps Go type safety on a byte bus: a TypeTag discriminator, a decode registry, and one envelope for every message.
NATS moves bytes. Go wants types. Every system built on a message bus has to bridge that gap somewhere, and the bridge you choose quietly shapes everything downstream: debuggability, versioning, how painful it is to add message number 87.
rysh — an agentic terminal multiplexer whose runtime (workspace, tabs, panes, agents, by default running on Claude with your own Anthropic key) talks over an embedded NATS bus — has a lot of message kinds: structural commands, input submission, per-mode output streams plus a merged one, history appends, status updates, share lifecycle, agent and humanoid control. All of it crosses the bus through one mechanism. This article is that mechanism.
One envelope for everything
Almost every message on the bus has the same outer shape:
type NATSEnvelope struct {
TypeTag string `json:"t"` // discriminator
ReplyTo string `json:"r"` // request/reply; empty for fire-and-forget
Payload json.RawMessage `json:"p"` // the typed body
}
Three fields, three jobs. TypeTag says what the payload is. ReplyTo (optional) carries the subject where a response should land. Payload is the actual typed message, deferred as raw JSON until someone who knows the tag decodes it.
The payloads themselves are plain structs in two msg packages — MsgCreateTab, MsgSubmitInput, MsgPaneResize, MsgConversationAppend, MsgShareEntity, MsgAgentPrompt, and so on. Dumb data, no behavior, JSON tags. They are the vocabulary of the system.
The registry: tag → decoder
The bridge from envelope to struct is a CodecRegistry: a map from TypeTag strings to decode functions, plus the reverse map from Go type to TypeTag for encoding.
r.Register(TagSubmitInput, "*msg.MsgSubmitInput", jsonDecoder[MsgSubmitInput]())
// jsonDecoder[T] unmarshals the payload into a new T and returns the pointer
Every message type is registered by one registry constructor, run when a process connects to the bus. The delivery path is then mechanical. Each actor has a small NATSBridge that subscribes to its subjects ({session}.pane.{paneID}.inbox, {session}.ws.inbox, …). On arrival: unmarshal the envelope, look up TypeTag in the registry, run the decoder, deliver the concrete struct into the actor's mailbox. By the time actor code runs, the bus has vanished:
func (p *PaneActor) Receive(ctx actor.Context) {
switch msg := ctx.Message().(type) {
case *msgs.MsgPaneSubmitInput: // already a real type — no envelope in sight
p.execute(msg.Text, msg.Mode)
case *msgs.MsgPaneResize:
p.resize(msg.Cols, msg.Rows)
}
}
Actors type-switch on structs, never parse JSON, never see tags. (A request arrives wrapped, so the actor has somewhere to Reply; more on that below.) The full serialization story lives in two places — envelope and registry — instead of being smeared across every Receive() in the codebase.
Failure modes are boring by design: unknown tag → drop (an old client publishing to a newer session shouldn't crash it); decode error → drop. The bus stays up; with message logging on, the evidence lands in the log.
Request/reply on a fire-and-forget bus
Most rysh traffic is fire-and-forget through NATSPublisher.Send(subject, msg). But some flows genuinely need answers — the big one being snapshots: the TUI asks for its layout over the bus ({session}.ws.snapshot), never from actor memory.
That's what ReplyTo is for. Request(subject, msg, timeout) wraps the message in an envelope carrying a reply inbox; the bridge hands the actor a RequestEnvelope, and the actor's Reply publishes its answer — same envelope format, same registry — back to that subject. The TUI's layout request is the same envelope sent with NATS's own request call, which fills in the reply subject itself:
payload, _ := json.Marshal(&msg.MsgGetWorkspaceSnapshot{LayoutOnly: true})
env, _ := json.Marshal(msg.NATSEnvelope{TypeTag: msg.TagGetWorkspaceSnapshot, Payload: payload})
reply, err := nc.Request(msg.T("ws", "snapshot"), env, 2*time.Second) // {session}.ws.snapshot
One envelope grammar serves both patterns. Actor RPC needs no second protocol — a request is just a message that happens to name its return address.
Why JSON, when protobuf exists
Deliberate, and worth defending since this is the article where readers will ask.
Debuggability has compounded. Every envelope is readable JSON. During development, tailing a live session — subscribe to <session>.pane.*.output or a pane's inbox — shows traffic you can read, not hex you must decode with the right .proto at hand. For a system whose whole pitch includes observability (every agent tool call visible in a pane), having the substrate itself be inspectable is philosophically consistent and practically priceless.
The vocabulary is wide and was churning. Dozens of message types, evolving weekly during buildout. JSON plus additive-field discipline (add fields, never repurpose, never renumber-because-there-are-no-numbers) let the vocabulary grow without codegen toolchains in the loop.
The hot path doesn't care. The bulkiest traffic — line-mode PTY output — is text riding in thin append messages; envelope overhead is marginal relative to the payload. Structural commands are human-rate. If profiling ever indicts the codec, decoding is already per tag, and encoding is json.Marshal in a few places. (A separate rysh-proto repo holds protobuf reference schemas for the envelope and a few messages; nothing serializes with them yet — the migration path is real, just not yet earned.)
The discipline that makes it hold
A registry is convention, and conventions need rules:
- A TypeTag is forever. Tags are contracts; renaming one strands every publisher that knows the old name. Structs can gain fields freely — tags never change meaning.
- Prune by consolidation, not deletion. Navigation messages share one
Directiontype rather than a message per direction where we could consolidate — and old tags stay registered so older publishers keep working. - Register in one place, fail at the send. A tag published anywhere must be registered everywhere that subscribes. Sending an unregistered type fails at the send call; an unknown tag at a subscriber is dropped — which is why every process builds the registry from the same constructor.
Trade-offs, honestly
- Manual registration is boilerplate. Every new message means: struct + tag and registration + handler case. Mechanically. Codegen from message definitions is the obvious fix; today we pay the toll for explicitness.
- Stringly-typed at the seam. The tag is a string; the compiler can't verify publisher and subscriber agree. Our mitigations are convention (tag == struct name, with a handful of exceptions) and one registry constructor — honest mitigations, not guarantees.
anyat the mailbox boundary. Decoders returnany; the type-switch inReceive()is where safety re-enters. Idiomatic for Go actor systems, but it's a runtime seam, not a compile-time one.
What we'd do differently: generate the registry (and the tags) from rysh-proto definitions from day one, keeping JSON on the wire but making publisher/subscriber agreement a build-time fact. The envelope itself we'd keep exactly as is — three fields have carried nearly every feature the product has grown, which is about the strongest endorsement a wire format can earn.
rysh is in early access and we're recruiting design partners — teams who want agents in their real workflow and will tell us what breaks. Direct line to the founders: rysh.ai/design-partner.
More in this series: Why rysh is built on proto.actor and NATS · Control plane vs data plane