Switchboard — Complete Reference

Switchboard routes AI work to a fleet of GPU hosts. You ask for an artifact and declare what the work is for; the broker decides where it runs, loads it if needed, and hands back a URL to call. This document covers every supported feature and configuration.

Overview

Three moving parts:

The unit of work is a ticket. You POST a request, the broker queues it, schedules a host, loads the model if it isn't running, and marks the ticket ready with a proxy URL. All your data-plane calls go through that URL — the real backend address stays hidden.

Quick start

import switchboard_client as sb, httpx

url = sb.request("qwen3-30b-a3b-q4",
                 environment="production", urgency="interactive")

r = httpx.post(f"{url}/v1/chat/completions", json={
    "messages": [{"role": "user", "content": "Hello"}]})
print(r.json()["choices"][0]["message"]["content"])
If your app uses more than one model, read Sessions and Co-residency before you build. Independent request() calls for models that can't co-reside will evict each other every turn — the most common cause of timeouts on this fleet.

Install & configuration

pip install switchboard-client --extra-index-url https://switchboard.wtray.com/wheels/
Use --extra-index-url, not --index-url. --index-url replaces PyPI, and this wheelhouse only carries switchboard-client — so pip cannot resolve its dependencies (httpx, websockets) and fails with ResolutionImpossible on a clean environment.

Environment variables read by the client:

VariableDefaultPurpose
SWITCHBOARD_BROKERhttp://192.168.1.33:8080Broker base URL
SWITCHBOARD_STT_ARTIFACTfaster-whisperArtifact the speech helpers resolve to
SWITCHBOARD_TTS_ARTIFACTkokoroArtifact the TTS helpers resolve to
The broker enforces a minimum client version and answers 426 Upgrade Required to older clients. Current line: 2.0.2.

Artifacts

An artifact is the concrete thing you ask for — a model id with its quantisation (qwen3-vl-32b-q4 vs qwen3-vl-32b-q8 are different artifacts), a ComfyUI workflow, or a service name (kokoro, faster-whisper, pyannote).

A run-profile is a tested way to run one artifact on a specific host/GPU with specific settings. One artifact has many profiles, and profiles of the same artifact can differ — notably in context length. Enumerate the catalog at runtime with sb.fleet() rather than hard-coding.

The broker never substitutes artifacts. Asking for -q4 will never give you -q8, even if the stronger one is loaded and free. Artifact choice is the caller's.

Workload class

Every request and session declares three independent axes. They drive scheduling, not model selection. environment and urgency are mandatory.

AxisValues (rank)Meaning
environmentrequired production (1) · development (0) Dominates scheduling. Any production work outranks all development work.
urgencyrequired realtime (3) · interactive (2) · batch (1) · background (0) Breaks ties within an environment. Preemption order only.
keep_alivedefault 30s seconds Warm-linger after the call if nothing else needs the model. Independent of urgency.

Rank is lexicographic: rank = environment×100 + urgency. A running service is committed at the highest rank actively using it and can only be evicted by a strictly higher rank — or unloaded once nothing needs it.

Requirements

Satisfy-or-queue hints. The broker only considers profiles that meet them, and returns 400 if no profile anywhere can. Unknown keys are ignored (forward-compatible).

KeyTypeEffect
min_contextintProfile's tested context must be ≥ this.
min_vram_mibintSummed VRAM across the profile's GPUs must be ≥ this.
needs_visionboolModel must be a vision model.
url = sb.request("qwen3-vl-32b-q4", environment="production", urgency="interactive",
                 requirements={"min_context": 32768})
Use min_context whenever the window matters. It both routes you to a capable profile and fails loudly instead of silently giving you a smaller window.

Placement

Constrain how a profile is laid out on the host. Pass placement= (and/or gpu= for a specific index).

TokenMatches
NoneAny placement (default).
"single"Variant occupies exactly one GPU.
"all"Variant spans more than one GPU (tensor-split).
"gpu0", "gpu1", …Exactly that GPU index.
"cpu"No GPU at all; uses system RAM.
"mixed"Uses GPU(s) and meaningful system RAM (e.g. MoE offload).
custom tagExact match against the manifest's placement tag.

gpu=1 is equivalent to placement="gpu1" and matches only single-GPU variants on that index.

Placement is a capacity tool, not just a preference. Pinning a small model to "cpu" can free an entire GPU for a large one — see Co-residency.

Sessions

A session declares the full set of artifacts a feature will use, so the broker can plan placement across the whole fleet at once, pre-warm them, and hold them for the session's lifetime — instead of scheduling each call blind to what comes next.

