A tmux-backed direct-send channel between CLI agents — across panes, sessions, and hosts.
agent-mesh lets Claude Code, Codex, your favorite REPL, or any other long-running CLI tool running inside a tmux pane talk to its peers — locally and across machines — without a daemon, a broker, or a browser. Each agent is auto-registered by tmux hooks, lives at a stable per-window address (hub-build-agent-x, worker-1-claude-foo, …), and is reachable through a single command:
notify-pane worker-1-claude-foo "tests green; pushing to main"
notify-pane worker-1-claude-foo --file /tmp/build-log.txtIt scales from "two Claudes on my laptop" to "six agents across three boxes" without changing the user experience.
Running multi-agent workflows already works the moment you give each agent its own tmux pane — you just can't talk between them. The usual workarounds are heavy:
| Workaround | Cost |
|---|---|
| Shared chat server / WebSocket hub | A service to run, secure, restart |
File-drop polling (tail -f mailbox) |
Latency, missed messages on restart, no cross-host story |
| Manual copy-paste between tmux panes | Tedious, error-prone, no automation |
| Custom protocols on top of SSH | Reinvented |
agent-mesh uses what's already on your boxes:
- tmux to find and address every pane
- SSH for cross-host transport
- systemd-user / cron for a 5-minute safety-net resync
- A small TOML address book that's kept in sync automatically
Total stack: ~400 lines of bash + a tmux conf snippet + 2 systemd unit files.
Direct delivery
send-keys lands the message exactly where the agent reads input. No polling, no broker; the recipient sees the message arrive in real time, prefixed with a sender tag so it can tell a peer ping from a human typing.
Auto-discovery Spin up a new tmux window or session. Within seconds it's in the registry on every host in the mesh, addressable by name. Kill the window — gone from the registry. No manual bookkeeping.
Per-window precision
A tmux session with 4 windows running 4 different Claude tasks is 4 distinct addresses (hub-dev-build-agent, hub-dev-test-agent, …), not one. No "I hope KanttBan is the active window."
File drops, not just text
notify-pane peer --file ./diff.patch ships the file to the recipient's ~/.agent-inbox/<sender>/<basename> over scp and posts a one-line notification. The agent on the other side reads the file from its own filesystem — no chunking the payload through the input box.
Mid-prompt safety
Before delivery, the wrapper takes a capture-pane of the recipient. If the last line looks like a half-typed prompt, the send is refused with exit code 3 (overridable with FORCE_SEND=1).
Mesh-replicated registry
Every host carries the same ~/.agents/registry.toml. Assembled symmetrically — each host writes its own slice (local.toml); a 5-minute systemd timer or the tmux hooks regenerate the merged view across the peers. There's no master; add a fourth host by editing one config file on each peer.
Sender tag
Every delivered message starts with [from <agent-name> @ <ISO-ts>]. Receivers can distinguish peer messages from user input, route replies back, and audit who said what when.
-
Multi-agent development pipelines — one Claude in the KanttBan repo pings another Claude in HeliosDB-Nano: "schema changed at row 142, please verify the migration works." Both Claudes work in parallel without you switching panes.
-
Distributed build coordination — a build agent on a beefy build host finishes a release; pings the release-master agent on the public-edge host with
--file ./artifact.tar.gz. The release-master picks it up, signs, uploads. -
Operator → agent messaging from a script — cron job notices a deploy succeeded; runs
notify-pane prod-watcher "deploy ${tag} complete at $(date -u)". The on-call Claude sees it in its input stream. -
Cross-agent reviewers — multiple Codex / Claude / human reviewers, each in their own tmux window, exchange notes on the same diff via the mesh.
-
Long-running task hand-offs — agent A finishes Phase 1, drops a state file to agent B, sends a one-line "Phase 1 done, your turn at
~/.agent-inbox/.../state.json." Agent B picks up where A left off. -
Mixed-vendor agent pools — works with anything that reads from stdin in a tmux pane. Claude Code, Codex, your in-house REPL,
geminiCLI,ollama run,aider, an unattendedbashwaiting for a file path — samenotify-pane <addr> "..."interface.
┌─────────────────────────────────────────────────────────────┐
│ HOST A (alias: hub) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ tmux session "dev" │ │
│ │ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │ win 0 build │ │ win 1 test │ ← claude │ │
│ │ └────────────────┘ └────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ ~/.agents/local.toml (this host's blocks) │
│ ~/.agents/registry.toml (assembled view, read by notify) │
└─────────────────────────────────────────────────────────────┘
▲ ▲
│ rsync local.toml fragments │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ HOST B (worker-1) │ … │ HOST C (worker-2) │
│ same scripts + state │ │ same scripts + state │
└─────────────────────────┘ └─────────────────────────┘
notify-pane worker-1-foo "hi"
├─ lookup worker-1-foo → host=worker-1 target=foo:0
├─ ssh worker-1 'tmux send-keys -l "[from hub-dev-build @ <ts>] hi"'
└─ ssh worker-1 'tmux send-keys Enter'
Auto-registration is driven by tmux hooks:
set-hook -g session-created "run-shell -b '~/bin/agent-register-hook on #{session_name}'"
set-hook -g session-closed "run-shell -b '~/bin/agent-register-hook off #{hook_session_name}'"
set-hook -g window-linked "run-shell -b '~/bin/agent-register-hook on #{session_name}'"
set-hook -g window-unlinked "run-shell -b '~/bin/agent-register-hook on #{hook_session_name}'"
set-hook -g window-renamed "run-shell -b '~/bin/agent-register-hook on #{session_name}'"A systemd user timer fires agent-discover every 5 minutes as a safety net for missed hooks / network blips.
- bash 4+, tmux 3.2+, rsync, ssh (anything from the last decade)
- SSH set up so
ssh <peer-alias>works without prompts between every pair of hosts in the mesh (bidirectional). Distinct keypairs per host strongly recommended. - (Optional) systemd-user for the 5-minute resync timer; cron works on macOS / non-systemd hosts.
On every host in the mesh:
git clone https://github.com/danimoya/agent-mesh ~/agent-mesh
cd ~/agent-mesh
./install.shinstall.sh is idempotent — re-run after git pull to roll out changes.
# Edit on one host, then replicate to every peer.
$EDITOR ~/.agents/peers.conf # one SSH alias per line
echo hub > ~/.agents/self.conf # this host's alias from peers.conf
# Push peers.conf to every other host
for p in $(grep -v '^#' ~/.agents/peers.conf | grep -v "$(cat ~/.agents/self.conf)"); do
scp ~/.agents/peers.conf "$p:~/.agents/peers.conf"
done
# Trigger first sync
~/scripts/sync-agent-registry.sh
notify-pane --list # should show every window across every hostnotify-pane <agent> "<body>"notify-pane <agent> --file ./report.md
notify-pane <agent> --file ./report.md "section 3 needs your review"notify-pane --list # every registered address, mesh-wide
notify-pane --whoami # this shell's sender idThe hook fills role/scope with placeholders. Make them meaningful:
tmux set-option -w -t dev:0 @agent-role "Test runner — Jest + Playwright"
tmux set-option -w -t dev:0 @agent-scope "Watches ./src for changes, runs full suite, reports red"
~/bin/agent-register-hook on dev # re-register dev session's windowsIf a window doesn't have -w options, it inherits the session-level @agent-role/@agent-scope (set without -w). Useful: tag the whole session "build infrastructure," then specialize per window.
Most of your panes will be busy with the agent itself; there's no shell to drop into. Use tmux run-shell -t <session>:<window>:
tmux run-shell -t dev:1 "AGENT_NAME='hub-dev-tests' ~/bin/notify-pane hub-dev-build 'tests green'"run-shell -b runs the command in a backgrounded shell with the window's tmux context — no need to interrupt the agent in the foreground.
| code | meaning |
|---|---|
| 0 | delivered |
| 1 | usage / missing args / unreadable --file |
| 2 | unknown agent (not in ~/.agents/registry.toml) |
| 3 | recipient looks mid-prompt; refused (override with FORCE_SEND=1) |
| 4 | ssh / tmux failure on the wire |
~/bin/notify-pane # the wrapper
~/bin/agent-register-hook # tmux hook handler (on/off <session>)
~/bin/agent-discover # one-shot session+window sweep
~/scripts/sync-agent-registry.sh # peer-fragment assembly + push
~/.agents/peers.conf # mesh membership (you edit)
~/.agents/self.conf # this host's alias (you edit)
~/.agents/local.toml # this host's slice (auto)
~/.agents/registry.toml # assembled view (read by notify)
~/.agent-inbox/<sender>/<file> # files dropped to this host
~/.config/systemd/user/agent-resync.timer # 5-min safety net (optional)
~/.tmux.conf # appended with the five hooks
- Splits (multiple panes inside one window) are NOT individually addressed. If you split, only the window-active pane sees
send-keys. Use separate windows per agent. |in window names breaks the parser (it's the format separator). Use letters/digits/-/_.- Renaming a window changes its slug. Anyone holding the old address misses the next message. Reconcile with
notify-pane --listafter renames. - The sender tag is informational, not authenticated — SSH is what gates who can deliver. If you give an untrusted user SSH to a host, they can
notify-paneas anyone. registry.tomlis replicated, so it's only as fresh as the last sync. The 5-min timer + hook-driven updates usually keep it within seconds of live; on a network split it can stale until reachability returns.- No reply routing at the protocol level. Receivers parse the sender tag if they want to reply (just
notify-pane <sender-slug> "...").
If you're building agent-mesh into an Alpine-based container and SSH between peers fails preauth with invalid user <name> (despite the user being in /etc/passwd and the container running as them), it's a known interaction between musl libc and OpenSSH's privsep sandbox. Switch the base image to a glibc-based one (Debian / Ubuntu / Rocky / Fedora …) or add UsePAM yes to sshd_config. Full investigation, attempts, and the two-commit fix in #1.
Real agent-mesh deployments on the canonical distros are not affected; this only bites if you containerize on Alpine.
- First-class pane addressing (split-view support) —
<host>-<session>-<window>-<pane-idx> - Optional encrypted body via age / gpg (for shared-host setups)
-
--reply-to <addr>convenience flag that just rewrites the sender tag - Per-agent ACLs (allowlist of senders) read from
@agent-allowtmux option - Cron-mode installer for macOS / non-systemd hosts
Apache 2.0. See LICENSE.