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 that runs on Claude by default — has exactly this shape of problem. So we stole the vocabulary.
Two kinds of traffic
Almost 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 → {session}.ws.inbox → WorkspaceActor
→ {session}.tab.{tabID}.inbox → TabActor
→ lane → pane group → pane
Each actor processes its mailbox one message at a time, so layout state needs no locks. That single property gives us ordering for free: a MsgResizePane queued behind a MsgCreatePane in the workspace inbox is handled after it. Every actor along the path updates its own layout state (that's why the message visits them), and the workspace marks the layout dirty and writes it to JetStream KV, coalesced to at most one write every two seconds.
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 travels down the hierarchy. The pane's read loop publishes bytes directly to NATS subjects:
{session}.pane.{paneID}.output.shell ← shell output (per-mode stream)
{session}.pane.{paneID}.output.ai ← agent output (per-mode stream)
{session}.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." Bulk data never passes through the workspace, tab, lane or group mailboxes (the only one it enters is its own pane's, which folds it into the buffer), so structural commands stay 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 raw-input subject, where a subscription writes it to the PTY:
TUI → {session}.pane.{paneID}.rawinput → PTY
One publish. For a local pane: no workspace, no tab, no group, and not even the pane's own mailbox. 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 checks prefixes: it runs ## system commands itself, routes @agent lines to autonomous agents, and lets ##> pipeline events through. For an ordinary line there's nothing for the tab/lane/group actors to contribute, so the workspace forwards it directly to the pane:
TUI → workspace (1 hop: parse & route) → pane (1 hop) = 2 hops
Routed through the full hierarchy, the same message would take five hops, four of them pure forwarding. Pass-through routing (input, rename, listener and share commands all skip the middle) cuts that to two. Navigation likewise uses a single Direction type instead of one message per direction.
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 → PTY subject | no actor | 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 (per-mode for the UI, sharing and
##pane listen; merged for session replay) 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 is buffered per pane and periodically reconciled against the pane's own snapshot — 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. Raw keystrokes, streaming agent output and pane sharing all ride the data plane today. Sharing's live frames first went through the workspace mailbox, and moving them out was exactly this rule applied.
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