REQS = [
  {"artifact":"qwen3-30b-a3b-q4",      "environment":"production","urgency":"interactive","keep_alive":120},
  {"artifact":"qwen3-vl-32b-q4",       "environment":"production","urgency":"interactive","keep_alive":120},
  {"artifact":"qwen3-embedding-4b-q8", "environment":"production","urgency":"interactive","keep_alive":120,
   "placement":"cpu"},   # frees a GPU for the VLM
]
with sb.session(REQS, priority=5.0) as s:
    text  = s.request("qwen3-30b-a3b-q4")
    vlm   = s.request("qwen3-vl-32b-q4")
    embed = s.request("qwen3-embedding-4b-q8", placement="cpu")
    # run the whole loop in here — all three stay warm

Each entry accepts artifact, environment, urgency, keep_alive (all required per entry), plus optional requirements, placement, gpu_index. requires may also be a plain list of artifact-id strings.

Session semantics

BehaviourDetail
LifetimeKept alive by a background heartbeat. Broker TTL defaults to 90 s; the client beats at max(5, ttl/3) — so a crashed client's plan is reclaimed automatically within the TTL.
A session is a subscriptionsb.subscribe(...) is an alias for sb.session(...). It holds its artifacts warm and committed at their declared workload class for as long as it heartbeats.
Standing demand does not ageUnlike a queued one-off request (whose weight climbs with wait time), a session exerts steady pull at its priority — it will not starvation-climb over other work. See scheduling.
priorityBaseline weight for this session's demand, and the default for its .request() calls. It biases which queued work wins a contended host; it does not override workload class — a higher environment/urgency still outranks it.
On closeThe broker stops needing those artifacts. They stay warm only for their keep_alive/min_warm_sec window, then unload. Nothing stays loaded that no client needs.
Standing entries are never dispatchedSession demand appears in the broker queue as a warmth signal, not as work to run. A non-zero queue length in the reconciler log is therefore normal and is not a backlog — check queue_depth for real pending work.

Verify a session is actually in use: sb.status()["sessions"] — if that is 0 while your app is running, you are making isolated request() calls and the broker is scheduling each one blind to the next.

A session is not a guarantee of co-residency. It gives the broker foresight, but physics still rules: if the declared set cannot fit anywhere simultaneously, the broker returns the best achievable plan and swaps for the rest. Verify with the capacity table.

Artifact catalog

Live as of this document's generation. Authoritative source is sb.fleet().

ArtifactKindParamsHostPlacementContextCostPort
comfyui-enginecomfyui-engineai-150gpu016000 MiB8188
comfyui-enginecomfyui-engineai-150gpu116000 MiB8189
comfyui-enginecomfyui-engineai-151gpu016000 MiB8188
pyannotediarizationai-151gpu12000 MiB7862
qwen3-embedding-4b-q8embed4Bai-150cpu3276810000 MiB RAM11500
qwen3-embedding-4b-q8embed4Bai-151gpu0327687500 MiB11500
gemma-4-26b-a4b-q4llamacpp26B (MoE, 4B active)ai-151gpu01638417000 MiB11504
mistral-small-3.2-24b-q4llamacpp24Bai-150gpu13276819000 MiB11503
mistral-small-3.2-24b-q4llamacpp24Bai-151gpu03276819000 MiB11503
qwen3-30b-a3b-q4llamacpp30B (MoE, 3B active)ai-150gpu03276823000 MiB11502
qwen3-30b-a3b-q4llamacpp30B (MoE, 3B active)ai-151gpu03276821000 MiB11502
qwen3-8b-q4llamacppai-9gpu0327688500 MiB11500
qwen3-vl-32b-q4llamacpp32Bai-150gpu03276828000 MiB11505
qwen3-vl-32b-q4llamacpp32Bai-151gpu03276822000 MiB11505
qwen3-vl-32b-q8llamacpp32B (dense)ai-150all4915247000 MiB11506
bge-reranker-v2-m3-q8rerank568Mai-150cpu81921500 MiB RAM11501
faster-whispersttai-151gpu14000 MiB7861
kokorottsai-151gpu11500 MiB7852
Note the per-profile context column — the same artifact id can be tested at different windows on different hosts. This is why min_context exists.

Co-residency & VRAM capacity

This is the most important operational section on this page.

Hardware budgets

