Run your own switchboard
Switchboard is a single Go binary and a PostgreSQL database. This guide takes you from nothing to a running instance with a verified webhook → todo → agent loop.
Every command here was run against a fresh database while writing it, and the outputs are the real ones. The one step that cannot be run for you is registering an OIDC client in your identity provider, since that happens in software this project doesn't ship — that section says exactly what switchboard needs and how to tell it worked.
What you need
| Requirement | Notes | |
|---|---|---|
| Database | PostgreSQL | The schema uses gen_random_uuid() and identity columns, so 13 or newer. Verified on 16; the project's own CI runs 18, and the Docker path below brings its own. Switchboard creates and migrates its own schema at startup — 24 migrations as of this writing. |
| Identity provider | Any OIDC provider | Needed for real logins, because every endpoint is vended by an accountable human. See Sign-in. |
| TLS | A reverse proxy | Switchboard speaks plain HTTP and expects something in front terminating TLS. |
There is no external secret manager, no message broker requirement, and no sidecar. One process, one database.
Configuration
Everything comes from the environment; serve takes no flags.
| Variable | Default | Purpose |
|---|---|---|
SWITCHBOARD_DATABASE_URL | — | Required. PostgreSQL DSN. |
SWITCHBOARD_BASE_URL | http://127.0.0.1:8080 | Set this. The externally reachable base URL, with no trailing slash. It builds the OIDC redirect URL, the vended MCP endpoint URLs, and the webhook ingest URLs you hand to producers. |
SWITCHBOARD_DOCS_BASE_URL | https://switchboard.stump.wtf/docs | Where the landing page's docs links point, with no trailing slash. The default is the hosted docs site: the docs image is not published to a registry self-hosters can reach, so the app serves no /docs and relative links there would 404. Serve the docs yourself (docs-site/ builds them) and point this at your own base. |
SWITCHBOARD_ADDR | 127.0.0.1:8080 | Listen address. The container image sets 0.0.0.0:8080. |
SWITCHBOARD_OIDC_ISSUER | — | Issuer URL. Discovery runs at startup. |
SWITCHBOARD_OIDC_CLIENT_ID | — | |
SWITCHBOARD_OIDC_CLIENT_SECRET | — | |
SWITCHBOARD_OIDC_REDIRECT_URL | <base>/auth/callback | Override only if your proxy rewrites paths. |
SWITCHBOARD_GITHUB_CLIENT_ID | — | Optional second login provider (GitHub OAuth, ADR-0026). When set with its secret, the login page gains "Log in with GitHub". |
SWITCHBOARD_GITHUB_CLIENT_SECRET | — | |
SWITCHBOARD_GITHUB_REDIRECT_URL | <base>/auth/callback | Override only if your proxy rewrites paths. |
SWITCHBOARD_SECRET_ENCRYPTION_KEY | — | Recommended. Encrypts webhook signing secrets at rest. 32 bytes, base64 or hex. Empty stores them in plaintext; see Secrets at rest. |
SWITCHBOARD_WEBHOOK_ALLOW_SUPPLIED_SECRET | off | Lets create_webhook accept a signing_secret for a source Switchboard normally mints secrets for (GitHub, Gitea, Cairn). Off, such a call is refused with invalid_argument, because a secret you pasted is one more secret to track and rotate. Provider-issued secrets — Stripe's and Slack's — are always accepted, are never stored in plaintext (they need SWITCHBOARD_SECRET_ENCRYPTION_KEY), and are recorded in webhook_secret_audit with a fingerprint of who wrote which. Such a webhook reports secret_supplied: true, and rotate_webhook replaces its secret with a minted one. |
SWITCHBOARD_DEV_LOGIN | off | Unauthenticated local login. Never in production. |
SWITCHBOARD_WEBHOOK_RATE_RPS | 10 | Per-client-IP refill rate (requests/second) of the inbound webhook throttle. Raise it if a producer's bursts outrun the bucket: a sender that never retries a 429 — Gitea deliveries among them — loses every event the limiter rejects. Per-webhook bucketing after signature verification is a tracked follow-up. Must be a positive integer. |
SWITCHBOARD_WEBHOOK_BURST | 20 | Per-client-IP bucket capacity of the same throttle: how many deliveries may arrive at once before throttling starts. Raise it together with the rate for a sender that files batches. Must be a positive integer. |
SWITCHBOARD_NOTIFY_HOOK_MAX | 5 | Per-endpoint ceiling on outbound notify hooks. 0 turns notify hooks off. Notify hooks also require SWITCHBOARD_SECRET_ENCRYPTION_KEY: without it, no hook can be created, because a hook secret is never stored in plaintext. |
SWITCHBOARD_NOTIFY_HOOK_ALLOW_CIDRS | empty | Comma-separated CIDRs that notify hooks may target despite the private-address rule. Off by default. Read the warning below before setting it. |
SWITCHBOARD_PUSH_ALLOW_HTTP | off | Allows plain http:// notify-hook targets. Never in production: notifications then travel unencrypted. |
SWITCHBOARD_METRICS_TOKEN | — | Scrape token for GET /metrics. Unset, the endpoint answers 401 to everything. At least 32 bytes. See Metrics. |
SWITCHBOARD_FRIENDING, SWITCHBOARD_PERSONAS, SWITCHBOARD_A2A, SWITCHBOARD_A2UI | off | Advanced capabilities, hidden until switched on. |
SWITCHBOARD_ATTEMPT_SUMMARY_FROM_RESULT | off | Set to true so a complete or fail with no summary stores the compact JSON of its result as the attempt summary. Off because clients written before attempt history never expected anyone else to read result: turning it on replays it to every later claimer in prior_attempts and get_todo. See Attempt history. |
A few limits live in the database's settings table rather than the environment: retention
(retention_max_age_days, 30 by default, and retention_max_rows, 500000), the per-todo attempt
history cap (attempt_history_max_per_todo, 50 by default and never below 5), and the interrupt-lease
deadline (interrupt_lease_ttl_seconds, 86400 by default: how long a todo may sit waiting on a client
for input or auth before the reaper returns it to the queue; any non-positive value leaves interrupted
todos interrupted indefinitely). They are read while
the service runs, so a change needs no restart. See
Attempt history.
:::danger SWITCHBOARD_NOTIFY_HOOK_ALLOW_CIDRS exposes those ranges to every tenant
Notify hooks refuse private, loopback, link-local, unique-local, CGNAT and multicast targets, so an
agent cannot aim Switchboard at your internal network. The allowlist lifts that for the ranges you
list, for every endpoint of every human on the instance, not only yours. Use it only on a
single-tenant or homelab install, and list the narrowest ranges that work: 127.0.0.1/32 for a
receiver on the same host, or one LAN host's /32.
- Loopback and link-local are opened only by an entry that lies wholly inside them, such as
127.0.0.1/32. A broad entry like0.0.0.0/0never opens127.0.0.1or169.254.169.254. - Exempting link-local (
169.254.0.0/16,fe80::/10) exposes cloud metadata services, which hand out instance credentials. Never list it on a cloud VM. - Not every metadata service is link-local. Alibaba Cloud's (
100.100.100.200) is in the CGNAT range and AWS's IPv6 one (fd00:ec2::254) is unique-local. Those two stay refused under any covering entry (0.0.0.0/0,100.64.0.0/10,fc00::/7,::/0) and open only when you list the exact address. Any other address a broad entry covers is exposed, so on a cloud VM check what your provider runs inside the ranges you list. - Switchboard's own listen address and port stay refused even when listed.
Startup logs a warning while the allowlist is set. :::
One setting is easy to get wrong: SWITCHBOARD_BASE_URL decides whether session cookies are
marked Secure. Switchboard sets that flag when the base URL starts with https://. Behind TLS,
the base URL must say https:// or you serve session cookies without the flag.
Generate an encryption key with either of these:
openssl rand -base64 32 # 44 characters
openssl rand -hex 32 # 64 characters
Get the source
Switchboard is MIT licensed and the repository is public at https://github.com/stump-wtf/switchboard:
git clone https://github.com/stump-wtf/switchboard.git
Published container images for linux/amd64 and linux/arm64 live at
ghcr.io/stump-wtf/switchboard, built on every v* tag. Everything below works from a clone, and
the Docker path works from the image alone — no checkout needed.
Dependencies are vendored in the repository, so the build needs no network and no module downloads.
Don't run go mod tidy: it can rewrite go.mod and vendor/, which is exactly the hermetic
property the vendoring exists to guarantee.
Start it
There are two paths. Docker is the shorter one and brings its own PostgreSQL; the binary path suits an existing database or a systemd unit.
With Docker
Fastest path — the published image, no checkout:
curl -fsSL -o compose.yaml https://raw.githubusercontent.com/stump-wtf/switchboard/main/deploy/docker/compose.yaml
docker compose up -d
That starts PostgreSQL alongside switchboard (image ghcr.io/stump-wtf/switchboard:latest,
multi-arch linux/amd64 + linux/arm64), waiting for the database to pass its health check first.
The service is published on 127.0.0.1:8080, and SWITCHBOARD_BASE_URL defaults to
http://127.0.0.1:8080.
If you would rather build from source, clone the repo and, from deploy/docker/:
docker compose build
docker compose up -d
That builds the image from the vendored source and runs the same stack.
docker compose logs switchboard
level=INFO msg="database ready"
level=INFO msg="switchboard listening" addr=0.0.0.0:8080 base_url=http://127.0.0.1:8080 oidc=false dev_login=false
level=INFO msg="listening for todo_ready wakeups" channel=todo_ready
curl http://127.0.0.1:8080/login then returns the login page. With no OIDC configured it says
"No login configured", which is the expected state until you do the
OIDC setup below — set the SWITCHBOARD_OIDC_* variables in the compose
environment, along with a real SWITCHBOARD_BASE_URL and a
SWITCHBOARD_SECRET_ENCRYPTION_KEY, and recreate the service.
The compose file is a starting point, not a production deployment: the database password is
change-me, the database volume is local, and there is no TLS. Read
Behind a reverse proxy before exposing it.
docker compose down stops it; docker compose down -v also deletes the database volume.
As a binary
go build -o switchboard ./cmd/switchboard
export SWITCHBOARD_DATABASE_URL='postgres://switchboard:secret@localhost:5432/switchboard?sslmode=disable'
export SWITCHBOARD_BASE_URL='https://switchboard.example.com'
export SWITCHBOARD_ADDR='127.0.0.1:8080'
export SWITCHBOARD_SECRET_ENCRYPTION_KEY="$(openssl rand -base64 32)"
./switchboard serve
A healthy start looks like this — the schema is created and migrated on first run, so there is no separate migrate step:
level=INFO msg="database ready"
level=INFO msg="webhook signing secrets encrypted at rest"
level=INFO msg="switchboard listening" addr=127.0.0.1:8080 base_url=https://switchboard.example.com oidc=true dev_login=false
level=INFO msg="listening for todo_ready wakeups" channel=todo_ready
Check the oidc= and dev_login= values on that line: they tell you which login paths are live.
sslmode=disable above assumes the database is on the same host or a private network. Across a
network, use sslmode=verify-full.
When it won't start
| Message | Cause |
|---|---|
db: empty DATABASE_URL | SWITCHBOARD_DATABASE_URL is unset. |
db: ping: … connection refused | The DSN is right but nothing is listening. |
cred: secret encryption key must decode (base64 or hex) to exactly 32 bytes | The key is the wrong length or encoding. |
config: SWITCHBOARD_METRICS_TOKEN is N bytes; it must be at least 32 … | The scrape token is too short. It must also be printable ASCII with no spaces. |
config: SWITCHBOARD_NOTIFY_HOOK_MAX="…" must be a non-negative integer | The notify-hook ceiling is not a whole number. Use 0 to turn notify hooks off. |
config: SWITCHBOARD_NOTIFY_HOOK_ALLOW_CIDRS: … is not a CIDR range or IP address | An allowlist entry is malformed. Use CIDRs such as 127.0.0.1/32, separated by commas. |
config: SWITCHBOARD_WEBHOOK_RATE_RPS="…" must be a positive integer … | The inbound webhook throttle's refill rate is not a positive whole number. Leave it unset for the default (10). |
config: SWITCHBOARD_WEBHOOK_BURST="…" must be a positive integer … | The inbound webhook throttle's burst is not a positive whole number. Leave it unset for the default (20). |
Each of these exits immediately and says which one it is.
Behind a reverse proxy
Two rules matter more than the rest:
- Do not buffer
/mcp/*. A vended MCP endpoint'sGETis a long-lived notification stream. A buffering proxy holds doorbells until the connection closes. Caddy:reverse_proxy … { flush_interval -1 }. nginx:proxy_buffering off;. - Pass a trusted
X-Forwarded-For. The pre-auth rate limiter keys on the client IP, and with TLS terminated upstream every agent otherwise arrives from one address and shares a bucket.
Switchboard is a complete OIDC relying party with its own login, so don't put forward-auth in front of it.
Sign-in (OIDC)
Register switchboard as a confidential client in your provider:
- Redirect URI:
<SWITCHBOARD_BASE_URL>/auth/callback - Grant: authorization code, with PKCE
- Scopes:
openid, plus whatever your provider needs for name and email
Then set the three SWITCHBOARD_OIDC_* variables and restart.
Switchboard trusts the issuer wholesale and enforces no assurance claim, which is only appropriate for a provider you control and would trust for admin access. Users are created on first sign-in, keyed on the OIDC subject, so whoever can authenticate at your provider can sign in here. Restrict access at the provider.
To confirm it worked: open /login. With OIDC configured you get a login button; with nothing
configured you get "No login configured", and /auth/login answers:
503 OIDC not configured (set SWITCHBOARD_OIDC_* or SWITCHBOARD_DEV_LOGIN=1)
SWITCHBOARD_DEV_LOGIN=1 mints a session for a fixed local user with no authentication at all. It
is for trying the loop on a laptop, never for a deployment.
Verify the whole loop
Do this once, on a fresh instance. It takes a few minutes and proves ingestion, verification, routing, and the agent surface all work together.
1. Vend an endpoint. Sign in, open Endpoints, and use + vend endpoint. Give it a queue
(inbox), the six todo verbs plus create_webhook, and a webhook allowance of 1 or 2 with source
type github. The reveal shows the MCP URL and a sbk_… credential once.
2. Create a webhook. From an MCP client connected to that endpoint, or any HTTP client speaking
MCP, call create_webhook:
{"source_type": "github", "target_queue": "inbox"}
You get back an ingest_url and a signing_secret (whsec_…), the secret shown only this once.
3. Send a signed delivery.
BODY='{"action":"opened","issue":{"number":1,"title":"self-host check","user":{"login":"you"}},"sender":{"login":"you"}}'
SIG="sha256=$(printf %s "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" | awk '{print $NF}')"
curl -sS -X POST "$INGEST_URL" \
-H 'Content-Type: application/json' \
-H 'X-GitHub-Event: issues' \
-H "X-GitHub-Delivery: $(uuidgen)" \
-H "X-Hub-Signature-256: $SIG" \
-d "$BODY"
Expect 202 and a body naming the todo it created:
{"todos":[{"id":"td_…","queue":"inbox","created":true}],"created":1,"trust_mode":"signed","verified":true}
4. Prove verification is real. Send the same body with no signature header:
401 {"error":"signature verification failed"}
Nothing is stored for a rejected delivery.
5. Work the todo. list_todos with {"queue": "inbox", "state": "pending", "limit": 5} returns
it, with attempt: 0 and max_attempts: 5. claim takes it under a lease (state: claimed,
attempt: 1, a lease_expires_at), and complete finishes it (state: done).
If all five steps behave that way, the instance is working: signatures are enforced, todos are durable, and agents can drain them.
Two things that look like bugs
- A repeated identical body collapses. Deliveries dedupe on the producer's delivery id, or on a
hash of the body when there is none. Posting the same JSON twice with plain
curlreturns"created": 0and the first todo's id — it isn't lost, it's the same work item. Send a uniqueX-GitHub-Deliveryper delivery, as real producers do. - The CLI says nothing is there before you log in.
switchboard endpoint listprintsnot logged in — run `switchboard login <URL>` first.
Routing rules: two live defects
Routing rules are optional — without any, deliveries land on the webhook's target queue. If you write them, know these two before you write a trust rule, because both are unfixed and both fail in the dangerous direction.
Rules fail open. A rule whose jq expression errors is recorded as a fault and treated as no
match. For a drop rule that means it stops dropping. A trust rule that reads a parameter of the
wrong type doesn't fail closed — it lets everything past. Defend by hand: read list parameters
through arrays and string parameters through strings, so a missing or mistyped value becomes an
empty list rather than an error:
{"id": "untrusted", "expr": "(($params.trusted | arrays) // []) as $t | ((.payload.sender.login // \"\") as $who | any($t[]; . == $who) | not)", "action": {"drop": true}}
Only an explicit "params": {} clears parameters. set_webhook_rules replaces rules and the
default action; it replaces params only when the field is present, so omitting it keeps your
allowlists. Sending {}, or a params object that leaves a list out, empties them — combined with
the above, a trust rule then matches nobody or everybody depending on how it is written. Every
rules verb echoes the params in force, so read them back from the response or list_webhook_rules.
The routing cookbook has tested recipes that already follow both rules.
What an endpoint can do
An endpoint is the whole capability grant: an MCP URL plus a credential, scoped at vend time to a set of queues, a list of tools, a webhook allowance, and a lifetime.
The endpoint's scope is the only capability boundary. It cannot be widened after vending — there is deliberately no verb for that — and no client-side configuration, skill, or prompt changes what it may do. To change what an agent can do, vend a new endpoint and revoke the old one. Revocation is immediate: the credential stops authenticating and live sessions are torn down.
Grant the smallest useful scope. The event-history tools in particular should go only to endpoints whose job needs them.
Metrics
GET /metrics serves Prometheus text format (text/plain; version=0.0.4) for VictoriaMetrics,
Prometheus, or anything else that scrapes it. It covers queue liveness, the todo lifecycle, webhook
deliveries and routing decisions, plus the Go runtime and process collectors. The
metrics spec names every series, and
ADR-0028 explains why the queue gauges lead.
The endpoint is closed until you give it a scrape token:
export SWITCHBOARD_METRICS_TOKEN="$(openssl rand -hex 32)"
The scrape token is a credential of its own. A vended endpoint token, an operator OAuth token, and a
signed-in browser session all get the same answer as no credential at all: 401 with an empty
body, so an unauthenticated prober learns no queue names. With the variable unset, every request
gets 401. A token shorter than 32 bytes, or one containing spaces or non-ASCII characters, stops
the service at startup. Surrounding whitespace is trimmed, so a token read from a file that ends in
a newline still works. The route is throttled per client address at 1 request per second with a
burst of 20, far above any real scrape interval.
Check it by hand:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/metrics
401
$ curl -s -H "Authorization: Bearer $SWITCHBOARD_METRICS_TOKEN" http://127.0.0.1:8080/metrics | grep '^go_goroutines'
go_goroutines 14
A scrape job with bearer auth (Prometheus and vmagent read the same block):
scrape_configs:
- job_name: switchboard
scheme: https
metrics_path: /metrics
scrape_interval: 30s
authorization:
type: Bearer
# Keeps the token out of the config file. `credentials: <token>` inline also works.
credentials_file: /etc/prometheus/secrets/switchboard-metrics-token
static_configs:
- targets: ["switchboard.example.com"]
switchboard_todo_attempts_closed_total{queue,outcome} counts how todo attempts end. Its
lease_expired and reaped outcomes are deaths, and each also counts once in
switchboard_todo_leases_expired_total. See Attempt history.
switchboard_interrupt_leases_reclaimed_total{queue} says how much of that lease-expiry signal is a
wait that outlived its holder: a todo paused on a client (input-required/auth-required) whose holder
never came back, taken past the interrupt deadline and returned to the queue. It is a cause marker, not
a fourth outcome — every increment is also a switchboard_todo_leases_expired_total increment, so
alerts keep reading one expiry series.
A series appears only once there is something to count. A counter nothing has incremented yet is
absent rather than zero, because a zero and an unmeasured value look identical once scraped. The
exceptions are the counters an increase() alert below reads: every cause of
switchboard_routing_faults_total and each collector's switchboard_metrics_collection_errors_total
start at 0, so the first fault after a restart still shows as an increase.
Intake and quarantine series
Rules fail closed, and untrusted deliveries wait in the owner's quarantine (see Route events with jq rules). Four series watch that path:
| Series | Type | Labels |
|---|---|---|
switchboard_routing_faults_total | counter | cause: timeout, error, compile, budget |
switchboard_quarantine_items_total | counter | reason: untrusted_actor, rule_fault, rule_action |
switchboard_quarantine_resolved_total | counter | outcome: released, discarded, expired; by: human, classifier, system |
switchboard_quarantine_oldest_seconds | gauge | none. It is the age of the oldest held delivery, or 0 when nothing is held. |
by says what kind of resolver acted, never which one: a human's id and a classifier's slug stay
out of the labels. Quarantine is also an ordinary queue in switchboard_queue_todos, with
queue="quarantine", so its depth shows up next to every other queue. It is a reserved label: it
never folds into __other__, however many queues reach the cap first, so the alert below can
exclude it by name.
Alerts
These rules load into Prometheus or vmalert as they are. Switchboard's test suite reads this block and evaluates the two queue alerts against the real collector's output. If an expression here stops doing what this section says, the build fails.
groups:
- name: switchboard
rules:
- alert: SwitchboardQueueStalled
expr: |
switchboard_queue_todos{state="pending",queue!="quarantine"} > 0
and ignoring(state) switchboard_queue_todos{state="claimed",queue!="quarantine"} == 0
for: 30m
annotations:
summary: "Work is waiting on {{ $labels.queue }} and nothing is claiming it."
- alert: SwitchboardQuarantineWaiting
expr: switchboard_quarantine_oldest_seconds > 86400
for: 15m
annotations:
summary: "A delivery has waited in quarantine for over a day. Release or discard it."
- alert: SwitchboardRoutingFaults
expr: increase(switchboard_routing_faults_total[15m]) > 0
annotations:
summary: "A routing rule faulted ({{ $labels.cause }}). Faulted deliveries are held, not routed."
- alert: SwitchboardMetricsCollectorFailing
expr: increase(switchboard_metrics_collection_errors_total[10m]) > 0
annotations:
summary: "The {{ $labels.collector }} collector failed, so its gauges are missing."
- The liveness alert must exclude
quarantine. Nothing ever claims from it: a human or a classifier releases or discards held items, soclaimedis always0there. Without thequeue!="quarantine"matchers, the alert would fire from the first held delivery until the last one was resolved. - Quarantine gets an age alert instead. Held items expire after 30 days, or sooner under your retention setting. A day-old item is someone's report that nobody has looked at. Tune the threshold to how often you review the Quarantine view.
- A routing fault is always worth a look. A faulted delivery went nowhere, and a rule that faults on every delivery quietly starves the lane behind it.
Where to go next
- Concepts in five minutes — the model your users will work in.
- Upgrading — read this before moving between releases. It names the breaking changes, and anything a release cannot undo.
- Vend an endpoint — the full scope surface.
- Receive your first webhook — GitHub, Gitea, Cairn, and signing your own producer.
- Routing cookbook, Working the queue well, Security model, and Troubleshooting.