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

Trade-offs, honestly

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

Try Rysh

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

Get started free →