HostGPU 0GPU 1System RAM budget
ai-150RTX 5090 — 30,000 MiB budget (32,607 physical)RTX 3090 — 22,000 MiB80,000 MiB
ai-151RTX 3090 — 22,000 MiB budget (24,576 physical)RTX 2080 Ti — 10,000 MiB24,000 MiB
ai-9GTX 1080 Ti — 10,000 MiB24,000 MiB
VRAM budget means "fits entirely in VRAM". It never means "the loader may spill to system RAM". Loaders that silently fall back to CPU still answer — orders of magnitude slower — and the broker would then schedule another service alongside thinking there's room.

The large-model conflict

The big text and vision models each require a gpu0, and there are only two gpu0s in the fleet. They cannot co-reside on the same host:

PairHostArithmeticFits?
qwen3-30b-a3b-q4 + qwen3-vl-32b-q4ai-150 gpu023,000 + 28,000 = 51,000 > 30,000No
qwen3-30b-a3b-q4 + qwen3-vl-32b-q4ai-151 gpu021,000 + 22,000 = 43,000 > 22,000No
qwen3-vl-32b-q4 + qwen3-embedding-4b-q8ai-151 gpu022,000 + 7,500 = 29,500 > 22,000No
qwen3-30b-a3b-q4 + qwen3-embedding-4b-q8ai-150 (gpu0 + cpu)23,000 VRAM + 10,000 RAMYes

The working three-model layout

A text + vision + embedding app can run fully resident — but only with embeddings pushed to CPU. This layout is verified working:

ai-150 / gpu0  ->  qwen3-30b-a3b-q4      # 23,000 of 30,000 MiB
ai-150 / cpu   ->  qwen3-embedding-4b-q8 # system RAM, no VRAM
ai-151 / gpu0  ->  qwen3-vl-32b-q4       # 22,000 of 22,000 MiB

Without placement="cpu" on the embeddings, the broker may park them on ai-151/gpu0, which blocks the VLM entirely and forces a swap on every loop turn.

Client API — core

request()

sb.request(artifact, *, environment, urgency, keep_alive=30.0, requirements=None,
           priority=0.0, placement=None, gpu=None, requester=None,
           broker=None, timeout_sec=300.0) -> str

Blocks until a healthy endpoint exists; returns the proxy URL. Raises RequestTimeout on timeout, SwitchboardError on failure/cancel, UpgradeRequired if the client is too old.

session() / Session

sb.session(requires, *, priority=0.0, requester=None, broker=None) -> Session
sb.subscribe(...)   # alias — a session IS a subscription

Introspection

FunctionReturns
sb.status(broker=None)Broker snapshot: enabled, queue_depth, queue, sessions, hosts.
sb.fleet(broker=None)Full inventory: per-host GPUs (name, budget, used, util) and every service variant.
sb.voices(broker=None)TTS voice list (union + per-host), cached ~60s server-side.

Cancellation

sb.cancel(ticket, *, broker=None)         # cancel a QUEUED ticket
sb.cancel_ticket(ticket, *, broker=None)  # release a DISPATCHED ticket

Client API — speech

Text to speech

sb.tts_say(text, *, character_voice="af_bella", language="en", speed=1.0,
           chunk_min_chars=50, chunk_max_chars=300, inter_chunk_gap_ms=80,
           pronunciation_overrides=None, output_path=None, bundle=None,
           environment="production", urgency="realtime", artifact=None,
           priority=0.0, timeout_sec=300.0) -> bytes | str

sb.tts_say_stream(text, ...)  # generator of (chunk_text, wav_bytes)

Output is 24 kHz mono float32 RIFF/WAVE. tts_say buffers into one WAV (bundling on by default); tts_say_stream yields per chunk so you can start playback immediately. Chunks are silence-trimmed, with inter_chunk_gap_ms of breath prepended after the first.

Speech to text

sb.transcribe(audio, *, language="en", initial_prompt=None, model=None,
              word_timestamps=True, hotwords=None, no_speech_threshold=None,
              low_confidence_threshold=None, suppress_hallucinations=True,
              hallucination_blocklist=None, ...) -> dict

audio accepts bytes, a path, or a file-like object.

async with sb.transcribe_stream(language="en", hotwords=["Karrthûn"],
                                silence_ms=700, min_speech_ms=100,
                                max_utterance_ms=30000,
                                partial_interval_ms=300) as s:
    await s.send(pcm_int16_16k_mono)
    async for ev in s.events(): ...

Event types: ready, vad_start, partial, final, vad_stop, error. Send raw int16 16 kHz mono PCM; any frame size works.

hotwords is the highest-value accuracy lever. Proper nouns (character names, places, jargon) are what generic models get wrong. The service runs a two-pass decode arbitration so biasing improves spelling without hallucinating the word list.

