Splitting Delivery Between send-keys and Headless Agents
iTerm2/tmux sessions running with RelayRoom. Each agent is connected to the same room.
RelayRoom started with a fairly specific assumption: a person is running AI coding agents from a terminal. That is still true. CLIs such as Claude Code, Codex, and Agy(Antigravity) live inside tmux, and the person can attach to the same session when needed. Each agent works in its own worktree. RelayRoom delivers messages and, when necessary, wakes the relevant session.
In that setup, tmux does quite a lot.
- The session name becomes the address of a part.
- The agent CLI stays alive inside that pane.
- A person can attach to the same screen and inspect the state.
- The pager can check whether the session is alive and deliver wake signals.
- The status line can show RelayRoom state.
tmux is not just a terminal multiplexer here. It is closer to the local execution substrate that keeps RelayRoom's agent sessions in place.
The problem was how the wake actually entered the CLI.
The Existing Path: tmux send-keys
When a new message arrives, the RelayRoom hub sends a wake to the pager. The pager finds the tmux pane for that part, types a short nudge with tmux send-keys, and then sends Enter. The agent CLI receives that input as if a person typed it, then calls MCP tools to check its inbox.
The flow is roughly this:
sequenceDiagram
participant Hub as RelayRoom hub
participant Pager as pager
participant Tmux as tmux pane
participant Agent as agent TUI
Hub->>Pager: SSE wake
Pager->>Tmux: send-keys -l "check your RelayRoom inbox"
Pager->>Tmux: send-keys Enter
Tmux->>Agent: prompt input
Agent->>Hub: inbox / show / reply through MCPThis path works without a separate vendor API. It reuses the interactive session that a person is already using. Whatever working context the agent has, the nudge lands directly in that session.
Early on, that was a reasonable choice.
It was also structurally fragile, because it works by imitating a person typing into another program's input box.
Failure Modes
Our engineering team uses RelayRoom actively.
Each developer runs a few sessions on their own machine, connects them to the same project, lets the agents ask each other questions, and archives the conversation.
I use an M1. The rest of the team is on MacBook M5s. New machines. I am not jealous. Mostly.
On the M5 machines, we got a report that
Entersometimes did not work when waking agents through tmuxsend-keys.
The most direct issue is that each TUI handles input differently.
A TUI such as Codex can detect a fast burst of input as paste. If the pager sends the nudge text and then immediately sends Enter, that Enter can be treated not as submit, but as a newline inside pasted text. The nudge stays in the composer, and nothing runs. A person has to look at the pane and press Enter manually.
This was not theoretical. We saw this shape of failure in practice. Adding a delay between the text and Enter can mitigate it, but that delay depends on the environment and the TUI implementation. It is hard to call that a dependable protocol.
The second issue is control characters. send-keys -l sends literal bytes into the terminal. If the input string contains control characters, the terminal may interpret them. RelayRoom has to sanitize this path separately. That defense is necessary, but it does not change the basic fact that the system is pushing text into another process's TTY.
The third issue is reproducibility. tmux version, terminal, shell, agent CLI, paste detection, and line editor behavior all sit in the path. In one environment wake works reliably. In another, the text remains in the composer. These issues are expensive to support. The logs say wake was sent. The pane shows that text arrived. But the agent did not run.
Somewhere in that gap, the path reveals itself as UI automation rather than a protocol.
Is send-keys Wrong? No.
This does not mean send-keys is bad and tmux should be removed. RelayRoom partly started from the opposite concern: avoid the cost and operational overhead of headless execution when a person already has an interactive session open. tmux is still valid. For a main part under human supervision, or for a long-running interactive agent, tmux is the right shape. A person can attach, watch, and intervene when needed. The session also preserves the workflow already held by the model and CLI.
The problem is not tmux. The problem is the delivery wire.
Claude has a path such as Channels, where wake can reach the session without pretending to type into a TUI. When that path exists, it is better than send-keys. But not every CLI exposes the same capability. For Codex and Antigravity, it is hard to assume a vendor-level path that safely injects messages into an already running TUI.
That leaves two options.
- Keep the interactive session and improve
send-keys. - Do not wake the interactive session; spawn a headless process that checks the inbox.
Only one of them does not need to win everywhere.
Headless Delivery
Headless delivery does not put keys into a tmux pane when wake arrives. Instead, it starts a non-interactive agent process with the right part identity, authentication, and configuration. That process uses RelayRoom MCP tools to check its own inbox, then replies or records state if needed.
sequenceDiagram
participant Hub as RelayRoom hub
participant Watcher as wake watcher
participant Proc as headless agent
participant Board as RelayRoom board MCP
Hub->>Watcher: SSE wake
Watcher->>Proc: spawn with part context
Proc->>Board: whoami / inbox / show / reply
Proc-->>Watcher: events / exit statusIn this path there is no tmux pane, no TUI composer, no paste detection, and no Enter key that can be swallowed as a newline. The agent starts as a non-interactive task whose job is to check the inbox and act.
Codex can support this shape through codex exec. Antigravity has a print/headless path such as agy -p. In local verification at the time, both could call the RelayRoom board MCP and use tools such as whoami, inbox, roster, and threads. The first failed attempts were not caused by headless execution itself. They were configuration failures: missing token injection, and confusion between channel MCP and board MCP.
The cost model mattered. If every headless run forced separate API billing, this path would be harder to justify. In the environments we checked, both Codex and Antigravity could run non-interactively under the user's login or plan quota. If that remains true, headless delivery is realistic for low-frequency worker parts.
This depends on each product's policy. RelayRoom should not assume that headless execution is always free. The better assumption is that delivery mode needs to be chosen with the cost model included.
Split Delivery by Part
RelayRoom does not need a binary choice between send-keys and headless delivery. Different parts have different operating requirements.
For a main part, or any part a person supervises often, the interactive session matters. The user can attach to tmux, inspect the state, and continue the prompt directly if needed. If the agent is Claude, channel delivery can be used. For other CLIs, improved send-keys may still be the practical path.
Worker parts are different. CI summaries, documentation cleanup, targeted inbox handling, and small review tasks do not always need a living TUI. When wake arrives, a process can start, check the inbox, leave a result, and exit. For these parts, headless is simpler.
The configuration could look like this:
part=main delivery=channel | send-keys supervision=human
part=backend delivery=send-keys supervision=human-optional
part=reviewer delivery=headless supervision=dashboard
part=docs delivery=headless supervision=dashboardThis also limits the blast radius of each approach. send-keys is used only where a human-visible session already exists. Headless is used where TUI injection can be avoided from the start. When a better vendor path such as Claude Channels exists, that path is used.
Delivery is therefore not a property of the agent type. It is a property of how the part is operated.
Observability Has to Be Rebuilt
Moving to headless delivery removes one thing: the terminal screen a person can attach to.
A tmux session is primitive, but useful as an observability tool. You can see whether the process is alive, what prompt was entered, and what the agent is printing. A headless process starts and exits. If it is not recorded properly, it can become more opaque than the TUI path.
So headless delivery needs its own observability.
- when the wake was received
- which command and profile were spawned
- which part identity was used
- which MCP tools were called
- write actions such as reply or close
- exit status
- a summary of stdout or JSON event stream
- usage reporting
This information needs to be recorded in RelayRoom events and shown in the dashboard. Headless must not become invisible automation. If we lose the tmux screen, we need a more structured execution record in return.
In some ways, headless can be better here. A TUI screen is readable to a person, but not especially structured for a system. Output such as codex exec --json can be attached to the dashboard and usage ledger more cleanly. Failure can also be narrowed down to something like MCP auth missing or process exited 1, instead of "Enter stayed in the composer."
Security and Authentication
Headless delivery also needs a clear authentication model.
In the interactive path, the CLI session is already logged in and the MCP configuration is already present in that environment. A headless process is launched separately, so RelayRoom has to decide which token is injected and how.
For Codex, injecting the board MCP bearer token through an environment variable is a good fit. It is easier to rotate and revoke than putting the token directly into a config file. Antigravity-style paths may use configuration files instead. If so, file permissions and storage location matter.
The important thing is not to mix channel MCP and board MCP. Channel MCP is for wake delivery. Board MCP is the tool surface agents use to work with threads and messages. A headless worker should use board MCP. Mixing the two can lead to auth errors, or to putting the right token into the wrong server path.
This sounds like implementation detail, but in operations it matters. If the wake path and work path are separated, the search space for failures is smaller.
Remaining Decisions
This design does not close every question. A few decisions remain.
First, concurrency. If several wakes arrive for the same part, should RelayRoom spawn several headless processes, or coalesce them into one? For most worker parts, coalescing is the better default. The inbox accumulates work, and multiple parallel runs for the same part can create duplicate replies or races.
Second, session continuity. If headless starts a new process every time, previous conversational state does not live inside the model session. That is a downside, but for worker parts it can also be an advantage. Required state should live in RelayRoom threads, the worktree, RELAYROOM.md, and the repo. Relying on state that exists only inside a model session is less reproducible.
Third, timeouts. A headless worker should finish. Unlike an interactive session that may stay alive indefinitely, it should check the inbox, perform the necessary actions, and exit within a limit. If it fails, it should leave an event and wait for the next wake or human intervention.
Fourth, human handoff. If a headless worker finds work it should not handle alone, it needs a path to open a thread for the main part or a person. Without that, it can fail silently or loop too long by itself.
Conclusion
send-keys was a practical early choice for RelayRoom. It can wake an existing tmux session, and it works across multiple CLIs without a separate vendor integration. But it is UI automation, not a protocol. It depends on TUI input handling and paste detection, and its failure modes vary by environment.
That does not mean tmux should go away. tmux is still a good execution base for agent sessions that people supervise. The assumption that needs to change is that every wake should be pushed through the same delivery path.
RelayRoom delivery should split by part.
- Interactive parts that people watch should keep tmux.
- When Claude provides a stable channel path, use it.
- Worker parts that do not need close supervision should run headless when TUI injection is fragile.
- Headless runs need observable events, usage, and exit status.
- The wake path and board MCP path should remain separate.
Headless delivery is not a replacement for tmux. It moves part of the work previously handled by send-keys onto a more suitable path.
Long-running work stays in sessions people can watch. Short, repeatable inbox work goes to headless workers. The main point is not to wake both through the same mechanism.
The product these wake and delivery paths belong to is RelayRoom.