📌 These docs reflect Vexa v0.12.23 — the current release candidate control-plane. Older self-hosts
should read against their pinned version.
The authoritative, per-release changelog is on GitHub Releases:
github.com/Vexa-ai/vexa/releases. This page summarizes the
notable line and the migration notes between majors.
0.12 — control-plane carve
0.12 reorganizes the backend around a single front door and server-derived identity.-
Planned meetings. A meeting is now one record from plan to transcript: create it ahead with
POST /meetings(title, time, optional link, optional workspace binding), edit/delete while planned, and the eventual bot spawn claims the same record. Members of a bound workspace see the plan, the live feed, and the transcript. See Meetings and Plan and share a meeting. -
Auto-join.
scheduledmeans the bot joins: a sweep sends the bot at start time (per-meeting toggle, loud failures). See the Meetings API. -
Calendar sync. Paste a secret ICS address (
PUT /user/calendar) and upcoming meetings with Meet/Zoom/Teams links import as planned meetings — no OAuth. See Calendar sync. -
One gateway. All public traffic enters through the gateway (
API_GATEWAY_HOST_PORT, default18056); it carries authentication and per-route scopes and routes to the internal services. -
Server-derived identity. The gateway resolves your
X-API-Key→ user and injects identity downstream. Asubjectin a request body or query is ignored — the server never trusts the client for identity. See Authentication and Identity & trust. -
Agent control plane. Dispatch, chat, routines, events, and workspace reads are unified under the
Agent API at the
/agent/*prefix (the legacy/api/*alias still resolves). -
Compose-based self-host. The open core brings the whole control plane up with
make all(Docker Compose) on a single Linux host. See Deployment. -
Open Knowledge Format workspaces. The workspace knowledge graph (
kg/) is an OKF v0.1 bundle: the entity frontmatter contract is a strict superset of OKF, seeds ship generatedindex.mdlistings, and the whole knowledge base is portable to any OKF consumer. See Browse the workspace.
Migrating from 0.10
- Route through the gateway. Point clients at the gateway base URL and send
X-API-Key; stop calling internal services directly. - Drop client-supplied identity. Remove any
subject/ user id you sent in bodies or query strings — it’s now derived from the key. - Use the
/agent/*prefix for control-plane routes (the old/api/*alias still works during transition). - Re-check your secrets. Set real values for
ADMIN_TOKEN,INTERNAL_API_SECRET,VEXA_DISPATCH_SIGNING_KEY, and DB/MinIO credentials — see Configuration.
Upgrade runbook: 0.10 to 0.12.x
The items above are the client-side contract changes. This section is the operator runbook for moving an existing 0.10.x install — with a populated database — onto the current 0.12.x line. The path was validated against a production migration in July 2026.Go straight to the latest 0.12.x — no intermediate hop
Upgrade 0.10.x → the latest 0.12.x release in one step. There is no staged upgrade path and none is needed: schema convergence is state-based, not sequential (see below), so stopping on an older 0.12.x buys nothing and costs you the fixes shipped since. In particular, older 0.12.x releases lack the fail-closed schema check described below, so a missing dedup index degrades silently instead of refusing to start — the failure mode that check exists to catch.The database: no Alembic, three manual steps
Vexa has no migration tool.ensure_schema() runs on every admin-api boot and converges the
schema: create_all → additive ADD COLUMN → data backfills → index sync. It never drops a table,
never rewrites an existing value, and never issues ALTER … SET DEFAULT. That is what makes the
upgrade order-independent — and it is also why three things a live 0.10 database needs are not
done for you.
Run these against the database before you deploy 0.12.x, as standalone psql statements (not
wrapped in a BEGIN). The runbooks with full SQL, policy notes, and rollback live beside the schema
in the repo:
core/identity/services/admin-api/src/admin_api/schema/.
1 · Pre-flight the active-meeting dedup index — do this first. 0.12.x adds a partial UNIQUE index
over meetings (user_id, platform, platform_specific_id) for non-terminal rows. If the table already
holds two or more active rows for the same key, the index cannot be built — and admin-api
fails closed: startup raises, the process exits 3, /health never answers, and the compose
healthcheck / Kubernetes probes never pass. Check for duplicates before you deploy anything
(read-only):
MIGRATION-0002),
re-run the query until it is empty, then build the index out of band so the build does not lock
writes on the hot spawn table:
CONCURRENTLY is why this is manual: ensure_schema runs inside a single transaction, and
CREATE INDEX CONCURRENTLY cannot. With the index already present, the boot-time sync matches it by
name and no-ops.
2 · Raise the max_concurrent_bots default. The per-user default moved 1 → 3 in 0.12. The
additive column sync neither changes the column default nor backfills existing rows, so on an
upgraded database every existing user stays on their old value. Raise-only, so it never lowers an
account already granted more
(MIGRATION-0003):
scopes column,
so the additive ADD COLUMN leaves them empty, and an empty scope set is a 403 on every core
route. ensure_schema backfills them at startup; left unbackfilled, an empty scope set is an
invisible revocation of every existing customer key
(MIGRATION-0004).
Empty rows converge to the full valid-scope set; already-scoped tokens are never widened.
Running on Kubernetes with the Helm chart? The chart’s migration Job is off by default
(
migrations.enabled: false) — the convergence you rely on is the one admin-api runs at boot, and
the manual steps above still have to be run against the database yourself.Kubernetes: check the namespace LimitRange before you upgrade
Every bot and agent is spawned as its own bare Pod, and a spawned Pod declares no resource requests or limits of its own. In a quota-controlled namespace this means the namespace’sLimitRange defaults are what the bot actually gets — and browser-based bots need real headroom.
Either run Vexa in a namespace with no LimitRange, or give that namespace the defaults Vexa’s own
production runs: default {cpu: 1500m, memory: 2560Mi}, defaultRequest
{cpu: 1000m, memory: 1100Mi}. The 2560Mi limit is a hard ceiling, not a suggestion — a Teams
call spikes past 2 Gi. Too small a default shows up as a bot that dies mid-join, not as a scheduling
error. See
Kubernetes (Helm).
Parity with the 0.10.x line
The publicapi.v1 contract is sealed hash-equal to main’s OpenAPI 1.5.0 and enforced in CI
(gate:contract-version), so the wire surface is identical by construction. Two honest caveats
follow from that:
- A sealed endpoint is not automatically a wired capability — some contract routes are not mounted in the open-core control plane yet, and this page says which.
- Anything
mainmerged after 1.5.0 is by definition not in 0.12 until a contract revision.
Not yet in 0.12
Verified carried (previously uncertain)
- Stop a bot —
DELETE /bots/{platform}/{native_meeting_id}is mounted and live end-to-end: the gateway forwards it and the meeting-api stop router handles it (leave command over redis + direct runtime teardown for a still-booting bot), with route-level tests. - Google Meet lobby-timeout vs host-denial (main PR #460) — carried in a stronger typed
form: admission ends in a typed
AdmissionOutcome(denial|lobby_timeout|join_failure); a denial maps torejected, a lobby timeout to the retryableawaiting_admission_timeout. - WS subscription authz for hex-derived native IDs (main PR #385) — subsumed: the 0.12 authorize-subscribe hop authorizes on DB ownership first; URL constructability is advisory only, so Vexa-generated 16-hex Teams IDs subscribe fine.
- Token scoping (main PR #436 territory) — present: admin token mint issues
vxa_<scope>_…tokens, accepts multi-scope?scopes=bot,tx, and the gateway enforces per-route scopes.
Known defects carried (flagged, not hidden)
- max-bots cap has a TOCTOU race — concurrent
POST /botscan overspill the per-user cap (bounded; reproduced and asserted by the stress lane; likely shared withmain). The atomic fix is on the enhancement list.
What 0.12 delivers instead
- One front door — a single gateway with auth + per-route scopes; server-derived identity
(client-supplied
subjectis ignored everywhere). - The agent control plane — dispatch, streamed chat, routines (cron/event), events, and workspace APIs; sandboxed CLI agents over an OKF v0.1 git workspace.
- Sealed, frozen contracts —
api.v1hash-equal tomain’s OpenAPI 1.5.0, enforced bygate:contract-version; parity itself is a gate (gate:parity). - A gate system
maindoesn’t have — module isolation, acyclic import graph, one front door per module, fail-loud fault surfacing, complete mediation (default-deny), per-service health, license/SBOM cleanliness, an architecture-compliance map. - A real-stack proof lane —
gate:compose+ MOCK_BOT drives the full control plane (join/reject/crash/timeout, recordings, webhook envelopes, WS frames) on every change. - Architecture as code — a validated FINOS CALM model with generated views, enforced in CI.
- The lite / compose / helm deploy trio —
deploy/lite(the whole control plane in one container, process runtime backend),deploy/compose(make all), anddeploy/helm(charts/vexa, bots spawn as Pods viaRUNTIME_BACKEND=k8s). All three are supported deploy paths; lite and helm docs land with the 0.12.x docs push (tracked inDOCS-GAPS.md). - An MCP server for the public API — the 0.10.6 meeting-control MCP service is ported
(9 tools + 4 prompts, stateless, every call authorized by the gateway with the caller’s key)
and runs as its own compose service today. Direction: one MCP server servicing all of Vexa’s
capabilities, fronted by the gateway — no separate service; the standalone port is an interim
exposure until the gateway mounts
/mcp. - The terminal workbench — the primary client surface (docs pending); the vendored dashboard remains available as an optional compose overlay.
- Honest roadmap/status docs — capabilities are claimed only with a green gate behind them.
Release-notes decisions (resolved by the maintainer, 2026-07-04)
- Vexa Lite ships as a supported deploy path — the lite/compose/helm trio; the release-image validation matrix proves it from published images, and its docs land with the 0.12.x docs push.
- MCP: the ported meeting-control server is carried as its own compose service for now; the direction is one MCP server servicing all of Vexa’s capabilities, fronted by the gateway — no separate service long-term.
- Interactive bots (chat / screen share / avatar): planned back in the 0.12.x line — contract-sealed today, control plane not yet wired.
discordingest andGET …/participants: planned back in the 0.12.x line with the nextapi.v1contract revision.
0.12.x maintenance fixes
-
Compose:
BROWSER_IMAGEpinned to the release tag (#659).deploy/composenow pinsBROWSER_IMAGEto the immutable release tag instead of a moving:v012tag, somake alldeploys are reproducible. See Deployment. -
Admin-API: empty token scopes backfilled on upgrade (#578). Upgrading a 0.10 stack to 0.12
backfills
api_tokenrows that had emptyscopes, so pre-existing tokens keep working under the scoped gateway. No action required — the backfill runs during schema sync. -
Lite: terminal transcription env wired (#628). In
deploy/lite, the[program:terminal]worker now receives the STT and internal-secret environment it needs, so speech-to-text works in the single-container lite deploy. See Deployment. - Webhooks: crash-safe retry drain (#520). The retry drain now uses a Redis reliable-queue, so failed webhook deliveries survive a storage outage and are still delivered after recovery instead of being lost.
- Google Meet: stop silence hallucinations (#617). Added ja/tr phrase lists plus a near-silent submit gate, so near-silent audio windows are no longer sent to Whisper — killing the phantom phrases Whisper emits for near-silence.
- Google Meet: empirical hallucination harvester (#617 follow-up). A single script that harvests hallucination phrase-lists empirically across every Whisper language — groundwork for the broader silence-hallucination work (#622).
- Credits (v0.12.10). @waqaskhan137 — the orphan-process-group diagnosis (#486) and the transcription-model findings whose regression rows now live in #522/#525; @m-tauqueer — driving the leave-when-alone value (#270) whose spec is now #545. Superseded PRs are credited on their threads; findings are contributions.
-
STT model id is now a deployment choice:
TRANSCRIPTION_MODEL(#522). The bot’s whisper client used to hardcodemodel=whisper-1on every live transcription request, so OpenAI-compatible backends that validate model ids (Groq, vLLM, LiteLLM, gateways) rejected every request — the bot joined and captured but produced no transcript. SetTRANSCRIPTION_MODEL(e.g.whisper-large-v3-turbofor Groq) in the stack.envand every request — bot pipeline and the terminal’s composer-mic dictation — carries that id end-to-end; unset, the wire stayswhisper-1byte-for-byte. The bundleddeploy/transcriptionunit still ignores the field (its model is its ownMODEL_SIZE). Credit: @waqaskhan137’s #489 prototyped the client-side half. See Configuration. -
Google Meet segments now carry a real
language(#523). The Meet lane stamps the STT-detected (or forced) language of each transcription window onto its segments, soGET /transcripts/...labels Meet segments the same way Zoom/Teams always did — mixed-language meetings read truthfully instead ofnull. The language contract (auto vs forced mode, window-level granularity) is now documented in Send a bot and the Meetings API. -
Terminal errors now speak user truth (#533). Every error a terminal surface shows states
what happened in the user’s vocabulary (“Couldn’t reach the Vexa server — check that the stack
is running.”, “Your API key was rejected — sign in again.”, or the backend’s own reason
verbatim) instead of transport plumbing like
/api/... → 502: upstream unreachable: ConnectError. The full technical string is preserved on the browser console for operators. A new presenter seam (presentErrorbesideApiError) plus a grep-guard test keep the 46 former raw render sites — and any future one — honest. -
Google Meet leave: confirmation-dialog fallbacks work again, and the error spam is gone (#542).
The browser-context leave click fed Playwright-only
:has-text()selectors todocument.querySelector, so every dialog-confirmation fallback (“Leave meeting”, “Just leave the meeting”, dialog-scoped Leave/End) was silently dead, and each leave attempt logged a burst ofnot a valid selectorerrors that read like join failures (#432). Leave buttons are now matched in-page with plain CSS plus real text matching, and the selector-validity gate CSS-parses every array declared browser-context, so this class of dead selector fails CI loudly instead of shipping green. -
Per-PR lite boot smoke: lite-only breakage now fails the PR, not the release (#581). A new
lite-smokejob inpr-valuebuildsdeploy/lite/Dockerfile.litefrom the PR’s own tree, boots it withmake -C deploy/lite up, probes the front doors, and runs the concurrent-bots smoke — scoped to PRs that touchdeploy/lite/**orcore/**, with no secrets (anonymous pulls). Three v0.12.2 release blockers were lite-only bugs this leg would have caught per-PR. A companiongate:lite-makefilestatically rejects the exact footgun class (a comment line inside a\-continued recipe block indeploy/lite/Makefile, which once shippeddocker runwith an empty image). -
Pre-active teardown attribution: lobby waits are no longer filed as join errors (#598). A bot
whose workload is torn down while it sits in
awaiting_admissionnow terminates withcompletion_reason: awaiting_admission_timeout(“the room never admitted the bot”) instead of the genericjoin_failure; bots destroyed atrequested/joiningkeepjoin_failure. Both reasons stay transient→retry, so only the label — what operators see on the meeting terminal — changes. -
Meeting-header bot controls follow the live WebSocket, never a stale snapshot (#674). When
the
meeting.statusstream is disconnected the header shows “Reconnecting…” and Stop/Send bot are disabled — a stale-live row can no longer offer an actionable “Stop bot” for a meeting the backend no longer has. A stop that races reality (404/409) now shows a human message (“This meeting is no longer active — refreshing the list.”) and reconciles the control, instead of a raw JSON toast. -
make probe— the standing full-journey smoke probe, per surface (#690). One command per install surface (make probe,SURFACE=compose|lite|helm) drives the whole journey through the gateway front door — spawn → schedule → boot → join → transcribe → live-view → stop — then sweeps every component’s logs once. Each stage prints Expected / Actual / Verdict and a red stage names where the journey broke, so an operator (or an agent mid-debug) gets a truthful install verdict in minutes with zero humans. See Kubernetes deployment and thedeploy/*/probe.shwrappers. -
Merge card: acceptance row —
Closescan no longer silently drop delivery legs (#712). The merge card grows a third row: every issue a PR would auto-close must have its whole Acceptance section delivered (checkbox and legacy-bullet shapes both parsed); undelivered legs red-card the PR until they ship, carry delivered evidence on the issue, or the link is re-filed asPart of #N. A dropped acceptance leg is now a decision on record, never a GitHub-keyword side-effect. See Delivery. -
Release witness receipts: full image digests or no pass (#713). The witness gate now rejects
any
sha256:in a receipt’s deployment fields that is not followed by the full 64-hex digest (prefixes and trailing ellipses fail, naming the field), and the generator’s skeleton asks for the full index + platform-image digests at fill time. The v0.12.10 receipt’s deployment field is completed to the full values in the same change — the record of which bytes were witnessed no longer needs a registry lookup to be evidence. -
Release runbook: publish step named (#714).
releases/README.md§the-two-phase-flow gains step 4 — after:v012moves, a human publishes the GitHub Release withgh release create vX.Y.Z --verify-tag --latest --notes-file <notes>; the guard’s retract-to-draft re-check now lives under the act it guards. An operator ships a release end-to-end from the runbook alone. -
Sealed
api.v1contract: security references now resolve (#531, closes #62). All 61 secured operations in the sealed OpenAPI document referencedAPIKeyHeader— a scheme the document never defines — so Swagger UI’s Authorize dialog could not attach the key and spec-driven client generators emitted clients that never sentX-API-Key. The per-operation references now point at the document’s own defined schemes:ApiKeyAuth(X-API-Key) on the 56 client operations,AdminApiKeyAuth(X-Admin-API-Key) on the 5/admin/{path}operations — the headers the running gateway and admin-api already honor.gate:schema’svalidate.mjsnow pins referential integrity (every referenced scheme must be defined, first offenders named), so a re-capture can never re-import the bug. -
Recordings: opening a recording mid-meeting no longer freezes it (#768). Master assembly is now
re-assemblable instead of write-once — a
GET /recordings/{id}/masterwhile the meeting is still recording serves the audio captured so far without permanently freezing the file, and every later read that finds new chunks rebuilds the master (repairing already-frozen recordings on their next read). The assembled-chunk-count is recorded and compared so the freeze cannot silently return. -
Recordings: long recordings over ~1000 chunks are assembled in full (#769). The chunk listing
now paginates
list_objects_v2to exhaustion (looping onIsTruncated/NextContinuationToken) instead of reading only the first 1000-key page, so a master built from a very long meeting no longer silently drops everything past the first page. A chunk-count mismatch is logged loudly. -
Helm: spread replicas across nodes with
topologySpreadConstraints(#770).replicaCount > 1no longer means “all on one node” — setglobal.topologySpreadConstraints(applied to every component) or a per-component<component>.topologySpreadConstraintsoverride, and the chart injects that component’s own pod selector when you omitlabelSelector, so a single block spreads each component’s own replicas. Empty by default (single-node / k3s installs are unaffected). See the chart README’s “Spreading replicas across nodes”. -
Speaker-stream tuning now reaches spawned bots (#771).
runtime.speakerStream.*(BOT_SPEAKER_MIN_AUDIO_SEC,BOT_SPEAKER_SUBMIT_INTERVAL_SEC,BOT_SPEAKER_CONFIRM_THRESHOLD,BOT_SPEAKER_MAX_BUFFER_SEC,BOT_SPEAKER_IDLE_TIMEOUT_SEC) rendered onto the runtime pod are now merged into each spawned bot workload’s environment, so the accuracy/latency knobs actually take effect on k8s and docker-backed deployments instead of rendering as dead config. See Configuration. -
Helm: agent-api no longer deadlocks on upgrade (#774). agent-api mounts the single
agent-workspacesPVC, whose default access mode isReadWriteOnce; the shared zero-downtime RollingUpdate (maxSurge:1) made any pod-spec-changinghelm upgradestall on a Multi-Attach error. agent-api now rendersstrategy: Recreateunder an RWO workspace (the same opt-out redis already carries), and keeps RollingUpdate when the workspace isReadWriteMany. -
Teams bot speaker attribution: the hint pipeline now reaches the transcriber (#498 — wiring
only; live attribution NOT yet delivered). ⚠ The v0.12.14 witness pass (2026-07-19) observed a
live 2-speaker Teams meeting still publishing every segment as
seg_N— the hints cross, the name resolution does not complete. #498 remains open; the end-to-end value lands in a later release. What this change DID deliver: the bot now bundles the Teams voice-level-outline speaker watcher (@vexa/teams-capture— the same module the desktop extension runs) into its browser bundle and wires it to the transcriber: hints cross the page→Node boundary under Teams’ truedom-outlinekind (previously erased to Zoom’sdom-active), on one epoch-ms clock with a loud skew guard, and a periodichint-counterslog line names the exact hop if a name ever goes missing again. A headless boundary CI test pins the wiring so the regression cannot ship silently. -
Bots fail fast when the control plane is unreachable, instead of an opaque crashloop (#530). On a
freshly autoscaled k8s node whose network hasn’t converged, a bot’s first callback to meeting-api can
be unreachable. The bot now makes its first
joininglifecycle emit load-bearing: if the meeting-api callback is unreachable it probes redis, and only if BOTH control-plane channels are down does it refuse to join — terminating in under a few seconds with a dedicated exit code (3) and an attributed terminal (failure_stage: requested,infra_fault: control_plane_unreachable) rather than a genericjoin_failureor a stuck-requestedmeeting. A reachable control plane adds zero latency (the secondary channel is never probed). Operators can now tell “broken node” from “broken join” in onekubectl describe. See Kubernetes deployment and Troubleshooting. -
Zoom speaker hints: watcher wired into the capture bundle (#538 — wiring only; live
attribution NOT yet delivered). ⚠ The v0.12.14 witness pass observed live Zoom segments still
publishing as
seg_N; #538 remains open. What this change DID deliver: the Zoom active-speaker watcher (createZoomSpeakers) is wired into the bot’s page-side capture bundle: active-speaker transitions cross to the name binder asdom-activehints, so Zoom bot segments carry the real participant’s name instead ofseg_Nplaceholders — the same attribution Google Meet and Jitsi already deliver. Flicker-debounced (a single ~250 ms tile blip never mislabels a turn); screen-share layouts and nameless tiles emit no hint rather than a wrong one. -
Google Meet authenticated join: fail closed on signed-out browser profile (#607). When
authenticated: truethe bot refuses to degrade to an anonymous join if the browser profile is signed out — the guard throws a typedAuthSessionError(anAdmissionErrorsubclass with outcomeauth_session_missing) which the join driver maps to the new permanentauth_session_missingcompletion reason: the control plane records the truth and never re-spawns against the dead profile.auth_session_missingis added to the lifecycle.v1 and api.v1 completion-reason enums (api.v1 also gains the previously missingstartup_alone; contracts re-sealed, lane:contract). The guard distinguishes a signed-out guest lobby (name input visible) from a signed-in-but-not-pre-admitted account (no name input), which still knocks via “Ask to join”. -
Authenticated bots: the user flow exists end to end (#724). Provision a signed-in bot session
with one command (
make login— sign in once, the session lands in the deployment’s userdata storage), then setBOT_AUTHENTICATED=trueon meeting-api and every stockPOST /botsspawns signed-in under the account identity. A failed session restore is now a typed, attributedsession-restoreerror instead of an unattributed pre-launch death. See Authenticated bots. - Authenticated bots: sessions survive use (#725). The bot writes the rotated browser session back to the userdata store on clean teardown, so the next spawn restores the freshest state instead of a decaying snapshot; a second concurrent spawn against the same stored session is refused with a 409 naming the conflict (one identity, one live bot). The session-lifetime levers are documented on Authenticated bots.
-
Google Meet auth-session guard is now pinned by an offline test (#756).
session.test.tsdrivesjoinGoogleMeeting’s authenticated branch against fabricated lobby fixtures: a signed-out guest lobby must throw the typedauth_session_missingrefusal and a signed-in not-pre-admitted lobby must knock. Deleting the guard turns the join module’s suite red — the guard is no longer carried only by the live leg. -
Signed-out detection on Google Meet no longer depends on English UI text (#757). The
authenticated lobby’s join CTA and the signed-out guest-lobby probe are now located by
structural (jsname/attribute) selectors first, with English text as last-resort fallbacks —
so a non-English lobby still fails closed with the actionable
auth_session_missingerror instead of a misleading “no join button found” timeout. Both selector arrays are covered by the selector-validity gate. -
Teams bot leave — dead browser-context fallbacks revived (#759). The Microsoft Teams
leave path ran its fallback list through the browser’s
document.querySelector, but 10 of the 25 entries were Playwright-only:has-text()locators — invalid CSS that threw on every call, so every text-labelled button (all three leave-confirmation dialog buttons included) was silently un-clickable and each leave attempt logged 10 “not a valid selector” errors. The array is now a{ css | text }matcher list driven by the same shared in-page clicker as Google Meet (#542), and it joins the selector-validity gate’s browser-context lane so the class fails loudly for Teams too. -
Governance: four D-book amendments from the v0.12.12 retro (#785). TAKE verdicts now name the head sha they examined (a later push makes the endorsement historical); one rule governs sealed-contract re-stamps (a back-compatible re-seal may ride a
lane:contractPR whose merge is the ruling, breaking changes get an issue-recorded ruling first); the closure mechanism gains mechanism-vs-tree verification over open prepared issues (catching deliveries no link sweep sees); and the incoming-report template now requires the deployed version / verified tag. See Delivery. -
Security floor: adm-zip bumped to 0.6.0 via pnpm override (#773). Closes Dependabot high
GHSA-xcpc-8h2w-3j85 (crafted-ZIP 4 GB allocation in adm-zip
<0.6.0), which rode in through onnxruntime-node’s install-time unpack under@vexa/mixed-pipeline. Exposure was postinstall-only, but the resolution layer now excludes the vulnerable range entirely. -
POST /botswith only ameeting_urlnow derives the meeting id — no more orphan meetings (#792). A url-only body used to return 201 while persistingnative_meeting_id='', creating a meeting (and a live bot) noDELETE /bots/...orGET /transcripts/...could ever address. The API now honors what api.v1 always promised: the URL is parsed to extractplatform,native_meeting_id, and the passcode (Zoom?pwd=/ Teams?p=); an unrecognizable URL is a typed422naming the missingnative_meeting_id, and aplatformthat disagrees with the URL is a422too. Explicitnative_meeting_idis never overridden. See Send a bot. - merge-card: concurrency group scoped per trigger event — near-simultaneous approval + label events no longer cancel each other’s runs, leaving a stale red required check on the final mile (#798)
-
meeting-api: shared-meetings list no longer melts the database under load (#800). The
meetings list combined owner, transcript-share, and workspace access in one
OR, which planned as a backward walk of the wholecreated_atindex — 100s+ per call for users with few or old meetings, enough concurrent calls to saturate the connection pool (this rolled back a hosted 0.12.12 production cutover). The three access branches now run as an indexedUNION(each with its own top-N scan) plus supporting indexes; worst-case calls drop from hundreds of seconds to sub-millisecond with identical results. Deployers: build the three newmeetingsindexesCONCURRENTLYbefore rolling the image on a large live table. -
helm chart: probe timeouts raised to 5s across app services (#802). Liveness/readiness/startup
probes previously inherited Kubernetes’ 1-second default
timeoutSeconds; on a busy single-event-loop service a healthy pod can hold/healthpast 1s, and the liveness kill turned load into a restart storm (observed in hosted production on meeting-api: pods serving 200s probe-killed every ~10 min). All HTTP-probed app deployments (meeting-api, gateway, admin-api, runtime, agent-api, terminal) now declaretimeoutSeconds: 5. -
Bot lifecycle callbacks survive meeting-api blips; completions carry a delivery marker (#806, #807).
The bot’s status-callback retry horizon grows from ~0.6s (3×200ms) to ~7.5s (5×500ms exponential) —
in hosted production a brief meeting-api disruption made seated, healthy bots unable to report
joining, and the reaper then failed their meetings; a longer horizon rides out such blips. Separately, every terminal transition now stampsmeeting.data.segments_captured, so a meeting that “completed” without capturing any transcript is queryable and alertable instead of being indistinguishable from a success. -
Record-always at the capture boundary: bots can now persist their raw
captured-signal.v1stream for offline replay. WithcaptureSignalEnabledin the invocation (orVEXA_CAPTURE_SIGNAL=1), the bot tees every raw capture frame — per-channel PCM audio plus the speaker events it was captured with — into a session JSONL, and logs each STT round-trip beside it (<session>.stt.jsonl). A recorded session replays byte-for-byte through the exact transcription pipeline with no live meeting (REPLAY_FIXTURE=<file> tsx src/replay.test.ts), andeval/src/distill.mjscuts a session down to a minimal fixture around a reported symptom (time window / speaker). A session stores both halves of the signal: the audio frames and — for Zoom/Teams/Jitsi, whose single mixed stream is named from active-speaker hints arriving on their own channel — those hints astype: "hint"records, so a replay reproduces who spoke, not just what was heard. Off by default; when off, the capture path is unchanged (a single branch). This is the diagnostic layer for zero-transcript and misattribution reports: the failing meeting’s input becomes a deterministic red test. -
Governance: a session’s own worktree comes before its first edit. The actor contract
(AGENTS.md) now spells out the two trespass tells —
git worktree addanswering “already used by worktree” and uncommitted files you didn’t write — and the remedy for each: branch from the ref into a fresh worktree of your own, never editmain, the primary checkout, or another session’s tree. D14b ties the claim lease to that own-worktree rule. -
An exhausted STT token is now caught where you set it — by transcribing real audio, not by
guessing from balances or account names. The config preflight’s STT probe posts a ~1s WAV
(the same request a bot’s first chunk makes); a metered backend that answers 402 becomes a
typed
exhaustedconfiguration fault that demotes/health, refuses bot spawns, and names the consequence (“meetings will complete with no transcript”). The old empty-body probe could never elicit the 402 and greened the dead token. The wizard’s Test button now runs the same round-trip and no longer consultsbalance_minutes— which reads 0.0 both for an exhausted token and for a billing-exempt service account that transcribes perfectly — and no account identity is hardcoded anywhere. The probe is metered, so it caches for 15 minutes. -
Cold first-run no longer dies on
invalid reference format(#812). Stock.env.exampleshipsBROWSER_IMAGE=vexaai/vexa-bot:${IMAGE_TAG}— docker-compose expands it, butmake all’s spawn-image pull read the literal string and handeddocker pullan invalid reference, breaking every fresh install at the last step. The pull now resolves${IMAGE_TAG}(and hardens the tag read against duplicate lines); a genuinely custom literalBROWSER_IMAGEstill pulls verbatim. -
Webhook deliveries now report every outcome (#815).
WebhookSink.deliverreturnsdelivered | suppressed | blocked | failed | queued, and that outcome used to be discarded — so a webhook a subscriber never received (an event type outside their filter, an SSRF-refused target, an endpoint returning 4xx) was indistinguishable from one that arrived, and even successful deliveries logged nothing. Each delivery now emits onewebhook_deliverylogevent carrying the outcome, the event type, the target host (never the full URL — it can carry a token in its path or query), the HTTP status and the error, atwarningfor anything that did not arrive. “My webhooks stopped” is now a one-query answer instead of a silent failure. A compose-stack test asserts the outcome is reported end-to-end.
Fixed
- A wrong STT URL or token is now caught where you set it, not by an empty transcript. The
config probe treated any answer except 401/403 as proof of a working backend, so a URL whose
transcriptions path 404s — the most common misconfiguration — passed every check. It is now
reported as
misconfiguredwith the reason, on the boot log and thesttrow ofGET /health. (#511) - The setup wizard’s green now means “a bot will transcribe”. On any non-Vexa OpenAI-compatible endpoint the test previously reported “reachable; token was not verified” as a PASS, so a rejected key tested green and failed mid-meeting. It now verifies with the same request a bot’s first audio chunk makes, and grades the endpoint’s own answer. (#511)
POST /botsrefuses a set-but-broken backend with a typed 503 carrying the probe’s reason, before the meeting row is written — instead of spawning a bot that joins and captures nothing. The refusal names how to re-test immediately. (#511)TRANSCRIPTION_SERVICE_URLaccepts both documented shapes everywhere. A full…/v1/audio/transcriptionsURL worked in meetings but double-pathed into a 404 in the boot probe and the terminal’s dictation route. All four consumers now share one rule: append the path only when it is absent. (#511)
Fixed
- A bot that never got into the meeting is no longer reported as a completed meeting. Stopping
a bot still sitting in the waiting room overwrote the stage it had reached, so the terminal
classifier concluded the bot had been live and persisted the run as
completedwith zero transcript — a failure the system reported as success. The stage now survives the stop, and such a run endsfailedwithfailure_stagenaming where it actually died. Reproduced on the deployed 0.12.14 build (meeting 24336’s own transition log records the illegal edge); the prior-era share of this shape was ~49% of zero-segment completions. (#807) - Cancelling a bot before it is admitted no longer re-spawns it three times. The terminal
reason for a user stop is now the user-terminal
stopped(permanent) rather thanawaiting_admission_timeout(transient), so a meeting the user deliberately walked away from is not retried on their quota. An admission wait that times out on its own is unchanged and still retried. (#807) - A redelivered
DELETE /bots/…no longer publishes a second leave command. The stop trigger is one-shot, guarded on the recorded user intent instead of a status side-effect — the previous guard held only against the in-memory test double, never against the real database. (#807)
Fixed
GET /bots/statusno longer reads a caller’s entire meeting history to answer a running-bots badge. It fetched every meeting the account ever had, with the fulldataJSONB, and filtered in Python — 4,896 meetings / 180 MB on one production account, 144 MB of thatbot_logsno endpoint renders. Four concurrent polls demanded roughly 740 MB transiently and OOM-killed the pod at the default 1 GiB limit. The status filter and the heavy-key projection now both happen in the query. (#803)GET /meetings/{id}fetches the row instead of enumerating the account and filtering by id. Access rules are unchanged — the same ownership/share union decides visibility — and the detail view still returns fulldata. (#803)
Fixed
- Zoom and Teams admission verdicts are now typed, so permanent failures are no longer
retried on your quota. Both platforms threw plain errors for every admission failure, which
the control plane classified as transient
join_failureand re-spawned up to 3× — a Zoom host denial re-knocked on the same host, the Zoom RTMS anti-bot wall was retried into the same wall, and a meeting restricted to signed-in users was rejoined just as signed-out. They now throw the same typedAdmissionErrorGoogle Meet and Jitsi already use: denials and auth walls are permanent (no retry), lobby timeouts stay transient (still retried). Teams additionally had an outer catch that re-wrapped any error — including a typed one — into a plain string; typed verdicts now pass through it. Failure reasons land incompletion_reason(awaiting_admission_rejected/awaiting_admission_timeout/auth_session_missing), making these modes measurable per platform for the first time. (#806)
Added
- The deprecated 0.10 dashboard is back in the stack as an off-by-default option — a
dashboardcompose profile (docker compose --profile dashboard up -d, port 13001) and adashboard.enabledhelm value, both wiring the pinned external imagevexaai/dashboard:0.10.6.3.14to the gateway’s hosted-compat surface exactly as hosted production runs it. It stays deprecated and versions on its own pinned tag (neverglobal.imageTag); it ships because it is still load-bearing — the authenticated-session flows are only walkable through it today — and the UI users actually see should belong to a release. Deletion, not porting, is the deprecation exit. (#813)
Fixed
-
Requesting a
browser_sessionbot answers a typed 422 instead of a 500 that poisons the retry. api.v1 seals more platforms than the meeting-bot invocation contract carries; with ameeting_urlattached, such a request wrote its meeting row and then died inside schema validation — a 500 plus an orphaned active row that made the user’s retry 409. The refusal now happens before any write, names the supported platforms, and points at the tracked restoration (#816). - The join layer refuses an unknown platform instead of silently running the Google Meet flow. The dispatch’s fallback branch WAS the Google Meet branch, so an unrecognized platform drove Meet selectors against an arbitrary URL and failed minutes later with misattributed selector errors. (#816)
-
Lite: bots no longer echo in Google Meet (#819). Vexa Lite left the bot’s microphone path
(
tts_sink/virtual_mic) unmuted by default, so meeting audio looped back and participants heard themselves. Lite now mutes the mic by default like the per-meeting bot does; on-demand speaking still unmutes for the utterance. See Deployment. -
Bot: no more “Socket already opened” on first use (#820). The redis adapters flipped a
connectedflag only afterconnect()resolved, so two concurrent first-use callers both saw it false and both connected — node-redis v4 throws on the second. Both adapters now share one idempotentmakeLazyConnectmemo, so concurrent first-use callers await a singleconnect(). -
A malformed
native_meeting_idis refused at the door, not by the database (#843). A meeting id longer than 255 characters, or one containing a NUL/control byte, used to travel the whole spawn path and fail inside the Postgres INSERT — surfacing as an opaque500about five seconds later. It is now a typed422naming the problem, alongside the other request-shape refusals. Only length and control bytes are checked: the id’s shape is deliberately not validated, because ids that look unusual do join real meetings. -
Meeting bots now leave ended calls instead of parking until the hard cap (#545). After the configured remote-audio silence window, empty rooms and silent bots-only rooms finish as
completed(left_alone);automatic_leave.max_time_left_aloneoverrides the 10-minute default. See Send a bot and get a transcript. -
A meeting with no transcript now says why. When the transcription backend refuses — an
exhausted token, a rejected key, an unreachable service — the bot counts the failures and
reports them once on its terminal
lifecycle.v1event, and meeting-api persists them tomeeting.data.stt_faultand onto themeeting.status_changewebhook. Previously those faults were fully typed and attributed inside the bot and then died in aconsole.error, so a meeting whose STT was dead completed indistinguishable from a silent room — the empty-transcript reports that could never be diagnosed after the fact. The report carries the backend’s own words (payment_required, HTTP 402, “Insufficient balance…”) and one count per kind, so a storm of per-chunk failures becomes one honest summary rather than a flood. -
Meeting timestamps serialize as UTC (#860).
created_at/start_time/end_timeleft the API as naive datetimes, so clients parsed server-local wall time as their own local time and rendered meetings hours off. Every meeting timestamp now carries theZsuffix, so standard client parsing lands on the correct local time. -
Bring-your-own STT endpoint, documented (#537). Any OpenAI-compatible transcription endpoint
can serve as the backend via
TRANSCRIPTION_SERVICE_URL+TRANSCRIPTION_SERVICE_TOKEN; the contract (request shape, auth, response fields) is now written down. See Custom STT endpoints. The FunASR/SenseVoice example is community-contributed and not yet independently validated (#863). -
Fixed: Teams transcripts no longer show a new fake speaker per line. Unattributed turns on the
mixed lane (Teams/Zoom) now carry the stable “Speaker” label instead of leaking the internal
seg_Ncluster id, so per-speaker consumers group them as one speaker; late attribution still repaints by segment id. (#890) -
Fixed: Teams/Zoom live transcripts now paint in real time on the dashboard instead of only
after a reload. The bot’s mixed-lane segment mapper wasn’t stamping
absolute_start_time, so the live renderer (which keys on it) skipped every pending draft; a prior producer-stamp fix had covered only Google Meet. (#895) -
Docs: default bot spawn requires STT and refuses loud; capture-only is explicit (#532). Quickstart, README, deployment, API, and how-to pages no longer claim that missing transcription silently yields an empty transcript. They document
transcribe_enabled/TRANSCRIBE_ENABLEDand the real 503 detail. See Configuration and Send a bot. -
Teams: a benign alert no longer evicts a just-joined bot (#600). The Teams removal monitor
matched a generic
[role="alert"](plus[role="alertdialog"],.error-message,.connection-error,.meeting-error), so a transient toast or the post-join AV-confirmation modal could trip a false removal ~1.5s after admission and self-leave withcompleted(evicted)and no transcript. The removal signal is now the removal/“meeting ended” text only. -
A bot that fails to start now surfaces the failure instead of a false success (#718). When the
runtime cannot start a bot workload (e.g. the bot image is absent),
POST /botsreturns502with the reason (the missing image is named) and the meeting is markedfailedon the spot — no more a201over a workload that never came up, followed by five silent minutes and a reason-lessfailed. The runtime kernel answers the spawn honestly (non-201 +workload_spawn_failed), the meeting-api refuses the dead spawn at both the runtime seam and the service, andmake devnow exits non-zero naming an unpulled bot image even when an earlier build step fails. -
MCP clients connect through the gateway front door, and the SSE stream stays open (#795). The
gateway now fronts the MCP streamable-HTTP transport at
/mcp:POST(messages) forwards buffered as before, andGET— the server→client SSE stream — is relayed on a dedicated streaming client with no read deadline, because a stream that is silent is a stream with nothing to push. Buffering that leg is what made a healthy stream look like a dead upstream: the proxy waited on the next body read, hit its 30-second read timeout, and answered a503the MCP service never saw (8 of 14 stream-open attempts in a 15-minute hosted window, whilePOSTwas 116/116 healthy on the same client). Point your MCP client at your Vexa API host —mcp-remote https://<your-api-host>/mcp --header "Authorization: Bearer $VEXA_API_KEY"— instead of the MCP service’s own port. The gateway carries the upstream’s status, content type andmcp-session-idverbatim; it never rewrites an MCP answer. - The meeting-api webhook-envelope introspection capture is bounded (#803). The production app previously retained every lifecycle envelope in an unbounded in-process list; it now keeps a capped recent-history ring while preserving the eval seam. The point-of-introduction regression is covered on the exact PR head. The release’s fresh production RSS readback is the remaining evidence for the broad “no continuing climb” claim.
-
A Redis outage no longer becomes a full core-API outage (#809). meeting-api reports Redis honestly on
/health— a newpipeline.redis_reachablecomponent surfacesfalseduring an outage while the shared probe stays200, so readiness holds and Postgres-backed reads (GET /meetings, durableGET /transcripts) keep serving. The genuinely Redis-dependent stop path (DELETE /bots) now fails narrowly with503(retryable, the stop is already recorded and reconciles when Redis returns) instead of an opaque500, and a returning Redis is picked up without a restart. Prevents the 2026-07-19 incident where a wedged Redis volume CrashLooped all replicas even though Postgres was healthy. -
Google Meet: a host denial now ends the meeting instead of hanging forever (#840). Meet loads
reCAPTCHA Enterprise invisibly on every join, so a background captcha frame sat on the denial
screen too — the bot read it as bot-detection, logged “staying for manual/agent solve” every two
seconds, and the meeting stayed
awaiting_admissionuntil the 10-minute lobby timeout (which is retried, so a host who said no could be knocked on again). An explicit denial (“denied your request”, “weren’t allowed to join”, …) now wins over any captcha on the page, only a visible challenge widget counts as a real captcha, and the stay-for-solve wait is bounded at two minutes. A denied bot reportsfailed / awaiting_admission_rejected— permanent, no re-knock. -
Webhooks gain a queryable, secret-safe delivery ledger API (#841 core
half). Core records recent per-user delivery outcomes (
delivered | queued | suppressed | blocked | failed) and serves them atGET /user/webhook/deliveries, carrying only the target host — never the webhook URL or signing secret. The hosted dashboard does not read this ledger yet; its real Delivery History consumer remains open in v0.12.20. - Google Meet lobby language is deterministic and locator failures are diagnosable (#846, #856). The bot pins the browser UI to English by default and records the resolved URL, page language, and visible button labels when the CTA cannot be found. The broad structural CTA scan is diagnostic-only and never clicks. The English path passed live; arbitrary non-English joining is not claimed.
-
Pin the bot browser’s UI locale so Google Meet renders English by construction (#856). The
bot never told its browser what language to be, so Meet localised from
Accept-Languageor IP geolocation and served non-English lobbies on EU/other egress — the root cause of the join-button-not-found class (#846). The browser now launches with--lang/--accept-lang, a Playwright contextlocale, and?hl=on the Meet URL, all driven by aBOT_UI_LOCALEknob (defaulten-US). The resolved locale (navigator.language,<html lang>) is now logged at lobby time and recorded inlast_erroron failure, so it is never invisible again. The #917 structural CTA scan is demoted to diagnostic-only (it records candidate labels and telemetry but never clicks), and the lobby selector lists now put exact-text entries first with the broad structural entry last as a locale-agnostic backstop. -
A bot waiting in a Google Meet lobby is no longer killed at 5 minutes (#862). The bot is handed
a 10-minute waiting-room budget, but it reports
awaiting_admissiononce and then waits silently — so the reconcile sweep, which only checked whether the workload was alive for bots already in the meeting, force-deleted healthy bots at 300s of legitimate quiet, seconds before hosts who admit at 4–5 minutes let them in. The liveness check now covers the whole pre-admission span, and the sweep’s patience for a not-yet-admitted bot is derived from the budget it issued, so it can never be shorter. -
A bot that never reached the meeting now reports why, and is retried (#862). When the runtime
confirms a pre-admission workload really is gone, the meeting is attributed to the stage it died in
(
awaiting_admission_timeout/join_failure) with the runtime’s own evidence — workload state and exit code — instead of the catch-allleft_alone.left_alonecounts as a normal ending, so it also suppressed the automatic re-join; these reasons are retryable, and the re-spawn happens again. -
Bot test doubles: shared
noopAloneness(+ siblings) and named missing-port errors (#865). Pure L2 scaffolding — no user-facing behaviour change. Stops every new orchestrator construction site rediscovering the requiredalonenessseam by crashing with a raw TypeError. -
Google Meet Stop during
awaiting_admissionnow reaches the pre-active withdraw path and cleans up the workload (#889). The bot subscribes to leave commands before the join race and routes Stop through its phase-aware abort instead of dropping the pub/sub message. The release witness still found that Meet’s host-side knock prompt can persist after cleanup; visible prompt withdrawal remains #839. -
POST /botsrejects anative_meeting_idcarrying URL characters (#892). A meeting id with?,#,&,=,/, or a space (e.g. a Teams passcode accidentally left on the id,397421056486982?p=…) now returns a typed422at intake instead of building a broken join URL and storing an unfindable record. Pass any passcode in thepasscodefield, or supply the fullmeeting_url. Bare ids across platforms (Meet dash-codes, Zoom digits, Teams19:…@thread.v2) are unaffected. -
Meeting-api removes the O(keyspace) Redis SCAN from the ten-second
db-writer hot loop (#893). Steady-state ticks use the authoritative
active_meetingsset; the self-healing scan runs only on startup and atDB_WRITER_RECONCILE_INTERVAL_S(default 300s). Exact-head tests prove the scheduling boundary. The fresh production saturation/readiness readback remains the release-level proof for the original live failure shape. -
Helm migrations Job now honors
global.imageTag(#900). A pinned-tag deploy withmigrations.enabled=truepreviously ran the schema-convergence Job from the rollingv012image instead of the deployed release tag — a schema/code skew risk. The Job now resolves its tag with the same precedence the component Deployments use (global.imageTagwins, falling back to themigrations/meetingApiimage tag). See Kubernetes deployment. -
admin-api retries the initial DB connect on cold start (#901). On a boot where Postgres DNS
isn’t resolvable yet, admin-api used to throw
socket.gaierrorand exit immediately, relying on the k8s restart loop (a transient RED an operator would see). It now retries the first connect with bounded exponential backoff (env-tunable viaDB_CONNECT_MAX_ATTEMPTS/DB_CONNECT_BASE_DELAY/DB_CONNECT_MAX_DELAY), then fails loud once the bound is exhausted. -
Teams login redirects fail fast with a typed cause instead of an
admission timeout (#915). A navigation to
login.microsoftonline.comnow terminates withteams_auth_redirect: … (url=…), carried intolast_error, rather than spending about 75 seconds searching a login page for meeting controls. The exact-head redirect fixture is green; this external redirect did not recur during the release witness and is not claimed as live-reproduced. -
MCP sessioned GET /mcp starts its SSE stream (#921). A
GET /mcpwith a validmcp-session-idnow emitstext/event-streamheaders promptly (and sse-starlette’s keep-alive ping on an idle stream). fastapi-mcp’s buffered HTTP adapter had swallowed the open SSE response so the server→client channel never opened. Sessionless GET still returns MCP’s own 400. -
Admin token mint honors JSON
scopes(and refuses unknown body fields) (#922).POST /admin/users/{id}/tokenswith{"scopes":["bot","tx"]}now mints those scopes instead of silently falling through to["bot"]. Query?scopes=/?scope=still work; unsupported body fields return422. See Authentication. -
A non-admitted bot death carries a human-readable cause instead of
reason: None(#926). The typed admission error now crosses the join driver and terminal callback, with a derived fallback so the branch cannot emit a reasonless failure. Exact-head fixtures cover Zoom and the shared non-admitted path. The original external Zoom failure did not recur during the release witness and is not claimed as live-reproduced. -
Vexa Lite pins its service interpreters to Python 3.12, so admin-api boots again (#927). The
single-container Lite image builds each service venv with
uv venvon a Playwright/jammy base whose only system Python is 3.10 — belowrequires-python >=3.11. An unpinneduv venvtherefore auto-downloaded the newest managed CPython (3.14), on which the frozen async-Postgres stack fails (asyncpg’s C extension will not build under 3.14; SQLAlchemy’s psycopg dialect import crashes at startup), soadmin-apientered a supervisor FATAL loop and API-key provisioning silently no-oped — the gateway then answered every request withAuthentication temporarily unavailable. All five Lite venvs (admin-api, runtime, meeting-api, gateway, agent) now pass--python 3.12, matching theFROM python:3.12-slimevery compose image (and helm, which ships those images) already pinned. Compose and helm never shared the exposure.
Versioning
Vexa follows semantic-ish versioning at theMAJOR.MINOR line; breaking changes are called out in the
GitHub release notes and mirrored in the migration section above. Pin a specific image with IMAGE_TAG
so deploys are reproducible.