TTS markup

tts_say* accept inline SSML-style markup:

TagEffect
<break time="500ms"/>Silence of that length (emitted as a silence-only WAV).
<break strength="weak|medium|strong|x-strong"/>150 / 300 / 600 / 1000 ms.
<voice name="am_eric">…</voice>Switch voice for the enclosed span.
<lang code="ja">…</lang>Switch language pipeline.
<prosody speed="1.2">…</prosody>Rate for the span (0.5–2.0).
<sub alias="…">…</sub>Speak the alias instead of the text.
<phoneme>…</phoneme>Explicit pronunciation.

Supported language codes: en, en-us, en-gb, es, fr, hi, it, pt, pt-br, ja, zh. pronunciation_overrides={"gaol":"jail"} applies a dict globally. sb.split_sentences(text) exposes the sentence splitter.

Client API — admin

FunctionEffect
sb.pin(host, config)Lock a host to a config. The scheduler will not swap it until unpinned.
sb.unpin(host)Release the pin.
sb.force_config(host, config)Force a host to a config immediately.
sb.restart(host, service)Restart one service on a host.
sb.set_enabled(bool)Global dispatch flag. When false, /status still answers but nothing dispatches.
Pins starve other work. A pinned host cannot serve anything outside its pinned config, so unrelated requests queue until their client timeout. Always unpin in a finally, and never leave a pin in place during normal operation.

How placement is decided

When a queued request has no running worker, the broker picks an owning host by sorting candidates on this key — lowest wins:

(evict, warmth, strength, in_flight, host)
TermMeaning
evict0 if the host can serve without evicting what it's already running/heading toward. Dominates everything else — this is what spreads a workload across the fleet instead of thrashing one box.
warmth0 if the host is already running or heading toward a service that serves this request. Deliberately request-specific: a host merely running something unrelated (e.g. the audio stack on its other GPU) is not warmer for this work and must not out-rank an idle host with better hardware. (Fixed 2026-07-31 — see change log.)
strength(−tested_context, −vram) — prefers the stronger run-profile. Added 2026-07-31; before this, ties fell through to alphabetical hostname, so the same artifact id could land on a far smaller context window by accident.
in_flightLeast loaded.
hostDeterministic final tiebreak (stable scheduler).

Among already-running replicas, pick_worker_for sorts by (worker_load, strength, host, service).

Aging. A queued request's weight is priority + age_factor × wait_seconds (age_factor = 1.0), so waiting work climbs and cannot starve forever. Standing (session) demand does not age — it is steady pull at its priority, not a starvation climb.

Swapping. A host only swaps to a new config when the aged weight of the work the new config serves exceeds what's currently served plus a swap_penalty of 30.

Warmth, TTLs & eviction

SettingDefaultMeaning
keep_alive30 sWarm-linger after a request.
min_warm_secper-artifactPhysical floor: a freshly loaded model won't be churned by equal/lower-rank work for this long.
idle_timeout_sec30 minDrain a non-empty host to empty after this idle window.
min_config_tenure_sec15 sMinimum time a config is held before reconsidering.
cold_start_grace_sec180 sWhile cold-starting, hold the decision this long — a big model takes 30–60 s and re-deciding every tick would thrash the agent.
proxy_ttl_sec15 minHow long a ready ticket's proxy URL stays usable.
queue_ttl_sec10 minA never-dispatched request older than this is treated as abandoned and reaped.
in_flight_ttl_sec60 sWindow during which a dispatched request still holds the host.
load_fail_threshold / load_backoff_sec2 / 600 sCircuit breaker: a service that never reaches healthy this many times in a row is benched, so a model that genuinely can't load (OOM, missing file, crash-on-start) can't loop forever.
A ticket's proxy URL outlives the model. proxy_ttl_sec is 15 minutes but keep_alive defaults to 30 seconds. A ticket can report ready while its backend has already been unloaded — calls then fail with upstream unreachable. Use the URL promptly, or hold the model with a session / longer keep_alive.

Autoscaling

The broker horizontally scales replicable services under sustained load. On by default; opt out per-artifact with "autoscale": false.

