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 entire runtime (workspace, tabs, panes, agents on Claude with your own key) communicates over an embedded NATS server — has a lot of message kinds: structural commands, input submission, four 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
Every message on the bus is the same outer shape:
type NATSEnvelope struct {
TypeTag string `json:"type"` // discriminator
ReplyTo string `json:"reply_to,omitempty"` // request/reply
Payload json.RawMessage `json:"payload"` // 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 an internal/msg package — MsgCreateTab, MsgSubmitInput, MsgPaneResize, MsgPaneShellOutputAppend, 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.
registry.Register("MsgSubmitInput", func(b []byte) (any, error) {
var m MsgSubmitInput
err := json.Unmarshal(b, &m)
return &m, err
})
Every message type registers exactly once, at startup. The delivery path is then mechanical. Each actor has a small NATSBridge that subscribes to its subjects (rysh.pane.{paneID}.inbox, rysh.ws.{session}.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.MsgSubmitInput: // already a real type — no envelope in sight
p.execute(msg.Input, msg.Mode)
case *msgs.MsgPaneResize:
p.resize(msg.Cols, msg.Rows)
}
}
Actors type-switch on structs, never parse JSON, never see tags. 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 → log and drop (an old client publishing to a newer session shouldn't crash it); decode error → log and drop. The bus stays up; 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 renders exclusively from state it requests over the bus (rysh.ws.{session}.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, and the responding actor publishes its answer — same envelope format, same registry — back to that subject:
snap, err := pub.Request("rysh.ws."+session+".snapshot",
&msgs.MsgSnapshotRequest{}, 2*time.Second)
One envelope grammar serves both patterns. There's no second protocol for RPC — 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 message on the bus is human-readable. During development, tailing a live session — subscribe to rysh.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 — 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, it's one interface deep and swappable per-tag. (A rysh-proto module exists in the monorepo for protocol definitions — 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. The navigation cleanup that replaced a family of per-direction messages with one message carrying a
Directionenum shrank the registry surface honestly — old tags retired with the release that stopped publishing them. - Register at startup, fail at startup. A tag published anywhere must be registered everywhere that subscribes; wiring mistakes surface as immediate log noise, not silent drops in production week three.
Trade-offs, honestly
- Manual registration is boilerplate. Every new message means: struct + registration + handler. Three files, mechanically. Codegen from message definitions is the obvious fix and it's on the list; 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) and startup registration — 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 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