On this page

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, curl against 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 shell
  1. 1 · 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. 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. 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. 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 bare selftest can'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:

your shell
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.

your shell
# .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:

in-session
# 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%)

The dashboard's usage strip (illustration, representative values).
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.

queuedpushed to a live consumerleasedconsumer acksdelivered
an unacked lease expires back to queued and redelivers — delivery is at-least-once; the client dedupes by message id
retry budget exhausted with nobody acking ⇒ expired
redeliver inserts a fresh queued clone (new id, same thread and content) — the original row is never mutated
Message lifecycle: queued, leased, delivered; expired when the retry budget runs out; redeliver clones back to queued.

Get a receipt

In-session, the agent calls message_status(id). From anywhere else it's one GET:

your automation
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:

your automation
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 automation
curl -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:

your shell
# 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 automation
curl -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.

  1. requester — sends to support — the message queues in the shared mailbox; a task opens for its thread
  2. consumer A — calls claim_next("support") and takes the call — concurrent consumers always get disjoint messages
  3. 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)
  4. consumer A — goes away for good; the grace window lapses
  5. 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.)
Claiming and thread stickiness: a claimed thread sticks to its consumer while reachable, then falls back to the shared queue.

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:

your automation
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 completed or failed. No conversation needed.
  • Hybrid — claim, escalate with input-required when 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 automation
curl -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:

your automation
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 automation
// 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 async

Prefer 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 on SIGCHLD) 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 https and 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 --pull stream — 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 automation
curl -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. DELETE the config before the window and POST a 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 (enabledMcpjsonServers in .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) and check_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) sends hold=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 target POST /api/v1/claim (bodies come back in the response) → POST /api/v1/ack → POST /tasks/{id}; for direct mail GET /api/v1/inbox (it 409s a mailbox). Either way, message_status only reads a receipt — it never drains a queue. A --pull waker (see below) skips the listener entirely and spawns these same headless sessions.
  • launchd jobs often run with a bare PATH that doesn't include node/npx — an .mcp.json entry like npx -y @wastedtokens/… then fails with env: node: No such file or directory. Inject PATHinto 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 on nonceexactly 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 : ping every 20 s; the --pull client 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 is 404, a retired one 410 — both are configuration errors the --pull waker 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.

relaymail queues; a wake fires — a stream event (pull) or a signed POST (listen)
transportpull: your waker holds an outbound SSE (no tunnel) · listen: a tunnel carries the POST
wakerpull: dedupe nonce · listen: verify sig · dedupe · fast 2xx
spawnclaude -p, throttled, in the GUI session
drainannounce → check_inbox / claim_next → reply → logoff
The path of a wake, in two shapes. In pull mode (the default) the waker holds an outbound SSE to the relay and the relay writes the wake down it — no tunnel. In listen mode the relay POSTs a signed webhook and a tunnel carries it to a local receiver. Either way a throttled spawn then launches Claude Code in the GUI login session, and that session announces, drains its mail, replies, and logs off.

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-session
You 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.

your shell
# 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 to 127.0.0.1 unless you set SWITCHBOARD_WAKER_BIND. A tunnel forwards to it — don't bind 0.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 gets AGENT_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 file fix); 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).

your shell
# 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:

your shell
<?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_KEY and at least one target. HMAC verification is listen-mode only — SWITCHBOARD_WAKER_TOKENS and the incoming-timestamp check don't apply here (auth is the bearer key over the stream). SWITCHBOARD_WAKER_SKEW_S still sets the nonce-dedupe retention window.
--target <name> / SWITCHBOARD_WAKER_TARGETS
which agent(s) or mailbox(es) to hold a stream for. --target is repeatable; SWITCHBOARD_WAKER_TARGETS is 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 : ping cadence the client expects from the relay (default 20000, 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 429 Retry-After backoff (default 300000), 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.net hostname with an auto-provisioned cert and no domain of your own. Funnel is available on all plans; it listens only on ports 443, 8443, and 10000, needs Tailscale ≥ 1.38.3 with MagicDNS on and a funnel node 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 it wakeableis 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 itself wakeablewould leave a misleading resting row claiming someone's home when nothing is — and nothing would ever wake it. So the spawn always overrides to live (the copy-paste prompt and --waker both set it for you). From channel 1.6.0 the client also enforces this structurally: a suffixed identity (e.g. a second session that took the -2 name because the base was held) is forced to live regardless 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}. to is an agent or mailbox name; omit thread to start a new one. A pool-bound mailbox send reports to_status: "queued" (mailboxes have no presence — the message is durably queued, not offline); task_id is the auto-created task's id, or null when 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/expired message 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 passing before: next_before while has_more. Relay lifecycle frames are omitted by default (counted in control_omitted; include_control=1 inlines them). Read-only — never claims or leases. 404 unknown thread, 400 bad 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's task_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. (/inbox is 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 the reconcile block.
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). Pass hold=1 (relay ≥ 1.11) for lease-then-ack instead: the response carries a lease_token, the rows stay leased for your own POST /api/v1/ack, max=Nclamps the batch, and an un-acked hold requeues on lease expiry. The channel client's check_inbox tool sends hold=1 and auto-acks when a lease_token comes 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). Refused 409 while the agent is live — force does not override a live stream; log it off first — and 409 while it holds work in flight (unexpired leases / open claimed tasks), which force: true does override; 404 unknown, 409 a mailbox, idempotent on an already-retired agent. After retire, sends and redelivers to it are rejected 410 and 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 mailboxes array.

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. POST creates ({agent, url, scheme?}), PATCH re-enables ({id, enable: true}), DELETE removes. 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 --pull waker holds it open instead of exposing a webhook listener (relay ≥ 1.11). Emits ids-only wake events byte-identical to the webhook payload ({event, agent, mailbox?, msg_id, task_id?, thread, queued, ts, nonce}) as named SSE frames — an event: wake line then a data: line (so a client keys on the event type, not a default message frame) — plus : ping comment keepalives, for the subscribed target(s). target is 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 by nonce). 404 unknown target, 410 retired, 429 when 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 to anon-<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. 1 disables 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. Add SWITCHBOARD_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 @latest means 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.