mcp server
A2AChannel
Desktop workspace for coordinating multiple Claude Code agents with typed handoffs (accept / decline / cancel / expire), durable SQLite ledger, and a human-in-the-loop UI.
Set up
Runs on your computer. Pick your agent and operating system.
This project does not publish an install method we can turn into a config. Follow the README instead.
README
synced Oct 21, 2026A2AChannel
Coordination primitives for parallel Claude Code sessions — messages, handoffs, interrupts, permission relay.
Why
Every Claude Code session is an island. Sub-agents spawned via the Task tool run in their own fresh context — the parent only sees the final return value, not the sub-agent's intermediate reasoning. Separate Claude Code sessions are even more isolated: no parent, no shared anything. A2AChannel gives those independent sessions a shared room, structured handoffs, and interrupts, so they can coordinate without you shuttling context between windows. Agents launch from the app itself — bundled terminal pane, bundled tmux, no .mcp.json editing, no separate windows to juggle.
The coordination is a protocol, not a chat app. Every message is a typed primitive with durable state, logged to an append-only SQLite ledger. You're in the room too. Handoffs and interrupts persist across restarts; pending work replays to the right agent on reconnect.
Layout
Top to bottom:
- Titlebar — icon buttons (Reveal MCP-configs in Finder, Edit global MCP servers, Settings/config.yml, Reload).
- Header — room switcher dropdown +
+ agentspawn button +Pause/Resumeinterrupt fanout. - Nutshell strip — per-room project summary (one paragraph). Click Edit to propose an update via the handoff primitive; broadcasts to every peer on accept.
- Room persistence row — toggle (orange when on) + footprint + Archive & reset button. Visible only when a concrete room is selected. See Persistent transcripts below.
- Chat panel (left) and terminal pane (right) split by a draggable splitter. Chat has a mention popover (
@), slash picker (/), emoji, attachments. The composer at the bottom has a focus-revealed hint row. - Terminal pane — pinned shell tab (your
$SHELL -il, cross-room) plus one tab per spawned agent. The active tab pulses orange when an agent needs attention. - Footer — connection status pill (left) + usage pill (right). The usage pill shows compact
Session 8% · resets 3h 51m | Weekly 80%populated passively from claude's banner — no polling, no API calls. Ticks down once a minute; dims after 15 min stale; turns orange at >75%, red at >90%. Click a chip to copy the reset delta.
Rooms
Every agent is registered in exactly one room; the human is a super-user visible in every room. target: "all" broadcasts, same-room agent-to-agent handoffs, and per-room nutshells all fan out within a room — no cross-project context pollution. Explicit peer targeting (to: "<name>") still crosses rooms when you want it to.
The spawn modal's Room field defaults to the git-root basename of the chosen cwd (walks up looking for .git; falls back to cwd basename). A datalist suggests rooms already in use. External-spawn agents without CHATBRIDGE_ROOM fall back to default.
A Pause / Resume pair of buttons next to the room switcher fans out canned interrupts to every agent in the currently-selected room — cooperative stop-and-re-read, not preemption.
Human affordances
Shell tab — pinned to the top of the terminal pane, cross-room, cross-project. A real tmux session running your $SHELL -il in $HOME; survives app restart with full scrollback, attachable from another terminal via the bundled tmux socket. Runs whatever you want — nvim, lazygit, fzf, docker, your dev server. Separate from agent tabs; claude belongs in an + agent tab, not here.
Four primitives
Messages — post, post_file
Free-text conversation. Send to you for the human, <agent-name> for a peer, or all to broadcast. post_file uploads a file from disk and delivers it to peers as [attachment: <absolute-path>]; 8 MiB cap, extension allowlist (jpg/jpeg/png/pdf/md by default).
| Tool | Required fields |
|---|---|
post | text, to |
post_file | path, optional to, optional caption |
Handoffs — send_handoff, accept_handoff, decline_handoff, cancel_handoff
Transfer a bounded unit of work to another participant. The recipient accepts or declines; declines require a reason so the sender can re-route. Handoffs carry a structured context payload (up to 1 MiB — diffs, contracts, file refs) and a TTL; expired handoffs transition automatically.
Decline-with-reason is the key mechanic: unlike chat, the sender gets a routable signal back, not silence.
| Tool | Required fields |
|---|---|
send_handoff | to, task, optional context, optional ttl_seconds (default 3600) |
accept_handoff | handoff_id, optional comment |
decline_handoff | handoff_id, reason |
cancel_handoff | handoff_id, optional reason |
Handoffs with task prefixed [nutshell] and context.patch set update the project's shared summary atomically on accept — one-line broadcast to every peer.
A nutshell is the project's shared running summary — one paragraph every agent sees at the top of the room. Any agent can propose an update by sending a handoff with task: "[nutshell] ..." and a context.patch string. On accept, the patch replaces the current summary and broadcasts to all peers.
Interrupts — send_interrupt, ack_interrupt
Soft preemption. Surfaces a red-bordered card stuck to the top of the recipient's chat until they acknowledge. Not a hard kill — the recipient keeps running — but a required ack means the signal can't be silently dropped. Reserve for genuine "stop and re-read this" moments.
| Tool | Required fields |
|---|---|
send_interrupt | to, text (≤500 chars) |
ack_interrupt | interrupt_id |
Permission relay — ack_permission
Claude Code's tool-use approvals (the Bash/Write/Edit prompts that pause the agent) are forwarded to the chat as sticky red cards pinned at the top of the window. Allow or Deny from anywhere in the app — no more hunting through xterm tabs to unblock an agent. The xterm dialog stays live as a fallback.
Any agent can ack any pending permission via ack_permission({ request_id, behavior }), so a dedicated reviewer agent can auto-approve routine tool calls. Requires Claude Code 2.1.81+, and for reviewer-style auto-ack, pre-allow the ack_permission MCP tool in the acking agent's /permissions.
Chat-first is the clean path — chatbridge relays the verdict upstream and Claude Code closes its own xterm dialog. Xterm-first leaves the chat card as a "ghost" because Claude Code doesn't notify the channel when the local dialog wins. Click the small × on the card to dismiss; the ledger records status="dismissed" (separate from allowed/denied, so the audit trail stays truthful — the hub never actually saw a verdict).
Slash commands from chat
Type / at the start of the composer to drive an agent's slash commands without leaving the chat. A picker opens listing every command available across the live agents in the currently-selected room — built-ins (/clear, /compact, /help, /cost, /model, /context, /usage, …) plus whatever lives under .claude/commands/ and .claude/skills/ for each agent's cwd, plus your personal ~/.claude/.... Each entry shows an N/M agents badge (how many of the room's live agents support it).
Sends require explicit targeting:
| Composer text | What happens |
|---|---|
/clear | refused — specify @agent or @all |
/context @builder | bytes typed into builder's xterm; response mirrored back to chat as a [a2a-capture] code block |
/clear @all | bytes typed into every live, non-busy agent in the room |
/openspec-propose ... @planner | bytes typed; - answer in chatbridge auto-appended so the agent posts its result back via mcp__chatbridge__post |
Two dispatch paths based on the command type:
- Panel commands (
/context,/usage,/cost,/memory,/agents,/skills,/help,/mcp,/model,/status,/permissions,/config,/release-notes,/doctor) go through a deterministic capture orchestrator that forces tmux geometry to 240×100 (avoiding narrow-width self-corruption), tees the panel render to a per-turn log, and posts the cleaned content back to chat under the agent's avatar. - Everything else (custom
.claude/commands/, MCP prompts, model-delegated work like/openspec-propose) takes a simple write-and-forget path.- answer in chatbridgeis auto-appended so the agent's response reaches chat viamcp__chatbridge__postinstead of getting stranded in the terminal. The directive is skipped if you already wrote it in your args.
Routing: /-prefixed messages bypass the channel entirely and write raw bytes to the agent's tmux PTY (via pty_write and pty_capture_turn Tauri commands). They do not become MCP notifications/claude/channel messages.
Guardrails:
- Slash mode is disabled when the room dropdown is on
All rooms— pick a concrete room first. Cross-room slash broadcasts are intentionally not possible. - Agents with a pending permission or interrupt are auto-skipped from
@allexpansion (the audit row notes the skip + reason). Claude's internal modal states (mid-stream, slash-picker open) are unobservable from outside the PTY; if a send lands in a bad state you'll see it in the xterm. /clearand/compacttargeting more than one agent prompt for confirmation (irreversible per-agent context wipe).external-state agents (claude sessions A2AChannel doesn't own a PTY for) are not selectable from the slash@-popover.
Each successful send produces a single system row in the chat log: human → /clear @all (planner, builder, reviewer, docs). The audit row is in-memory only — lost on hub restart. Persistent transcripts (see below) capture it to JSONL on disk if the room has them on.
Mode cycling (Shift+Tab)
Claude's three modes (Auto / Accept Edits / Plan / Normal) cycle on Shift+Tab. From chat:
- Press Shift+Tab in the composer → broadcasts to every live agent in the current room. Same room rules as slash.
- Type
Shift+Tab @agent+ Send → targets one agent. Variants accepted:shift+tab,shifttab,shift-tab. Case-insensitive.
After each send the audit row reports the actual mode each agent landed on (Plan, Accept Edits, Auto, Normal) by reading claude's prompt-frame footer via tmux capture-pane. No client-side guessing — drift-free even if you also press Shift+Tab in the visible terminal.
Quickstart
- Install:
brew tap mnw/a2achannel && brew trust mnw/a2achannel && brew install --cask a2achannel. Launch the app. - Click
+ agentin the header, enter a name, pick the harness (claudeorpi), the project directory, then Launch. A2AChannel spawns the chosen agent binary inside a bundled tmux session in an embedded terminal tab. No.mcp.jsonediting, no separate terminal. - Repeat for each agent. They register with the hub, appear in the roster pills, and can
post/send_handoffto each other and to you.
Harnesses
The hub is harness-agnostic — it speaks HTTP + SSE and has no idea what an agent is — so claude and pi agents share rooms, handoffs and interrupts freely.
claude(default) spawnsclaude --dangerously-load-development-channels server:chatbridge. That flag is mandatory: it's what makes thechatbridgeMCP channel deliverable. Without it, agents canpostbut never hear incoming messages. If you launchclaudefrom your own terminal instead, you add it yourself.pi(pi.dev) spawnspi --no-approve -e <bundled extension>. Pi has no MCP support by design, so the chatbridge ships as a pi extension (hub/channel/pi-extension.ts) that registers the same tools in-process and tails/agent-streaminto the agent's turn. Delivery is per-kind: interrupts arrive as a mid-turnsteer, handoffs asfollowUp, briefings and nutshell updates without waking the agent at all. Pi agents also inherit your~/.claude/skillsand~/.claude/commands.
The harness is captured at spawn and immutable for the session — changing it means killing the agent and respawning, same as the room.
Install
Homebrew (recommended)
brew tap mnw/a2achannel
brew trust mnw/a2achannel
brew install --cask a2achannel
brew trust is required: Homebrew refuses to load casks from third-party taps until you explicitly trust them. Without it the install fails with Refusing to load cask … from untrusted tap.
Apple Silicon macOS only. brew upgrade --cask a2achannel to update, brew uninstall --zap --cask a2achannel for a full wipe including ~/Library/Application Support/A2AChannel.
Ad-hoc signed — on first launch macOS may ask you to confirm via System Settings → Privacy & Security → Open Anyway.
From source
| Requirement | Install |
|---|---|
| macOS on Apple Silicon | — |
| Bun | curl -fsSL https://bun.sh/install | bash |
| Rust | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh |
| Xcode Command Line Tools | xcode-select --install |
git clone https://github.com/mnw/A2AChannel.git
cd A2AChannel
bun install
./scripts/install.sh
install.sh compiles the Bun sidecar, builds the Tauri shell, ad-hoc codesigns, copies to /Applications, and launches. Rebuilds on rerun (~60 s Rust incremental).
Contributors with push access: ./scripts/release.sh <version> bumps versions, tags, builds, uploads the GitHub release, and updates the Homebrew tap in one shot.
Running agents
Inside the app (default): + agent button → name + cwd → Launch. Terminal pane opens with an xterm tab for each agent. Slash commands, permission prompts, and interactive tools work there directly. The tab pulses orange when an agent needs your attention.
From your own terminal (classic): copy the MCP config (click the file-icon in the header), paste into your project's .mcp.json, then:
claude --dangerously-load-development-channels server:chatbridge
The agent appears in the roster the moment chatbridge registers; in the app's terminal pane it shows as an external-state tab (A2AChannel doesn't own its PTY).
Config
~/Library/Application Support/A2AChannel/config.yml (seeded with comments on first launch):
human_name: human
claude_path: ~/.claude/local/claude
anthropic_api_key: ""
attachments_dir: null
attachment_extensions:
- jpg
- jpeg
- png
- pdf
- md
theme: default # default | rose-pine-dawn | rose-pine-moon
font_scale: 1.0 # 0.85 – 1.25
fonts:
ui: "" # prepended to the built-in chain; empty = use defaults
mono: ""
editor: "" # e.g. `code`, `cursor`, `subl`, `open -a Cursor`
chat_history_limit: 1000 # in-memory chat ring-buffer cap, [10..100000]
Edit, click ↻ in the header to reload — hub restarts with the new values, no app relaunch.
claude_pathdefaults to Anthropic's installer location; override if yours lives elsewhere.anthropic_api_keyleft empty means claude uses its keychain OAuth (the usual case); set it for API-key auth without touching your shell.chat_history_limitis the in-memory ring-buffer cap. Hub restart wipes the chat log regardless of persistence — JSONL stays on disk as forensic record but is not replayed.
Global MCP servers at ~/Library/Application Support/A2AChannel/mcp.json (open via the connector-graph icon in the titlebar). Servers in this file are merged into every per-agent .mcp.json at spawn time; the chatbridge server name is reserved by A2AChannel and silently stripped from your config.
Architecture
┌─────────────────────────────────────────────────────────────┐
│ A2AChannel.app (Tauri 2 — native macOS) │
│ │
│ ┌─────────────────────┐ ┌───────────────────────┐ │
│ │ Webview │◄─SSE───┤ a2a-bin (hub mode) │ │
│ │ chat | term pane ├─POST──►│ Bun sidecar │ │
│ │ (xterm.js in pane) │ │ 127.0.0.1:<dynamic> │ │
│ └──────┬──────────────┘ │ SQLite ledger │ │
│ │ Tauri IPC └──┬───────────────────┘ │
│ ▼ │ │
│ ┌──────────────┐ │ │
│ │ pty.rs │ spawns & attaches│ │
│ │ portable-pty │──► bundled tmux ──┼──► claude (per agent) │
│ │ registry │ (shared sock) │ │ │
│ └──────────────┘ │ │ stdio │
│ │ ▼ │
│ │ a2a-bin (channel) │
└─────────────────────────────────────┼────────┬──────────────┘
│ │ SSE + POST
(agents from external terminals) │
┌──────────────────────────────┼───────┤
┌─────▼──────────┐ ┌────────────────▼───┐ ┌▼───────────────┐
│ a2a-bin │ │ a2a-bin │ │ a2a-bin │
│ (channel mode) │ │ (channel mode) │ │ (channel mode) │
│ agent=alice │ │ agent=bob (ext) │ │ agent=… │
└─────┬──────────┘ └───┬────────────────┘ └─┬──────────────┘
│ │ │
┌─────▼───────┐ ┌─────▼───────┐ ┌─────▼───────┐
│ Claude Code │ │ Claude Code │ │ Claude Code │
│ session │ │ session │ │ session │
└─────────────┘ └─────────────┘ └─────────────┘
a2a-bin (hub mode) — HTTP + SSE server on a dynamic loopback port. Owns the chat log, per-agent queues, attachments on disk, SQLite ledger for structured primitives (events + derived state, WAL mode). Bearer-token auth on all routes; read routes also accept ?token= for EventSource and <img>.
a2a-bin (channel mode) — MCP server, one per Claude Code session. Reads the discovery files at ~/Library/Application Support/A2AChannel/hub.{url,token}, tails /agent-stream, forwards messages into Claude's context as <channel> notifications, exposes the 8 coordination tools.
Webview — vanilla HTML/CSS/JS, no framework. main.js orchestrates SSE + chat + nutshell; each structured Kind (handoff, interrupt, permission) lives under ui/kinds/<kind>.js and self-registers with KindCardRenderer so main.js dispatches by prefix instead of branching per kind. terminal.js owns the PTY pane + xterm.js lifecycle. Fonts vendored locally (Inter, Fraunces, JetBrains Mono, CaskaydiaMono Nerd Font).
pty.rs — per-agent PTY registry. Spawns a tmux session via the bundled tmux binary on a shared socket, attaches via portable-pty, streams base64-encoded bytes to xterm.js over Tauri events. Raw PTY bridge — no tmux -C control mode, no send-keys for input forwarding.
Bundled tmux — static tmux 3.5a for aarch64-apple-darwin, built via scripts/build-tmux.sh, bundled in the app.
Full protocol schemas, endpoints, and state machines: docs/PROTOCOL.md.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| UI stuck on "connecting…" | Hub didn't start | Check ~/Library/Logs/A2AChannel/hub.log |
| Agent pill never appears | Missing --dangerously-load-development-channels flag | Restart claude with the flag — channel notifications silently drop without it |
| HTTP 401 in hub.log | Caller presented no/stale token | Click ↻ in the header to mint fresh discovery files |
HTTP 413 on /send / /upload / /handoffs | Body over limit (256 KiB / 8 MiB / 1 MiB) | Trim; move large context into a file reference |
| Agent says "permission denied" on attachment | Attachments folder outside agent's allowed dirs | Add to ~/.claude/settings.json permissions.additionalDirectories, or relaunch claude with --add-dir <folder> (before --dangerously-load-development-channels) |
| Agent posts but never receives | Same cause as "agent pill never appears" | — |
| "unidentified developer" dialog | Ad-hoc signing + Gatekeeper | xattr -dr com.apple.quarantine /Applications/A2AChannel.app, or right-click → Open once |
| Terminal tab blank after Launch | Claude's alt-screen buffer didn't flush | Click inside the xterm and press Enter, or drag the window edge to force SIGWINCH |
Multiple a2a-bin hubs listening | pkill a2achannel bypassed Tauri's cleanup, orphaning the old hub | Always use ./scripts/install.sh (has orphan-sweep); to recover, pgrep -fl a2a-bin, kill the hubs with A2A_MODE=hub, relaunch |
Limitations
- macOS ARM64 only. No Windows, Linux, or Intel Mac builds.
- Research-preview dependency. Requires
claude --dangerously-load-development-channels; theclaude/channelMCP capability shape may change upstream. - In-memory roster + chat log. Agent names, presence, and the live chat reset on hub/app restart. Handoffs/interrupts/nutshell persist (SQLite). Chat history can be persisted to disk per-room as JSONL (see Persistent transcripts below) — that's an offline forensic record, not a replay source.
Persistent transcripts (opt-in)
Off by default. The room-persistence row exposes a switch toggle (orange when on); flipping it on writes every subsequent chat entry for that room to ~/Library/Application Support/A2AChannel/transcripts/<basename>.jsonl (mode 0600, line-delimited JSON, v: 1 schema).
When the active file hits 10,000 lines it's renamed to <basename>.000001.jsonl and a fresh active file starts. Rotated chunks are never auto-deleted — they're archive.
~/Library/Application Support/A2AChannel/transcripts/
├── ab12cd34-auth_review.jsonl ← active, ≤ 10,000 lines
├── ab12cd34-auth_review.000001.jsonl ← rotated chunk (oldest)
└── ab12cd34-auth_review.000002.jsonl ← rotated chunk
Each line: {"v":1,"id":42,"from":"planner","to":"human","text":"...","ts":"...","room":"auth-review"}. Grep, cat, scp, jq — standard text tooling works.
The Archive & reset button rotates the active file to a new numbered chunk (non-destructive — past data is preserved on disk) and resets the chat window. On the next reconnect, agents see fresh context. To genuinely wipe everything, remove the chunks manually with rm.
Hub restart wipes the in-memory chat log; the JSONL is not replayed back into the live chat. This is intentional — closing all your agents and restarting the hub gives a clean slate, regardless of whether transcripts are on. The on-disk JSONL stays untouched for grep/cat/jq review.
Caveats
- Anything pasted into chat (tokens, API keys, debug dumps) ends up on disk in plain text. There is no auto-redaction; opting in means accepting that trade.
- Rotated chunks accumulate without bounds; a busy room over months can reach hundreds of MB. Move chunks out of the data dir periodically if you want capped history without losing data.
License
MIT. See LICENSE.