Skip to main content
📌 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. scheduled means 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, default 18056); 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. A subject in 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 generated index.md listings, 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):
Zero rows → go straight to building the index. Rows returned → retire the stale duplicates to a terminal status first (policy and the exact statement are in 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):
3 · API-token scopes — applied automatically on boot. 0.10 tokens predate the 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’s LimitRange 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 public api.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:
  1. 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.
  2. Anything main merged after 1.5.0 is by definition not in 0.12 until a contract revision.
Every row below was verified against the 0.12 tree (route tables, module code, or a live-edge probe), not inferred from planning docs.

Not yet in 0.12

Verified carried (previously uncertain)

  • Stop a botDELETE /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 to rejected, a lobby timeout to the retryable awaiting_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 /bots can overspill the per-user cap (bounded; reproduced and asserted by the stress lane; likely shared with main). 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 subject is 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 contractsapi.v1 hash-equal to main’s OpenAPI 1.5.0, enforced by gate:contract-version; parity itself is a gate (gate:parity).
  • A gate system main doesn’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 lanegate: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 triodeploy/lite (the whole control plane in one container, process runtime backend), deploy/compose (make all), and deploy/helm (charts/vexa, bots spawn as Pods via RUNTIME_BACKEND=k8s). All three are supported deploy paths; lite and helm docs land with the 0.12.x docs push (tracked in DOCS-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.
  • discord ingest and GET …/participants: planned back in the 0.12.x line with the next api.v1 contract revision.

0.12.x maintenance fixes

  • Compose: BROWSER_IMAGE pinned to the release tag (#659). deploy/compose now pins BROWSER_IMAGE to the immutable release tag instead of a moving :v012 tag, so make all deploys are reproducible. See Deployment.
  • Admin-API: empty token scopes backfilled on upgrade (#578). Upgrading a 0.10 stack to 0.12 backfills api_token rows that had empty scopes, 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 hardcode model=whisper-1 on 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. Set TRANSCRIPTION_MODEL (e.g. whisper-large-v3-turbo for Groq) in the stack .env and every request — bot pipeline and the terminal’s composer-mic dictation — carries that id end-to-end; unset, the wire stays whisper-1 byte-for-byte. The bundled deploy/transcription unit still ignores the field (its model is its own MODEL_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, so GET /transcripts/... labels Meet segments the same way Zoom/Teams always did — mixed-language meetings read truthfully instead of null. 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 (presentError beside ApiError) 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 to document.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 of not a valid selector errors 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-smoke job in pr-value builds deploy/lite/Dockerfile.lite from the PR’s own tree, boots it with make -C deploy/lite up, probes the front doors, and runs the concurrent-bots smoke — scoped to PRs that touch deploy/lite/** or core/**, with no secrets (anonymous pulls). Three v0.12.2 release blockers were lite-only bugs this leg would have caught per-PR. A companion gate:lite-makefile statically rejects the exact footgun class (a comment line inside a \-continued recipe block in deploy/lite/Makefile, which once shipped docker run with 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_admission now terminates with completion_reason: awaiting_admission_timeout (“the room never admitted the bot”) instead of the generic join_failure; bots destroyed at requested/joining keep join_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.status stream 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 the deploy/*/probe.sh wrappers.
  • Merge card: acceptance row — Closes can 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 as Part 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 :v012 moves, a human publishes the GitHub Release with gh 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.v1 contract: security references now resolve (#531, closes #62). All 61 secured operations in the sealed OpenAPI document referenced APIKeyHeader — 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 sent X-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’s validate.mjs now 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}/master while 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_v2 to exhaustion (looping on IsTruncated/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 > 1 no longer means “all on one node” — set global.topologySpreadConstraints (applied to every component) or a per-component <component>.topologySpreadConstraints override, and the chart injects that component’s own pod selector when you omit labelSelector, 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-workspaces PVC, whose default access mode is ReadWriteOnce; the shared zero-downtime RollingUpdate (maxSurge:1) made any pod-spec-changing helm upgrade stall on a Multi-Attach error. agent-api now renders strategy: Recreate under an RWO workspace (the same opt-out redis already carries), and keeps RollingUpdate when the workspace is ReadWriteMany.
  • 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’ true dom-outline kind (previously erased to Zoom’s dom-active), on one epoch-ms clock with a loud skew guard, and a periodic hint-counters log 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 joining lifecycle 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 generic join_failure or a stuck-requested meeting. A reachable control plane adds zero latency (the secondary channel is never probed). Operators can now tell “broken node” from “broken join” in one kubectl 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 as dom-active hints, so Zoom bot segments carry the real participant’s name instead of seg_N placeholders — 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: true the bot refuses to degrade to an anonymous join if the browser profile is signed out — the guard throws a typed AuthSessionError (an AdmissionError subclass with outcome auth_session_missing) which the join driver maps to the new permanent auth_session_missing completion reason: the control plane records the truth and never re-spawns against the dead profile. auth_session_missing is added to the lifecycle.v1 and api.v1 completion-reason enums (api.v1 also gains the previously missing startup_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 set BOT_AUTHENTICATED=true on meeting-api and every stock POST /bots spawns signed-in under the account identity. A failed session restore is now a typed, attributed session-restore error 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.ts drives joinGoogleMeeting’s authenticated branch against fabricated lobby fixtures: a signed-out guest lobby must throw the typed auth_session_missing refusal 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_missing error 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:contract PR 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 /bots with only a meeting_url now derives the meeting id — no more orphan meetings (#792). A url-only body used to return 201 while persisting native_meeting_id='', creating a meeting (and a live bot) no DELETE /bots/... or GET /transcripts/... could ever address. The API now honors what api.v1 always promised: the URL is parsed to extract platform, native_meeting_id, and the passcode (Zoom ?pwd= / Teams ?p=); an unrecognizable URL is a typed 422 naming the missing native_meeting_id, and a platform that disagrees with the URL is a 422 too. Explicit native_meeting_id is 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 whole created_at index — 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 indexed UNION (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 new meetings indexes CONCURRENTLY before 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 /health past 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 declare timeoutSeconds: 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 stamps meeting.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.v1 stream for offline replay. With captureSignalEnabled in the invocation (or VEXA_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), and eval/src/distill.mjs cuts 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 as type: "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 add answering “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 edit main, 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 exhausted configuration 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 consults balance_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.example ships BROWSER_IMAGE=vexaai/vexa-bot:${IMAGE_TAG} — docker-compose expands it, but make all’s spawn-image pull read the literal string and handed docker pull an 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 literal BROWSER_IMAGE still pulls verbatim.
  • Webhook deliveries now report every outcome (#815). WebhookSink.deliver returns delivered | 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 one webhook_delivery logevent 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, at warning for 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 misconfigured with the reason, on the boot log and the stt row of GET /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 /bots refuses 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_URL accepts both documented shapes everywhere. A full …/v1/audio/transcriptions URL 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 completed with zero transcript — a failure the system reported as success. The stage now survives the stop, and such a run ends failed with failure_stage naming 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 than awaiting_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/status no longer reads a caller’s entire meeting history to answer a running-bots badge. It fetched every meeting the account ever had, with the full data JSONB, and filtered in Python — 4,896 meetings / 180 MB on one production account, 144 MB of that bot_logs no 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 full data. (#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_failure and 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 typed AdmissionError Google 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 in completion_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 dashboard compose profile (docker compose --profile dashboard up -d, port 13001) and a dashboard.enabled helm value, both wiring the pinned external image vexaai/dashboard:0.10.6.3.14 to the gateway’s hosted-compat surface exactly as hosted production runs it. It stays deprecated and versions on its own pinned tag (never global.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_session bot 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 a meeting_url attached, 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 connected flag only after connect() 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 idempotent makeLazyConnect memo, so concurrent first-use callers await a single connect().
  • A malformed native_meeting_id is 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 opaque 500 about five seconds later. It is now a typed 422 naming 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_alone overrides 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.v1 event, and meeting-api persists them to meeting.data.stt_fault and onto the meeting.status_change webhook. Previously those faults were fully typed and attributed inside the bot and then died in a console.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_time left 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 the Z suffix, 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_N cluster 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_ENABLED and 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 with completed(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 /bots returns 502 with the reason (the missing image is named) and the meeting is marked failed on the spot — no more a 201 over a workload that never came up, followed by five silent minutes and a reason-less failed. 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, and make dev now 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, and GET — 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 a 503 the MCP service never saw (8 of 14 stream-open attempts in a 15-minute hosted window, while POST was 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 and mcp-session-id verbatim; 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 new pipeline.redis_reachable component surfaces false during an outage while the shared probe stays 200, so readiness holds and Postgres-backed reads (GET /meetings, durable GET /transcripts) keep serving. The genuinely Redis-dependent stop path (DELETE /bots) now fails narrowly with 503 (retryable, the stop is already recorded and reconciles when Redis returns) instead of an opaque 500, 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_admission until 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 reports failed / 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 at GET /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-Language or 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 context locale, and ?hl= on the Meet URL, all driven by a BOT_UI_LOCALE knob (default en-US). The resolved locale (navigator.language, <html lang>) is now logged at lobby time and recorded in last_error on 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_admission once 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-all left_alone. left_alone counts 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 required aloneness seam by crashing with a raw TypeError.
  • Google Meet Stop during awaiting_admission now 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 /bots rejects a native_meeting_id carrying 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 typed 422 at intake instead of building a broken join URL and storing an unfindable record. Pass any passcode in the passcode field, or supply the full meeting_url. Bare ids across platforms (Meet dash-codes, Zoom digits, Teams 19:…@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_meetings set; the self-healing scan runs only on startup and at DB_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 with migrations.enabled=true previously ran the schema-convergence Job from the rolling v012 image 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.imageTag wins, falling back to the migrations/meetingApi image 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.gaierror and 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 via DB_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.com now terminates with teams_auth_redirect: … (url=…), carried into last_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 /mcp with a valid mcp-session-id now emits text/event-stream headers 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}/tokens with {"scopes":["bot","tx"]} now mints those scopes instead of silently falling through to ["bot"]. Query ?scopes= / ?scope= still work; unsupported body fields return 422. 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 venv on a Playwright/jammy base whose only system Python is 3.10 — below requires-python >=3.11. An unpinned uv venv therefore 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), so admin-api entered a supervisor FATAL loop and API-key provisioning silently no-oped — the gateway then answered every request with Authentication temporarily unavailable. All five Lite venvs (admin-api, runtime, meeting-api, gateway, agent) now pass --python 3.12, matching the FROM python:3.12-slim every compose image (and helm, which ships those images) already pinned. Compose and helm never shared the exposure.

Versioning

Vexa follows semantic-ish versioning at the MAJOR.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.