Proposal: CustomPaneBackend protocol — decouple agent teams from tmux CLI to unblock Ghostty, WezTerm, Zellij, KILD, and remote deployments

Status Open
Maintainer reply None cached
Activity 8 comments · opened Feb 18, 2026

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-keys gets garbled (mmcd instead of cd). Root cause: tmux split-window + send-keys are 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.

View original on GitHub ↗

8 Comments

github-actions[bot] · 6 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/23615
  2. https://github.com/anthropics/claude-code/issues/24189
  3. https://github.com/anthropics/claude-code/issues/23572

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

Wirasm · 6 months ago

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:

JSON with "method" key  →  CustomPaneBackend JSON-RPC handler
otherwise               →  existing KILD ClientMessage handler

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 — maps ctx_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_output as base64 PTY stream, context_exited with 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 initialize time, so it can:

  • Assign self_context_id correctly (which session is ctx_0)
  • Scope child sessions to the right leader for list and cascade destroy

My current implementation uses a session_hint field in the initialize params:

// Claude Code → backend
{
  "id": "1",
  "method": "initialize",
  "params": {
    "protocol_version": "1",
    "session_hint": "<value of CLAUDE_PANE_BACKEND_SESSION_ID env var>"
  }
}

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 as session_hint in initialize.params.

Concrete ask: would you be open to including session_hint (or an equivalent field like initiator_id) in the initialize params, populated from CLAUDE_PANE_BACKEND_SESSION_ID if 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.

ThinkOffApp · 6 months ago

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.

stanislc · 6 months ago

Built a zellij implementation of this from the other side — a tmux shim that translates all ~20 commands Claude Code issues into zellij action calls: zellij-claude-teams

The 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_agent taking argv[] directly — the current send-keys approach requires escaping through shell, FIFO, and eval just to preserve argument boundaries.

marciogranzotto · 5 months ago

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

m13v · 5 months ago

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

m13v · 5 months ago

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.

h-network · 4 months ago

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-keys mechanics. 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-keys for messaging. No capture-pane for 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.

h-cli-development:architect     ← coordinator
h-cli-development:orchestration
h-cli-development:interface
h-cli-development:core
h-cli-development:llm           ← one Claude instance per pane
h-cli-development:monitor
h-cli-development:hssh
h-cli-development:knowledge
h-cli-development:security
h-cli-development:redis         ← message bus

The protocol

  • Redis pub/sub as the message bus — architect publishes task notifications, agents subscribe and signal completion. Push-based, not polling. This directly avoids the mailbox bug in #23456 where 8/8 agents sat idle with "read": false because they never polled.
  • Git branches as artifact exchange — each agent works on its own branch, pushes code + a structured REPLY.md, signals done via Redis. No shared mutable state, no file stomping.
  • ANNOUNCEMENT.md per agent per round — scoped task specs pushed to each agent's branch.
  • Atomic rounds — architect declares a round, all agents execute in parallel, all signal done, then architect merges. No partial merges. Main stays clean. Communication artifacts are stripped before reaching main.

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