Home › Blog › Why rysh is built on proto.actor and NATS
Blog

Why rysh is built on proto.actor and NATS

Jul 19, 20266 min readBy the Rysh team

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:

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 m := ctx.Message().(type) {
    case *msg.MsgPaneSubmitInput:
        p.execute(m.Text, m.Mode)
    case *msg.MsgPaneResize:
        p.resizePTY(m.Cols, m.Rows)
    case *msg.MsgGetPaneSnapshot:
        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. (The documented exception is the terminal-emulator wrapper at the PTY boundary, and the rule isn't perfectly kept yet — 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 in front of every actor as well (inside the daemon, actors still message each other directly). Every actor gets a subject:

{session}.ws.inbox             → WorkspaceActor
{session}.tab.{tabID}.inbox    → TabActor
{session}.pane.{paneID}.inbox  → PaneActor
{session}.pane.{paneID}.output → merged pane output stream
{session}.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 share actor that relays a teammate's input when they control 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 see the text the pane is producing. 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 per-session KV buckets in the same embedded server that moves the messages. Detach; reattach tomorrow; your workspace is where you left it. No separate database dependency.

And because the NATS server is embedded — compiled in, storing with the project under .rysh/nats on your own disk — none of this costs you an ops burden. rysh ships as a self-contained binary, with no separate broker to run. It runs on your own machine, with your own Anthropic key and your data. (To be precise about what we are: open-core — the CLI is Apache-2.0, the server is proprietary.)

What we rejected

Trade-offs, honestly

Would we choose the pair again? Yes — mostly because sharing, headless control, autonomous agents and external channel "humanoids" are all built from 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

Try Rysh

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

Get started free →