Control plane vs data plane: keeping keystrokes fast and commands ordered
Structural commands flow through actor mailboxes. PTY bytes and keystrokes skip the queue. How rysh splits its message traffic.
Network engineers solved this problem decades ago: route decisions about the network differently from the packets themselves. The control plane wants correctness and ordering; the data plane wants throughput and latency. Conflate them and you get a router that drops packets while it thinks.
rysh — an agentic terminal multiplexer where every pane is a conversation backed by a real PTY and, optionally, an agent running on Claude — has exactly this shape of problem. So we stole the vocabulary.
Two kinds of traffic
Everything in rysh moves as messages over an embedded NATS bus, between actors that own state (workspace → tabs → lanes → pane groups → panes). But the traffic is wildly bimodal:
Control traffic — create a tab, close a pane, focus left, resize a column. Rare (human-speed), small, and ordering is everything. If "create pane" and "resize pane" race, you resize a pane that doesn't exist yet.
Data traffic — PTY output from a compiling codebase, streaming agent responses, and raw keystrokes while you're inside vim. High-frequency, bursty, and latency is everything. Nobody cares if two output chunks from different panes interleave "wrong"; everybody cares if typing feels mushy.
One pipeline can't serve both masters. So rysh has two.
The control plane: mailboxes, in order
Structural commands flow through the actor hierarchy. The TUI publishes a typed message to the workspace inbox:
TUI → rysh.ws.{session}.inbox → WorkspaceActor
→ rysh.tab.{tabID}.inbox → TabActor
→ lane → pane group → pane
Each actor processes its mailbox sequentially — one message at a time, no locks. That single property gives us ordering for free: by the time MsgPaneResize is processed, the MsgCreatePane ahead of it in the queue has fully completed. Every actor along the path updates its own layout state (that's why the message visits them), and the workspace persists structural changes to JetStream KV as they happen.
Sequential mailboxes are "slow" in the way a single-lane road is slow. For human-initiated structural changes, that's nothing. The queue is empty almost always.
The data plane: publish and get out of the way
PTY output never enters a mailbox chain. The pane's read loop publishes bytes directly to NATS subjects:
rysh.pane.{paneID}.output.shell ← shell output (per-mode stream)
rysh.pane.{paneID}.output.ai ← agent output (per-mode stream)
rysh.pane.{paneID}.output ← merged stream, interleaved by arrival
Subscribers — the UI, the sharing pipeline, another pane that ran ##pane listen — consume whichever stream they need. A build spraying megabytes of output is a firehose aimed at a topic, not a queue of messages waiting behind "focus next pane." Mailboxes never back up with bulk data, so the control plane stays responsive during the firehose.
Keyboard input is the extreme case. In normal modes, input is a submitted line — that can afford a hop or two. But when a pane is in raw mode (you're inside vim, htop, less — rysh detects the alternate screen buffer and switches automatically), every keystroke must become bytes on a PTY now. So the TUI publishes MsgRawKeyInput straight to the pane's own subject:
TUI → rysh.pane.{paneID}.inbox → PaneActor → PTY
One hop. No workspace, no tab, no group. The message doesn't visit actors that have nothing to say about it.
The hybrid: input submission
Regular input (you type a line, hit enter) is the interesting middle case. It needs one control-plane stop: the WorkspaceActor parses prefixes — ## system commands, @agent routing to autonomous agents, ##> pipeline events — and decides where the line goes. But after that decision, there's nothing for the tab/lane/group actors to contribute. So the workspace forwards directly to the pane:
TUI → workspace (1 hop: parse & route) → pane (1 hop) = 2 hops
Before this optimization, the same message traversed the full hierarchy: five hops, four of which were pure forwarding. The refactor that introduced pass-through routing (input, rename, listener and share commands all skip the middle) cut that to two, and consolidated a pile of per-direction navigation messages into a single Direction enum while it was in there.
The scorecard
| Traffic | Path | Hops | Optimized for |
|---|---|---|---|
| Create/close/focus/resize | full hierarchy | up to 5 | ordering, state ownership |
| Submitted input | workspace → pane | 2 | routing + low latency |
| Raw keystrokes | TUI → pane | 1 | latency |
| PTY / agent output | direct to topics | — | throughput, fan-out |
The rule that generates the table: a message should visit exactly the actors that own state it affects. Structural changes affect layout at every level — they visit every level. A keystroke affects one PTY — it visits one actor.
Trade-offs, honestly
- Two delivery paths means two failure modes. "Why didn't this message arrive?" now starts with "which plane was it on?" We've accepted a debugging tax for a latency win.
- Dual-publish costs writes. Shell and AI output go to both a per-mode topic and the merged topic. Two publishes per chunk. We took the redundancy so that consumers (UI wants merged; sharing wants per-mode streams separable) don't need to re-multiplex — but it's not free.
- The bypass is a confession. Pass-through routing admits that the pure hierarchy was the wrong path for high-frequency messages. We kept the hierarchy where each hop earns rent (structure) and bypassed it where it didn't (data). Architecturally honest, aesthetically impure.
- Ordering across planes is undefined. A control message and a data message published "simultaneously" have no guaranteed relative order. In practice this doesn't bite — output rendering is snapshot-driven and per-pane — but it's a real property of the design, not an oversight we're hiding.
What we'd do differently: not much here, genuinely. This split is the decision that has aged best — every performance-sensitive feature since (raw mode, streaming agent output, pane sharing) landed on the data plane without touching control-plane correctness.
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: The actor hierarchy · Emulating a terminal inside a terminal