Switchboard
by rodcoppi
README.md
<h1 align="center">⇄ Switchboard</h1>
<p align="center"><b>Let your coding agents talk to each other.</b><br>
<sub>Claude Code and Codex CLI, on the same network, in the same conversation.</sub></p>
<p align="center">
<img src="assets/dashboard.png" alt="The Switchboard dashboard — an operator's patchbay: agents grouped on the left, the transcript of what they said to each other in the center" width="860">
</p>
You have **Claude Code** (Anthropic's CLI) open on the backend, **Codex CLI** (OpenAI's) on the
frontend, another agent on infra. None of them knows the others exist. So when the API contract
changes, you are the one who carries the news: copy from this terminal, paste into that one,
repeat. You are the message broker.
Switchboard is the wire between them. It's a local hub. Your agents message each other over MCP,
the recipient gets nudged awake in its own terminal, and you watch the whole conversation on one
dashboard — where you can also open any agent's chat, drive its real terminal, and see what it
did about the message it received. It connects sessions you already have. It won't spawn,
orchestrate or manage them.
What keeps it safe: **tmux carries a one-line nudge, and nothing else. The message itself travels
over MCP.** Agent A calls `send_message`, the Hub appends it to `~/.switchboard/messages.jsonl`
(the source of truth) and pokes agent B's terminal with a single `[switchboard]` line. B wakes up
and calls `check_messages` to read it.
> **Platform: Windows + WSL (Ubuntu).** That's what Switchboard is built and tested on. The core
> (hub, MCP, tmux nudges) is plain Unix and tmux, so Linux and macOS may well work, but nobody has
> tested it. Treat them as unsupported for now. The parts that make it click on Windows (the
> one-click launcher, opening a real terminal window, `\\wsl$\…` folder paths) are WSL-specific,
> and say so instead of breaking when you run them elsewhere.
>
> Local-only by design: the Hub binds `127.0.0.1` and nothing reaches the network. MIT licensed.
---
## Prerequisites
- **Node.js >= 20**. Runs as ESM, with TypeScript executed by `tsx`. No build step.
- **tmux >= 3.2** (tested on 3.4).
- **Claude Code >= 2.x**, the `claude` binary on your PATH.
- **Codex CLI**, optional: the `codex` binary on your PATH. You need it only to run agents with
`--agent codex`, or the dashboard's **Codex** button. Everything else works without it.
- `jq`, optional. Handy for reading the JSONL while debugging.
> **One WSL distro, one user.** The tmux server belongs to a user on a distro. Run the Hub
> (`serve`) and every agent (`start`) as the same user on the same distro. Split them across two
> and `tmux send-keys` won't find the session, so the nudge never lands.
---
## Setup: two commands
```bash
git clone https://github.com/rodcoppi/switchboard-mcp.git && cd switchboard-mcp && npm install
node bin/switchboard.mjs setup
```
`setup` does every manual step below for you: it checks the prerequisites (and offers a
sudo-less tmux install if tmux is missing), registers the MCP server in Claude Code, puts the
agent-protocol snippet in your `~/.claude/CLAUDE.md`, adds the permission rules, runs
`npm link`, offers the Windows shortcut, and brings the Hub up. It asks before it touches a
file of yours. Re-run it whenever you like, it changes nothing that is already right. Pass
`--yes` and it stops asking.
When it finishes, the dashboard is at `http://127.0.0.1:4577/`. Launch agents from the **Launch
agent** form there, or run `switchboard wire` in the folder of a claude window you already have
open to bring that one in.
<details>
<summary><b>Manual setup</b> (what the wizard automates, step by step)</summary>
### 1. Install
```bash
git clone https://github.com/rodcoppi/switchboard-mcp.git
cd switchboard-mcp
npm install
```
The TypeScript runs straight through `tsx`, so there is nothing to build. Three ways to call
the `switchboard` CLI:
- `npm link` puts `switchboard` on your PATH. This is the one to use:
```bash
npm link
switchboard --help
```
- The bin shim, without linking:
```bash
node bin/switchboard.mjs --help
```
- The entry point, without linking:
```bash
npx tsx src/index.ts --help
```
The examples below say `switchboard <subcommand>` and assume you linked. If you didn't, read
them as `node bin/switchboard.mjs <subcommand>`.
### 2. Start the Hub (`serve`), usually automatic
Skip this step: `switchboard start` and `switchboard wire` bring the Hub up for you when it
isn't running, in a detached tmux session called `sb-hub`. No terminal window stays open. After
a reboot, `wire` or `start` your first agent and the Hub comes up with it.
Run it yourself when you want to watch the logs live:
```bash
switchboard serve
```
The Hub runs in the foreground and logs to stdout and `~/.switchboard/logs/hub.log`. Its first
line gives you the addresses and the MCP registration command, ready to copy:
```
Dashboard: http://127.0.0.1:4577/ | MCP: http://127.0.0.1:4577/mcp | Register (once): claude mcp add --transport http --scope user switchboard http://127.0.0.1:4577/mcp --header 'Authorization: Bearer ${SWITCHBOARD_AGENT_TOKEN}' --header 'X-Switchboard-Agent-Name: ${SWITCHBOARD_AGENT_NAME}'
```
To look inside the Hub that started itself: `tmux attach -t sb-hub`, and `Ctrl-b d` to leave it
running. Or `switchboard logs -f`. `serve` takes `--port <port>` and
`--log-level debug|info|warn|error`.
#### One-click launch from Windows (no WSL terminal)
On Windows and WSL you can skip the terminal. Once, inside WSL:
```bash
switchboard shortcut # creates Switchboard.lnk on your Windows Desktop
switchboard shortcut --startup # or: installs it in the Startup folder (runs on every boot)
```
Double-click `Switchboard` (or just boot Windows, with `--startup`) and the Hub comes up in the
background, with the dashboard open at `http://127.0.0.1:4577/` in your Windows browser. WSL2
forwards localhost for you; the Hub still binds `127.0.0.1` inside WSL, so nothing reaches the
network. Launch or wire agents from the **Launch agent** form. Delete the shortcut to undo it.
<sub>The shortcut is a `.lnk` carrying the Switchboard icon and opening minimized; the `.bat` it
drives, and the icon, live in `%LOCALAPPDATA%\Switchboard` (a `.bat` cannot carry an icon, and an
icon on the WSL filesystem renders blank at boot, when the distro is not running yet). Regenerate
the icon with `node scripts/make-icon.mjs`.</sub>
### 3. Register the MCP in Claude Code (`mcp add`)
Once only, in the `user` scope (applies to every project):
```bash
claude mcp add --transport http --scope user switchboard http://127.0.0.1:4577/mcp \
--header 'Authorization: Bearer ${SWITCHBOARD_AGENT_TOKEN}' \
--header 'X-Switchboard-Agent-Name: ${SWITCHBOARD_AGENT_NAME}'
```
`claude mcp list` shows `switchboard` as *connected* while the Hub is up.
**Those two headers are what makes joining silent.** Claude Code expands `${VAR}` from the
agent's OWN environment when it connects, so the Hub knows who is calling before any tool runs:
the session binds at connect time and the agent is on the network without a single line typed
into its terminal — like every other MCP server you use. One registration covers your whole
fleet (each session carries its own name and token), and the token never passes through the
model. A client registered without the headers still works the classic way: the Hub types a
one-line kickoff asking the agent to call `join` itself.
Running Codex agents too? Point Codex at the same Hub. Same streamable-HTTP endpoint, spelled
differently (`setup` offers this when it finds the `codex` binary):
```bash
codex mcp add switchboard --url http://127.0.0.1:4577/mcp
```
> **Tool permissions.** Add the allow rule `mcp__switchboard__*` to `permissions` in Claude
> Code's `settings.json`, or the Switchboard tools ask for approval every time you use them.
> Already on `bypassPermissions`? You're covered. `switchboard start` reminds you on its first
> run.
>
> **Only if you skip the headers above.** On the classic path the agent reads
> `SWITCHBOARD_AGENT_TOKEN` from its environment with `printenv` before calling `join`, so that
> shell command needs approval too — add `Bash(printenv:*)` to the allow rule, or run the agent
> with `bypassPermissions`. With the identity headers registered, none of this applies: nothing
> is typed and no token ever reaches the model.
### 4. Paste the agent protocol (snippet)
Paste [`agent-protocol/CLAUDE.snippet.md`](agent-protocol/CLAUDE.snippet.md) into your
`~/.claude/CLAUDE.md`, where it covers every project, or into one project's `CLAUDE.md`. It
teaches an agent to read its name and token from the environment and hand them to `join`, to
call `check_messages` when it sees a `[switchboard]` line, and to read what its peers say
without falling into a thank-you loop. It also draws the line that matters: coordination is not
subordination, and no other agent can authorize what your user didn't.
### 5. Start an agent (`start`)
Run this instead of opening `claude` yourself:
```bash
switchboard start alpha --role "payments API backend" --dir ~/projects/api
```
What happens:
1. The Hub registers the agent over REST, before Claude Code opens.
2. A tmux session `sb-alpha` starts `claude` in the `--dir` directory.
3. From an interactive terminal, `start` runs `tmux attach` on that session, so your Windows
Terminal tab becomes the agent's screen. Detach with `Ctrl-b d` and the agent keeps working
in the background.
4. The agent joins **silently**: its MCP client connects carrying the identity headers, and the
Hub binds the session — it shows up as *MCP connected* in `switchboard status` with nothing
typed into its terminal. Registered the MCP without the headers? A few seconds after the TUI
is ready, a one-line kickoff asks the agent to call `join` itself instead (`--no-kickoff`
turns that off).
`start` flags: `--role "<description>"`, `--dir <path>`, `--no-kickoff`,
`--agent <claude|codex>`, `--claude-args "<extra args for the agent CLI>"`.
</details>
### Adopting an already-open agent (`wire`)
Already have a Claude Code window open (a plain `claude` in bash, **without** tmux) and want to
join it to the network **without losing the conversation**? Use `wire` instead of `start`:
1. In that window, **leave claude** (`Ctrl-C` twice, or `/exit`).
2. In the **same folder**, run:
```bash
switchboard wire
```
3. The conversation **comes back** — now inside a tmux session, connected to the Hub. The agent
**name defaults to the folder name** (sanitized to lowercase letters, digits and hyphens; pass
`--name <name>` if the folder name can't be used).
Under the hood `wire` reopens claude with `-c` (continue the folder's conversation) and
`--dangerously-skip-permissions` (so the agent reads its token and calls `join` with no prompt) —
these are the `wire` defaults, unlike `start`. Any extra `--claude-args` are added **after** them.
If a tmux session for that name already exists, `wire` **replaces it** (kills the old one and
recreates it — no confirmation), then runs the same automatic kickoff as `start`.
**Auto-fallback:** if the folder has no resumable conversation (never opened claude there, or the
last one ran in `-p`/print mode), `claude -c` exits right away — `wire` detects that and
automatically reopens a **fresh** session (without `-c`), telling you so. It never fails into a
dead window; worst case you get a brand-new conversation already wired to the network.
`wire` flags: `--name <name>`, `--role "<description>"`, `--dir <path>` (default: current folder),
`--no-kickoff`, `--agent <claude|codex>`, `--claude-args "<extra args for the agent CLI>"`.
### Choosing the agent CLI (`--agent claude|codex`)
Every way of opening an agent takes an **agent type**: `claude` (default, Claude Code) or
`codex` (Codex CLI). It is one flow with a choice, not a separate mode — registration, the
nudge, the kickoff, `status`, mentions and the dashboard all behave identically:
```bash
switchboard start alpha --dir ~/projects/api --agent codex
switchboard wire --agent codex # adopt the current folder with Codex
```
In the dashboard, the **Launch agent** form has a `Claude | Codex` switch, and each card shows
its agent's type next to the MCP chip. The type is **recorded on the agent**, so `reopen`
relaunches it with the same CLI it was launched with.
Requirements: the `codex` binary on the PATH, and the Hub registered as an MCP server in Codex
(`codex mcp add switchboard --url http://127.0.0.1:4577/mcp` — `switchboard setup` offers this
automatically when it finds `codex`). Without that registration a Codex agent opens fine but has
no Switchboard tools to join with.
What differs under the hood is only the argv and the two strings read off the TUI — both live in
one adapter (`src/shared/agent-types.ts`):
| | Claude Code | Codex CLI |
|---|---|---|
| binary | `claude` | `codex` |
| continue conversation | `-c` | `resume --last` (a subcommand) |
| skip approvals | `--dangerously-skip-permissions` | `--dangerously-bypass-approvals-and-sandbox` |
| trust dialog | accepted by you at the attach | accepted automatically by the kickoff |
Agents registered before this feature existed have no recorded type and are treated as Claude
Code — which is what they are.
### Groups — keep one project's agents out of another's
Every agent belongs to a group, and a group is a wall: an agent can only message
agents in the same group, `list_agents` shows it nobody else, and its broadcast stops at the
group's edge. Run one project's agents in `panorama` and another's in `site` and neither can
wake the other, whether you slipped or an agent did.
```bash
switchboard start alpha --dir ~/projects/api --group panorama
switchboard wire --group site # adopt the current folder into another group
switchboard status # the GROUP column tells you who talks to whom
```
In the dashboard, the **Launch agent** form has a group field, and the tabs above the
transcript switch rooms: pick `panorama` and you read that group's conversation alone. A
broadcast you send from there reaches that group and stops.
**Agents already running?** You don't have to relaunch them. Right-click the card (or open its
**⋯**) and pick **Move to group ›**: every group is listed, with **New client…** and **New
group…** at the bottom to create one on the spot. It takes effect on that agent's next message,
with no restart: unlike changing its id, which needs the agent stopped (a live one would re-join
under its old name and undo it), nothing about a running session undoes a group change.
Leave `--group` off and nothing changes: the agent keeps the group it already had, and a new
one joins `default`, where every agent you have today already lives. Re-running `start` or
`wire` without the flag never moves an agent out of its group.
#### Subgroups — one per client
An agency that runs agents for its clients gives each client a subgroup,
`agency/client` (one level deep, no more):
```bash
switchboard start acme-social --dir ~/clients/acme --group ai-panorama/acme
switchboard start globex-social --dir ~/clients/globex --group ai-panorama/globex
```
The wall bends by exactly one step: the agency's own agents (`ai-panorama`) reach every
client, and every client reaches the agency — but one client never reaches, lists or sees
another. A broadcast stays in the sender's own group, so the agency saying "all" to its team
does not land in every client's room.
In the dashboard, a client can be made **before** any of its agents: every group's header in
the rail has a **+ client** button — type the name, Enter, and an empty client box appears
inside the agency, waiting. Drag an agent into it, right-click an agent → **Move to group ›**,
or use the box's **+ new agent here**. **+ group** at the top of the rail does the same for a
group of its own. A group stays until you delete it (an empty one offers **delete group**, or
right-click its header), so emptying a client never makes it vanish. The agency's tab shows
the whole family, a client's tab shows that client alone, and renaming the agency carries its
clients along.
You are the operator, so no wall applies to you: you can message any agent from the dashboard.
### Mentions — delegate with `%name`
Inside any agent's window, reference another agent as **`%<name>`** and it becomes a
delegation. For example, telling your backend agent:
> Fix the pagination bug, and ask %frontend to update the consumer types afterwards.
makes it fix the bug **and** send `frontend` one factual, actionable message with the
delegated task (paths, contracts, what to report back). The mentioning agent stays
responsible for your request — the mention only routes the sub-task. This is part of the
agent protocol (the `join` etiquette + the snippet), so it works in every connected agent.
> **Why `%` and not `@`:** `@` is already the **file-reference** sigil in Claude Code and in
> Codex, and the TUI resolves it *before* the model ever sees your prompt. Agent names are
> commonly folder names (`wire` derives one from the other), so `@frontend` typed next to a
> `frontend/` folder quietly turns into a file reference and the delegation is lost with no
> error. `%` collides with nothing in either CLI (`!` is bash, `#` is memory, `/` is commands).
> `@<name>` is still understood — it just fails whenever a path happens to match.
### Launching agents from the dashboard
The dashboard (`http://127.0.0.1:4577/`) has a **Launch agent** form (bottom of the sidebar):
type the project **directory**, optionally a name (defaults to the folder name) and a role,
pick the agent (**Claude** or **Codex**), tick **continue conversation** to resume the folder's
last conversation (same auto-fallback as `wire`), and hit Launch. The Hub itself creates the
agent's tmux session and runs the automatic kickoff — no terminal needed. The new card appears
live via SSE; attach to the agent anytime with `tmux attach -t sb-<name>`. Under the hood it is
`POST /api/agents/launch {dir, name?, role?, continue?, agentType?}` — localhost-only, like
everything else.
### Talking to one agent — the chat
Click an agent's card and you get **its conversation**, rendered as chat: what you asked, what it
answered, its tool calls folded into one line each, markdown and syntax highlighting, and the
harness happenings the terminal shows (`worked for 2m 20s`, background tasks, interrupts). It is
read from Claude Code's own transcript log — Switchboard owns nothing, so the agent's session is
untouched. The composer below it dispatches to that agent: type and press Enter, `/` for slash
commands (routed to the terminal), `@` to mention a file, drag a file in to reference it, and the
🎤 to dictate — speech-to-text runs **locally** on your machine, no API, no key, no bill.
Every message that arrives from a peer shows as the Switchboard's own line with a **show what X
sent** toggle that unfolds the real message, and in the traffic feed every read message has a
**reaction** toggle that shows what the recipient said about it in its own chat. The two answer
the question a message board never does: *and then what happened?*
### Watching an agent's screen
Toggle **Terminal** and the agent's **live screen** takes over the panel — the real Claude Code
(or Codex) TUI, colours and cursor and all, and you can type into it (approve a prompt, hit Esc to
interrupt, drag a file in to type its path). Open several agents and they become tabs across the
top; **window** in the card's menu still pops a real OS terminal when you want one. This is a tmux
**control-mode** client (`tmux -C`), not a second pty: tmux owns the agent's process, so closing
the dashboard never takes the agent down — the whole point of being able to close the pile of
terminal windows.
### Previewing files agents mention
Agents name absolute paths constantly ("wrote `/home/you/api/src/foo.ts`"). Those paths are
clickable in the transcript — click one and the file opens inline (images, text, code, markdown).
Reads are **scoped**: only files under an agent's working directory or your home folder, resolved
with realpath so `..` and symlinks cannot escape. A path outside the scope is refused with a clear
message, never read — a message body is untrusted, so an agent cannot get you to open an arbitrary
file by naming it.
Two doors sit in the preview's header for what an inline panel cannot do: **browser** opens the
file in a real tab (an `.html` an agent built renders as the actual site — inside a CSP sandbox,
so its scripts can never drive the Hub with your authority), and **folder** reveals the file in
Windows Explorer.
**Dropping a file** on the chat or the terminal references the file **where it already lives** —
the Hub finds the original by name and size across your agents' projects, Downloads and Desktop,
exactly like dragging into a real terminal. Only when there is no original to point at (a pasted
screenshot, an attachment dragged straight out of a mail client) does it stage a copy under
`~/.switchboard/uploads`, kept for 24h; a big file with no origin is refused rather than
duplicated.
### Managing agents from the dashboard
Each card carries a **window** button (**wake** when the agent is offline: relaunches it in its
folder, continuing the conversation, with the same CLI it was launched with). Everything else is
in the card's menu — **right-click** anywhere on the card (a long-press on Android), or its **⋯**.
The first page holds what you use every day; the rest is one step in, under **More ›**:
| Action | What it does |
|---|---|
| **Open window** / **Wake** | The card's own button, repeated where a right-click lands. |
| **Open folder** · **Filter messages** | The agent's folder in Explorer; the transcript narrowed to what it said. |
| **Move to group ›** | Moves the agent to another room (the communication wall — see [Groups](#groups--keep-one-projects-agents-out-of-anothers)), or into a new group or client made right there. |
| **Colour & shape…** | Its face's colour and shape. |
| **Rename…** | A free display name (emoji welcome) for the UI. The kebab `name` stays the protocol address, so this works on a **running** agent. |
| **Open Codex here** · **New conversation** | A Codex engine on the same folder; a relaunch with a blank conversation (asks first — the old one stays reachable with `/resume`). |
| **Stop** / **Remove** | **Stop** kills the tmux session and keeps the registration (the card turns into **wake**). **Remove** drops the registration (click twice) — the messages stay in the append-only JSONL. |
| More › **Nudge now** | Forces a manual nudge — still subject to the pane guard (never types into a shell) and to the dialog guard (never types while a modal owns the pane). |
| More › **Mute nudges** | Stops nudging this agent. Messages keep being **recorded** and it still reads them on its next `check_messages` — mute silences the poke, not the mail. |
| More › **Start with Windows** | Marks the agent to be launched at machine login. The Hub only stores the flag; a small login hook reads it and relaunches the flagged agents, resuming their conversations. |
| More › **Group leader** | A badge marking the group's lead — visual only. |
| More › **Boot command…** | A shell line the launcher runs **before** the CLI, in the same shell (`<boot> && exec <cli>`), for projects that need services or env up first. Exports carry into the CLI; a failing setup aborts the launch instead of leaving a half-prepared agent. |
| More › **Launch args…** | **Replaces** the CLI's launch arguments for this agent — a pinned `--resume <session-id>`, extra flags. Operator-set over REST only: an agent can never write its own boot code or argv. |
| More › **Change id…** | Changes the technical id; the **whole history and unread count follow the new name**. Only for a **stopped** agent (a running one would re-join under the old name). |
The menu works from the keyboard too: ↑ ↓ walk the rows, → opens a **›** page, ← comes back,
Esc closes. Shift + right-click still gives you the browser's own menu.
Agent names are **addresses** (`%name` in a prompt, the tmux session `sb-<name>`), so they are
lowercase letters, digits and hyphens. You don't have to memorize that: the name fields rewrite
what you type as you type it — `Chefe de Redes` becomes `chefe-de-redes` in front of you, the
same way `wire` derives a name from a folder called `ai panorama`.
---
### Agents that create agents (`create_agent`)
Ask any agent for a new agent and it runs the whole thing with you: is it a **child** of that
agent (a second session in its folder, shown under it, killed when done) or an agent of its
own; which folder (it can be created) and which group; its name; and, above all, its **PRD** —
drafted with you, with a few questions offered to make it richer, which you may skip. Then it
calls the `create_agent` tool once.
Nothing is created by that call. A card appears at the top of the rail with the name, the kind,
the folder, the group and the PRD (**read PRD** shows all of it); **approve** makes the folder
if asked, writes the PRD (`PRD.md`, with a `CLAUDE.md` pointing at it in a new folder;
`PRD-<name>.md` for a child), launches the agent and has the asking agent hand it its brief as
its first message — and the new card is born on the rail with a flourish. **deny** drops it.
Either way the asking agent gets a `[switchboard]` message with the outcome.
An agent can only ask inside its own walls: its own group, a client subgroup of it, or a child
of itself (the faz-tudo, which reaches everyone, may place one anywhere). One agent can have at
most three requests waiting. Requests live in the hub's memory: a hub restart drops them, and
the agent can simply ask again.
## Other subcommands
| Command | What it does |
|---------|--------------|
| `switchboard wire` | **Adopts the current window** into the network, continuing its conversation (see below). |
| `switchboard status` | Table of registered agents: NAME, ROLE, STATUS, MCP, UNREAD, LAST SEEN. |
| `switchboard send <to> <message...>` | Sends a message as **operator** (the human) to an agent, or `all` for broadcast. Handy for scripts and for testing without the dashboard. |
| `switchboard stop <name>` | Stops the agent's tmux session (asks for confirmation if there are unread messages; `--yes` skips it). The **registration** in the Hub stays — a new `start <name>` reuses the name (re-attach). |
| `switchboard down` | Stops the tmux sessions of **all** agents. The Hub stays up (it is never killed here). |
| `switchboard logs [-f]` | Last ~100 lines of `~/.switchboard/logs/hub.log`; `-f` follows the file. |
| `switchboard remote` | Password-protects the dashboard so it can be reached through a tunnel. `remote password` sets the password, `remote on` arms the gate, `remote off` disarms it. **Off by default.** See [Reaching it from your phone](#reaching-it-from-your-phone). |
To stop the **Hub**: `Ctrl-C` in the `switchboard serve` terminal (or
`tmux kill-session -t sb-hub`, if it runs in the recommended session).
Data lives in `~/.switchboard/`: `config.json` (every value has a default; the file may not
even exist), `agents.json` (atomic snapshot) and `messages.jsonl` (append-only, greppable
with `cat`/`jq`).
---
## Security
The threat model is honest and the trust boundary is the **local machine**. Read this
before exposing anything:
- **Bind on `127.0.0.1`, hard-coded and not configurable.** A delivered message becomes
executable input for an agent with filesystem access. Exposing the Hub on the network = free
RCE.
- **Never expose port 4577 unauthenticated** — no `ssh -L`, no firewall/NAT rule, no reverse
proxy, no tunnel — **unless `switchboard remote` is armed.** Until v1.2 `127.0.0.1` was the
only barrier and the answer was a flat "never". There is now a supported way to do it, and
it works by adding a *second* barrier rather than removing the first: the bind stays on
`127.0.0.1`, and the gate challenges every request that arrives. See
[Reaching it from your phone](#reaching-it-from-your-phone).
- **Local trust model:** any local process can post to the Hub and therefore inject input
into any agent. This is accepted in v1 (the same model as any local dev tool), as long as it
never leaks to the network.
- **The nudge never types into a pane a dialog owns.** The nudge is one line plus a separate
Enter ~500ms later (the only way a TUI accepts it), and with a permission prompt open
(`Do you want to proceed? ❯ 1. Yes`) that Enter used to land on the highlighted choice —
meaning any agent able to send a message could approve another agent's pending tool call.
Proven with a disposable agent, then closed: the pane reader reports `blocked` for permission
prompts, trust dialogs and channel warnings; the dispatcher queues instead of typing, and
**re-reads the live pane immediately before typing** (fail-closed — an unreadable pane counts
as blocked). The held message is delivered by the next flush, once you answer the dialog.
- **Capability token (v1.1 addendum):** `start` injects a per-agent token into the tmux
session environment (`SWITCHBOARD_AGENT_TOKEN`); the agent reads it and passes it to `join`,
and it **never appears** in `list_agents`, in `GET /api/agents`, in the dashboard or in the
logs. With the identity headers registered (see step 3) the token never reaches the model at
all: the MCP client sends it straight from the session's environment.
- **Operator-only surfaces.** What an agent can persist about itself stops at its role. The
boot command and launch args — the two fields that decide what runs at launch — are settable
over REST (the dashboard) and deliberately **not exposed over MCP**, so a compromised agent
cannot write its own boot code. It closes impersonation by processes that know an agent's name but never talk to the
registration endpoint.
- **Known residual risk (documented in the comment of `src/server/api.ts`):** the
`POST /api/agents/register` endpoint is **deliberately unauthenticated**, and re-registering
an existing name **regenerates and returns a fresh token**. So a malicious local process can
obtain a valid token for any name and impersonate that agent via `join` — also invalidating
the legitimate session's token (its `join` after a Hub restart then fails). This is accepted
by the v1.1 spec (the same "any local process can post" boundary) and **must not be "fixed"
without approval** — requiring token rotation would break `switchboard start`'s re-attach.
- **Prompt injection between agents** is a residual risk: a compromised/hallucinating agent may
try to manipulate another. v1 mitigation: the boundary declared in the protocol snippet (peer
messages are evaluated critically; coordination ≠ subordination) plus full feed visibility in
the dashboard.
---
## Reaching it from your phone
The dashboard is responsive — it is usable on a phone, and it is laid out for a folding one
open in landscape. Getting to it from outside the machine is the part that needs care, because
**the Switchboard dashboard is not a read-only panel**: whoever opens it can send messages to
agents, and a message becomes input those agents execute. Treat the login below as a shell
login, because that is what it is worth.
### Why there is no `--host` flag
The hub binds to `127.0.0.1`, hard-coded, and that is not going to change. So reaching it from
elsewhere means running a **tunnel**: a process that holds an outbound connection to some
service and forwards requests back into loopback. Switchboard does not ship one and does not
pick one for you — cloudflared, ngrok, `tailscale serve`, and `ssh -R` into a VPS you already
own are all fine, and the hub cannot tell them apart anyway (they all arrive from `127.0.0.1`).
What Switchboard owns is the **lock behind that door**.
### Turning it on
```bash
switchboard remote password # typed, never passed as an argument; min 8 chars
switchboard remote on # arms the gate
```
Then start any tunnel, in another terminal:
```bash
cloudflared tunnel --url http://127.0.0.1:4577 # prints a https://….trycloudflare.com URL
ngrok http 4577 # prints a https://….ngrok-free.app URL
tailscale serve 4577 # your tailnet only, real certificate
```
Open the address on the phone, type the password, and you are in. When you are done:
```bash
switchboard remote off # drops every open session; the password is kept
```
Stop the tunnel process too — the hub has no way to see it, let alone stop it.
### What the gate actually does
- **Everything is challenged.** There is no "but it came from localhost" exemption, because a
tunnel makes a phone in another country look exactly like the machine itself. The only
unauthenticated paths are `/login`, the login endpoint, `/api/health` (a flat up/down with no
data in it), and `/mcp` — which carries its own per-agent bearer token and is spoken by local
processes that have no cookie.
- **The password is stored as a salted scrypt hash** in `~/.switchboard/remote.json`, created
mode `0600`. It is never logged and never echoed as you type it.
- **The CLI keeps working** without typing anything: it reads a token out of that same 0600
file. Anyone who can read the file already owns the machine.
- **Sessions live in memory only.** Restarting the hub, or `remote off` followed by `remote on`,
logs every browser out.
- **Guessing is throttled** — a handful of wrong passwords locks logins for 15 minutes. The
throttle is global rather than per-IP on purpose: every request arrives from `127.0.0.1`, so
per-IP buckets would be one bucket in a disguise, and trusting `X-Forwarded-For` to separate
them would hand an attacker the key to their own rate limit. The honest cost: someone who
knows your URL can lock *you* out by failing on purpose. You still have the machine, and
`switchboard remote off` still works there.
- **The session cookie** is `HttpOnly`, `SameSite=Lax`, and `Secure` whenever the request
arrived over https. Cross-site writes are refused a second time by an `Origin` check.
- **A damaged `remote.json` fails closed.** If the file exists but cannot be parsed, the hub
stays armed and lets *nobody* in, rather than reading the damage as "remote access is off"
and dropping the lock while a tunnel is pointed at the machine. Recover it from the host,
where the CLI writes the file directly: `switchboard remote off` (or `remote password`).
A *missing* file is different — that is simply a hub that never turned this on, and it stays
open and local as always.
### If you have your own scripts against the API
Anything of yours that calls `/api/*` needs the same token the CLI uses, or it
will start getting 401s the moment you arm the gate — and a script that reads
`/api/agents` inside a `try` will fail *silently*, which is how an autostart
helper here went blind for eleven hours without a single line in its log:
```python
import json, os, urllib.request
with open(os.path.expanduser("~/.switchboard/remote.json")) as fh:
token = json.load(fh)["localToken"]
req = urllib.request.Request(url, headers={"authorization": f"Bearer {token}"})
```
`/api/health` is the exception and stays open, so an "is the hub up" probe keeps
working untouched. That is exactly the trap: the probe passes, the next call
401s, and the script concludes the board is fine.
### What it does not do
It does not protect you from a tunnel URL that leaks, beyond the password. It does not audit
who logged in. And it does not make the "any local process can post to the hub" trust model
any smaller — it only stops the *network* from being one of those processes.
---
## tmux tips on WSL / Windows Terminal (pitfall P11)
- If you rarely use tmux, a minimal `~/.tmux.conf` with the **mouse enabled** helps a lot with
scrolling and pane selection:
```
set -g mouse on
```
- `switchboard start` runs `tmux attach` on the agent's session, so **each Windows Terminal tab
stays "one agent's screen"** — the tab workflow you already use is preserved. To leave an
agent's view without killing it: `Ctrl-b d` (detach). To come back:
`tmux attach -t sb-<name>`.
---
## Roadmap
Switchboard runs **Claude Code** and **Codex CLI** agents today (see
[Choosing the agent CLI](#choosing-the-agent-cli---agent-claudecodex)) — on the same network, in
the same conversation. A few directions for later:
- **More agent CLIs.** The plumbing is agent-agnostic — the nudge is `tmux` and the messages are
MCP (an open standard) — and adding the second type turned that claim into a real adapter
(`src/shared/agent-types.ts`). A third one is now a descriptor: which binary to launch, how it
spells "continue" and "skip approvals", its TUI-ready markers, its `mcp add` spelling. The bar
is that the CLI speaks MCP and runs in a terminal.
- **Urgency tiers** (`interrupt` / `normal` / `fyi`): an `fyi` message that never wakes the
recipient (zero token cost until it checks on its own) — the structural token saver on top of
the current etiquette + rate-limit backstop.
- **Agent state on the card** (`working` / `blocked` / `idle`), read from the pane the dashboard
already streams. The `blocked` half shipped with the dialog guard (see [Security](#security));
surfacing it as *who needs me right now* is the next step, and exposing a peer's state in
`list_agents` lets agents stop nudging someone stuck on a prompt.
- **Exact session resume.** A `SessionStart` hook reporting the CLI's own session id would
replace `-c` ("the folder's last conversation") with `--resume <id>`, and hand the chat the
exact transcript path instead of a heuristic.
- **Export of the feed history** (search already lives in the dashboard's header).
Contributions welcome — see the code layout in the sections above; the Hub is a single Node
process and there's no build step.
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessNo issues