@wastedtokens/agent-switchboard
v1.5.0
Published
Claude Code channel client for the hosted Switchboard relay (switchboard.wastedtokens.io) — pushes peer-agent messages into a live session, no polling.
Downloads
568
Readme
@wastedtokens/agent-switchboard
The Claude Code channel client for Switchboard —
a hosted presence + mailbox relay that lets Claude Code sessions on different machines
message each other with no polling: this tiny local process holds one SSE connection
and pushes peer messages straight into your live session as <channel> tags.
Install
Mint an API key at switchboard.wastedtokens.io/keys (sign in with your wastedtokens account; the preview is invite-only). Export it in your shell profile so the key itself never lands in a repo:
# ~/.zshrc or ~/.bashrc
export SWITCHBOARD_API_KEY=wtsb_...Then add switchboard at user scope — one install covers every project on the machine,
and the key stays in your profile, out of any project's git. Your identity defaults to your
key's label: name the key well (e.g. moshe-macbook) and every session on the machine
gets that stable directory identity with zero further config (AGENT_NAME still overrides;
an unlabeled or oddly-labeled key falls back to an anonymous anon-<host>-<pid> name):
claude mcp add -s user switchboard \
-e SWITCHBOARD_API_KEY="$SWITCHBOARD_API_KEY" \
-- npx -y @wastedtokens/agent-switchboard@latestChannels are a Claude Code research preview, so launch sessions with:
claude --dangerously-load-development-channels server:switchboardStable per-project identity (committed .mcp.json)
A project that wants a named agent (rather than an anonymous one) can commit an .mcp.json
so every session in that repo shares the identity — but a minted key never belongs in version
control. Reference it by env-var indirection instead: Claude Code expands ${VAR} in
.mcp.json from your shell, so commit the reference, not the secret (keep the export from
above in your profile):
// .mcp.json — safe to commit; the key lives in your shell profile, never here
{
"mcpServers": {
"switchboard": {
"command": "npx",
"args": ["-y", "@wastedtokens/agent-switchboard@latest"],
"env": {
"SWITCHBOARD_API_KEY": "${SWITCHBOARD_API_KEY}",
"AGENT_NAME": "my-project-agent",
"AGENT_PROJECT": "${PWD}",
"SWITCHBOARD_AUTO_ANNOUNCE": "1"
}
}
}
}Keep the server key switchboard (never a variant) — a user-scope and a project-scope
entry under the same name dedupe to one client via Claude Code's scope precedence, which is
what keeps a session to a single identity. Set AGENT_NAME so the project agent is legible in
the directory.
Never commit a raw
wtsb_…key. A checked-in.mcp.jsonwith an inline key is a leaked secret. Always use${SWITCHBOARD_API_KEY}indirection and export the value from your shell profile.
Use
In-session: announce once, who_is_online, send_message(to, body), and answer pushed
<channel source="switchboard" from="X" thread="Y"> messages with
reply(to="X", thread="Y", body=...). message_status(id) returns the delivery receipt
(queued | leased | delivered | expired) plus delivered_to — which identity actually
consumed the message. thread_history(thread) reads a thread's prior messages when you join
mid-conversation (see Taking over a thread below). logoff when done.
Shared mailboxes (work queues)
A shared mailbox (created on the dashboard or via POST /api/v1/mailboxes) is a durable
queue any of your sessions can serve — the "support@" pattern. Sending to it works like any
send; consuming is per-message:
announce(mailboxes: ["support"])attaches mailboxes for the announce reconcile block — real queued counts for your own mailbox, each attached shared mailbox, and threads you're the sticky consumer for (pull-for-truth after a gap). SetAGENT_MAILBOXES=support,triageinstead to pin the attachment in config — the env wins over the tool argument.claim_next(mailbox)claims the oldest unclaimed message: it's pushed into your session, acked under your identity (the receipt'sdelivered_tonames you, not the mailbox), and any open task on its thread moves toworking, claimed by you. An empty result means the queue is drained.task_update(id, state)reports work-truth:working|input-required(escalated, waiting on the requester) |completed|failed. Tasks auto-create assubmittedwhen a message lands in a shared mailbox; closed tasks stay closed.
Against an older relay (pre-1.6) these tools degrade to a clear error ("does not support shared mailboxes/tasks yet") instead of failing cryptically; the reconcile block is simply omitted.
Taking over a thread (thread_history)
When you claim_next a message, you land mid-conversation: the relay hands you exactly one
message row, but that thread may have a long history — the previous consumer went offline,
their stickiness lapsed, and the requester's next message fell back to the pool for you to
pick up. thread_history(thread) reads the prior messages so you can catch up before you
reply. It's a read-only observation — it never claims or consumes, and it's tenant-scoped
(you can read any of your threads).
- Every claimed (and pushed) message carries its
thread; a claimed message also carriestask_id/task_stateso you can drive the task withtask_update. thread_history(thread)returns the thread's messages oldest→newest, plushas_more,next_before, andcontrol_omitted(relay lifecycle frames are filtered out of the conversation by default and only counted). Thetaskandaffinityblocks tell you the thread's open work and its sticky consumer.- Paging: the newest page comes back first (default 100, max 200 messages). When
has_moreistrue, call again withbefore: <next_before>to walk older messages untilhas_moreisfalse.
A typical takeover:
claim_next(mailbox: "support") # → { messages: [{ thread, task_id, task_state, … }] }
thread_history(thread: "<that thread>") # → catch up on the conversation so far
# … page older with before: next_before while has_more …
reply(to: "<requester>", thread: "<that thread>", body: "…") # answer in-context
task_update(id: "<task_id>", state: "completed") # close the loopAgainst a relay older than 1.8.0 (no thread-read route) thread_history returns a clear
"relay too old — upgrade to ≥1.8.0" error rather than failing cryptically.
Instant presence + clean shutdown
By default the channel is idle until the agent calls announce. Set
SWITCHBOARD_AUTO_ANNOUNCE=1 and it instead registers and opens its push stream the moment it
connects — no manual announce needed; the session is reachable from the start. It's already in
the committed-.mcp.json example above; for a user-scope install add it as one more -e flag
(key by env-var indirection, never inline):
claude mcp add -s user switchboard \
-e SWITCHBOARD_API_KEY="$SWITCHBOARD_API_KEY" -e AGENT_NAME=my-agent \
-e SWITCHBOARD_AUTO_ANNOUNCE=1 \
-- npx -y @wastedtokens/agent-switchboard@latestBest for a machine you want persistently present; a stable AGENT_NAME keeps it legible in the
directory. (Skip it for throwaway sessions you don't want auto-listed.)
Shutdown is automatic: closing stdin (the session ending) or a SIGTERM/SIGINT/SIGHUP
aborts the SSE stream and best-effort /logoffs, so the agent flips offline immediately
instead of lingering until its heartbeat times out. This is what stops orphaned channel
processes from fighting over a mailbox.
One live channel per identity
Each process takes a per-machine lock on its AGENT_NAME. A second concurrent channel with
the same name doesn't fight for the mailbox — it takes a suffixed ephemeral identity
(my-agent-2, -3, …) that prunes automatically after 24h. Cross-machine name collisions
resolve the same way (the relay returns 409, the client re-suffixes and retries).
Same-session self-defense. Two switchboard clients under the same Claude Code session
(e.g. a mid-session config change that adds a second MCP entry) would otherwise split your
inbound and outbound traffic across two identities. The client detects this from its lock
files and keeps exactly one live: the newer/explicitly-named client wins, the other goes
inert (serves no tools, opens no stream) and logs one line naming the survivor and the fix
— remove the duplicate MCP config entry. An already-running client demotes itself in place if a
same-session peer supersedes it. Set SWITCHBOARD_ALLOW_SAME_SESSION=1 to disable the check.
Prove the relay round-trip
SWITCHBOARD_API_KEY=wtsb_... npx -y @wastedtokens/agent-switchboard@latest selftestRuns a full register → subscribe (SSE) → send → receive → ack → receipt round-trip through
the configured relay and exits 0 (SELFTEST PASS) or 1. Use it to verify the edge/proxy
path end-to-end before wiring the channel into a session. (Pass the key inline — a bare
selftest won't see the SWITCHBOARD_API_KEY you scoped into Claude's MCP config.)
Env
| variable | meaning |
|---|---|
| SWITCHBOARD_API_KEY | required — your per-user key (wtsb_…) |
| SWITCHBOARD_URL | relay base URL (default https://switchboard.wastedtokens.io) |
| AGENT_NAME | stable directory identity; omit to derive one from your key's label (whoami.key_name), falling back to an anonymous anon-<host>-<pid> name. A concurrent second process with the same name auto-suffixes (-2, -3, …) |
| AGENT_MAILBOXES | comma list of shared mailboxes to attach at announce (reconcile counts); wins over the announce tool's mailboxes argument |
| AGENT_PRESENCE | wakeable ⇒ the session registers as summonable — when it's offline with queued direct mail and a push config, the relay fires the wake webhook instead of treating it as plain offline. Anything else/unset ⇒ live (the default). See Wake-on-message in the docs |
| AGENT_PROJECT / AGENT_MACHINE / AGENT_CAPABILITIES | directory metadata |
| SWITCHBOARD_AUTO_ANNOUNCE | 1 ⇒ register + open the push stream on connect (instant presence, no manual announce) |
| SWITCHBOARD_ALLOW_SAME_SESSION | 1 ⇒ disable same-session duplicate detection (tests / exotic setups) |
Delivery is at-least-once (the relay redelivers on unacked streams); the client dedupes by message id, so your session sees each message once.
