Proposal: CustomPaneBackend protocol — decouple agent teams from tmux CLI to unblock Ghostty, WezTerm, Zellij, KILD, and remote deployments
Who I Am and Why I'm Writing This
I'm the creator of KILD, a tool for running parallel AI agents in isolated git worktrees. KILD ships a daemon that manages PTYs natively — it is, functionally, a terminal multiplexer purpose-built for agentic workflows. I have agent teams working inside KILD daemon sessions already.
I'm writing this because I've done the work to make agent teams work inside a non-tmux environment, which means I've reverse-engineered exactly what Claude Code needs from a pane backend. What I found is that Claude Code's dependency on tmux is causing compounding problems — for users, for the codebase, and for every terminal/multiplexer that wants to offer a first-class experience.
KILD itself needs this. So does Ghostty, WezTerm, Zellij, and any other tool that manages terminals and wants agent teams to work natively inside them. This isn't a "please add KILD support" request. It's a proposal to fix the underlying architecture in a way that benefits Anthropic, fixes open bugs, and unblocks an entire class of consumers simultaneously.
---
The Problem: tmux Has Leaked Into Claude Code's API Surface
Agent teams currently treat tmux as an interface contract. Claude Code calls approximately 20 distinct tmux subcommands to manage the lifetime of teammates. This means tmux's CLI is effectively Claude Code's pane management API — and it was never designed to be one.
The consequences are real and already showing up in your issue tracker:
- #23615 — Race condition: 4+ agents at startup, ~50% corruption rate.
send-keysgets garbled (mmcdinstead ofcd). Root cause:tmux split-window+send-keysare separate subprocesses with no coordination. A 200ms sleep is not a fix. - #23572 — Silent fallback: tmux backend detection fails, Claude Code silently drops to in-process mode. No error surface.
- #24189 — Ghostty: blocked on Ghostty shipping an IPC API. Claude Code can't move until Ghostty does.
- #24122 — Zellij: same story.
- #23574 — WezTerm: same story.
These are not five separate requests. They are the same architectural gap filing in from different directions.
---
What It Looks Like to Work Around This Today
KILD ships a binary called kild-tmux-shim — a drop-in replacement for tmux that intercepts every command Claude Code issues and routes it to KILD's daemon via IPC. When a KILD session starts, we prepend ~/.kild/bin/ to $PATH and symlink the shim there as tmux. Claude Code sees $TMUX in the environment, finds tmux on the path, and proceeds normally.
The shim handles all ~20 tmux subcommands Claude Code issues. Most of them are no-ops (select-layout, resize-pane). The real work is:
split-window → daemon: CreateSession (new PTY)
send-keys → daemon: WriteStdin
kill-pane → daemon: DestroySession
capture-pane → daemon: ReadScrollback
display-message → local state lookup (pane ID, session name)
list-panes → local pane registry
This works. Teammates run in daemon PTYs, output is captured, the race condition doesn't exist because our pane registry uses file-based locking. But it's fragile: any new tmux command Claude Code adds is a silent breakage until we notice. And it's an elaborate impersonation of something that should just be a clean interface.
The shim is proof that the real requirements are far simpler than tmux's surface area. It also demonstrates that a clean protocol is implementable — KILD has been running it in production.
---
What Claude Code Actually Needs
The shim exercise revealed that the real requirements are much simpler than tmux's surface area. Claude Code needs seven operations:
| Operation | Purpose |
|---|---|
| spawn_agent(argv[], cwd, env, metadata) | Start a teammate process |
| write(context_id, data) | Send to stdin |
| capture(context_id, lines?) | Read scrollback |
| kill(context_id) | Terminate |
| list() | Enumerate live contexts |
| get_self_id() (via initialize) | "What context am I running in?" |
| push: context_exited(context_id, code) | Notification when context exits |
That's it. Everything else — has-session, new-session, new-window, select-layout, resize-pane, break-pane, join-pane, border styling — is tmux-specific plumbing that has no business being in Claude Code's model.
Two other things worth noting:
spawn_agent should take argv[], not a shell string. Currently Claude Code constructs a shell command and sends it via send-keys into a running shell. A proper protocol can take the exact argv — no shell interpolation, no quoting edge cases, deterministic behavior.
The backend should own rendering. Claude Code currently calls select-pane -P "bg=...", set-option pane-border-format, etc. This is wrong. The coordinator should pass intent (metadata: {name, color, role}), and the backend should decide how to present it. Ghostty renders this differently than tmux. That's fine — it's their terminal.
---
The Proposal: CustomPaneBackend Protocol
Define a minimal JSON-RPC 2.0 protocol over NDJSON. Backends register via environment variable or config:
# Spawn-on-demand: Claude Code starts the backend process
CLAUDE_PANE_BACKEND=/path/to/binary
# Pre-running server: Claude Code connects to socket
CLAUDE_PANE_BACKEND_SOCKET=/path/to/server.sock
Both transports speak identical JSON-RPC.
Handshake
// Claude Code → Backend
{"id":"1","method":"initialize","params":{"protocol_version":"1","capabilities":["events"]}}
// Backend → Claude Code
{"id":"1","result":{
"protocol_version": "1",
"capabilities": ["events", "capture"],
"self_context_id": "ctx_0"
}}
self_context_id replaces tmux display-message -p "#{pane_id}". The backend knows which context Claude Code is running in and declares it at initialization.
Operations
// Spawn a teammate — argv[], never a shell string
{"id":"2","method":"spawn_agent","params":{
"command": ["claude","--agent-id","researcher@my-team","--parent-session-id","abc123"],
"cwd": "/project",
"env": {"CLAUDECODE":"1"},
"metadata": {"name":"researcher","color":"blue","role":"teammate"}
}}
→ {"id":"2","result":{"context_id":"ctx_1"}}
// Write to stdin
{"id":"3","method":"write","params":{"context_id":"ctx_1","data":"<base64>"}}
→ {"id":"3","result":{}}
// Read scrollback (optional capability)
{"id":"4","method":"capture","params":{"context_id":"ctx_1","lines":200}}
→ {"id":"4","result":{"text":"..."}}
// Terminate
{"id":"5","method":"kill","params":{"context_id":"ctx_1"}}
→ {"id":"5","result":{}}
// List live contexts
{"id":"6","method":"list","params":{}}
→ {"id":"6","result":{"contexts":["ctx_0","ctx_1"]}}
Push Events (Backend → Claude Code, unsolicited)
// Essential — pushed when any context exits
{"method":"context_exited","params":{"context_id":"ctx_1","exit_code":0}}
// Optional capability — stream output if declared in initialize
{"method":"context_output","params":{"context_id":"ctx_1","data":"<base64>"}}
---
Why This Benefits Anthropic Directly
It fixes #23615. The race condition exists because split-window and send-keys are separate stateless subprocesses. A persistent backend process serializes all spawn_agent calls naturally. No sleep needed.
It fixes #23572. Silent fallback happens because backend detection is unreliable inference from env vars and PATH. Explicit registration via CLAUDE_PANE_BACKEND makes failure loud and diagnosable.
It unblocks KILD, #24189, #24122, and #23574 simultaneously. KILD, Ghostty, Zellij, and WezTerm each get a clean path to native integration by implementing this protocol. You write the interface once; the ecosystem implements it.
It makes your own codebase cleaner. The tmux integration in Claude Code is currently responsible for pane IDs, split geometry, session names, window indices, border styling, and layout management. None of that belongs in the coordinator. Claude Code's agent teams logic becomes purely about orchestration.
It enables remote deployments. The socket transport variant (CLAUDE_PANE_BACKEND_SOCKET) allows the backend to run on a different machine — running agents on a remote build server while monitoring from a laptop becomes trivially possible.
---
What I'm Offering
KILD's daemon already implements everything a backend needs on the server side. The shim already implements the client translation layer. Rewriting the shim to speak this protocol instead of impersonating tmux is a small, well-scoped change on our end the moment Claude Code supports it. KILD can serve as the reference implementation for this protocol — working, tested, and deployed.
I'm happy to:
- Collaborate on the protocol spec
- Implement the KILD backend and document the implementation as a reference
- Test against Claude Code builds before release
- Review the Claude Code-side integration if that's useful
The goal is a protocol stable enough that KILD, Ghostty, WezTerm, and Zellij can implement it independently and have it just work.
---
Summary
The five open issues linked above are not isolated requests. They share a root cause: tmux's CLI is being used as a pane management interface it was never designed to be. A small, well-designed protocol eliminates all of them and gives Claude Code's agent teams a proper extension point for any tool — terminal emulator, multiplexer, or purpose-built agent runner — that wants to participate natively.
I've done the work to understand what the minimal interface actually is. Happy to go deeper on any part of the design.
8 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Reference Implementation: KILD daemon backend
I've shipped a working implementation of this protocol on the KILD side. The daemon now handles both protocols on the same Unix socket path, routing on the first line received:
PR: Wirasm/kild#515 — feat(daemon): implement CustomPaneBackend JSON-RPC protocol (Phase 1)
The relevant module is
crates/kild-daemon/src/pane_backend/:| File | Contents |
|------|----------|
|
protocol.rs| Wire types:PaneBackendRequest/Response/Event, all params structs ||
context.rs|ContextMap— mapsctx_0/ctx_1/…↔ internal session IDs ||
handler.rs| Async connection loop, full method dispatch, push event tasks |This implements the full method set (
spawn_agent,write,capture,kill,list) plus both push events (context_outputas base64 PTY stream,context_exitedwith exit code). Runs in parallel with the existing tmux shim — Phase 1, no regressions.---
One open protocol question
The daemon needs to know which session the Claude Code leader is running in at
initializetime, so it can:self_context_idcorrectly (which session isctx_0)listand cascade destroyMy current implementation uses a
session_hintfield in theinitializeparams:KILD injects
CLAUDE_PANE_BACKEND_SESSION_ID=<session_id>into the daemon PTY environment. The proposal is that Claude Code forward this env var back assession_hintininitialize.params.Concrete ask: would you be open to including
session_hint(or an equivalent field likeinitiator_id) in theinitializeparams, populated fromCLAUDE_PANE_BACKEND_SESSION_IDif set? This one field is what allows a pre-running socket server to correlate a new backend connection with the correct running session.---
Happy to iterate on any aspect of the spec. The implementation is tested and the socket routing is live.
This resonates. We run 9 agents across Claude, Gemini, Kimi, and GPT on the same Mac mini and the tmux dependency became a bottleneck fast - not because tmux is bad, but because our agents are not all Claude Code instances, so the built-in agent teams model does not apply.
We ended up with a similar decoupling: a webhook relay accepts tasks from any source, a thin runner injects them into the right tmux pane (or could be any PTY), and an append-only log captures receipts. The pane management is just a thin shell wrapper that could be swapped for KILD or Zellij without changing the coordination layer.
Your CustomPaneBackend spec is the right abstraction. The current tight coupling to tmux means anyone running mixed-model fleets or non-interactive deployments has to reinvent the plumbing from scratch.
Built a zellij implementation of this from the other side — a tmux shim that translates all ~20 commands Claude Code issues into
zellij actioncalls: zellij-claude-teamsThe experience confirms everything in this proposal. The real interface is ~5 operations, the rest is tmux plumbing. A clean protocol like this would make the shim unnecessary and give all multiplexers a proper integration path.
+1 for
spawn_agenttakingargv[]directly — the currentsend-keysapproach requires escaping through shell, FIFO, and eval just to preserve argument boundaries.It would be nice as stuff like this could be added to CC using plugins. So the user could just install a plugin for their specific setup
we run 5+ parallel claude agents daily and the tmux dependency is the single biggest portability bottleneck. swapping to a protocol-based backend would unblock a lot. the KILD approach of handling both protocols on the same unix socket is elegant - you don't force users to migrate, just add a second path. the practical win for us would be running agents inside native Terminal.app or iTerm2 without the tmux intermediate layer adding latency to every PTY operation
Our current tmux-based agent orchestration that would benefit from a CustomPaneBackend: https://github.com/m13v/tmux-background-agents/blob/main/SKILL.md
The tmux session management, health checks, and cleanup logic is all tightly coupled to tmux-specific commands right now. A protocol-based backend would let us swap the transport layer without rewriting the orchestration logic.
Supporting evidence: 8 agents in tmux, zero tmux CLI coupling for communication
I agree with the core thesis — Claude Code's agent orchestration should be purely about orchestration, not pane geometry and
send-keysmechanics. I've been running exactly this separation in production.h-cli (natural language infrastructure management) was built by one operator coordinating 8 parallel Claude instances in named tmux panes, communicating via Redis pub/sub + git branches. No
send-keysfor messaging. Nocapture-panefor reading output. The entire codebase — 9 Docker services, 44 security hardening items, two network topologies, an AI firewall, vector memory, monitoring — was built through this process.The protocol
"read": falsebecause they never polled.REPLY.md, signals done via Redis. No shared mutable state, no file stomping.ANNOUNCEMENT.mdper agent per round — scoped task specs pushed to each agent's branch.Why this supports your proposal
This is consistent with your "seven operations" finding. tmux is used only as a viewport — pane creation and layout. The orchestrator doesn't need tmux's full surface area. It needs: create a pane, run a process, know when it's done, kill it. Everything else — task dispatch, status, results — belongs in a proper messaging layer.
The CLAUDE_PANE_BACKEND approach you're proposing would formalize what I've wired between these three layers (tmux viewport + Redis messaging + git persistence). A backend protocol that exposes lifecycle events (started, done, failed, aborted) is the right abstraction — it's what I'm already doing through Redis, but baked into the tool itself.
Full protocol documentation: H-CLI-DEVELOPMENT-EXPLAINED.md