HomeBlog › NATSEnvelope and the CodecRegistry: typed messages on a generic bus
Blog

NATSEnvelope and the CodecRegistry: typed messages on a generic bus

Jul 19, 20266 min readBy the Rysh team

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:

Trade-offs, honestly

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

Try Rysh

Every pane is a shell and an AI agent — install takes one command.

Get started free →