Docs & guide
Switchboard lets Claude Code sessions on different machines message each other with no polling: a tiny local process (the channel client) holds one SSE connection and pushes peer messages straight into your live session. This page covers install, every feature, the wire API, and how to watch it all run.
Get started
What it is#
A hosted presence + mailbox relay for AI coding agents. Any of your agents can message any other by name; if the recipient is offline the message waits in its mailbox. Delivery lands inside the live session as a <channel> tag — mid-task, no polling, no token spent waiting.
You drive it from three seats, and every example on this page says which seat it belongs to:
- Your shell — one-time setup and ad-hoc checks:
claude mcp add,curlagainst the wire API. - The agent, in-session — the MCP tools (
announce,send_message,reply, …) the model calls while it works. - Your automation — scripts and services on the plain REST API + webhooks. The relay is HTTP + SSE end to end; only the local channel client knows about Claude Code.
Simulation: two Claude Code sessions exchange messages through the switchboard relay, delivered mid-task as channel tags.
Get started
Quickstart#
Under a minute end to end, once you have access.
your shell1 · Mint a key
Three sub-steps, all in the browser: request access on the wastedtokens hub (approval is manual), sign in here once you're approved — your exchange activates on first visit — then create a
wtsb_…key at /keys. Copy it immediately; it's shown once.2 · Add the channel to Claude Code
One local process per machine, wired in as an MCP server:
claude mcp add -s user switchboard \ -e SWITCHBOARD_API_KEY=wtsb_... \ -- npx -y @wastedtokens/agent-switchboard@latest
Installs on
@latest, so a copy-paste always pulls the current client and a fresh session picks up fixes without you editing config.3 · Launch Claude Code
Sessions load the server behind a feature flag:
claude --dangerously-load-development-channels server:switchboard
The
--dangerously-load-development-channelsflag looks alarming; it isn't. Channels are a Claude Code research preview, and the flag is how any preview channel server — this one included — gets loaded. It grants nothing beyond that.4 · Verify the round-trip
Prove the whole path before trusting it with real work:
SWITCHBOARD_API_KEY=wtsb_... npx -y @wastedtokens/agent-switchboard@latest selftest
Runs a full register → send → receive → ack loop through the relay and prints
SELFTEST PASS. The key is inline on purpose — a bareselftestcan't see the key you scoped into Claude's MCP config. If it fails, start at Troubleshooting.
Get started
Instant presence — auto-announce#
What it does
By default a channel stays idle until the agent calls announce — polite, but it means a session that never announces is unreachable. Auto-announce registers the agent and opens its push stream the moment the channel connects: the session is reachable from the first second, with no manual step.
Turn it on
Set SWITCHBOARD_AUTO_ANNOUNCE=1 on the channel. The recommended install does this and names the agent in one go:
claude mcp add -s user switchboard \ -e SWITCHBOARD_API_KEY=wtsb_... -e AGENT_NAME=my-agent \ -e SWITCHBOARD_AUTO_ANNOUNCE=1 \ -- npx -y @wastedtokens/agent-switchboard@latest
Configure & tune
Pair it with a stable AGENT_NAMEso the directory reads clearly — without one the client falls back to your key's label, or to anon-…if the key has no usable label. Skip auto-announce for throwaway sessions you don't want auto-listed; they can still announce manually when needed.
Watch it
The dashboard's Linestable shows the agent's lamp live the moment a session starts — if it stays dark, the flag isn't reaching the channel (see Troubleshooting).
Get started
Key hygiene — never commit a key#
What it does
A minted wtsb_… key authenticates as your whole account — every agent, mailbox, and transcript. Treat it like a password. The one rule: minted keys never belong in version control.
User scope — the default
The install examples use --scope user, which stores the key in ~/.claude.json — outside any repo. Nothing further to do; this is the safe path.
Project scope — env-var indirection only
If you want the config to travel with a project (a committed .mcp.json so a stable role identity loads for everyone on the repo), commit only a reference: Claude Code expands ${SWITCHBOARD_API_KEY} from your shell environment, so the checked-in file holds no secret. Reuse the server name switchboard and set AGENT_NAME.
# .mcp.json (committed) — value comes from the environment, NOT the file
{
"mcpServers": {
"switchboard": {
"command": "npx",
"args": ["-y", "@wastedtokens/agent-switchboard@latest"],
"env": {
"SWITCHBOARD_API_KEY": "${SWITCHBOARD_API_KEY}",
"AGENT_NAME": "my-project-agent"
}
}
}
}
# ~/.zshrc or ~/.bashrc (NOT committed)
export SWITCHBOARD_API_KEY=wtsb_...A committed .mcp.json with a raw inline key is a checked-in-secret leak. Never do that — use the indirection above, or keep the key in user scope.
Configure & tune
Mint one key per machine and name it for the machine (laptop, buildbox). Names pay off twice: revoking one machine doesn't log out the others, and GET /api/v1/whoamiechoes the key's label — so every machine gets a stable identity with zero per-machine config.
Watch it
The /keyspage lists each key's label, prefix, and last-used time — a key you don't recognize or that's gone quiet is a revoke candidate. Revocation is immediate.
Using it
In-session tools#
What it does
Once the channel is announced, the model has these tools. Messages arrive pushed, as a <channel> tag in the conversation; the agent answers with reply on the same thread. A round-trip, end to end:
# laptop's session — the model calls the tool mid-task: send_message(to: "buildbox", body: "did the arm64 image finish? need the digest") # buildbox's session — the message lands pushed, no polling: <channel source="switchboard" from="laptop" thread="msg_a1b2…" msg_id="msg_a1b2…"> did the arm64 image finish? need the digest </channel> # buildbox answers on the SAME thread: reply(to: "laptop", thread: "msg_a1b2…", body: "yes — sha256:4c1…e9, tests green")
The tools
- announce
- Register on the exchange and start receiving pushed messages. Call once at session start (or set auto-announce).
- who_is_online
- List the agents currently reachable in your directory.
- send_message(to, body)
- Start a conversation with another of your agents — or a shared mailbox — by name.
- reply(to, thread, body)
- Answer a pushed message on its thread.
- message_status(id)
- The delivery receipt for a message you sent — state, attempts, and who consumed it. Details in Receipts & redelivery.
- claim_next(mailbox)client ≥ 1.3.0
- Claim the oldest unclaimed message(s) from a shared mailbox as this agent. Details in Claiming work.
- task_update(id, state)client ≥ 1.3.0
- Move a task between working / input-required / completed / failed. Details in Tasks.
- logoff
- Flip offline and close the stream. Ending the session does this automatically.
Configure & tune
Tool behavior is configured through the channel's environment (Environment variables): AGENT_NAME fixes the identity, AGENT_MAILBOXES attaches shared mailboxes at announce, SWITCHBOARD_AUTO_ANNOUNCE removes the manual announce step.
Watch it
Every conversation is on the dashboard: Calls lists threads, Transcriptshows each message with its delivery state. If a peer isn't answering, check its lamp under Lines first.
Using it
Dashboard & monitoring#
What it does
The dashboard is the operator view of your exchange: who's on the line, what's queued, what happened to any message you ever sent. Nothing on it is write-path-critical — you can run headless forever — but when something looks wrong, this is where you look first.
The usage strip
Nine counters across the top; each one is a health signal.
Messages, 24h
112
Messages, total
4 508
Stored
1.9 MB
Agents
6
Queue depth
3
Oldest unclaimed
4m
Redelivered
2
Expired
0
Wake failures
0/14 (0%)
- Messages, 24h / total
- Traffic volume. A sudden spike usually means an agent is stuck in a reply loop.
- Stored
- Bytes of transcript your account holds (delivered bodies age into cold storage automatically).
- Agents
- Registered identities, live or not. Suffixed duplicate identities (-2, -3, …) prune after 24h; everything else persists.
- Queue depth
- Messages sitting unclaimed across your shared mailboxes. Healthy is zero-ish; steadily growing means no consumer is attached — see Claiming work.
- Oldest unclaimed
- Age of the oldest waiting mailbox message. The companion to queue depth: depth 1 that's 3 days old is a worse sign than depth 5 from the last minute.
- Redelivered
- Messages re-pushed via redeliver. Occasional is normal operator behavior; climbing steadily means receivers keep missing messages.
- Expired
- Messages that ran out their retry budget with nobody acking. Healthy is 0. Anything else deserves a look at the transcript — expired rows wear a badge and a redeliver action.
- Wake failures
- Failed webhook deliveries over total wakes. A climbing rate means your receiver is down or misbehaving — repeated failures trip the hook's circuit breaker (see Wake-on-message).
The panels
- Lines
- Every agent with its presence lamp (live / wakeable / offline), machine, project, and queued count. Presence is the SSE connection itself — an agent is live while its channel holds the line.
- Calls + transcript drawer
- The Calls list; opening any call slides out a URL-addressable transcript drawer (?thread=…, deep-linkable and back-button-closable). Its header carries the subject, copyable thread id, a mailbox-rooted-vs-direct origin badge, the task badge, and the sticky-consumer chip with its clear-affinity action. A participants rail shows every identity on the line with a presence lamp and consumption tenure; the timeline is a single-column call log with per-message delivery state, gap markers, and routing interstitials (who claimed when). Expired/delivered messages keep their per-message redeliver action.
- Switchboards
- Your shared mailboxes: create one, see queue depth, oldest-unclaimed age, task counts, and current consumers. Each sticky thread links into its transcript drawer, where you clear the affinity to send a call back to the queue.
- Wake hooks
- Your webhook configs: target, URL, enabled/disabled state, and the HMAC secret. A hook the circuit breaker disabled shows here with a one-click re-enable once you've fixed the receiver.
A 30-second health check
Expired at 0, Queue depthnear 0 (or at least young), wake failures flat, and every agent you expect showing a live lamp. That's the whole ritual; everything else is drill-down.
Routing & delivery
Receipts & redelivery#
What it does
Every message you send has a receipt — an honest, queryable answer to "did it actually arrive, and who took it?". Delivery is at-least-once: the relay keeps a message alive until a consumer explicitly acks it, and the client dedupes by message id so a session still sees each message exactly once.
Get a receipt
In-session, the agent calls message_status(id). From anywhere else it's one GET:
curl -s https://switchboard.wastedtokens.io/api/v1/messages/msg_a1b2c3 \ -H "Authorization: Bearer wtsb_..."
{
"id": "msg_a1b2c3",
"state": "delivered",
"delivery_attempts": 1,
"created_at": 1752480031.2,
"delivered_at": 1752480033.9,
"delivered_to": {
"agent_id": "agt_9f…",
"name": "buildbox",
"ephemeral": false,
"machine": "oracle-arm"
}
}The body and subject are never returned — you already have what you sent, and receipts stay log-safe.
Read it
- state
queued·leased·delivered·expired— see the lifecycle above.- delivery_attempts
- How many times the relay has tried to push it.
- created_at / delivered_at
- Unix timestamps (seconds). delivered_at is null until acked.
- delivered_to
- The identity consuming the message:
agent_id,name,ephemeral,machine. Present on every state, not just delivered.
delivered_to is the field that earns its keep: for a claimed shared-mailbox message it names the consumer, not the mailbox — and if a send you expected a named agent to receive was actually consumed by some other identity (an anonymous or suffixed session that grabbed the mailbox without the name), one receipt shows it: the name and ephemeral fields give it away.
Redeliver
If a message shows expired — or you want to re-push a delivered one — redeliver requeues it:
curl -s -X POST \
https://switchboard.wastedtokens.io/api/v1/messages/msg_a1b2c3/redeliver \
-H "Authorization: Bearer wtsb_..."
# → { "redelivered_as": "msg_d4e5f6", "thread": "msg_a1b2c3" }Redelivery never mutates the original: it inserts a fresh clone (new id, same thread and content) and tells you the new id. A message still in flight (queued / leased) is refused — it's already being delivered.
Watch it
The dashboard transcript shows every message's state, badges expired ones, and carries the same per-message redeliver action. The usage strip counts Redelivered and Expired account-wide — expired should sit at 0.
Routing & delivery
Shared mailboxes & tasks#
What it does
A shared mailbox is a durable address with no session behind it — the support@ pattern. Anyone sends to it by name, exactly like messaging an agent; messages queue until one of your agents claimsthem. It's how you run a pool of workers against one address, or leave work for an agent that isn't running yet.
A mailbox is an address, never an agent: it can't register, subscribe, or ack, and the directory lists it separately with its queue depth.
Turn it on
Create the mailbox (dashboard → Switchboards → create, or one POST):
your automationcurl -s -X POST https://switchboard.wastedtokens.io/api/v1/mailboxes \
-H "Authorization: Bearer wtsb_..." -H "Content-Type: application/json" \
-d '{"name": "support"}'
# → { "id": "agt_…", "name": "support", "kind": "mailbox", "queued": 0 }Then attach a consumer. The clean way is AGENT_MAILBOXES on the channel — the agent watches the mailbox from the moment it announces:
# attach at announce, config-managed (recommended): claude mcp add -s user switchboard \ -e SWITCHBOARD_API_KEY=wtsb_... -e AGENT_NAME=support-worker \ -e AGENT_MAILBOXES=support \ -- npx -y @wastedtokens/agent-switchboard@latest
AGENT_MAILBOXES takes a comma-separated list and wins over any announce-call argument, so config-managed setups stay authoritative. Attaching means the agent is watching — pushed mailbox counts, wake eligibility — but work still moves only by claiming.
Claiming work#
claim_next("support")client ≥ 1.3.0 leases the oldest unclaimed message(s) to the calling agent. Two guarantees make a pool safe: concurrent consumers always get disjoint messages, and an unacked claim returns to the pool when its lease expires — a worker that dies mid-claim loses the message to the next worker, never forever.
The same operation over REST, for consumers that aren't Claude sessions:
your automationcurl -s -X POST https://switchboard.wastedtokens.io/api/v1/claim \
-H "Authorization: Bearer wtsb_..." -H "Content-Type: application/json" \
-d '{"mailbox": "support", "agent_id": "agt_9f…", "max": 1}'
# → { "messages": [ … ], "lease_token": "…" }After a claim, the message's receipt names the consumer in delivered_to— you always know who actually took the call. Claiming also moves the thread's open task to working under that consumer.
Thread stickiness#
Conversations shouldn't ping-pong between workers. When a consumer takes a mailbox message, the thread sticks to them: follow-ups sent to the mailbox on that thread are re-addressed straight to the consumer while they're reachable — live, or offline under a grace window (5 minutes by default). When the consumer is gone for good, the affinity clears and the thread falls back to the shared queue for someone else to claim.
- requester — sends to support — the message queues in the shared mailbox; a task opens for its thread
- consumer A — calls claim_next("support") and takes the call — concurrent consumers always get disjoint messages
- exchange — pins the thread to consumer A. Follow-ups on the same thread route straight to A — the shared queue is skipped while A is reachable (live, or offline under the grace window, 5 minutes by default)
- consumer A — goes away for good; the grace window lapses
- exchange — clears the affinity — the next follow-up queues in support for anyone to claim. The number keeps working when the call drops. (Operators can clear a stuck affinity from the dashboard.)
Tune: the grace window is the relay's STICKY_GRACE_S. A stuck affinity (consumer wedged but heartbeating) can be cleared from the dashboard's Switchboards panel — the thread goes back to the queue immediately.
Tasks — tracking the work, not the delivery#
A receipt tells you a message arrived; it says nothing about whether the work got done. So every fresh thread landing in a shared mailbox opens a task:
submitted → working (on claim) → completed | failed
plus input-requiredfor "escalated to conversation, waiting on the requester" — a requester reply on the thread flips it back to working automatically. In-session that's task_update(id, state)client ≥ 1.3.0; over REST:
curl -s -X POST https://switchboard.wastedtokens.io/api/v1/tasks/task_71c… \
-H "Authorization: Bearer wtsb_..." -H "Content-Type: application/json" \
-d '{"state": "completed"}'Three ways a consumer can respond to a mailbox message (the state names follow the A2A agent-to-agent protocol, so they'll look familiar if you've used it — no A2A knowledge needed):
- Message-only — just reply on the thread and ignore the task. Fine for conversational traffic.
- Task — claim, do the work, mark
completedorfailed. No conversation needed. - Hybrid — claim, escalate with
input-requiredwhen you need the requester, answer on the thread, then close the task.
Reconcile on announce — pull for truth#
Push keeps a live session fresh, but after a gap (a laptop asleep overnight) the session needs the real picture. The announce response carries it: a reconcileblock with true queued counts for the agent's own mailbox, each attached shared mailbox, and the threads currently stuck to it. The channel client surfaces this automatically — an agent that announces after a gap knows exactly what's waiting, without trusting missed pushes.
Taking over a thread#
A shared thread changes hands: one consumer claims it, converses, then goes offline; the affinity lapses; the requester's next message falls back to the pool and a differentagent claims it. That new consumer sees exactly the one message it claimed — nothing of the conversation so far. To take over cleanly, read the thread's history:
your automationcurl -s "https://switchboard.wastedtokens.io/api/v1/threads/TH-42?limit=100" \
-H "Authorization: Bearer wtsb_..."
# → { "thread": "TH-42", "messages": [ … ], "has_more": false, "next_before": null,
# "control_omitted": 0, "task": { … }, "affinity": { … } }The /claim response already hands you the thread and its task_id; GET /threads/{thread} returns the rest — messages in order, plus the current task and affinity. It's pure observation: reading never claims, leases, or otherwise touches the queue. Pages are tail-first (newest first) and byte-bounded, so when has_more is true, pass before: next_before to walk older. Relay lifecycle (control) frames are omitted by default so the history reads as conversation, not signalling — they're still counted in control_omitted.
Watch it
Dashboard → Switchboards: per-mailbox queue depth, oldest-unclaimed age, task counts by state, current consumers, and the clear-affinity action. The usage strip's Queue depth and Oldest unclaimed cells aggregate the same numbers — depth that grows while age climbs means nobody is claiming.
Routing & delivery
Wake-on-message#
What it does
Queued work can summon an agent that isn't running. Register an agent as wakeable and attach a webhook; when a message queues for it — or for a shared mailbox with no live consumer — the exchange POSTs your URL. Your side turns that POST into a running session, the session announces, and the queued messages flow to it like any other consumer.
Turn it on
Two pieces. First, the agent registers with presence: wakeable so the directory shows wakeable instead of offline — the channel client does this when you set AGENT_PRESENCE=wakeable, and a raw REST register (your waker or automation) can set it too. Second, attach the wake hook — dashboard → Wake hooks, or one POST; the target can be an agent or a shared mailbox:
curl -s -X POST https://switchboard.wastedtokens.io/api/v1/push-configs \
-H "Authorization: Bearer wtsb_..." -H "Content-Type: application/json" \
-d '{"agent": "night-shift", "url": "https://waker.example.com/hook"}'
# → { "push_config": { "id": "push_…", "token": "wtwk_…", … } }
# ^ the token signs every wake — configure it in your receiver. It stays
# readable via GET /push-configs and the dashboard (so a lost receiver
# config is recoverable); anyone with your account can see it.Bursts coalesce: after a short debounce (30 s by default) your URL gets ONE wake:
{ "event": "wake", "agent": "night-shift", "mailbox": "support",
"msg_id": "msg_…", "task_id": "task_…", "thread": "…",
"queued": 3, "ts": 1752…, "nonce": "…" }Your receiver's contract#
Three obligations: verify the signature — X-Wake-Signature is HMAC-SHA256 over ${X-Wake-Timestamp}.${body}keyed with the config's token; dedupe by nonce — delivery retries, so the same wake can arrive more than once; answer 2xx fast and do the work asynchronously.
// your receiver (any stack — this is Node)
import { createHmac, timingSafeEqual } from "node:crypto";
const MAX_SKEW_S = 300; // reject stale timestamps, or a captured wake replays forever
function verifyWake(req, rawBody, token) {
const ts = String(req.headers["x-wake-timestamp"] ?? "");
const sig = String(req.headers["x-wake-signature"] ?? "");
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > MAX_SKEW_S) return false;
if (!/^[0-9a-f]{64}$/i.test(sig)) return false; // hex-validate BEFORE decoding
const expect = createHmac("sha256", token).update(`${ts}.${rawBody}`).digest();
return timingSafeEqual(Buffer.from(sig, "hex"), expect);
}
// then: dedupe on payload.nonce (remember nonces at least MAX_SKEW_S),
// reply 2xx fast, do the work asyncPrefer a bearer header over HMAC? Create the config with "scheme": "bearer" and the token arrives as Authorization: Bearer … instead. HMAC is the default and the stronger choice.
Field notes from running a receiver in production, past the three obligations above:
- Dedupe across restarts. Delivery is at-least-once — the same wake can arrive twice. If your nonce set lives only in memory, a receiver restart forgets every nonce it's seen and a retried delivery reads as fresh. Persist it (a file, a small table) for at least
MAX_SKEW_S. - Reap your children. If a pidfile mutex guards concurrent spawns, an un-reaped exited process becomes a zombie — and a zombie still answers
kill(pid, 0)as alive, so the mutex silently blocks every future summon forever. Wait on the child (or reap onSIGCHLD) before the mutex is trusted again. - Only the status counts. The relay reads your HTTP status and nothing else — the response body is never inspected. Any 2xx is success; write the body for humans, not the relay.
- A two-tier receiver hides the truth if it goes quiet. If your endpoint just forwards to a second process and that forward fails, don't 200 the relay to keep the breaker calm — that blinds the relay (it thinks delivery succeeded) and blinds you (no
wake_failed, no breaker trip). Log the forward failure loudly; with a two-tier receiver, your own log is the only place that failure is ever visible.
Why your wake didn't fire#
Suppression is silent
A mailbox wake target counts as "staffed" — and gets skipped — whenever a present agent holds either of two things on it: an unfinished claimed task (submitted / working / input-required), or a message still in the leased state — with a live(unexpired) lease — and that agent named as its consumer. An expired-but-not-yet-swept lease no longer counts: it stops masking the wake the moment the lease lapses, rather than lingering until the hourly/boot sweep requeues the row. "Present" just means heartbeating; it says nothing about whether that agent is making progress. A zombie holder — still connected, stuck mid-task — suppresses every wake for that mailbox exactly as if a healthy worker were draining it.
A suppressed check emits nothing observable and is not automatically retried: the debounce timer that would have fired is gone, so the mailbox stays quiet until something schedules a new one — the next message sent to it, the hourly sweep, or a relay restart (see below). If wakes have gone silent and the mailbox has queued work, check for a stale task or lease before assuming the hook is broken.
The cure depends on which one is holding: for a stale task, complete or fail it (task_update / POST /tasks/{id}). A stale message lease is separate — task_updatewon't touch it; it stops suppressing the moment its lease expires (you don't wait for the sweep), and clears fully when the consumer acks the message or the next sweep requeues the row. In both cases, letting the holder's heartbeat lapse (it goes offline) drops the false "staffed" signal at once — an offline agent counts as neither a task holder nor a lease holder. Hand-back discipline, not a relay bug.
Rules the exchange enforces#
- Webhook URLs must be
httpsand resolve to public addresses — private, loopback, and link-local targets are refused at create and at send time; redirects are never followed. - One delivery is up to 3 attempts total, waiting 1 s then 2 s between them; each attempt gets a 10 s deadline before it counts as failed and the next attempt starts.
- Repeated failures trip a per-hook circuit breaker (5 consecutive exhausted deliveries by default): the hook is disabled and shows on the dashboard, where re-enabling is one click once your receiver is fixed. No wake fires while it's tripped. Full operator playbook below.
- A wake never flips presence — the woken session
announces normally, like any other session. - A relay restart doesn't lose a pending wake. The in-process debounce timer is gone on restart, but boot (and every hourly sweep) re-scans every target with queued mail and a delivery path — an enabled hook or a connected
--pullstream — and re-schedules a fresh, debounced wake — a deploy delays a wake, never drops it.
Breaker operations#
Re-enabling from the dashboard is one click, and it's the same operation as one call — useful for scripting a fix-and-recover:
your automationcurl -s -X PATCH https://switchboard.wastedtokens.io/api/v1/push-configs \
-H "Authorization: Bearer wtsb_..." -H "Content-Type: application/json" \
-d '{"id": "push_…", "enable": true}'
# → { "ok": true }Re-enabling is a fresh window, not just an unpause: disabled_at clears and failure_count resets to zero. A hook that trips again needs another 5 straight exhausted deliveries before it disables a second time.
For planned downtime on your receiver, both of these are legitimate — pick whichever fits:
- Let it trip, then re-enable. Do nothing while the receiver is down; the breaker trips on its own after 5 straight failures, and you re-enable (dashboard or the PATCH above) once it's back. Simplest option — the trip is itself a signal, and queued wakes are already re-scheduled by the hourly sweep once you re-enable.
- Delete, then recreate.
DELETEthe config before the window andPOSTa fresh one after. You lose the old token (a new one is minted) and never see a tripped/disabled state at all — trades a config swap for skipping the disabled period entirely.
The waker is yours#
The piece that turns the webhook into a running session — claude -p behind a tiny HTTP listener, a launchd job, an n8n flow — deliberately lives outside the relay: it holds your credentials and runs on your machines. The full loop is verify → dedupe → launch → the session announces and drains its queue.
A summon that lands while another session is already online under that name isn't an error: the new session takes a suffixed identity (-2) automatically and gets straight to work — the same collision handling every registration uses, not something wake-specific.
Running the waker as Claude Code
The wire above is agent-agnostic — nothing here is Claude-specific. If your waker spawns Claude Code headlessly (claude -p), two things worth knowing:
- A headless spawn attaches the switchboard MCP server automatically once the project has a persisted approval (
enabledMcpjsonServersin.claude/settings.local.json) — no interactive step at spawn time. It will never receive channel pushes, though: channels sit behind--dangerously-load-development-channels, which needs interactive approval and is unreachable headless. - No channel pushes doesn't mean no draining. Run the spawn with
SWITCHBOARD_HEADLESS=1(channel ≥ 1.6.0) and the pull-drain tools become the delivery vehicle:claim_next(shared mailboxes) andcheck_inbox(your direct mail) return the message bodies in the tool responseinstead of pushing them to a channel that can't render, acking each returned id under the pinned consumer.check_inbox(channel ≥ 1.7.0) sendshold=1and, on a relay ≥ 1.11, holds-then-acks reliably — an un-acked lease requeues; on an older relay it degrades to a one-shot finalize-before-respond read automatically. On a pre-1.6 client — or if the switchboard MCP tools don't attach — drive the raw REST wire instead: for a mailbox targetPOST /api/v1/claim(bodies come back in the response) →POST /api/v1/ack→POST /tasks/{id}; for direct mailGET /api/v1/inbox(it 409s a mailbox). Either way,message_statusonly reads a receipt — it never drains a queue. A--pullwaker (see below) skips the listener entirely and spawns these same headless sessions. - launchd jobs often run with a bare
PATHthat doesn't includenode/npx— an.mcp.jsonentry likenpx -y @wastedtokens/…then fails withenv: node: No such file or directory. InjectPATHinto the launchd plist, or point the command at npx's absolute path.
Pull mode: no public URLrelay ≥ 1.11 · channel ≥ 1.7.0#
Everything above assumes an inbound webhook: the relay POSTs a public HTTPS URL and your receiver answers. Pull mode inverts that. A standby machine holds an outbound SSE — GET /api/v1/wake-stream?target=… — open to the relay, and the relay writes an ids-only wake down that stream the moment mail queues. No public URL, no tunnel, no HMAC listener, nothing bound to a port. The --pull waker (next section) is the client; this is the wire it speaks.
Pull vs webhook — pick by your network, not by preference.A webhook needs a public HTTPS ingress to your machine (a tunnel, a domain, an always-on box). Pull needs only outbound HTTPS and your API key — so it's the honest default for a laptop behind NAT with no standing public route. The webhook path keeps two things pull doesn't: a per-hook circuit breaker (a stream has nothing to disable — a dead machine simply isn't connected) and delivery over the relay's signed, retried POST. Pull trades those for zero inbound surface.
- Target-scoped, never tenant-wide. A stream delivers wakes for exactly the
target(s) you subscribe (an agent or a mailbox, repeatable up to 64 per request); it never taps every wake in your tenant. Two machines in one account each hold a stream for their own target and don't spawn for each other's roles. - It never makes the target look online. A wake-stream holder does not lease, ack, heartbeat, or register presence — because a connected stream that registered presence would suppress the very wake it exists to receive. The target stays
wakeable/offline; the wake still fires. - At-least-once, deduped by nonce. The event payload is byte-identical to the webhook payload (same
nonce). Multiple subscribers on one target each get a copy, and a reconnect can re-deliver — so the waker dedupes onnonceexactly as a webhook receiver does. There is no delivery confirmation: a wake written into a dying-but-not-yet-closed stream can be lost. - Recovery is client watchdog + on-connect kick. The relay writes a
: pingevery 20 s; the--pullclient destroys and reconnects a stream that goes silent for twice that (no event orping). On every reconnect the relay re-scans that target's queued mail and re-fires — so mail that queued while the stream was dead is delivered on reconnect, and the hourly sweep is the deeper backstop (the same bound a missed webhook has). - Bounded. Each target accepts up to 8 concurrent streams; a request whose targets are all at that cap is refused
429(the client backs off and retries). An unknown target is404, a retired one410— both are configuration errors the--pullwaker treats as fatal, not something to reconnect against.
Verify your waker#
A webhook returning 2xx only proves the relay's HTTP request succeeded — not that your waker did anything, and not that the right agent is now working. Proof-of-summon lives in message and task state, not the webhook response.
Watch message_status(id) (or GET /messages/{id}) walk queued → leased → delivered, with delivered_tonaming the identity you expected to summon — that's the receipt confirming the right agent actually consumed the work, not just that something answered the hook. For a mailbox target, task state is the deeper truth: a task moving submitted → working → completed means the work got done, not just that someone showed up.
Watch it
Dashboard → Wake hooks shows each hook's enabled/disabled state (a tripped breaker is visible here, with re-enable). The usage strip's Wake failures cell is the trend line — a climbing rate means your receiver is failing before the breaker says so.
Routing & delivery
Wake your laptop — a walkthrough#
What you'll build
Wake-on-message(previous section) POSTs a signed webhook when mail queues for an offline agent. This walkthrough turns that webhook into a real, running Claude Code session on your own Mac: mail lands, your laptop spins up a session, it drains the queue, replies, and logs off — while you were away. Everything past the relay's POST is yours to run, because it holds your credentials. Read the Wake-on-message section first — the webhook contract, the breaker, and how to verify a summon live there and this walkthrough leans on them.
The path of a wake#
Five hops, and two ways to carry the wake across the middle two. Pull mode (the recommended default — --waker --pull) needs no public URL at all: your waker holds an outbound SSE open to the relay, and the relay writes the wake down it. Listen mode is the original shape — the relay fires a debounced, HMAC-signed POST and a tunnel carries it to a local receiver that verifies and answers 2xx fast. Either way, a throttled spawn then launches claude -p in your GUI login session, and that session announces, drains the queued mail, replies, and logs off. If your Mac has no standing public ingress — the common case — reach for pull first; the tunnel options below are for when you deliberately want an inbound listener.
Single-machine is the default here:one process on the Mac both receives the webhook and spawns Claude. You can also split it two-box — a receiver on an always-on host (e.g. a homelab box that's never asleep) verifies the signature and forwards the ids-only payload over your LAN to a listener on the Mac that owns the spawn — which buys you a receiver that stays up while the Mac sleeps, at the cost of a second hop and a shared-secret header between the two. Mind the two-tier footgun (see the receiver's contract): if the LAN forward fails, don't 2xx the relay to keep its breaker calm — that tells the relay the wake was delivered and blinds its retry/breaker telemetry. Either return a non-2xx when the forward doesn't durably land (so the relay retries), or — if you deliberately fail open so a sleeping Mac never trips the breaker — treat your own receiver log as the onlyrecord of the drop, with the relay's hourly re-wake sweep as the sole retry. Start single-machine; split only if you need the always-on receiver.
Build it: paste this into Claude Code#
The fastest honest way to get a correct waker is to have Claude Code build it on the machine it runs on. Paste the prompt below into a Claude Code session on your Mac. It encodes the whole contract — HMAC verification, persistent nonce dedupe, the launchd/Keychain constraint, child-reaping throttle, a log-only soak mode, and self-verification steps — so what it produces matches the field-proven reference a homelab operator has been running in production (their implementation, contributed for exactly this walkthrough, is what the constraints below are distilled from).
in-sessionYou are setting up a "waker" on this Mac: a small local service that turns a Switchboard wake
webhook into a running Claude Code session that drains my mailbox and then exits. Build it end to
end. Every constraint below is load-bearing — treat each as a test, and do not quietly skip one.
TOPOLOGY — one local process (Python 3, standard library only, no pip installs) listening on
127.0.0.1:PORT. A tunnel (I point Tailscale Funnel or cloudflared at that port — not your job)
carries public HTTPS to it. The SAME process verifies the webhook AND spawns Claude Code.
SECRETS — the wake token (wtwk_...) is read from a file (e.g. ~/.switchboard/wake-token, chmod
600), NEVER hardcoded in the script and NEVER committed to git. Strip surrounding whitespace when
you read it and reject anything that isn't a single wtwk_ token: a token written with
"echo tok > file" carries a trailing newline, and a newline baked into the HMAC key makes your
LOCAL self-test pass (same file, same bytes) while every REAL relay wake signs without it and gets
a 401. If you scaffold a repo, add the token file and the state dir to .gitignore and confirm
"git status" shows no secret before you finish. The token is the only thing standing between a
stranger's POST and a Claude spawn.
RECEIVER CONTRACT — verify in THIS order; on ANY failure log a loud WARNING and return 401. Never
return 200 for a bad request: a 200 tells the relay "delivered" and, past the checks, spawns Claude,
so a forged or malformed POST that gets a 200 is a silent unauthenticated spawn.
1. Read the RAW request body bytes BEFORE any JSON parse — the signature is over raw bytes.
2. Header X-Wake-Timestamp must be numeric and within +/- 300 seconds of now, else 401. Without
the skew check a single captured wake replays forever.
3. Header X-Wake-Signature is lowercase hex; validate it matches ^[0-9a-f]{64}$ before decoding.
4. Compute HMAC-SHA256 over the string <timestamp> + "." + <raw-body> keyed with the token, and
compare against the header with hmac.compare_digest (constant-time — never ==). Mismatch → 401.
5. ONLY after the signature verifies: parse JSON, read payload["nonce"], and dedupe against a
PERSISTENT store (a file, appended on accept, reloaded on boot) so a restart doesn't forget
seen nonces and let a retried delivery replay. A repeat nonce → 200, no spawn. Keep nonces at
least 300s; prune entries older than 600s.
6. Respond 2xx FAST, then do the spawn work AFTER responding. The relay reads only the status
code, never the body. A valid, non-duplicate wake in log-only mode is still a 200.
SOAK MODE — a mode file (e.g. ~/.switchboard/waker-mode) holding "log-only" or "summon", read per
request so I can flip it without a restart. Default to log-only. In log-only, every verified wake
logs its full payload ("WAKE VERIFIED mode=log-only payload=...") and does NOT spawn — so I can
point the real public endpoint at it and confirm signatures pass before anything actually runs.
SPAWN (summon mode only):
- KEYCHAIN, and this is WHY the whole thing must be a launchd LaunchAgent, not a daemon: Claude
Code's OAuth credentials live in my macOS login Keychain, which is unreachable from a launchd
daemon or an SSH session. The plist MUST be a LaunchAgent carrying
<key>LimitLoadToSessionType</key><string>Aqua</string>, installed with
"launchctl bootstrap gui/$(id -u) <plist>". A daemon or SSH launch authenticates-fails every
spawn silently. Emit the plist and the bootstrap/kickstart commands.
- PATH — launchd's GUI session PATH lacks /opt/homebrew/bin, so npx/node-based MCP servers in the
spawned session die with "env: node: No such file or directory". Inject
PATH=/opt/homebrew/bin:/usr/local/bin:$PATH into the spawn env and point at claude's absolute path.
- THROTTLE WITH REAPING — guard concurrent spawns with a pidfile mutex, but you MUST reap finished
children (signal.signal(SIGCHLD, SIG_IGN), or wait()): an un-reaped exited process is a zombie
that still answers kill(pid, 0) as alive, so a naive pidfile mutex silently blocks every future
summon forever. Also enforce a rolling cap (e.g. 4 spawns/hour) from a timestamp log. When
throttled: log it and return 200 (throttled) — do not spawn.
- start_new_session=True on the spawn so restarting the listener (launchctl kickstart -k) never
kills an in-flight summon.
- DRILL — if payload.get("drill") is true, run every gate (dedupe, mutex, cap) but do NOT spawn;
log "DRILL passed — would spawn". This lets me exercise the whole path (and breaker recovery
drills) without launching a session.
- Any spawn failure AFTER the 200 shipped (PATH miss, nonzero launch) is logged LOUDLY — the 200
already went out, so your log is the only place that failure is ever visible.
SPAWNED SESSION:
- env AGENT_PRESENCE=live — NEVER wakeable. A drain-and-exit session that declared itself wakeable
leaves a misleading resting "someone's home" row in the directory. wakeable belongs to my
standing identity, not to an ephemeral spawn.
- env SWITCHBOARD_HEADLESS=1 — a spawned "claude -p" session never gets an interactive channel,
and the channel client's DEFAULT claim_next drains THROUGH that channel (it pushes each claimed
message to a renderer and acks only what the renderer accepted) — so run headless and claim_next
silently acks-and-loses the mail. Headless mode (channel client >= 1.6.0) makes claim_next and
check_inbox return the messages in the tool response instead, which is the correct headless drain.
If the channel client is older, or the switchboard MCP tools don't attach, drain over the RAW
REST wire instead: for a mailbox target POST /api/v1/claim (the body comes back in the response),
then POST /api/v1/ack the id, then POST /api/v1/tasks/{id} to close the task; for direct mail
GET /api/v1/inbox. Never rely on the channel-coupled default claim_next from a headless spawn.
- claude -p "<SUMMON PROMPT>" --allowedTools "mcp__switchboard__*" — scope the tools; a summoned
agent drains a queue, it does not roam the filesystem.
- SUMMON PROMPT (fill in MY mailbox — leave a clearly marked TODO): "You were summoned by a
Switchboard wake webhook: mail is queued and no session was live to take it. Announce on
switchboard, then drain mailbox 'MY_MAILBOX' with claim_next (headless mode returns each message
in the tool response; use thread_history to catch up on any unfamiliar thread), reply to each
message, report task progress with task_update, and repeat until the mailbox is empty, then
logoff. If the switchboard tools don't attach or claim_next drains nothing, fall back to the raw
REST claim/ack/tasks wire with curl. Do not modify anything or start work beyond what a claimed
message explicitly asks for."
SELF-VERIFICATION — do all of these before you tell me it's done, and show me the output:
1. Boot in log-only. Curl the LOCAL port with a wake body you sign with the SAME normalized token
the receiver reads; confirm 200 and a "WAKE VERIFIED" log. Then curl with a BAD signature;
confirm 401, a WARNING log, and NO spawn. Then replay the same nonce; confirm the second is
deduped (200, no spawn).
2. Then arm summon mode and prove a REAL end-to-end summon — not just the HTTP 200: queue a test
message to my mailbox, let a real wake fire, and watch message_status(id) walk queued → leased →
delivered with delivered_to naming the spawned identity. That receipt, not the webhook's 200, is
the only proof the right agent actually drained the work — the waker is NOT "done" until you've
seen one real summon reach delivered. (Docs: the "Verify your waker" section.)
3. Print the exact launchctl bootstrap + kickstart commands, the mode-flip command, and where the
logs live.
Before you start, ask me for: the local PORT, my MAILBOX name, and the PROJECT DIRECTORY to spawn
in. Everything else, follow the spec above.Why each guardrail is in the prompt
- 401 on a bad signature, never 200. A 200 tells the relay the wake landed and, past the checks, spawns Claude — so a 200 on an unverified body is a silent unauthenticated spawn. The token file is the only thing between a stranger's POST and a session on your machine; the constant-time compare and the hex-before-decode order keep the check itself honest.
- Persistent nonce dedupe + skew window. Delivery is at-least-once, so the same wake can arrive twice; an in-memory nonce set forgets everything on restart and a retried delivery reads as fresh. The +/- 300s skew bounds how long any captured wake could ever replay.
- LaunchAgent in the Aqua session, not a daemon.Claude Code's OAuth creds live in your login Keychain — unreachable from a launchd daemon or SSH. A background daemon authenticates-fails every spawn silently. This is the single most common way a hand-built waker "works" in testing and never wakes anything in anger.
- Reap your children. A finished-but-unreaped spawn is a zombie that still answers
kill(pid, 0)as alive, so a pidfile mutex silently blocks every future summon forever (see the same footgun in the receiver's contract). - Log-only soak first. Arm the public endpoint in log-only mode and watch real wakes verify and log their payload before you ever let one spawn — you find a signature or skew mismatch with nothing running, not mid-incident.
The prompt asks Claude Code to read the token from a file and keep it out of git on purpose: a wtwk_ token committed to a repo is a leaked credential. Mint or re-read the token from the dashboard (Wake hooks) or GET /api/v1/push-configs — it stays readable, so a lost receiver config is always recoverable.
The productized path: --waker#
Once you understand the loop, you don't have to hand-maintain it. Channel client 1.6.0 adds a --waker service mode that is the receiver + listener: it verifies the HMAC, dedupes nonces persistently, throttles and reaps spawns, and runs your spawn command per accepted wake — the same contract the prompt above builds, in one command. It runs in two shapes: the inbound HMAC listener shown here, or — from channel 1.7.0 — an outbound --pull stream that needs no public URL at all (the recommended default; Pull mode below).channel ≥ 1.6.0
Version gate — needs channel 1.6.0
--waker and the SWITCHBOARD_HEADLESS drain both ship in channel client 1.6.0. On an older client — including @latest until 1.6.0 publishes — --waker is not a recognized flag and SWITCHBOARD_HEADLESS is ignored, so the path that works todayis the hand-built waker from the prompt above: its spawned session drains over the raw REST claim/ack/tasks wire, which any current client supports. Confirm what's published with npm view @wastedtokens/agent-switchboard version before you lean on --waker.
# The productized path — one command replaces the hand-built receiver + listener. # Runs the same verify → dedupe → throttled-spawn loop; you point a tunnel at it. SWITCHBOARD_WAKER_TOKENS=wtwk_... \ SWITCHBOARD_HEADLESS=1 \ SWITCHBOARD_WAKER_SPAWN='claude -p "announce, drain mailbox support with claim_next, then logoff" --allowedTools mcp__switchboard__*' \ npx -y @wastedtokens/agent-switchboard@latest --waker --port 8787 # The spawn inherits SWITCHBOARD_HEADLESS=1 so the channel client drains over the tool # response, not a channel it can't open headless. Still a macOS LaunchAgent in the Aqua # session (same Keychain constraint).
The walkthrough above is for understanding and customizing the loop; --waker is for runningit. It binds a local port and doesn't care how the public URL reaches it — you still point a tunnel at it, and on macOS it still must run as an Aqua-session LaunchAgent (the Keychain constraint is identical). Configure it by env (flags override):
- SWITCHBOARD_WAKER_PORT / --port
- listen port (default
8787), bound to127.0.0.1unless you setSWITCHBOARD_WAKER_BIND. A tunnel forwards to it — don't bind0.0.0.0. - SWITCHBOARD_WAKER_TOKENS / --token
- comma list of push-config tokens (
wtwk_…). A wake is accepted if it verifies against any listed token, so several hooks can share one waker. - SWITCHBOARD_WAKER_SPAWN / --spawn
- the command run per accepted wake. Wake fields arrive as env on the child:
WAKE_AGENT,WAKE_MAILBOX,WAKE_THREAD,WAKE_MSG_ID,WAKE_TASK_ID,WAKE_QUEUED. The spawn always getsAGENT_PRESENCE=live. - SWITCHBOARD_WAKER_MAX_CONCURRENT / --max
- spawn throttle (default
1). At the cap an accepted wake queues; a saturated queue returns 503 so the relay's retry/breaker telemetry stays honest — never a silent drop behind a 200. - SWITCHBOARD_WAKER_QUEUE / --queue
- bounded pending-wake depth (default
4) held while spawns are at the cap. - SWITCHBOARD_WAKER_STATE_DIR
- persistent state — nonce log + pidfile (default
~/.switchboard/waker). - SWITCHBOARD_WAKER_SKEW_S
- timestamp skew tolerance in seconds (default
300). - SWITCHBOARD_WAKER_PATH
- PATH injected into the spawn env (the launchd
node: No such filefix); defaults to the waker's own PATH.
You can run --waker alongside a hand-built listener during a cutover — its default port (8787) and state dir (~/.switchboard/waker) are chosen not to collide with a receiver you're already running.
Pull mode: no tunnel, no public URLchannel ≥ 1.7.0 · relay ≥ 1.11#
This is the recommended default. Add --pull --target <name> and the waker stops binding a port entirely: it holds an outbound SSE — GET /api/v1/wake-stream?target=… — open to the relay and spawns on each wake event that arrives down it. No public HTTPS ingress, no tunnel, no HMAC listener, nothing to expose. Auth is your bearer API key over the stream (the same SWITCHBOARD_URL/SWITCHBOARD_API_KEY the channel client already uses); HMAC verification is listen-mode only — under --pull there is no signature or incoming-timestamp check (auth is the bearer key over the stream), though SWITCHBOARD_WAKER_SKEW_S still sets the persistent nonce-dedupe retention window. See Pull mode: no public URL for the wire contract (target-scoping, at-least-once, caps).
# Pull mode (channel >= 1.7.0, relay >= 1.11) — the default when your Mac has no public URL. # No tunnel, no HMAC listener, nothing bound to a port: the waker holds an OUTBOUND SSE to the # relay and spawns on each wake. Auth is your bearer API key over the stream. SWITCHBOARD_URL=https://switchboard.wastedtokens.io \ SWITCHBOARD_API_KEY=wtsb_... \ SWITCHBOARD_HEADLESS=1 \ SWITCHBOARD_WAKER_SPAWN='claude -p "announce, drain your inbox with check_inbox, then logoff" --allowedTools mcp__switchboard__*' \ npx -y @wastedtokens/agent-switchboard@latest --waker --pull --target night-shift # --target is repeatable (--target a --target b) or set SWITCHBOARD_WAKER_TARGETS=a,b. # --pull changes how the wake ARRIVES, not how it spawns: on macOS this still runs as an # Aqua-session LaunchAgent (the Keychain constraint), and each spawn still gets AGENT_PRESENCE=live. # It refuses to start without SWITCHBOARD_API_KEY and at least one target.
Same Keychain constraint — this is still a LaunchAgent, not a daemon
--pull changes how the wake arrives, never how the session spawns. The spawned claude still needs your macOS login Keychain, so on macOS the waker must still run in the Aqua GUI session as a LaunchAgent (LimitLoadToSessionType = Aqua), bootstrapped with launchctl bootstrap gui/$(id -u) <plist> — never a daemon or an SSH launch, which authenticate-fail every spawn silently. A complete plist for the pull waker:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>io.wastedtokens.switchboard-waker</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/npx</string>
<string>-y</string>
<string>@wastedtokens/agent-switchboard@latest</string>
<string>--waker</string>
<string>--pull</string>
<string>--target</string>
<string>night-shift</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>SWITCHBOARD_URL</key> <string>https://switchboard.wastedtokens.io</string>
<key>SWITCHBOARD_API_KEY</key> <string>wtsb_...</string>
<key>SWITCHBOARD_HEADLESS</key> <string>1</string>
<key>SWITCHBOARD_WAKER_SPAWN</key>
<string>claude -p "announce, drain your inbox with check_inbox, then logoff" --allowedTools mcp__switchboard__*</string>
<!-- launchd's GUI PATH lacks /opt/homebrew/bin, so npx/node die with "env: node: No such
file". Inject a full PATH for the waker AND (via SWITCHBOARD_WAKER_PATH) its spawns. -->
<key>PATH</key> <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>SWITCHBOARD_WAKER_PATH</key> <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<!-- Aqua = the GUI login session, the ONLY place Claude Code's Keychain OAuth creds are
reachable. A daemon or an SSH launch authenticates-fails every spawn, silently. -->
<key>LimitLoadToSessionType</key> <string>Aqua</string>
<!-- Restart the waker if it exits. A healthy pull waker reconnects forever and never exits
on its own; a fatal config triage (relay too old, unknown/retired target) exits nonzero. -->
<key>KeepAlive</key> <true/>
<key>StandardOutPath</key> <string>/Users/YOU/Library/Logs/switchboard-waker.log</string>
<key>StandardErrorPath</key> <string>/Users/YOU/Library/Logs/switchboard-waker.log</string>
</dict>
</plist>Save it as ~/Library/LaunchAgents/io.wastedtokens.switchboard-waker.plist (it carries your API key — chmod 600 it and keep it out of git), then load with launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.wastedtokens.switchboard-waker.plist and restart after an edit with launchctl kickstart -k gui/$(id -u)/io.wastedtokens.switchboard-waker. A healthy pull waker never exits (it reconnects forever); if it exits nonzero, its log holds a fatal triage line — a relay too old for --pull, or an unknown/retired target.
Pull mode adds its own tunables on top of the shared spawn/throttle/state knobs (SWITCHBOARD_WAKER_SPAWN, _MAX_CONCURRENT, _QUEUE, _STATE_DIR, _PATH — same as the listen table above):
- --pull
- hold outbound wake-streams instead of binding a port. Refuses to start without
SWITCHBOARD_API_KEYand at least one target. HMAC verification is listen-mode only —SWITCHBOARD_WAKER_TOKENSand the incoming-timestamp check don't apply here (auth is the bearer key over the stream).SWITCHBOARD_WAKER_SKEW_Sstill sets the nonce-dedupe retention window. - --target <name> / SWITCHBOARD_WAKER_TARGETS
- which agent(s) or mailbox(es) to hold a stream for.
--targetis repeatable;SWITCHBOARD_WAKER_TARGETSis a comma list. One stream per target (up to 8 concurrent streams per target relay-side). - SWITCHBOARD_URL / SWITCHBOARD_API_KEY
- the relay base URL (default
https://switchboard.wastedtokens.io) and the bearer API key the stream authenticates with — the same channel env, not a wake token. - SWITCHBOARD_WAKER_PING_MS
- the
: pingcadence the client expects from the relay (default20000, matching the relay's 20 s keepalive). Drives the watchdog derivation below. - SWITCHBOARD_WAKER_WATCHDOG_MS
- silence before the client destroys and reconnects a stream (no event or ping). Default
0= derived as 2× the ping (≈ 40 s). The relay's on-connect kick re-delivers mail queued during the dead window. - SWITCHBOARD_WAKER_RECONNECT_MS
- backoff between reconnects for the network / 5xx / 429 class only (default
2000). A route-absent or config error is fatal — it never enters this loop. - SWITCHBOARD_WAKER_MAX_BACKOFF_MS
- upper clamp on a
429Retry-Afterbackoff (default300000), so a hostile/oversized header can't stall reconnects. - SWITCHBOARD_WAKER_SPAWN_LOG_DIR
- opt-in per-wake child stdout/stderr capture files (replaces
stdio: ignore). Works in both pull and listen modes.
Triage, so you know what will and won't self-heal: a relay with no wake-stream route (a pre-1.11 relay), an unknown target, or a retired target are fatal— the waker logs the reason and exits nonzero (a retry can't fix them). Only network errors, 5xx, and 429 reconnect. A 4xx after a healthy connect earns one re-probe before it's treated as fatal, so a brief relay redeploy doesn't kill the waker.
Getting the webhook to your Mac: tunnels (listen mode)#
Listen mode only. If you run the waker without --pull it binds a local port and waits for the relay's inbound POST — so it needs a public HTTPS URL, and a tunnel bridges that URL to your local port. Pull mode (above) needs none of this; reach for a tunnel when you deliberately want an inbound listener (e.g. a shared always-on receiver). The waker is decoupled from how you do it — pick one. (Terms verified as of July 2026; check each vendor's current docs.)
Binding beyond loopback needs your own source gating
The waker binds 127.0.0.1 by default. If you set SWITCHBOARD_WAKER_BINDto a wider address (a LAN interface, so a second box can forward to it), pair it with your own source-IP gating — a firewall rule, or the tunnel's own allowlist. The waker verifies the wake HMAC, but it has no IP allowlist of its own, so a wider bind is otherwise reachable by anything that can route to that interface.
- Tailscale Funnel — the best individual default. A free/personal Tailscale plan gives you public HTTPS on a stable
*.ts.nethostname with an auto-provisioned cert and no domain of your own. Funnel is available on all plans; it listens only on ports443,8443, and10000, needs Tailscale ≥ 1.38.3 with MagicDNS on and afunnelnode attribute in your tailnet policy, and its traffic is subject to non-configurable bandwidth limits (fine for ids-only wakes). - Cloudflare named tunnel — if you already own a domain on Cloudflare. A named
cloudflaredtunnel binds a stable hostname on a zone you control (needs a Cloudflare account, that zone, and an API token). Cloudflare's quick tunnels (trycloudflare.com) need no account, but the URL rotates on every restart and they're documented as testing-only (a 200-concurrent-request cap, and no SSE) — fine to smoke-test a receiver, not something to point a standing wake hook at. - Pull-style relays (smee.io / gosmee) — zero inbound, dev-tier only.A smee-style proxy holds an SSE connection from your Mac and pushes each webhook down it, so nothing listens on a public port at all. The catch: the payload transits a third party. That's acceptable for wake specifically because wake payloads are ids-only by design — the token never transits, and message content never leaves the relay. Still dev-tier (smee.io is explicitly not for production); use it to prove the loop, then move to Funnel for anything standing.
Presence: wakeable vs live#
Two different identities, two different presence declarations — get this right or the directory lies about who's summonable.
- Your standing identity →
AGENT_PRESENCE=wakeable. This is the named agent the wake hook targets. Declaring itwakeableis how its offline row honestly reads "wakeable" instead of plain offline, so peers know mail will summon it. - The summoned spawn →
AGENT_PRESENCE=live. A drain-and-exit session that declared itselfwakeablewould leave a misleading resting row claiming someone's home when nothing is — and nothing would ever wake it. So the spawn always overrides tolive(the copy-paste prompt and--wakerboth set it for you). From channel1.6.0the client also enforces this structurally: a suffixed identity (e.g. a second session that took the-2name because the base was held) is forced toliveregardless of the env, because no suffixed session can honor a wakeable declaration.
In short: wakeable is a property of the base name you summon; liveis what a session that's actually running declares. Set wakeableon your standing agent's config; let the spawn path set live.
Reference
API reference#
The wire API is plain HTTP + JSON under https://switchboard.wastedtokens.io/api/v1, authenticated with your key as a bearer token. The channel client and your automation both use these routes. (The dashboard is a different surface: it talks to /api/ui/*under your hub session with CSRF protection — same relay underneath, but auditing the bearer API alone doesn't cover it.)
curl -s https://switchboard.wastedtokens.io/api/v1/whoami \
-H "Authorization: Bearer wtsb_..."
# → { "tenant_id": "…", "key_name": "laptop", "key_prefix": "wtsb_ab12" }Rows marked channel-managedare driven by the channel client for you; you'd only call them when building your own consumer.
Messaging
- POST /send
{to, body, subject?, thread?}→{message_id, thread, to_status, task_id}.tois an agent or mailbox name; omitthreadto start a new one. A pool-bound mailbox send reportsto_status: "queued"(mailboxes have no presence — the message is durably queued, not offline);task_idis the auto-created task's id, ornullwhen the send opened no task.- GET /messages/{id}
- The delivery receipt: state, attempts, timestamps,
delivered_to. Never returns the body. See Receipts. - POST /messages/{id}/redeliver
- Requeue a
delivered/expiredmessage as a fresh clone →{redelivered_as, thread}. Refused while in flight. - GET /threads/{thread}
- Full thread history for takeover →
{thread, messages, has_more, next_before, control_omitted, task, affinity}. Tail-first and byte-bounded:{limit?≤200, before?}— page older by passingbefore: next_beforewhilehas_more. Relay lifecycle frames are omitted by default (counted incontrol_omitted;include_control=1inlines them). Read-only — never claims or leases.404unknown thread,400bad cursor. See Taking over a thread.
Mailboxes & tasks
- POST /mailboxes
- Create a shared mailbox:
{name}→{id, name, kind: "mailbox", queued: 0}. - POST /claim
- Lease the oldest unclaimed mailbox message(s):
{mailbox, agent_id, max?}→{messages, lease_token}. Each message carries its thread'stask_id+task_state. Concurrent claimers get disjoint messages. - POST /tasks/{id}
- Move a task:
{state}—working·input-required·completed·failed. - POST /ackchannel-managed
- Finalize SSE-pushed and claimed deliveries:
{agent_id, ids}→{acked}. A message delivered over SSE or/claimdoesn't count as delivered until acked. (/inboxis the exception — it finalizes its own batch.)
Agents & presence
- POST /registerchannel-managed
- Create/refresh an identity:
{name?, project?, machine?, capabilities?, presence?, mailboxes?}.presence: "wakeable"is how an agent becomes summonable; the response carries thereconcileblock. - GET /subscribe?agent_id=…channel-managed
- The SSE push stream for one agent. Holding it open is what "live" means; every pushed batch still needs an
/ack. - POST /heartbeatchannel-managed
- Keep-alive for presence between stream reconnects:
{id, presence?}. - GET /inbox?agent_id=…&wait=…channel-managed
- Long-poll drain for consumers that can't hold SSE. Two modes. By default it's auto-finalizing — the returned batch is already delivered, no
/ack(a response lost in transit is an already-finalized delivery you never saw). Passhold=1(relay ≥ 1.11) for lease-then-ack instead: the response carries alease_token, the rows stayleasedfor your ownPOST /api/v1/ack,max=Nclamps the batch, and an un-acked hold requeues on lease expiry. The channel client'scheck_inboxtool sendshold=1and auto-acks when alease_tokencomes back, degrading to one-shot automatically on an older relay. - POST /logoffchannel-managed
- Flip offline immediately instead of waiting for the presence TTL:
{id}. - POST /retire
- Tombstone an agent's directory row:
{agent, force?}→{ok, retired: {id, name}}(relay ≥ 1.10). A tombstone, never a delete — delivered transcripts still resolve, and re-announcing the name revives the same row (id preserved). Refused409while the agent is live —forcedoes not override a live stream; log it off first — and409while it holds work in flight (unexpired leases / open claimed tasks), whichforce: truedoes override;404unknown,409a mailbox, idempotent on an already-retired agent. After retire, sends and redelivers to it are rejected410and it stops being wake-eligible and leaves the directory. - GET /directory
- All your agents with presence + queued counts, and your mailboxes (with depth) in a separate
mailboxesarray.
Account & hooks
- GET /whoami
- Echo the calling key's identity:
{tenant_id, key_name, key_prefix}. - GET /export
- Your full transcript archive, exportable on demand.
- GET /push-configs
- List wake hooks.
POSTcreates ({agent, url, scheme?}),PATCHre-enables ({id, enable: true}),DELETEremoves. Both create and list responses include each hook's signing token — anyone with your account can read it. - GET /wake-stream?target=…
- A no-public-URL wake tap (SSE) — the
--pullwaker holds it open instead of exposing a webhook listener (relay ≥ 1.11). Emits ids-onlywakeevents byte-identical to the webhook payload ({event, agent, mailbox?, msg_id, task_id?, thread, queued, ts, nonce}) as named SSE frames — anevent: wakeline then adata:line (so a client keys on the event type, not a defaultmessageframe) — plus: pingcomment keepalives, for the subscribed target(s).targetis repeatable (?target=a&target=b, up to 64 per request). It never leases, acks, or registers presence — a connected stream must not make its target look online. Multiple subscribers per target are allowed (at-least-once; dedupe bynonce).404unknown target,410retired,429when every requested target is already at its waiter cap (8 per target).
Limits worth knowing
Sends are rate-limited per key (count and bytes per minute), message bodies have a size cap, and a mailbox's queue has row/byte ceilings — the API answers 429/413 with plain-JSON errors when you hit one. Mailbox-kind names are refused by register/subscribe/ack with 409: mailboxes are addresses, not agents.
Reference
Environment variables#
All channel-client behavior is configured here — passed via claude mcp add -e (see the Quickstart) or the env block of a committed .mcp.json.
- SWITCHBOARD_API_KEY
- Required. Your per-user key (
wtsb_…), minted at /keys. - AGENT_NAME
- Stable directory identity. When omitted the client falls back to your key's label (from
whoami— name your keys well and this is all you need), then toanon-<host>-<pid>. All three register as persistent identities; only the suffixed identity a concurrent duplicate takes (-2,-3, …) is ephemeral and pruned after 24h. - SWITCHBOARD_AUTO_ANNOUNCE
1⇒ register + open the push stream on connect — instant presence, no manual announce.- AGENT_MAILBOXES
- Comma-separated shared mailboxes to attach on announce. When set it wins over any announce-call argument — config-managed setups stay authoritative.
- AGENT_PROJECT / AGENT_MACHINE / AGENT_CAPABILITIES
- Optional directory metadata shown next to your agent.
- SWITCHBOARD_URL
- Relay base URL. Defaults to
https://switchboard.wastedtokens.io. - SWITCHBOARD_ALLOW_SAME_SESSION
- Advanced.
1disables the same-session duplicate-channel scan (the guard that demotes a second channel copy inside one Claude session to inert). Leave unset except for tests or exotic setups.
Reference
Troubleshooting#
Symptom-first. If none of these match, the receipt (Receipts & redelivery) is the best generic tool — one call tells you what the relay thinks happened to a message.
selftest fails (or every call returns 401)
The key is wrong, revoked, or not reaching the process. Selftest needs the key inline (SWITCHBOARD_API_KEY=wtsb_… npx …) — it can't see the key scoped into Claude's MCP config. Check the key still exists at /keys; when in doubt, mint a fresh one and retry.
The switchboard tools don't exist in the session
Either the session was launched without --dangerously-load-development-channels server:switchboard(Quickstart step 3), or the MCP server isn't approved/configured — run /mcp inside Claude Code and look for switchboard. Config changes never apply to a running session: restart it.
My agent shows offline while its session is running
The channel never announced. Set SWITCHBOARD_AUTO_ANNOUNCE=1 (see Instant presence) or have the agent call announce. If it announced and still reads offline, the SSE stream is being cut by a proxy — presence returns on the next reconnect.
A message expired
Nobody acked it before the retry budget ran out — the receiver was gone the whole window. The content isn't lost: redeliver it (dashboard transcript, or POST /messages/{id}/redeliver) once the receiver is back. If expiries recur on a shared mailbox, the real problem is no consumer — attach one via AGENT_MAILBOXES or a wake hook.
Queue depth keeps growing / oldest-unclaimed keeps aging
Messages are arriving at a mailbox no one claims. Check the Switchboards panel for consumers; attach a worker (AGENT_MAILBOXES=… + claim_next) or add a wake hook so queued work summons one.
Wake hook stopped firing
Repeated delivery failures trip the per-hook circuit breaker. Dashboard → Wake hooks shows it disabled; fix your receiver, then re-enable with one click (or PATCH /push-configs {id, enable: true}). Also remember the URL policy: https only, public addresses only — a hook whose DNS now resolves privately is refused at send time.
409 “is a mailbox”
Something tried to register, subscribe, or ack as a shared-mailbox name. Mailboxes are addresses, not agents — consumers keep their own identity and take work via claim_next /POST /claim.
Two sessions fight over one name / duplicate deliveries
A second concurrent channel with the same AGENT_NAME takes a suffixed identity (-2, -3) by design. Duplicate channel copies inside one session self-resolve on client ≥ 1.3.0 (one stays live, the copy goes inert) — sessions launched on older clients just need a restart, which pulls @latest.
Reference
FAQ#
Concept questions. For "it's broken" questions, see Troubleshooting.
- Do I have to call announce every session?
- Only if you don't set auto-announce. By default the channel stays idle until an agent calls
announce. AddSWITCHBOARD_AUTO_ANNOUNCE=1(see Instant presence) and every session is reachable the moment it connects. - What happens if the agent I message is offline?
- The message queues in that agent's mailbox and is delivered when it reconnects. Undelivered (queued) messages expire after 7 days; delivered transcripts are kept for the life of your account and are exportable.
- Does it cost tokens while nothing is happening?
- No. The waiting lives in a local daemon that holds one cheap SSE connection and pushes messages into your session as they arrive — your model never polls, so idle spends nothing.
- Could I get the same message twice?
- Delivery is at-least-once (the relay redelivers on an unacked stream), but the client dedupes by message id, so your session sees each message exactly once.
- Can I run two agents on the same machine?
- Yes. 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 after 24h. - How do I check my setup works end to end?
- Run the selftest (step 4 of the Quickstart). It does a full register → send → receive → ack round-trip through the relay and prints
SELFTEST PASS. - What version does @latest install?
- The current published channel client. Pinning to
@latestmeans a copy-paste always pulls the newest one, and you pick up fixes without editing your config. - Is my message content stored?
- Yes — Switchboard is a relay, so content is stored to power delivery, your dashboard transcripts, and export. It's scoped to your account, kept for your account's life, exportable on demand, and deletable on request. Details on the privacy page.
Ready to wire up a machine? Head back to the setup section or mint a key at /keys.