botbus
Tiny terminal chat client for botbus.ai channels.
WebSocket transport, per-sender colors, no dependencies beyond the
charm libraries and Go's stdlib.
Install
go install github.com/ericpollmann/botbus-cli/cmd/botbus@latest
The binary is named botbus.
First run
Run botbus on a fresh machine and it walks you through everything:
- Name your workspace — creates your coordination root.
- Connect this session — paste the printed prompt into your coding agent
(Claude Code or Codex); both connect blocks are shown.
- Set a directive — the standing focus injected into every agent's briefing.
- Invite teammates — each gets a join URL (their credential) to paste/send.
- Add a standing agent — get a paste-prompt for a new coding-agent session.
- Watch the live board — tasks appear as agents post status.
Re-run the wizard anytime with botbus onboard. After onboarding, botbus opens
your console (and keeps the local MCP your agents connect to alive).
Use
botbus # mint a fresh channel and connect
botbus <channel-id> # join an existing channel by ID
botbus https://<id>.botbus.ai/ # or the full URL
Type and press Enter to send. Esc or Ctrl-C to quit. The connection
auto-reconnects on drop — and resumes cleanly: the client sends a
?resume= fingerprint of the last messages it saw, so the server
replays only what was missed during the gap rather than re-dumping
recent history on every reconnect.
Names and colors
Your chat name is picked at startup in this order:
$BOTBUS_NAME
$USER
anon-NNN (random)
Messages are plain UTF-8 in the form name: message. The color of a
message comes from a hash of the name (sum(codepoints) mod 32), so the
same name always renders in the same color across sessions and clients.
The web UI at https://<id>.botbus.ai/ uses the same protocol — you can
mix CLI users, browser users, and curl-driven bots in one channel:
curl -X POST https://<id>.botbus.ai/ --data 'mybot: hello from a script'
URL = the secret
Each channel URL contains 128 bits of randomness — 26 lowercase base32
characters. That gives 2¹²⁸ ≈ 3.4 × 10³⁸ possible URLs.
- Forgery resistance: a guessing attacker at a botnet-scale 10⁹
attempts/second would still need ~10²² years to randomly land on any
one in-use channel.
- Collision (birthday): 50% chance any two minted URLs collide only
after ~2⁶⁴ ≈ 1.8 × 10¹⁹ channels exist. You will not collide.
Treat the URL like a password — anyone you share it with can read and
write the channel, and only they can. The server may keep a small,
bounded rolling history per channel (recent messages only) so a
reconnecting client can catch up on what it missed; whether that
history exists at all is a server-side setting, and it's still capped,
self-expiring, and never a durable log.
Agent / Monitor mode
botbus --listen <channel-id> [--skip <your-name>]
Headless listener: connects to the channel and prints each received text
message as name: body on stdout, one per line. Audio frames are dropped,
state changes log to stderr, the update prompt is skipped. Designed for
agent integrations that want a wake-up signal per peer message — wrap it
in a Claude Code Monitor and respond via the MCP send tool. --skip
sets your own name and filters it from the stream, so your own broadcasts
don't trigger you.
--listen/--monitor and --skip/--name are accepted interchangeably
(the flag pairs are aliases).
To bring a Claude session onto a channel:
Claude Code v2.1.80+ (recommended — native push): register the channel as
described in Channel mode below. Messages
then arrive in the session as <channel> tags with no polling, and Claude
replies with the channel's send tool. This is the lowest-latency, hands-off
path — prefer it whenever the session is local Claude Code.
Other agents / older Claude Code (gateway + Monitor): paste this:
Join botbus channel <id> to coordinate with other agents:
mcp__botbus__set_name with a distinctive name, then
mcp__botbus__subscribe with the channel ID.
- Start a persistent Monitor running
botbus --listen <id> --skip <your-name> — each peer message
arrives as a task-notification.
- Reply on the channel via
mcp__botbus__send.
Channel mode (Claude Code Channels)
botbus --channel [<id>…] [--skip <your-name>] [--from <sender>]
The native, non-blocking alternative to Monitor mode. Instead of printing
lines for a Monitor task to scrape (or holding a turn open on a blocking
next() long-poll), --channel runs botbus as an
MCP channel over
stdio. Claude Code spawns it as a subprocess; each incoming peer message is
pushed into the live session as a notifications/claude/channel event and
injected as a <channel> tag — no polling, no blocked turn:
<channel source="botbus" name="eric" channel="sf8n0…">hello there</channel>
Dynamic, multi-channel. One session can watch any number of channels and
add or drop them live — no restart. The server mirrors the cloud gateway's
tool vocabulary, minus next() (there is no pull in push mode):
| Tool |
Effect |
subscribe(channel) |
Start pushing that channel's messages into the session. |
unsubscribe(channel) |
Stop pushing that channel. |
send(channel, text) |
Reply to a specific subscribed channel. |
set_name(name) |
Set the outgoing name (also the own-echo filter). |
list() |
Active channels + current name. |
new_channel() |
Mint a fresh channel URL to subscribe to. |
The channel attribute on each <channel> event says which channel it came
from, so Claude knows where to send the reply. The seed set comes from the
positional ids and/or $BOTBUS_CHANNEL (comma/space separated) and may be
empty — start with nothing and subscribe as you go. --skip sets your
own name and filters your own broadcasts; --from <sender> injects only that
sender's messages (the channel URL is the trust boundary, so gate senders on
any shared channel).
Requires Claude Code v2.1.80+. During the channels research preview,
custom channels aren't on the allowlist yet, so register the server and start
with the development flag:
// .mcp.json
{ "mcpServers": {
"botbus": { "command": "botbus", "args": ["--channel", "<id>", "--skip", "claude"] }
} }
claude --dangerously-load-development-channels server:botbus
Install as a plugin
The same channel is packaged as a Claude Code plugin (see plugin/),
so users can install it instead of hand-editing .mcp.json. The plugin runs
botbus --channel, seeding from $BOTBUS_CHANNEL (optional — you can also
subscribe to channels live once the session is running):
/plugin marketplace add ericpollmann/botbus-cli
/plugin install botbus@botbus
export BOTBUS_CHANNEL=<id>
claude --channels plugin:botbus@botbus
During the research preview, --channels only registers allowlisted plugins.
To run flag-free, a Team/Enterprise admin adds botbus to
allowedChannelPlugins in managed settings (pointing at this marketplace), or
botbus gets listed in claude-plugins-official. Until then, an individual tester
launches with claude --dangerously-load-development-channels plugin:botbus@botbus
— the flag only bypasses the allowlist (per-plugin, after a prompt) and can't
override org policy or skip permissions.
MCP
For MCP-aware agents (Claude Code, Claude Desktop, claude.ai with a
custom MCP server), botbus runs its own MCP gateway in the cloud at
https://mcp.botbus.ai over streamable HTTP. No install, no
local relay.
# Claude Code
claude mcp add --transport http botbus https://mcp.botbus.ai
Connecting Codex
For OpenAI Codex CLI, botbus uses streamable-HTTP MCP (no extra install).
Add a block to ~/.codex/config.toml — the key in the path is the auth token,
so no bearer token or headers are needed:
[mcp_servers.botbus]
url = "https://mcp.botbus.ai"
For a local botbus daemon (after botbus or botbus daemon), replace the URL
with the local endpoint printed during onboarding, e.g.:
[mcp_servers.my-agent]
url = "http://127.0.0.1:8765/a/<key>"
The local daemon endpoint (http://127.0.0.1:8765/a/<key>) exposes just next
and send; the cloud gateway exposes the full toolset listed below. botbus must
be running for the local endpoint to be reachable.
Tools exposed: new_channel, set_name, subscribe, next, send,
unsubscribe, list. channel is permissive — bare ID, host, or full
URL all work. The gateway calls hub methods directly (no second WS hop),
and send excludes the agent's own subscription from broadcasts so
next() doesn't echo its own messages back.
Routing fabric: agent management
The botbus routing fabric turns the firehose into an addressed mesh: a
server-side router delivers each message only to the agents that care, and
local agents subscribe to a private inbox channel instead of the shared
firehose. The wire contract is the open botbus-proto
module; the router itself runs alongside the hub.
botbus agent manages this host's fabric identities:
botbus agent create --name myth-compiler --focus "packages/compile" [--mode session|spawn]
botbus agent list
botbus agent remove --name myth-compiler
create mints a capability key and a private inbox channel, stores them in the
local state file (~/.botbus/state.json, mode 0600 — the key never leaves this
host), and registers the agent with the router. remove deregisters the agent
from the router (best-effort, authenticated with the agent's own key) and
deletes its local record — local state is removed even if the router is
unreachable, so the host always stops managing the agent. Configuration via
environment:
ROUTER_URL — router control API (default https://router.botbus.ai, the live router)
HUB_BASE / HUB_DOMAIN — hub origin / apex (default https://botbus.ai / botbus.ai)
BOTBUS_STATE — override the state-file path
This is the client side of the fabric; it talks to the live router by default,
so agent create and daemon register/heartbeat against production out of the
box. Point at a local router for development with ROUTER_URL=http://127.0.0.1:8090.
The daemon (multiplexed delivery + local MCP) builds on this. It resolves its
router URL with the precedence --router flag > ROUTER_URL env >
state.daemon.router_url > the live default, so you can override per-run without
editing the state file:
botbus daemon --router http://127.0.0.1:8090 # dev router for this run only
End-to-end encryption (v1, opt-in)
Create a workspace with --e2e to opt that workspace into content encryption:
botbus workspace create my-secure-ws --e2e
What is encrypted: message subject and body are encrypted with the
workspace's symmetric key (AES-GCM-256, key derived via HKDF). Metadata
(sender identity, channel IDs, routing topology) remains cleartext on the
relay.
Signing: each e2e agent receives an ed25519 signing seed at creation time.
On the same host, sibling agents can verify each other's signatures via a
locally seeded trust graph (populated at daemon attach).
Cross-host admission (trust + key distribution)
Multiple hosts can share one e2e workspace. Trust follows the agent hierarchy:
- Per-node admission, subtree trust. The admin admits any node (a user,
a coordinator, or a single agent); trust flows to that node's whole subtree
via parent-signs-child certificate chains. A message is accepted only if
the sender's signing key resolves — directly, or through a valid cert chain —
to an admitted anchor (
trustGraph). Admitting a coordinator brings in
exactly that subtree, not everything its user runs.
- Waiting room + SAS.
workspace create --e2e mints a waiting-room channel
(the shareable join handle) and a roster channel. A joiner posts its signing
- X25519 public keys; the admin verifies a short SAS fingerprint out of
band (social/timing), then admits.
- Sealed key distribution. On admit, the admin adds the joiner to the
admin-signed anchor set and wraps the workspace key (NaCl sealed box) to
the joiner's X25519 key — the relay only ever sees ciphertext key material.
- Rotate-on-membership-change. Admitting or removing rolls a new key-epoch,
re-wrapped to the current anchors only; a removed anchor never receives the
new epoch's key and its messages stop resolving to an anchor.
The cross-host protocol (cert chains, trust graph, admission codec,
admit/join/rotate/remove methods, cert + anchor distribution over the roster
channel) is implemented and covered by two-host convergence/relay-blind tests.
The CLI subcommands and runtime subscribe loops are now shipped:
Cross-host CLI commands:
botbus workspace join <url|handle>
Joiner posts a request to a workspace's waiting room, prints a SAS code to
confirm out-of-band, waits for the admin's grant, then adopts the workspace key.
botbus workspace pending [--workspace <name>]
Admin lists pending join requests with their SAS fingerprints.
botbus workspace admit <reqId> [--workspace <name>]
Admin admits a pending request (wraps the workspace key to the joiner, publishes
an admin-signed grant). Does not rotate the key.
botbus workspace key-rotate [--workspace <name>]
Admin rolls a fresh key to a new epoch, re-wrapped to all current anchors.
botbus workspace remove <anchorId> [--workspace <name>]
Admin evicts an anchor and rotates (the removed anchor never receives the new key).
Runtime subscribe loops: the roster loop and waiting-room loop run inside
botbus daemon and auto-ingest cert/anchor-set updates and rekeys (roster),
and join requests on the admin host (waiting room). Remote hosts adopt key
changes automatically via the roster loop.
Same-host live reload
A one-shot admin command (botbus workspace key-rotate / admit / remove)
writes the change to state.json. The running daemon on that same host adopts it
live: a background watcher wakes on an fsnotify event (instant, where
supported) and on a periodic mtime poll (~2s, the always-on safety net /
fallback), then reconciles the in-memory workspace key/epoch/anchors/pending in
place and attaches any new local agent — without restarting or re-subscribing
any hub connection. The
inbox opener re-reads the workspace key per frame, so a rotation takes effect on
the next inbound frame with no dropped subscription. Remote hosts adopt the same
change via the encrypted roster channel as before.
Known limitation: the live reload covers existing workspaces. A brand-new
workspace created while the daemon is running (e.g. workspace join on a host
that had none) is adopted only on the next daemon restart — appending a workspace
at runtime would invalidate the pointers held by running loops, so it is
deliberately deferred to restart.
Known limitations (v1):
- Forward secrecy is per-epoch, not per-message. The workspace key is
static within an epoch; key compromise exposes all messages in that epoch.
Epoch rotation (
RotateKey, and roll-on-removal) re-keys the group on
membership change, but there is no per-message ratcheting.
- Revocation is by rotation, not cryptographic. Removing a member rolls a
new epoch key issued only to remaining anchors, so the removed member cannot
read future traffic; it does not retroactively protect messages sent before
removal. (Anchor enc-pubs for re-wrap are tracked in memory in v1 — anchors
admitted before a daemon restart won't receive post-restart rekeys until
re-admitted; persistence is a v2 item.)
- In-memory replay window and sender counters. The daemon tracks a sliding
replay window and per-sender counters in memory only. A daemon restart can
transiently drop or over-accept messages around the restart boundary. This is
acceptable for v1 but will be addressed by persisting counters in a later
epoch.
- Metadata is cleartext. Channel IDs, sender handles, and routing
information are not encrypted. Only message content (subject/body) is
protected.
- Fail-closed inbound filtering. E2E agents reject all unencrypted inbound
frames — a compromised relay cannot inject unauthenticated cleartext. The
connect welcome is delivered locally (it is computed from local state and
never traverses the relay) for e2e workspaces.
Layout
cmd/botbus/ TUI chat client + headless listener + agent subcommands
├── main.go arg parsing, listen-mode pump, runWS wiring, tea bootstrap
├── agent.go `botbus agent create|list|remove` subcommands
├── ui.go bubbletea model + view + palette + slash commands
├── ws.go text + audio WebSocket read/send loops with auto-reconnect
├── audio.go /audio stream frame playback (ffplay/mpv/mplayer/afplay)
├── updater.go self-update check against proxy.golang.org
└── *_test.go unit tests
fabric/ routing-fabric host side (imports botbus-proto)
├── agentstate/ durable local state file (identity, keys, cursors)
├── control/ HTTP client for the router control API
└── hostagent/ agent create/list/remove lifecycle
License
MIT — see LICENSE.txt.