SettingDefaultMeaning
worker_capacity2Concurrent requests one replica absorbs before it's "full".
scale_up_backlog3Backlog beyond capacity that justifies a new replica.
scale_up_sustain_sec8 sBacklog must persist this long (cold starts aren't free).
scale_down_idle_sec90 sDrain an extra replica after this idle.

Conservative by design: only a real, persistent backlog on free hardware triggers a replica, and autoscaling never evicts live work.

Broker HTTP API

Base: http://192.168.1.33:8080. Use the client unless you have a reason not to.

Data plane

RoutePurpose
POST /requestSubmit. Body RequestIn; returns {ticket}.
GET /request/{ticket}?wait=NLong-poll (≤30 s). Returns TicketState: state = queued|ready|failed|cancelled, plus proxy_url, expires_in_sec, eta, reason.
DELETE /request/{ticket}Cancel.
ALL /r/{ticket}[/{path}]Data-plane proxy to the backend. All verbs. This is what proxy_url points at.

Sessions

RoutePurpose
POST /sessionOpen. Body SessionIn; returns {session_id, ttl_sec, plan}.
GET /session/{sid}Current placement plan.
POST /session/{sid}/heartbeatKeep alive.
DELETE /session/{sid}Release.

Introspection & control

RoutePurpose
GET /healthz · GET /Liveness · HTML dashboard.
GET /status · GET /fleet · GET /voicesSnapshot · inventory · voices.
POST /pin · /unpin · /force-config · /restartPlacement control.
POST /admin/enableGlobal dispatch flag.
GET /workflow/{aid} · POST /workflow/{aid}/renderComfyUI workflow inspect / render.
POST /agent/{host}/status · GET /agent/{host}/desired · /manifestAgent control plane (agents only).

Clients advertise their version via X-Switchboard-Client; below the floor the broker answers 426.

Manifest schema

Fleet catalog: ~/switchboard/manifests/artifacts.json — the source of truth, projected into per-host configs. Host budgets: ~/switchboard/manifests/hosts/<host>.json. The broker hot-reloads on mtime change; no restart needed.

Artifact

FieldMeaning
idArtifact id clients request.
kindllamacpp, embed, rerank, stt, tts, diarization, comfyui-engine, comfyui-workflow.
model{name, params, quant, context, vision, source, notes}.
min_warm_secLoad-cost floor (not a priority).
autoscaleSet false to opt out of replica scaling.
profiles[]Run-profiles — see below.

Run-profile

FieldMeaning
host, placement, gpus[]Where it runs and how it's laid out.
vram_mib{gpu_index: MiB}. Must be ≥ actual usage — under-declaring causes overcommit and OOM.
cpu_mibSystem RAM consumed (CPU-only or MoE offload).
contextTested context for this profile. Matched by min_context.
port / ports[]Listening port(s); must not collide within a config.
launch[], cwd, env{}Verbatim launch command (tokens like {share} substituted). If present, used exactly as written.
endpoint, health_urlBackend address and health probe.
skip_if_missing[]Files that must exist or the variant is unavailable.
exclusive_with[]Services that cannot co-run.
Changing context requires changing the launch flag too. The context field is what the scheduler matches; --ctx-size in launch[] is what actually runs. Keep them in sync or the catalog lies.

Operations

TaskCommand
Restart brokersystemctl --user restart switchboard-broker.service
Broker log~/switchboard/logs/broker.log
Agent + per-service logs/mnt/ai-<host>/switchboard/logs/
Deploy agentsscripts/deploy.sh
Manifest backups~/switchboard/manifests/backups/
Edit catalogEdit manifests/artifacts.json — hot-reloads, no restart.
Web endpoints. /docs (this page) and /wheels/ are public. The dashboard and broker API at switchboard.wtray.com are password-gated, because /fleet and /status expose host names, LAN addresses and GPU models. LAN clients should use http://192.168.1.33:8080 directly — no password.

Troubleshooting

Requests time out repeatedly

Almost always model thrash: two models that can't co-reside are being requested alternately, and each turn pays a 15–60 s swap against a client timeout that's too short.

"Upstream unreachable" from a ready ticket

The proxy URL outlived the model (15 min TTL vs 30 s keep_alive). Re-request, and hold the model with a session or a longer keep_alive.

Everything queues and nothing dispatches

Check for a stray pin (sb.status()pinned) and the global enable flag. A pinned host serves only its pinned config.

A model won't load

Check the per-service log on the host share. After 2 consecutive failures to reach healthy, the circuit breaker benches it for 10 minutes. Common causes: OOM (VRAM under-declared), missing model file, bad split.

Silently smaller context than expected

You landed on a profile with a smaller tested window. Pass requirements={"min_context": N} to make it fail loudly instead.

Change log

2026-07-31 (later 3)

2026-07-31 (later 2)

2026-07-31 (later)

2026-07-31

2026-07-30