HomeBlog › Control plane vs data plane: keeping keystrokes fast and commands ordered
Blog

Control plane vs data plane: keeping keystrokes fast and commands ordered

Jul 19, 20266 min readBy the Rysh team

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

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

Try Rysh

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

Get started free →