Home › Blog › 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 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:

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 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

Try Rysh

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

Get started free →