Multi-tenant quickstart
VoiceGateway tags every voice session with an optionaltenant_id so a single deployment can serve many customers and account for each one separately. This guide walks an operator through the four moves the multi-tenant surface enables:
- Tag a session with a tenant at session-create.
- Issue a virtual API key scoped to that tenant.
- View per-tenant costs, metrics, and replay in the dashboard.
- Export per-tenant data for billing or analysis.
Prerequisites
- VoiceGateway installed (
voicegw --versionto confirm). - Daemon running (started by
voicegw onboardorvoicegw serve). The daemon serves the dashboard at the daemon URL (defaulthttp://127.0.0.1:8080). - A
voicegw.yamlwithcost_tracking.db_pathset (the dashboard reads the same SQLite database the gateway writes to).
1. Tag a session with a tenant
Three independent surfaces, listed in order of “least to most operator coupling.” Pick whichever fits your deployment.Option A: pass tenant_id to attach_session
The cleanest path when your worker code knows the tenant. Refer to the Python SDK reference for the full signature.
Option B: inference.set_tenant("…")
The ContextVar escape hatch for code that does not own the AgentSession construction. Sets tenant_id_ctx for the rest of the async context; subsequent factory calls inherit the scope.
None to leave the ContextVar untouched (it does not clear a previously-set tenant). Use inference.reset_tenant_id() to clear it explicitly between sessions in long-lived tasks.
Option C: scoped virtual API keys
When the caller is not your own agent code (a partner integration, a third-party voicebot) but you can give them a unique API key, issue a virtual key scoped to their tenant. The auth middleware auto-tags every session that arrives bearing that key. See Step 2 for the workflow.Sessions without a tenant: the “unattributed” bucket
Sessions where none of the three surfaces set a tenant gettenant_id = NULL in storage. The dashboard renders these as a muted unattributed pill. The dashboard’s tenant filter has a dedicated entry for the unattributed bucket so you can audit which sessions slipped through.
2. Issue a virtual API key
The dashboard is the only surface that issues virtual keys. The CLI is read-only by design: a CLI that printed the plaintext key would leak it via shell history and scrollback.- Open the dashboard at
http://127.0.0.1:8080/api-keys(the daemon’s serve port). - Click + Issue Key.
- Fill in:
- Name (required): a human label, e.g.
acme-prod. - Tenant scope (optional): the tenant id you want auto-attached. Leave blank for an unscoped key.
- Issued by (optional): free-form audit string.
- Name (required): a human label, e.g.
- Hit Issue Key. The next modal shows the full key exactly once. Copy it into your secret store before closing.
vk_AABBCCDDEEFFGGHHIIJJKKLLMMNNOOPP (35 characters: vk_ + 32 base32). The first 8 characters (vk_AABBC) persist as the visible prefix so you can identify a key in the list without exposing the secret; the whole key is bcrypt-hashed before storage.
Ship the key to the caller as Authorization: Bearer vk_…. From that point:
- Scoped keys auto-tag every session. A body-level
tenant_idthat disagrees with the key’s scope returns403. - Unscoped keys allow the body to declare any tenant.
- Static API keys (the
auth.api_keysblock invoicegw.yaml) never set a tenant.
Revoke
The same API Keys page exposes a Revoke action per row. Revocation is soft: the row stays for audit and the stale-key surface, but verification rejects further requests bearing the key within ~30 seconds.Stale-key detection
Keys whoselast_used_at (or issued_at, for never-used keys) is older than api_key_stale_days (default 90, per-project overridable in voicegw.yaml) surface with a yellow stale badge. Hide revoked rows with the toggle at the top of the page when triaging.
3. View per-tenant costs and metrics
Every cost, log, sessions, metrics, and replay page in the dashboard now respects thetenant URL parameter.
- Use the Tenant typeahead in the filter strip (top-right of each page) to scope to one tenant, or pick Unattributed to see only sessions without attribution.
- The filter persists across navigation: switching from Costs to Sessions to Metrics keeps the same tenant in scope because the value lives in the URL.
- Selecting All tenants clears the filter without losing project or time-range scope.
The Tenants tab
GET /api/tenants (consumed by the typeahead) returns the index of every tenant the gateway has seen, ordered by most recent activity. Each entry carries the session count, total cost, and first/last-seen timestamps. The unattributed bucket appears as a separate entry below the list.
Per-session attribution
The Sessions page has a Tenant column that renders the row’stenant_id as a clickable pill: clicking it scopes the page to that tenant immediately. The SessionDetail modal also shows the tenant pill next to the session id so you can verify attribution without leaving the row.
4. Export per-tenant data
For billing exports or third-party analysis, two paths.CLI
/api/tenants returns. tenant show exits 1 when the tenant has no sessions so CI scripts can branch.
The voicegw costs command does not accept a --tenant flag. The dashboard’s /api/costs?tenant=… endpoint is the canonical per-tenant cost source.
Direct SQL
Thesessions table carries tenant_id. For ad-hoc analysis:
requests, turns, dead_air_events, and replay_* tables all carry the column too, so any join-and-aggregate workflow can add tenant_id to the GROUP BY without schema gymnastics.
Known limitations
A few operator workflows are deliberately out of scope. Plan accordingly.- No CLI issuance of virtual keys. The plaintext surface is the dashboard’s “show key once” modal; a CLI flow would leak via shell history.
- No
voicegw costs --tenant. The dashboard’s/api/costs?tenant=…is the canonical per-tenant cost source. - No re-tag affordance for already-attributed sessions. Once a session has a non-NULL
tenant_id, the dashboard cannot change it; the COALESCE rule inlog_requestonly fills NULL slots. - Virtual keys do not carry RBAC scopes. A verified vk grants the same access a wildcard static key would.
Where the design lives
- Migration:
src/voicegateway/storage/migrations/0005_tenant_attribution.py. - ContextVar:
src/voicegateway/inference/_session_context.py. - Auth middleware:
src/voicegateway/server/main.py::build_app+src/voicegateway/core/auth.py. - Repos:
src/voicegateway/storage/api_keys_repo.py,src/voicegateway/storage/tenants_repo.py. - Dashboard API:
src/dashboard/api/main.py(search for/api/tenantsand/api/api_keys). - Frontend primitives:
src/dashboard/frontend/src/components/{FilterBar,TenantFilter,TenantPill}.tsx.