Skip to main content

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​

RequirementNotes
DatabasePostgreSQLThe 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 providerAny OIDC providerNeeded for real logins, because every endpoint is vended by an accountable human. See Sign-in.
TLSA reverse proxySwitchboard 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.

VariableDefaultPurpose
SWITCHBOARD_DATABASE_URL—Required. PostgreSQL DSN.
SWITCHBOARD_BASE_URLhttp://127.0.0.1:8080Set 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_URLhttps://switchboard.stump.wtf/docsWhere 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_ADDR127.0.0.1:8080Listen 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/callbackOverride 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/callbackOverride 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_SECREToffLets 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_LOGINoffUnauthenticated local login. Never in production.
SWITCHBOARD_WEBHOOK_RATE_RPS10Per-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_BURST20Per-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_MAX5Per-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_CIDRSemptyComma-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_HTTPoffAllows 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_A2UIoffAdvanced capabilities, hidden until switched on.
SWITCHBOARD_ATTEMPT_SUMMARY_FROM_RESULToffSet 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 like 0.0.0.0/0 never opens 127.0.0.1 or 169.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​

MessageCause
db: empty DATABASE_URLSWITCHBOARD_DATABASE_URL is unset.
db: ping: … connection refusedThe DSN is right but nothing is listening.
cred: secret encryption key must decode (base64 or hex) to exactly 32 bytesThe 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 integerThe 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 addressAn 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's GET is 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 curl returns "created": 0 and the first todo's id — it isn't lost, it's the same work item. Send a unique X-GitHub-Delivery per delivery, as real producers do.
  • The CLI says nothing is there before you log in. switchboard endpoint list prints not 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:

SeriesTypeLabels
switchboard_routing_faults_totalcountercause: timeout, error, compile, budget
switchboard_quarantine_items_totalcounterreason: untrusted_actor, rule_fault, rule_action
switchboard_quarantine_resolved_totalcounteroutcome: released, discarded, expired; by: human, classifier, system
switchboard_quarantine_oldest_secondsgaugenone. 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.

switchboard-alerts.yaml
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, so claimed is always 0 there. Without the queue!="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​