Home › Blog › 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 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

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

Try Rysh

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

Get started free →