Why rysh is built on proto.actor and NATS
A terminal multiplexer is secretly a distributed system. Why rysh chose actor mailboxes and a message bus over goroutines and locks.
A terminal multiplexer is secretly a distributed system.
That sounds like an excuse for over-engineering, so let me list what is actually running when you open rysh with four panes:
- four PTY read loops, each pumping bytes out of a shell process
- up to four LLM tool-use loops (rysh is an agentic multiplexer — every pane is a conversation that can run an agent; the engine runs on Claude, with your own Anthropic key)
- a keystroke stream that must reach exactly one pane with low latency
- a rendering loop that needs a consistent view of all of the above
- optionally: a pane being shared to a remote collaborator, an autonomous agent with no pane at all, and a background build you kicked off an hour ago
All of it is concurrent. All of it is stateful. Most of it can outlive the UI, because rysh sessions detach and reattach like tmux sessions do.
The classic Go answer is goroutines, channels, and a handful of mutexes. We didn't do that. rysh is built on proto.actor (actor model) with NATS as the transport, and an embedded JetStream instance for persistence. Here's the reasoning, including the costs.
Decision 1: actors, because state wants one owner
The failure mode of channel-and-mutex architectures isn't performance — it's that state ownership becomes folklore. Which goroutine is allowed to touch the output buffer? Who resizes the PTY? After six months, the answer is "read the code carefully and pray."
The actor model makes ownership structural. Every pane is a PaneActor. Its PTY handle, output buffers, mode, and history belong to it and nothing else. Communication is messages into a mailbox, and Receive() processes them one at a time:
func (p *PaneActor) Receive(ctx actor.Context) {
switch msg := ctx.Message().(type) {
case *msgs.MsgSubmitInput:
p.execute(msg.Input, msg.Mode)
case *msgs.MsgPaneResize:
p.resizePTY(msg.Cols, msg.Rows)
case *msgs.MsgPaneSnapshotRequest:
ctx.Respond(p.buildSnapshot())
}
}
Sequential processing per actor means the resize can't race the snapshot, because they're queue neighbors, not concurrent threads. Our internal rule is blunt: no mutexes in actors. (There are exactly two documented exceptions, both at the PTY/terminal-emulator boundary — that gets its own article.)
The hierarchy also mirrors the product: a WorkspaceActor owns TabActors, tabs own lanes, lanes own pane groups, groups own panes, and panes spawn children like the LLMPromptExecutionActor that runs agent loops. Kill a tab, and the supervision tree tears down everything under it. The org chart of the process matches the UI you're looking at.
Decision 2: NATS, because addresses beat pointers
proto.actor would work fine with in-process references. We deliberately put NATS between everything instead. Every actor gets a subject:
rysh.ws.{session}.inbox → WorkspaceActor
rysh.tab.{tabID}.inbox → TabActor
rysh.pane.{paneID}.inbox → PaneActor
rysh.pane.{paneID}.output → merged pane output stream
rysh.pane.{paneID}.status → status updates
A small bridge per actor subscribes to its subjects, decodes typed messages, and delivers them into the mailbox. Producers just publish; they hold a subject string, not a pointer.
This buys three things that turned out to be load-bearing:
Location transparency. The TUI is just another publisher. So is rysh send <session> "run the tests" --mode prompt, which drives a detached session from any shell with no TUI attached. So is the collaboration server when a teammate controls your shared pane. None of these needed special plumbing — the bus doesn't care who publishes.
Observability. Message-passing systems can be opaque. A bus makes them tailable: subscribe to a pane's output subject and you're watching the same stream the UI renders. Pane sharing, pipeline events between panes, and remote collaboration all fell out of "output is a topic, not a private buffer."
Persistence where the messages already are. NATS JetStream ships a KV store. Workspace structure and pane state persist into two KV buckets in the same embedded server that moves the messages. Close the laptop; reattach tomorrow; your workspace is where you left it. No separate database dependency.
And because the NATS server is embedded — one per session, in-process, storing under ~/.local/state/rysh/nats/{session}/ — none of this costs you an ops burden. rysh installs as one binary. It's also self-hostable: your machines, your Anthropic key, your data. (To be precise about what we are: self-hostable, not open source.)
What we rejected
- Plain goroutines + channels. Works at demo scale; ownership erodes at product scale. We wanted the discipline enforced by structure, not by review comments.
- gRPC between components. Point-to-point RPC couples every caller to every callee's location and lifecycle. We wanted pub/sub semantics for output streams and fire-and-forget for the data plane.
- SQLite for state. A fine database, but it would be a second infrastructure component with its own write paths, next to a bus that already ships a KV store.
Trade-offs, honestly
- Messages cost more than function calls. Envelopes are JSON-encoded; every hop is a serialize/deserialize. For structural commands (create tab, focus pane) this is irrelevant. For hot paths it isn't — which is why PTY output and raw keystrokes bypass the actor hierarchy and ride NATS subjects directly, and why raw keyboard input reaches a pane in a single hop. The control-plane/data-plane split is a direct consequence of taking this cost seriously.
- JSON, not protobuf. Deliberate for now: being able to
nats sub 'rysh.pane.*.output'during a debugging session and read the traffic has paid for itself many times. If envelope encoding ever shows up in a profile, the codec layer is one interface deep. - Two frameworks to learn. proto.actor's documentation is thinner than, say, Akka's; new contributors need a day with the actor model before the codebase makes sense. We think the structural payoff is worth it, but it's a real onboarding tax.
- An embedded broker is a heavyweight dependency. We accept a full NATS server inside a terminal app. We'd rather carry that weight than reimplement subjects, request/reply, and KV persistence badly.
Would we choose the pair again? Yes — mostly because every major feature since (sharing, headless control, autonomous agents, external channel "humanoids") reused the same two primitives: an actor that owns state, and a subject you can publish to.
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, your feedback shapes the roadmap: rysh.ai/design-partner.
More in this series: An embedded NATS server in every session · Control plane vs data plane