📌 These docs reflect Vexa v0.12.26 — 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. - Microsoft Teams speaker attribution (#499). Teams uses CSRC transport activity to route the mixed audio into per-speaker transcription windows with the same LocalAgreement buffering contract as Google Meet. A transport-contested phrase is currently published under both contributing tracks — detected and counted in pipeline health, not yet resolved or marked in the public transcript.
-
Every deploy surface now runs Valkey 8.1.9 instead of Redis (#653). Vexa Lite, Docker
Compose, and Helm all use Valkey — the Linux Foundation’s BSD-3 fork of
Redis 7.2.4 — for the bus, scheduler, and per-dispatch streams. This gives every surface (Lite
included)
XAUTOCLAIMorphan-reclaim parity, and moves off source-available Redis ≥7.4 (RSALv2/SSPLv1). The store is wire-compatible (RESP):REDIS_URLand everyredis.*config key are unchanged, so existing overrides keep working. See Deployment. -
Two new license/parity gates close the class the audit exposed (#653).
gate:image-licensesaudits container-image pins and image-baked binaries (whichgate:licensesnever saw), andgate:runtime-parityasserts every surface’s engine version supports the RESP commands the code actually calls — the rung that catches a backing store shipped without a capability the code assumes.
:latest is checked as an unchanged negative control unless its
promotion was explicitly requested.
- Start here: hosted now runs Vexa 0.12 for meeting bots and transcription (#954). The Overview page no longer says hosted vexa.ai is on the 0.10 line. It states the current boundary: hosted runs 0.12 for meeting bots and transcription but does not include the agent runtime — agents and the full stack require self-hosting.
- Meeting completion now reports the service that actually ran (#984). Lifecycle events carry producer-observed transition times, and completed meetings expose privacy-safe bot runtime and transcription-provider outcomes so hosted billing can distinguish Vexa transcription from a customer endpoint without inspecting configuration or credentials.
- Self-hosted deployments can opt into an external service authority without importing hosted billing policy (#988). Meeting-api can now ask a signed, versioned authority before bot spawn and at each one-minute active-service boundary; stock OSS remains explicit allow-all when the integration is unset. See Configuration.
-
Operators can route terminal meeting facts to one signed, boot-frozen system callback (#992).
The optional destination accepts only
meeting.completedandbot.failed, has its own retry/dead-letter lane, and may explicitly target an in-cluster HTTP service without weakening the SSRF guard on customer-configured webhooks. See Configuration. - Admin identity reads now resolve an existing user by authoritative ID (#994). Internal service consumers can bind admission and lifecycle work to the authenticated numeric user identity without falling back to email or creating a user as a side effect.
-
Fixed (Teams): speaker attribution now names participants again. Teams migrated its meeting UI to Fluent-UI v9, whose hashed class names churn every release — the old class-based name selectors resolved to
"", so every hint was suppressed and all speech fell back toSpeaker. Names now resolve from Teams’ stabledata-tidon the[data-stream-type]tile wrapper (anchored on the voice-level outline). Thevdi-frame-occlusionspeaking signal was correct all along; only the name lookup had rotted. -
Fixed (Teams): transcription no longer repeats words. Teams delivers the whole meeting as a single server-side audio mix (track id prefixed
mainAudio) — witnessed live: the standard web client receives exactly one audio receiver — but the bot is handed an extra redundant track whose audio is already in that mix. The mixed lane combined both, double-feeding every word to the transcriber. Teams now transcribes only themainAudiomix. Separately, the WebRTC hook was mirroring each remote track twice (bothaddEventListener('track')and theontracksetter fired) — now deduped by track id. -
Automate meeting summaries with n8n, documented (#1076). New n8n guide walks the
no-code path end to end — calendar trigger,
POST /bots, transcript fetch, summary, Slack — and states plainly that the published community template still points at the retiredgateway.dev.vexa.aibase URL, which must be swapped forapi.cloud.vexa.aiafter import. Also covers the self-hosted reachability variants and the option to skip the calendar trigger entirely by using Vexa’s own calendar sync. The/n8nURL had been drawing steady traffic to a 404. -
The MCP server has a page, and it states the boundary (#1077). New MCP server
page lists the nine tools, gives a working client config, and says plainly what the surface does
and does not cover: fronted by the gateway at
/mcpon self-hosted compose from 0.12.18, not deployed by the Helm chart and therefore unavailable on hosted or Kubernetes, and never proven against a real MCP client end to end (#888). The service README’s Status block, which still listed the gateway-fronted/mcpforward as planned while its own body documented the shipped behaviour, now matches.
/chatgpt-transcript-share-links — the 0.10.x public share-link flow is sealed-but-unserved in 0.12; page states what replaced it and what works today (#1078).
docs: honest-status page at /interactive-bots — per-surface truth for the interactive family (chat read works; speak/chat-write/screen/avatar sealed-unserved), with the shape-design question linked (#1086).
docs: redirect five guessed paths onto the pages that already answer them — /ws/events, /local-webhook-development, /how-to/webhooks, /api/webhooks, /cookbook/share-transcript-url — measured from the docs edge sensor.
-
Manage multiple calendar feeds from the hosted dashboard (#1150). Calendar is now a
first-class sidebar page with named, independently synced and disconnected ICS connections.
Meetings deduplicate across feeds, retain every named source under Imported from, and keep
existing single-calendar API clients compatible. The page groups upcoming auto-joins by calendar,
assigns each connection its own bot display name, and persists the complete source event metadata
on the planned meeting. The Meetings page requests run history separately, so
idleandscheduledcalendar plans appear only in Calendar and no longer displace transcript rows. See Calendar sync. - One bot per meeting, even under concurrent requests (#1185). Simultaneous requests for the same meeting now resolve to a single bot rather than occasionally admitting two, and a request for a meeting that is already live adopts the running bot instead of starting a second one.
- Self-hosted deployments fail closed when the one-bot-per-meeting guarantee cannot be enforced (#1187). The admin service now refuses to start if its database is missing the unique index that prevents duplicate live meetings, instead of starting and allowing duplicates to slip through. Operators upgrading an existing deployment apply the schema migration shipped with this release before rolling out. See Deployment.
- A failed auto-join retries on a bounded backoff instead of repeatedly (#1200). When a calendar occurrence’s bot fails to join, the next attempt waits out a fixed backoff, and the reason for the wait is readable on the meeting instead of being silent.
- Scheduled bots arrive two minutes early and wait longer to be admitted (#1210). Calendar auto-joins now dispatch two minutes before the scheduled start rather than one, and a bot waits up to fifteen minutes in the lobby before giving up. See Calendar sync.
- A meeting already served or stopped is never re-armed (#1211). Calendar sync no longer recreates and re-dispatches an occurrence whose bot already attended the meeting or was stopped by the user; only genuine failures retry, and only within that occurrence’s own window.
- An explicit stop is honoured on every path (#1212). Stopping a scheduled meeting before its bot is dispatched now cancels that occurrence instead of letting the sweep send a bot anyway; a stop that races a starting bot leaves nothing running; and a stopped meeting records the stop as its outcome rather than a join failure. A meeting the user stopped can no longer be continued in place — request a new bot instead.
- Calendar auto-joined meetings are recorded like manually started ones (#1217). A bot dispatched from a connected calendar now follows the same recording setting as one started from the API or the dashboard. Previously those meetings were transcribed but never recorded, and the meeting page reported the missing audio as normal. See Calendar sync.
- Teams speaker names no longer lock to the wrong participant (#1228). When a participant joined during the first minute, a name could be bound permanently to another speaker’s audio and never revised, leaving one person’s words under another’s name for the rest of the meeting. Names earned before the participant list is complete are now provisional and corrected as evidence arrives.
-
Added (Meetings API):
GET /meetings/{platform}/{native_meeting_id}/participants— who was in a meeting, as far as the core actually knows. Every row is labelled with itssource:invite(the calendar invitation’s attendee list, which does include people who never spoke — for meetings imported from a connected calendar) andspeaker(distinct speakers heard in the transcript, first-heard first). Owner-scoped: another user’s meeting answers404, never an empty roster. The response also carriesobserved_roster: "not_recorded", which is the honest part — nothing in the core records who was actually present, so an emptyparticipantsmeans attendance was never captured, never nobody attended. No identity resolution is performed: someone both invited and heard appears once per source, because matching a voice label to an invitee is a guess. -
Teams bots join with a separate
passcode(#892). A numeric Teams meeting ID plus itspasscodenow builds the join URL Teams actually uses —…/meet/<id>?p=<passcode>— instead of interpolating the id into the thread-id deep link and dropping the passcode; a19:…@thread.v2id keeps its…/l/meetup-join/path.teams_base_hostis honoured for the personal and GCC-High/DoD clouds (unknown hosts are a422), and the passcode is kept off the storedconstructed_meeting_urland out of the bot’s logs. Unsupported password aliases (password,meeting_password, …) are refused with a typed422namingpasscoderather than accepted and silently ignored. See Send a bot. -
Spawned bot and agent-worker Pods declare CPU/memory requests and limits, so quota-controlled
namespaces admit them (#1005). The runtime accepted
resourceson aruntime.v1WorkloadSpec but dropped it before the backend, so every dynamically created Pod was unsized — and a namespace whose policy requires each container to declare requests and limits (aResourceQuota, a restricted OpenShift project) rejected both shipped workload classes at admission. Resource intent now crosses the backend port, and the Kubernetes backend submits a complete Pod manifest carrying the container’s requests and limits alongside its image, command, env, labels, workspace mounts and scheduling. Size the two classes independently with the chart’sruntime.workloadResources(meetingBot/agentWorker); each value sets both the request and the limit, and leaving a class empty keeps the previous unsized behaviour. Enforcement is Kubernetes-only — the docker and process backends accept the same intent without acting on it. See Deployment. -
Interactive meeting capabilities get one API home — and the status page stops overstating speak (#1089).
ADR-0035
settles where speak / chat / screen / avatar live: a single
POST /bots/{platform}/{native_meeting_id}/actsendpoint carrying anacts.v1act, withPOST/DELETE …/speakandPOST …/chatkept as aliases for callers already coding against them;…/screenand…/avatarretire fromapi.v1. The status page is corrected in the same change: a bot spawned through the public API cannot speak today even once the route lands, becausevoice_agent_enabledis accepted byPOST /botsand silently dropped before the invocation is built. See Roadmap status.
(e.stdout || e.stderr || e) was discarding the real error at all 21 call sites (#1107). A gate that fails because the worktree has no dependencies installed now says so.
-
Fixed: a bot whose audio capture breaks mid-meeting no longer leaves a live meeting as
completed(left_alone)(#1192).left_alonehad one oracle — whether remote audio frames were arriving — so a broken capture chain was indistinguishable from an emptied room, and the meeting ended as a recorded success with an empty transcript. The bot now also reads whether any remote stream is currently connected and live: connected streams delivering nothing for a whole silence window are a capture fault, not an empty room. The bot holds the meeting open, logsaloneness: capture-fault suspected, and attempts one capture restart. Behaviour change worth knowing: where the platform keeps a server-side mix alive on an empty meeting (Teams), affected runs now end at themaxActiveMsceiling instead of at the silence window — holding a live meeting is preferred to reporting a false success. An empty room with no connected streams still resolvesleft_aloneexactly as before. -
Changed (API): your meetings list is ordered by when the meeting happened (#1226).
GET /meetingspreviously ordered by row creation, so a meeting imported from a calendar after it ran could sort above one that happened later. The list now orders by event time, and meetings that have not reached a terminal state stay pinned at the top where you are looking for them. See Meetings. -
New (MCP): an agent can file a ticket from inside its own session (#1272). The
report_issuetool lets a calling agent that hits a wall tell the operator what happened, in its own words, without leaving the session. Supplyingmeeting_idbinds the report to the cluster’s own record of the same meeting, so an operator reads the agent’s account and the machine’s account of the same seconds side by side. The caller’s credential is verified before anything is filed, and the caller’s API key never reaches the ticket sink — only a salted fingerprint of it. Ticketing is off until the deployment setsVEXA_TICKET_SINK_URL; until then the tool answers503and nothing else changes. - Restarting mid-leave no longer strands a meeting in “stopping” (#1280). When Vexa restarted while a bot was still leaving, the meeting could sit in “stopping” — shown as active — indefinitely across a redeploy burst. A meeting whose stop was explicitly requested now converges on the short stop grace.
-
The bot and Lite images now build on Ubuntu 24.04 (Noble) (#1011). The meeting-bot image and
the Vexa Lite single-container image moved from the Jammy to the Noble Playwright base — one base
for both. Self-hosters running the agent worker also get a fix: it already exec’d its Python
interpreter as a non-root per-subject uid, but that interpreter lives under
/root, which is0700, so every dispatch died withPermission deniedand respawned./rootis now traverse-only for others (chmod o+x); it stays unlistable and root-owned files keep their modes. - More accurate Zoom speaker attribution (#1018). Zoom transcripts are named per-participant with a self-correcting vote, so a speaker’s words are far less likely to land under the wrong name.
-
Known limit — the Zoom bot joins as a web client, so not every Zoom meeting can be
recorded. Some Zoom accounts and meetings block automated browser clients outright; the bot is
refused at the door there, ends as
failedwith reasonzoom_requires_rtms, and no transcript is produced. This is a platform policy on the host’s side, not a fault in the bot — those meetings need Zoom’s Realtime Media Streams path, which this release does not ship (#706). Meetings that do admit a web client are unaffected, and this release also improves the bot’s ability to be admitted at all. -
Text-only custom STT responses now reach the transcript (#1148). OpenAI-compatible
endpoints may return only
text; the mixed pipeline now assigns that text to the submitted speech window instead of discarding it for missing provider timestamps. See Custom STT endpoints. -
Self-host:
DEFAULT_BOT_NAMEnow names the bots the terminal sends (#1258). The terminal hardcodedbot_name: "Vexa"on every join, so the variable compose, Lite and helm already passed to meeting-api could never take effect. The terminal now omitsbot_nameand lets the deployment decide, making a rename a.envedit plus a meeting-api restart — no terminal rebuild. The stock name is unchanged (Vexa), an explicitbot_nameinPOST /botsstill wins, andNEXT_PUBLIC_DEFAULT_BOT_NAMEis now a real terminal build arg for baking a name into the image. See Configuration. -
Custom STT: JSON-only Voxtral models now transcribe meetings. Vexa negotiates down from
verbose_jsontojsononce when a backend rejects verbose output, while Whisper backends keep their richer segment metadata. See Use a custom STT endpoint.
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.