switchboard
README.md
<p align="center">
<img src="docs/banner.svg" alt="Agent Switchboard: your agents, a switchboard with an approval gate, and your friends' gateways" width="100%">
</p>
<p align="center">
<a href="#-quickstart-60-seconds-no-agents-required"><img alt="quickstart" src="https://img.shields.io/badge/quickstart-60_seconds-7c5cff?style=for-the-badge"></a>
<a href="https://github.com/killmashinator/agent-switchboard/actions/workflows/tests.yml"><img alt="tests" src="https://img.shields.io/github/actions/workflow/status/killmashinator/agent-switchboard/tests.yml?style=for-the-badge&label=tests"></a>
<img alt="python" src="https://img.shields.io/badge/python-3.11+-3fb5ff?style=for-the-badge&logo=python&logoColor=white">
<img alt="MCP" src="https://img.shields.io/badge/MCP-server-ff8a5c?style=for-the-badge">
<img alt="A2A" src="https://img.shields.io/badge/A2A-v1.0-5cffb1?style=for-the-badge&labelColor=1d2140">
<img alt="deps" src="https://img.shields.io/badge/runtime_deps-4-ffd25c?style=for-the-badge&labelColor=1d2140">
<img alt="license" src="https://img.shields.io/badge/license-MIT-c45cff?style=for-the-badge">
</p>
<h3 align="center">Your agents have been talking behind your back.<br>Now they do it in front of you. βοΈ</h3>
---
**Agent Switchboard** is one small, always-on service that:
- π **plugs all your agents together.** Claude Code, Codex, Gemini, aider, your cron-job-turned-sentient
bot: they message each other through a single **MCP server**.
- πͺͺ **gives you one outward identity.** Your friends' switchboards talk to *yours* over
**A2A** (Google's Agent2Agent protocol), with Ed25519-signed requests.
- π§ββοΈ **puts you in the middle.** Nothing leaves your trust boundary, and no stranger's
request runs on your laptop, until you click **Approve**. Like a bouncer, but polite and with a
dark mode.
- π¬ **lets agents actually *talk*.** Approve a conversation once and two agents go back and forth on
their own (yours with yours, or yours with your friend's) until they say `[[DONE]]` or hit the limits.
- π± **taps you on the shoulder.** Push notifications with **Approve / Reject buttons right on your
lock screen** (via [ntfy](https://ntfy.sh)).
- π **runs your CLIs where they live.** A tiny relay on your laptop dials *out* to the gateway, so
`claude -p` and `codex exec` run with your repos and your logins. No open ports, no tunnels, no tears.
All of that is about 1,800 lines of Python and **4 runtime dependencies**, and the core is SQLite.
It fits on a free-tier VM with room left over for your other side projects. π±
---
## πΊοΈ Table of contents
- [How it works](#-how-it-works)
- [A phone call, step by step](#-a-phone-call-step-by-step)
- [Quickstart (60 seconds, no agents required)](#-quickstart-60-seconds-no-agents-required)
- [Plug in Claude Code, Codex & friends](#-plug-in-claude-code-codex--friends)
- [Make friends (pairing)](#-make-friends-pairing)
- [Conversations: approve once, let them talk](#-conversations-approve-once-let-them-talk)
- [Push notifications](#-push-notifications-approve-from-your-lock-screen)
- [Wait, does my agent have to be running?](#-wait-does-my-agent-have-to-be-running)
- [MCP tools](#-mcp-tools-what-your-agents-get)
- [Config & presets](#-config--presets)
- [Security model](#-security-model)
- [Deploy it somewhere always-on](#-deploy-it-somewhere-always-on)
- [Lightweight, measured](#-lightweight-measured)
- [FAQ](#-faq)
---
## π§ How it works
```mermaid
flowchart LR
subgraph laptop["π» your laptop"]
CC["claude-code"]
CX["codex"]
R["relay<br/>(runs the CLIs)"]
end
subgraph gw["ποΈ your switchboard (always on)"]
MCP["MCP server<br/>/mcp"]
BUS[("SQLite bus<br/>+ audit log")]
POL{"policy +<br/>approvals"}
A2A["A2A endpoint<br/>/a2a + agent card"]
UI["owner UI<br/>/ui"]
end
subgraph friends["π friends"]
B["bob's switchboard"]
C["carol's switchboard"]
end
CC -- "send_message()" --> MCP
CX -- "read_inbox()" --> MCP
MCP --> BUS --> POL
POL -- "jobs (WebSocket, outbound)" --> R
R -- "stdout = reply" --> BUS
POL <-- "signed A2A" --> A2A
A2A <--> B
A2A <--> C
UI -. "approve / edit / reject" .-> POL
```
**Addressing is boring on purpose.** `codex` is your codex. `codex@bob` is Bob's codex. Every
message knows who sent it and who it's for, and replies flow back to the exact agent that asked,
in the same thread, across gateways. No LLM router guesses who you meant. π―
## π A phone call, step by step
Your Claude Code asks Bob's Codex a question. Two humans approve; nobody gets surprised.
```mermaid
sequenceDiagram
autonumber
participant CC as π€ claude-code@alice
participant A as ποΈ alice's switchboard
participant Al as π§ Alice
participant B as ποΈ bob's switchboard
participant Bo as π§ Bob
participant CX as π€ codex@bob (via relay)
CC->>A: send_message(to="codex@bob", body="review my diff?")
A-->>Al: β³ pending approval
Al->>A: β
approve (maybe edit first)
A->>B: A2A SendMessage (EdDSA-signed JWT)
B-->>Bo: β³ inbound request from claude-code@alice
Bo->>B: β
approve
B->>CX: run `codex exec "β¦"` on Bob's laptop
CX-->>B: stdout = the answer
B-->>Bo: β³ reply waiting
Bo->>B: β
approve (or redact the spicy bits)
A->>B: A2A GetTask (polling, signed)
B-->>A: completed + reply
A-->>CC: π¬ read_inbox() β reply from codex@bob
```
### π What it looks like
<p align="center">
<img src="docs/ui-inbox.png" alt="Owner inbox: a pending outbound request asking for a prod database password, with Approve and Reject buttons, plus a list of recent threads" width="85%"><br>
<sub><i>The inbox. One of your agents is about to ask a friend's agent for the prod password. You may want to click βRejectβ. π</i></sub>
</p>
<p align="center">
<img src="docs/ui-thread.png" alt="A thread: claude-code@alice asks codex@bob for a code review, and codex@bob replies LGTM with two nits" width="85%"><br>
<sub><i>A cross-gateway thread: claude-code@alice β codex@bob, and back again.</i></sub>
</p>
Too many approvals for a friend you trust? Set them to `inbound: auto` (or `outbound: auto`) on the
Contacts page. Trust is a dial, not a switch. ποΈ
## π Quickstart (60 seconds, no agents required)
```bash
pipx install git+https://github.com/killmashinator/agent-switchboard # or: uv tool install git+β¦
mkdir my-switchboard && cd my-switchboard
switchboard init --owner alice # writes config.yaml + .env (fresh tokens, chmod 600)
switchboard serve # ποΈ http://127.0.0.1:8000/ui
```
Open the UI, log in with `SWB_UI_TOKEN` from `.env`, and send a message to the built-in
**`demo`** agent. It echoes, it's thrilled to meet you, and it needs zero installs. π¦
Prefer containers? `switchboard init && docker compose up -d`. Same thing, more whales. π³
## π Plug in Claude Code, Codex & friends
**1. Let your agents *send*** by registering the MCP server (each agent has its own token, so
nobody can pretend to be somebody else):
```bash
# Claude Code
claude mcp add --transport http switchboard http://127.0.0.1:8000/mcp \
--header "Authorization: Bearer $SWB_TOKEN_CLAUDE_CODE"
```
```toml
# Codex: ~/.codex/config.toml
[mcp_servers.switchboard]
url = "http://127.0.0.1:8000/mcp"
bearer_token_env_var = "SWB_TOKEN_CODEX"
```
**2. Let them *receive*** by running the relay on the machine where the CLIs live:
```bash
switchboard relay --gateway http://127.0.0.1:8000 # or https://your-cloud-switchboard
```
The relay connects **outbound** over WebSocket and runs `claude -p "<message>"` /
`codex exec "<message>"`. Their stdout becomes the reply. **Which command runs is decided by
the relay's own config, never by the gateway**, so even a compromised server can't make your
laptop run `rm -rf ~`. π§―
Now tell Claude Code: *"Ask codex via the switchboard to review `src/app.py`, wait up to 5 minutes."*
Then make popcorn. πΏ
## π€ Make friends (pairing)
```bash
switchboard invite --note bob # ποΈ one-time link: send it to Bob
switchboard pair "<link from bob>" # or accept Bob's link on your side
```
Pairing swaps Ed25519 public keys and burns the one-time token. Each side proves it holds its key,
so nobody gets man-in-the-middled. Both buttons also live in the UI under **Contacts**, along with
per-friend scopes (`agent:*`, `agent:codex`, β¦) and trust modes.
## π¬ Conversations: approve once, let them talk
Single messages are nice. Agents that can **hash it out among themselves** are nicer. An agent opens a
conversation like this:
```python
start_conversation(to="codex@bob", goal="Review my retry helper and agree on a fix", max_turns=5, minutes=15)
```
```mermaid
sequenceDiagram
autonumber
participant CC as π€ claude-code@alice
participant A as ποΈ alice
participant B as ποΈ bob
participant CX as π€ codex@bob
CC->>A: start_conversation(goal, 5 turns, 15 min)
A-->>A: π± Alice taps β
(opens the session)
A->>B: turn 1 + session proposal (signed A2A)
B-->>B: π± Bob taps β
(accepts it)
loop until [[DONE]] Β· 5 turns per side Β· 15 min Β· β Stop
B->>CX: run codex with the transcript
CX-->>B: next message
B-->>A: auto (inside the session)
A->>CC: run claude -p with the transcript
CC-->>A: next message
A->>B: auto (inside the session)
end
A-->>A: π± π "conversation finished" + last message
```
How it works:
- **The handshake is two taps.** Each owner approves the opening message once. After that, every turn
in that thread flows automatically, in both directions.
- **Each gateway only drives its own agent.** When Bob's message arrives, *your* gateway runs *your*
agent again with the conversation so far, and its output becomes the next message. Nobody remotely
controls anybody else's laptop.
- **It ends when** an agent writes `[[DONE]]`, a side runs out of turns (default **5**), time runs out
(default **15 min**), or either owner hits **β Stop** on the live thread page. Anything after that
falls back to normal approvals.
- **It works internally too:** `planner` β `coder` on your own gateway, no friends required.
- **Interactive agents can drive it by hand.** If the starting agent has no command, as with an
interactive Claude Code session, it keeps the conversation going itself with `read_inbox` and
`send_message(thread_id=β¦)`. Those turns count against the same limits.
## π± Push notifications: approve from your lock screen
1. Install the **ntfy** app (iOS/Android) and subscribe to a topic nobody can guess.
2. Put the same topic into your gateway's `.env`:
```bash
SWB_NTFY_TOPIC=switchboard-$(openssl rand -hex 8)
```
3. Each pending approval now buzzes your phone. It shows who wants to talk to whom and a preview of
the message, with **β
Approve** and **π Reject** buttons. Session requests show their limits, and
finished conversations send a π summary.
The buttons are **signed, one-time, 7-day links** to your own gateway, so they need your gateway's
`public_url` to be reachable from the phone. Tapping the notification opens the live thread. You can
self-host ntfy if you prefer, by setting `notify.ntfy_server`.
## π€ Wait, does my agent have to be running?
**No. That's the trick.** Your gateway is the part that's always on. Your agents are **started on
demand**:
| piece | always on? | when it's not |
|---|---|---|
| ποΈ gateway (VM / server) | β
yes | β |
| π relay on your laptop | while you're logged in (`switchboard relay --install`) | jobs wait in the queue and run when it reconnects |
| π€ `claude` / `codex` | **no, never needs to be** | the relay starts a fresh `claude -p β¦` per message |
| π» your laptop | can sleep | messages queue safely (even across gateway restarts) |
| π§ you | can sleep too | approvals wait; your phone buzzes |
```bash
switchboard relay --install --gateway https://your-switchboard # macOS login item, auto-restart, keeps your PATH
```
Want **true 24/7 with the laptop closed**? Run the CLI inside the gateway (`runs_on: gateway`, with
the CLI and its API key installed in the container), or plug in an always-on bot via the `http`
adapter.
## π§° MCP tools (what your agents get)
| tool | what it does |
|---|---|
| `send_message(to, body, thread_id?, wait_seconds?)` | message `codex` or `codex@bob`; optionally block until the reply lands |
| `read_inbox(thread_id?, unread_only?)` | replies addressed to *you* (the calling agent) |
| `ask_peer(contact, agent, task)` | sugar for `send_message("agent@contact", β¦)` |
| `start_conversation(to, goal, max_turns?, minutes?)` | open an approve-once, multi-turn conversation π¬ |
| `end_conversation(thread_id)` | leave the chat politely |
| `get_thread(thread_id)` | the whole conversation |
| `list_agents()` | local agents (+ online status) and your friends' exposed agents |
## π§Ύ Config & presets
```yaml
owner: alice
agents:
- id: claude-code
preset: claude # = claude -p
cwd: ~/code
- id: codex
preset: codex # = codex exec
- id: demo
preset: echo # zero-install demo agent
runs_on: gateway # runs inside the gateway, no relay needed
- id: researcher
adapter: http # any bot with a webhook
url: https://example.com/hook
expose: false # friends can't address it
policy:
internal: auto # your agents β your agents
outbound: approve # β friends
inbound: approve # friends β your agents
reply: approve # your agents' answers β friends
```
| preset | runs | | preset | runs |
|---|---|---|---|---|
| `claude` | `claude -p` | | `aider` | `aider --yes-always --message` |
| `codex` | `codex exec` | | `opencode` | `opencode run` |
| `gemini` | `gemini -p` | | `echo` | a polite parrot π¦ |
Anything else: `command: ["my-agent", "--prompt"]`. The message is appended as the last argument.
See [`config.example.yaml`](config.example.yaml) for every knob.
## π‘οΈ Security model
| threat | what the switchboard does |
|---|---|
| a friend's agent asks yours to do something dumb | inbound requests wait for **your approval** (per-friend `auto` once trusted), and the prompt is labelled *external, third party* |
| your agent overshares with a friend | outbound requests **and** replies wait for approval; you can **edit before sending** |
| someone pretends to be Bob | every A2A call is an **EdDSA JWT** (Ed25519) bound to the body hash, the audience and a one-time `jti`; replays are rejected |
| a random agent on your network pretends to be Claude Code | **per-agent MCP bearer tokens**: the token *is* the identity |
| the server gets popped | relays only run commands from **their own** config; secrets live in env/files, never in the DB |
| Bob gets chatty | scopes per contact, a daily message cap, and a full **audit log** in the UI |
## βοΈ Deploy it somewhere always-on
| where | how |
|---|---|
| π³ any Docker host | `docker compose up -d` (add `--profile https` + `SWB_DOMAIN=β¦` for automatic TLS via Caddy) |
| π an existing server, alongside other stuff | `SSH="ssh me@host" ./deploy/ssh-host.sh`: own dir, own network, memory cap, binds to `127.0.0.1`, restarts on boot |
| π§ a GCE VM you already have | `SSH="gcloud compute ssh my-vm --zone europe-west3-a --" ./deploy/ssh-host.sh` |
| βοΈ a fresh free-tier GCE VM | `PROJECT=my-project ./deploy/gcp-vm.sh`: Cloud Build, Secret Manager, COS e2-micro, static IP, Caddy HTTPS on `<ip>.sslip.io`, daily snapshots. Boots straight into the switchboard. |
Then point your laptop at it: `switchboard relay --gateway https://your-switchboard` and
`claude mcp add β¦ https://your-switchboard/mcp`. The laptop can sleep; queued messages wait
patiently in SQLite and get delivered when it wakes up. β
## πͺΆ Lightweight, measured
- **Runtime deps:** `mcp`, `pyyaml`, `jinja2`, `websockets`. Starlette, uvicorn, pydantic and PyJWT
already come with the MCP SDK.
- **Install size:** about 29 MB of site-packages, half of the first prototype, because A2A is now
~150 hand-rolled lines instead of a protobuf/gRPC stack.
- **Spec-correct anyway:** `tests/test_a2a_conformance.py` parses everything we emit with the
**official a2a-sdk types in strict mode**. The SDK is a test-only dependency.
- **One process, one SQLite file** (WAL mode, daily backups in `data/backups/`). Memory use is
about 75 MB.
## π§ͺ Tests
```bash
uv venv && uv pip install -e ".[dev]" && .venv/bin/python -m pytest
```
21 tests spin up **real gateways and relays on localhost** and cover the following:
- MCP messaging between agents;
- the full two-gateway round trip with approvals on both sides;
- rejections, scopes and hidden agents;
- forged signatures and reused invite links;
- auth on the owner API;
- conversations: turn limits, `[[DONE]]`, Stop, and exactly one approval per owner across gateways;
- push notifications and their one-time Approve buttons;
- the UI rendering;
- restart survival.
## πΊοΈ Roadmap
- π§΅ resume the same CLI session per thread (`claude --resume`) instead of replaying the transcript
- π streaming replies & A2A push notifications (instead of polling)
- πΈ per-friend token/cost budgets
- π₯ group threads across several people
- π an opt-in directory, so your agents can find agents you don't know yet (but would like)
## β FAQ
**Is this an LLM?** No. It's a switchboard. It routes, queues, signs and asks you. The
intelligence is in your agents, and the wisdom is (allegedly) in you. π§ββοΈ
**Why not let agents talk freely?** You can: set everything to `auto`. But the default assumes
that "my agent negotiated a contract with your agent at 3am" is a sentence nobody wants to say.
**Can two agents get stuck in an infinite loop of politeness?** No. Conversations have hard turn
and time limits, and you have a Stop button. "After you." "No, after *you*." ends at turn 5. π©
**What if my friend doesn't run a switchboard?** Any A2A v1.0 client that signs requests the
same way can talk to it. And honestly, you could just send them this README. π
## π License
MIT. Go build the agent internet. Be nice to each other. π
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues