The actor hierarchy: workspace → tab → lane → group → pane → agent
rysh's UI tree is an actor tree. One owner per piece of state — and a routing shortcut that cut message hops from 5 to 2.
Every process has an org chart, whether you draw it or not. In most codebases it's implicit — this goroutine kind of owns that map, this package sort of manages those handles. In rysh we drew it, and then we made the compiler-adjacent thing enforce it: the org chart is the actor tree.
rysh is an agentic terminal multiplexer — every pane is a conversation that can be a shell, an autonomous agent (running on Claude, your own key), a chat, or an external channel. The UI you see — tabs containing columns of stacked panes — maps one-to-one onto a supervision tree of actors, each with its own NATS inbox and its own exclusively-owned state.
The tree
WorkspaceActor rysh.ws.{session}.inbox
├── TabActor (per tab) rysh.tab.{tabID}.inbox
│ └── LaneActor (per column) rysh.lane.{laneID}.inbox
│ └── PaneGroupActor rysh.pane-group.{groupID}.inbox
│ └── PaneActor rysh.pane.{paneID}.inbox
│ ├── LLMPromptExecutionActor (agent tool-use loops)
│ ├── SharedOutputActor (redaction + shared stream)
│ └── listener actors (##pane listen, remote shares)
├── ShareRegistryActor → UpstreamShareActor (per active share)
├── AgentRegistryActor → AgentActor (per autonomous agent)
└── HumanoidRegistryActor → HumanoidActor (per external-channel agent)
Each level owns exactly what its name says:
- WorkspaceActor — the set of tabs, command routing, workspace persistence to JetStream KV.
- TabActor — lanes within the tab: column layout, flex weights, focus between lanes.
- LaneActor — pane groups within one column: vertical splits, row flex, focus between groups.
- PaneGroupActor — the stack of panes in one group: rotation (stacked panes behave like a deck of cards — only the top one is live), snapshot collection.
- PaneActor — one pane: the PTY, the output buffers, shell/prompt mode, history.
- PaneActor's children — the agent loop, the shared-output pipeline, listeners. A pane delegates everything long-running to children so its own mailbox stays responsive.
And to the side, the registries: autonomous agents and humanoids are headless — no PTY, no UI presence — so they hang off the workspace through their own registry actors rather than living in the visual tree. Same engine underneath (each spawns its own LLMPromptExecutionActor), different parent.
Why mirror the UI?
Because the hard question in any stateful system is "who is allowed to touch this?" — and mirroring makes the answer visual. Where does column flex live? Look at the screen: columns are a tab-level arrangement → TabActor. Who knows which pane in a stack is on top? The group → PaneGroupActor. Who owns scrollback? The pane. You can navigate the codebase by looking at the UI.
It also makes lifecycle mechanical. Actors form a supervision tree: close a tab and the tab's subtree — lanes, groups, panes, each pane's agent and sharing children — is torn down by the framework, not by hand-written cleanup that forgets things. Spawning is the same in reverse: ##agent spawn materializes an actor with children, addressable at its own subject, in one operation.
And because every actor processes its mailbox sequentially, ownership isn't just documentation — it's a concurrency guarantee. One owner, one queue, no locks. (The full argument is in the proto.actor + NATS article.)
The routing lesson: hierarchy is for structure, not for transit
Early on, every message walked the tree. Type a line into a pane and it hopped TUI → workspace → tab → lane → group → pane: five hops, four of which were actors shrugging and forwarding.
That's the classic hierarchy tax. The fix was to sort messages by what they actually touch:
Structural messages (create, close, focus, resize, layout) still traverse the full path — legitimately, because every intermediate actor updates its own layout state along the way. The hops do work.
Pass-through messages (input submission, rename, listener start/stop, share start/stop) skip the middle entirely. The workspace does its one real job — parsing ## system commands and @agent routing — then sends straight to rysh.pane.{paneID}.inbox. Five hops became two.
Raw keystrokes (vim, htop — interactive raw mode) go further: the TUI publishes directly to the pane. One hop. The workspace never sees them.
The same cleanup pass consolidated a family of per-direction message types (focus-left, focus-right, next, prev, up, down…) into a single navigation message with a Direction enum — less codec surface, fewer types to register on the bus.
The principle we ended with: a message should visit exactly the actors whose state it changes. The hierarchy earns its hops on structural traffic and gets bypassed everywhere else.
Trade-offs, honestly
- LaneActor is the layer we argue about. Some weeks it feels like real ownership (vertical splits, row flex); some weeks it feels like ceremony between tab and group. A flatter tree with richer tab state is a plausible alternative. We keep the lane because split-down layouts genuinely have column-scoped state — but it's the first thing we'd re-litigate.
- Bypasses are admissions. Pass-through routing concedes the pure hierarchy was wrong for those messages. We prefer the honest hybrid over either extreme: full traversal everywhere (slow) or a flat soup of actors (no structure to lean on).
- Deep trees make snapshots cascade. Rendering state is collected recursively — workspace asks tabs, tabs ask groups, groups ask panes. Elegant, but the depth is on the latency path for every UI refresh. That design (and its costs) gets its own article.
- Registries duplicate a pattern. Share, agent, and humanoid registries are three near-identical parent-of-dynamic-children shapes. Deliberate duplication for now — three concrete implementations before one abstraction — but the abstraction is visibly overdue.
If you take one thing: draw your process's org chart before the code makes one up for you. Ours happens to look exactly like the screen — which means anyone who has used rysh for five minutes already knows the architecture.
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: Control plane vs data plane · Snapshot-driven rendering