Let multiple Claude Code sessions talk to each other.
One session writes straight into another's prompt over tmux, and every message lands in a shared conversation log any session can review.
You run one Claude Code session to write code and another to review the
PR β and you keep playing carrier pigeon between them. session-bridge removes
the pigeon. Each session lives in its own tmux pane under a role, and
they message each other directly.
The canonical setup is a 3-session loop:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β gestor Β· talks to you, orchestrates the workflow β
βββββββββββββββββ¬ββββββββββββββββββββββββββ¬βββββββββββββββββ
send β β send
βΌ βΌ
ββββββββββββββββ ββββββββββββββββββββ
β dev β ββββββββΆ β reviewer β
β writes code β send β reviews the PR β
ββββββββββββββββ ββββββββ ββββββββββββββββββββ
send
gestor= manager. Rename the roles to whatever you like β they are just strings.
Two transports, on purpose:
| Transport | What | Needs polling? |
|---|---|---|
| PUSH | send injects text into the peer's tmux pane (set-buffer β bracketed-paste β Enter). The peer receives it as a brand-new user turn. Claude Code even queues it if the peer is mid-task. |
No β it's pushed. |
| PULL | read_peer captures what's currently on a peer's screen. Used to watch a session (e.g. the manager noticing the dev is asking the user something). |
Yes β pair with the native /loop skill. |
Every message is also logged twice, so nothing is lost and any session can catch up:
conversation.jsonlβ the single ordered cross-session thread.mailbox/<role>.jsonlβ a per-role inbox (audit + fallback).
Bracketed paste (paste-buffer -p) means multi-line messages, quotes and
whitespace survive intact β the TUI won't treat embedded newlines as submits.
State lives under ~/.claude/session-bridge/, so it is shared across every
Claude Code session on the machine and survives /clear.
~/.claude/session-bridge/
βββ registry.json # role β tmux pane mapping
βββ prompts.json # role β persistent system prompt (restored on /clear)
βββ conversation.jsonl # global ordered thread
βββ mailbox/
βββ gestor.jsonl
βββ dev.jsonl
βββ reviewer.jsonl
Requires Bun and tmux, and Claude Code launched inside a tmux pane.
git clone git@github.com:c2developers/session-bridge-mcp.git
cd session-bridge-mcp
bun installRegister the server with Claude Code at user scope (available in every project):
claude mcp add session-bridge -s user -- bun /abs/path/to/session-bridge-mcp/src/index.tsOr drop it into a project's .mcp.json (see .mcp.example.json):
{
"mcpServers": {
"session-bridge": {
"command": "bun",
"args": ["/abs/path/to/session-bridge-mcp/src/index.ts"]
}
}
}Configure the state directory with SESSION_BRIDGE_DIR if you don't want the
default ~/.claude/session-bridge/.
# Three tmux sessions, each will run its own Claude Code
tmux new-session -d -s gestor
tmux new-session -d -s dev
tmux new-session -d -s reviewer
# In each one, launch `claude` and give it its role as the first message:
# gestor β "registra esta sesiΓ³n como 'gestor' con session-bridge"
# dev β "registra esta sesiΓ³n como 'dev' con session-bridge"
# reviewer β "registra esta sesiΓ³n como 'reviewer' con session-bridge"Each session calls register(role) once β it auto-detects its own pane from
$TMUX_PANE. After that they can reach each other by role name.
Bootstrap trick: an already-registered session can register the others for you by injecting the instruction into their panes (the manager does exactly this). It just needs their pane ids from
tmux list-panes -a.
| Tool | Signature | Purpose |
|---|---|---|
register |
(role, pane?) |
Register the current session under a role. Auto-detects the tmux pane from $TMUX_PANE; pass pane to override. Call once per session. |
list_peers |
() |
All registered roles, their panes, cwd and last-seen time. |
send |
(to, from, text, submit?, tag?) |
Push a message into a peer's prompt. submit=false types it without sending; tag=false drops the [from:β¦] prefix. Also logs to the thread + mailbox. |
read_peer |
(role, lines?) |
Capture the peer's visible screen (default 50 lines of scrollback). |
detect_question |
(role, lines?) |
Classify the peer's screen β busy / waiting_select / waiting_text / idle / unknown β and parse any open menu's options + heading. Lets the manager notice when a peer needs an answer. |
respond |
(role, keys) |
Send raw control keys to drive an interactive menu/permission prompt, e.g. ['1'], ['Down','Down','Enter'], ['Escape']. For prose, use send. |
history |
(limit?, role?) |
Read the shared conversation, newest last. Filter by a role involved. |
inbox |
(role, limit?) |
Read a role's mailbox β a poll/fallback if a pushed message was missed. |
set_prompt |
(role, prompt) |
Save a persistent "system prompt" / briefing for a role. Survives /clear and restarts; auto-reinjected by the SessionStart hook. |
get_prompt |
(role) |
Read back a role's saved prompt ({found:false} if none). |
list_prompts |
() |
All saved role prompts. |
delete_prompt |
(role) |
Forget a role's saved prompt so it stops being reinjected. |
Receiving messages is push, so no loop is needed for that. A loop is only for
watching a peer's screen and noticing when it's stuck on a question
(plan-mode approval, a clarifying menu, a permission prompt). detect_question
classifies the screen so the loop doesn't bother a busy peer; respond then
drives the menu.
With Claude Code's native /loop skill, run this in the manager:
/loop 30s para 'dev' y 'reviewer' llama detect_question. Si state=='waiting_select'
o 'waiting_text', enséñame la pregunta y las opciones y decide la respuesta según el
flujo: contΓ©stala con respond(role, keys) para menΓΊs (p.ej. ['1','Enter']) o con
send(...) para texto. Si la decisiΓ³n es del usuario, pΓ‘rate y pregΓΊntame. Si state
=='busy' o 'idle', no hagas nada.
Menus vs prose. A menu/permission prompt is not a text turn β typing into it does nothing useful. Use
respondwith key tokens for those, andsendonly when the peer is genuinely waiting for free text.
Slash commands are just text injected into the prompt:
send(to:'dev', from:'gestor', text:'/clear')
/clear wipes a session's context β including who it is and how it should
behave. Re-typing each role's briefing every time is the pigeon problem all over
again. So a role's "system prompt" can be saved once and restored automatically.
-
Save a briefing per role (usually the
gestorsets them up):set_prompt(role:'dev', prompt:'You are the dev. Work on the assigned issue, keep commits small, open a PR and ping the reviewer. Never merge yourself.')It's written to
prompts.jsonand survives/clear, restarts and reboots. -
Restore happens on its own. A
SessionStarthook fires on/clear(and on startup/resume), reads the session's$TMUX_PANE, reverse-looks-up its role inregistry.json, and reinjects that role's saved prompt asadditionalContext. The session wakes up already knowing its identity β no re-typing, no manualget_prompt.
Wire the hook once in ~/.claude/settings.json (user scope, so it fires in
every session regardless of project):
{
"hooks": {
"SessionStart": [
{
"matcher": "clear|startup|resume",
"hooks": [
{
"type": "command",
"command": "/abs/path/to/bun run /abs/path/to/session-bridge-mcp/src/session-start-hook.ts",
"timeout": 15
}
]
}
]
}
}The hook is a silent no-op when it can't help: outside tmux, on an
unregistered pane, or when no prompt is saved for the role β so it never blocks a
session from starting. If a pane is bound to several roles (e.g. dev and
developer), the most recently registered role wins.
The prompt is restored as context, not as a literal model system prompt β Claude Code has no API to overwrite its own system prompt mid-session. In practice an injected role briefing at session start behaves the same way.
- You β gestor: "implement issue #14".
- gestor β dev:
send(to:'dev', from:'gestor', text:'Take issue #14: <summary>. Branch <domain>/14-slug. Ping the reviewer with the PR URL when ready.') - dev codes. If it needs a user decision, the gestor (watching via
/loop+read_peer('dev')) sees it and answers β or escalates to you. - dev β reviewer:
send(to:'reviewer', from:'dev', text:'PR #21 ready: <url>. Please review.') - reviewer reviews β
send(to:'dev', β¦)with changes, orsend(to:'gestor', β¦)with "approved". - dev fixes β back to step 4. reviewer confirms β merges, closes issue/PR.
- reviewer β gestor: "merged & closed". The gestor sends
/clearto dev and kicks off the next task.
| Symptom | Fix |
|---|---|
register fails: $TMUX_PANE is unset |
You launched Claude Code outside tmux. Start it inside tmux, or pass pane (tmux display-message -p '#{pane_id}'). |
Message logged but delivered:false |
The target pane died. Re-register that session (pane ids change when tmux restarts). The message is still in the mailbox. |
| Peer didn't react to a pushed message | It was busy; Claude Code queues it. Or check its inbox(role). |
| MCP added mid-session but tools missing | Restart Claude Code β user-scope servers load at startup. |
Prompt not restored after /clear |
The session must have been registered in that pane and have a saved prompt (get_prompt(role)). The hook only fires if wired in settings.json; check it with /hooks or run the script manually piping {"source":"clear"} with TMUX_PANE set. |
send injects text into another Claude Code prompt and read_peer reads its
screen. Anyone who can write to ~/.claude/session-bridge/registry.json, or
drive your tmux server, can inject prompts into your sessions. Run this only
on a trusted local machine. There is no network listener β all coordination
is local files + the local tmux socket.
MIT Β© c2developers