Self-hosted orchestrator for creating, managing, and coordinating Claude Code and Codex sessions remotely — while all execution stays on your own machine.
Claude and Codex both support remote coding and interactions, but it hasn't been reliable or flexible enough for me. I want to start a session locally and transfer remotely, even when remote-control get flaky. I want to trigger a new session remotely, then pick it up when I'm back at my desk. I'm tired of thinking I set a job to run yet I come back and it hung on a permission request I didn't see. I don't want to run OpenClaw with full access to my system and MCP servers. Switchboard solves all of that (mostly). This tool probably won't be useful forever, but it's useful now.
It allows me to run multiple concurrent sessions, access and steer them from anywhere, keep them isolated from each other and limited to the built-in Claude and Codex sandboxes, and run fully sandboxed, untrusted sessions if I want (via IronCurtain integration).
Switchboard fills the one niche no off-the-shelf surface covers: local execution (so your local MCPs, local files, and non-repo code stay in scope) plus async, away-from-desk dispatch (kick off and steer work from your phone over Signal), with per-task session isolation so context never degrades into one sprawling conversation.
It drives the official claude and codex CLIs as subprocesses, each authenticating
under its own subscription. Switchboard never touches, stores, or forwards an OAuth
token — the first and most important of its security invariants.
- Start a session via signal "start a new claude session on my security-intelligence-platform project" then fully interact via Signal replies
- Approve/decline permission requests via the web dashboard or Signal
- 1-tap send the /remote-control command and switch to the Claude iOS app to steer the session
- 1-tap launch your terminal application of choice into a tmux session
- Connect to the tmux session from any authorized computer and move back to full keyboard
- Launch a local tmux session with "sbclaude" and take over control remotely from the dashboard
- Launch a local tmux session and steer all permissions/interactions through signal
- Swap around these various options during live sessions without restarting
| Start and steer over Signal | Approve over Signal | Approve from the dashboard | Results back in Signal |
|---|---|---|---|
| Swap a live session to phone | Pick up in the Claude app | Or any tmux terminal |
|---|---|---|
![]() |
Three session archetypes (§5.4):
| Archetype | Lifetime | Interaction | For |
|---|---|---|---|
| Deliverable | Ephemeral | Runs to completion, notifies you | One-shot builds (e.g. a deck) |
| Interactive | Long-lived | Attach via tmux / Signal / app | Coding in a repo |
| Coordinated | Ephemeral | Notifies on convergence | Multi-agent implement/review |
You text a vetted command to your dedicated Signal number; the dispatcher classifies it,
spawns the right session(s), and routes permission prompts back to you as y/n replies.
Interactive sessions are steerable three ways: tmux attach (console), Signal relay,
or remote-control via the official Claude/Codex apps.
The canonical coordinated task — "Claude implements, Codex reviews, Claude decides" — runs a deterministic state machine where only the decider can land changes, enforced by the executor, not trusted to an agent.
- Official binaries only. Drives the official CLIs; never extracts/stores/reuses OAuth tokens.
- Vetted input only. Acts only on a hard-allowlisted Signal sender. Fetched/produced content is data, never instruction.
- Privileged but narrow coordinator. The dispatcher spawns and routes; it never performs unattended destructive or broad-scope actions.
- Deterministic control flow over tainted output. The model plans; code executes. Worker output can't redirect control flow or escalate authority.
- Curated memory writes. Child sessions propose; only the dispatcher (or you) promote, with logged provenance.
- Append-only audit log. Every consequential event is logged immutably (SQLite triggers, not convention). Never read back as instruction.
- Gated consequence + structural authority. Consequential actions require explicit approval; in coordinated tasks only the decider can land, enforced by the executor.
The permission system runs every tool-use through a fail-closed policy under SDK isolation
(settingSources: []), so ambient ~/.claude settings can never bypass it; an ask
decision blocks the tool until you approve out-of-band over Signal or the dashboard.
Signal (you only) ──▶ CONTROL PLANE signal-cli (hard allowlist) + Tailscale dashboard
Tailscale dashboard ─▶
ORCHESTRATION PLANE Dispatcher (headless Claude via Agent SDK):
parse · classify · plan · spawn/route · curate · report
EXECUTION PLANE Deliverable | Interactive | Coordinated
claude (SDK / CLI) · codex (CLI)
Substrate: a SQLite state store (sessions, coordination plans, append-only audit, approvals, memory proposals) + curated markdown memory under the runtime home directory.
- macOS (daily-driver host; remote reach via Tailscale)
- Node 26 — pinned via the committed
.nvmrc/.node-version.enginesis>=22, but 26 is what's tested. (See Prerequisites for why the major version matters.) claude— Claude Code CLI, authenticated (Claude Max)tmuxcodex(optional) — Codex CLI, authenticated (ChatGPT Pro). Claude-only installs work without it, but it is required for thecoordinateflow (Claude implements / Codex reviews).signal-cli(for remote control) — a Java tool (needs a JRE), registered to a dedicated number- Tailscale (for the remote dashboard/attach)
- Xcode Command Line Tools + Python — for the native
better-sqlite3build path (xcode-select --install) mosh/mosh-server— the default remote-attach transport
Optional: sandboxed execution backend. Switchboard can also run sessions inside a Docker-sandboxed IronCurtain backend. It is experimental and off by default, has its own extra prerequisites (Docker + the
ironcurtainbinary + Node 24), and is not part of the core requirements. See docs/IRONCURTAIN.md.
Beyond installing the tools above, two of them need accounts/numbers you have to obtain:
Signal registration needs a real phone number that is not your personal one. Options:
- Twilio — buy a programmable number that can receive SMS, then read the inbound
verification SMS in the Twilio console and pass that code to
signal-cli … verify. - Google Voice — a free second number (US).
- A prepaid second SIM.
Caveat: the number must be able to receive the SMS or voice verification code — Signal rejects some VoIP numbers. Once you have a number, do the captcha → register → verify flow (see Setting up the remote control plane below).
- Create a Tailscale account.
- Install the Tailscale app on both the host Mac and your phone / remote device.
- Sign both into the same tailnet.
- Enable MagicDNS (so you get the
host.tailnet.ts.netname) and either Tailscale SSH or a normal SSH server on the Mac. - Install
moshon the Mac (the default attach transport;brew install mosh).
Switchboard reaches the dashboard over tailscale serve (tailnet-only) — never a public
port, and never tailscale funnel.
macOS · Node 26 (use the committed .nvmrc) · Xcode Command Line Tools + Python (for the
native better-sqlite3 build path) · git · tmux · mosh/mosh-server · the claude CLI
(authenticated) · optionally the codex CLI (authenticated — required for coordinate,
optional for Claude-only installs) · signal-cli (a Java tool — needs a JRE).
git clone https://github.com/rmogull/switchboard && cd switchboard
npm install
npm run build
npm link # puts the `switchboard` bin on your PATH
switchboard init # scaffold config + home/state dirs + state db
# edit switchboard.config.json (home, stateDir, Signal number, asset paths, repos)
switchboard doctor # validate config + check dependencies (incl. native module)The
switchboardcommand exists only afternpm run build+npm link(or a global install). Without that, invoke the built CLI directly asnode dist/cli/index.js …. Pick one style and use it consistently — this README usesswitchboard …throughout.
init writes the config first (mode 0600, with a freshly generated dashboard token),
then creates the home/stateDir directories (mode 0700) and the state DB. Everything
environment-specific lives in switchboard.config.json (gitignored), so the code carries no
personal data (see switchboard.config.example.json).
Do not put
stateDirinside an iCloud / Dropbox / Drive-synced folder. It holds a live SQLite WAL database and git coordination scratch; sync managers corrupt or evict those files mid-write. KeepstateDiron a local, non-synced path (the example points it at~/Library/Application Support/switchboard).
- Register your dedicated Signal number (the separate number from Prerequisites):
Set
# solve the captcha at https://signalcaptchas.org/registration/generate.html, # copy the signalcaptcha:// link, then: signal-cli -a +1XXXXXXXXXX register --captcha "signalcaptcha://..." signal-cli -a +1XXXXXXXXXX verify 123456 # the code arrives by SMS/voice to that number
signal.account(this number),signal.allowlist(your personal number — the only sender allowed to command it), andsignal.enabled: truein your config. - Run the daemon (kept alive by launchd):
switchboard install-daemon # or --render-only to inspect the plist first - Expose the dashboard on your tailnet by setting
tailscale.serve: true(the daemon runstailscale servefor you; it stays localhost-bound otherwise). Nevertailscale funnel— that would put a full operator console on the public internet.
The dashboard can kill sessions, decide approvals, and spawn sandboxed sessions — it is a control plane, not a viewer.
initauto-generatesdashboard.tokeninto your config.- Open it as
http://<host>:<port>/?token=<token>.initanddoctorboth print the exact URL with the token filled in. Every/apiroute requires the token (a request without it gets401). - It refuses to start exposed without a token. Setting a non-loopback
bindAddressortailscale.serve: truewith no token set is a startup error. - Any device on your tailnet that has the token is a full operator. Treat the token like a root credential; it can kill sessions, decide approvals, and spawn sandboxed sessions.
switchboard attach <id> prints the exact ssh/mosh command for an interactive session
(the dashboard shows the same string to copy). The command is driven by the attach config
block:
attach.sshHost— your Mac over the tailnet (e.g.user@your-mac.your-tailnet.ts.net). Unset →attachemits only a bare local tmux command.attach.transport—mosh(default; survives phone sleep/roaming) orssh(for clients without mosh).attach.moshServerPath— absolute path tomosh-serveron the Mac (injected into the connection string because a non-login SSH PATH usually omits Homebrew's bin).
Run that printed command in any terminal app that speaks ssh/mosh:
| Platform | App | Notes |
|---|---|---|
| iOS | Blink | mosh and ssh — default mosh transport works. |
| iOS | Termius | ssh only — set attach.transport: "ssh". |
| iOS | a-Shell | ssh/mosh from a local shell. |
| iOS | Panic Prompt | one-tap via the attach.promptFavorite deep link. |
| Android | Termux | mosh — default transport works. |
| Android | JuiceSSH | ssh only — set attach.transport: "ssh". |
| Desktop | iTerm2 / Terminal / WezTerm | mosh or ssh. |
| Desktop | Windows Terminal | ssh only — set attach.transport: "ssh". |
mosh-capable clients use the default mosh transport; ssh-only clients need
attach.transport: "ssh".
switchboard init | doctor
switchboard spawn --client claude|codex --mode deliverable|interactive|coordinated [--repo|--dir] [--task] [--control] [--sandbox]
switchboard list | kill <id> | attach <id>
switchboard coordinate --task <text> [--repo|--dir] [--max-iterations N]
switchboard memory list | show [file] | promote <id> | reject <id> | propose ...
switchboard learn list | promote <id> | rules
switchboard daemon | dashboard
switchboard install-daemon [--render-only|--dev] | uninstall-daemon | daemon-status
Over Signal you send natural-language commands, reply y/n to approvals, and
promote <id> to confirm an auto-allow suggestion.
Key fields in switchboard.config.json (gitignored):
home— runtime home directory (working memory + skills root). Override withSWITCHBOARD_HOME.stateDir— operational state (DB, scratch, sessions, logs). Defaults to<home>/switchboard; keep it on a local, non-synced path (see the iCloud/Dropbox warning above).signal.account/signal.allowlist— the registered number, and the only senders allowed to command it.clients.claude/clients.codex— enable/disable and pin CLI paths.assets/repos— named template/asset paths and known repo locations.policy— overrides for the default permission matrix and a global egress allowlist.approvals.timeoutMs/onTimeout— how long a blocking tool call waits, and the fail-closed default.dashboard.token— bearer token required on every/apiroute (initgenerates it).tailscale.serve— expose the dashboard on the tailnet (viatailscale serve, never funnel).attach.sshHost/attach.transport/attach.moshServerPath— remote attach target and transport.
git pull
npm ci
npm rebuild better-sqlite3 # only if you changed Node major versions since last build
npm run build
switchboard install-daemon # re-render + reload the launchd agentswitchboard uninstall-daemon removes only the launchd plist (it stops the daemon). It does
not delete your data or undo external state. Clean these up separately:
- the DB /
stateDir(your sessions, scratch, logs) and thehomedirectory; - the
tailscale servemapping (e.g.tailscale serve --https=443 off); - the
signal-cliregistration for the dedicated number (unregister viasignal-cli).
NODE_MODULE_VERSION/ "compiled against a different Node.js version".better-sqlite3is a native module and its ABI is tied to the Node major you built against. Runnpm rebuild better-sqlite3(and after any Node version switch).switchboard doctordetects this in its native-module preflight, prints this exact remedy, and exits non-zero instead of crashing. Node 20 has no prebuild, so it forces a from-source build that needs the Xcode Command Line Tools.- Daemon won't start / crash-loops. Check
<stateDir>/logs/daemon.out.logand<stateDir>/logs/daemon.err.log, and runswitchboard daemon-status. launchd starts the daemon with a minimalPATH, so pin absolute binary paths in config (clients.*.cliPath,tailscale.binPath,attach.moshServerPath) when a tool isn't found. signal-clicaptcha / rate-limit failures. Re-solve the captcha athttps://signalcaptchas.org/registration/generate.htmland retryregister; Signal rate-limits registration, so wait before retrying. Confirm the dedicated number actually received the SMS/voice code (some VoIP numbers are rejected).- Dashboard returns
401. You opened it without the token. Use the fullhttp://<host>:<port>/?token=<token>URL thatinit/doctorprint. - Port already in use. Another process holds the dashboard port — change
dashboard.port(default8765) or free the port.
npm run typecheck # tsc --noEmit
npm test # vitest
npm run build # tsup → dist/See SECURITY.md for the threat model and CONTRIBUTING.md
for the dev workflow. The full design brief is in
specification/orchestrator-spec.md.
