Skip to main content
This page starts from a symptom you can see. If the run has already ended and you want to know what ended it, every terminal meeting carries a reason — Completion reasons is the reference for all ten.

Bot won’t join the meeting

A POST /bots returns success once the bot is dispatched, not once it’s in the call — a join can still fail. Check the bot’s live state:
  • Bot image not built (self-host) — the meeting bot is built from source (make bot), not pulled. If it’s missing, the bot can’t spawn and the meeting sticks at requested; if you’re on the stale published vexaai/vexa-bot:dev (the old 0.10 line) the bot reaches joining then fails the lifecycle.v1 handshake. Fix: make bot, and point BROWSER_IMAGE at the built tag.
  • Bad meeting idnative_meeting_id is the id inside the join URL (e.g. abc-defg-hij), not the whole URL. Wrong platform value also fails: use google_meet, zoom, or teams.
  • Waiting room / admission — on Meet and Teams the bot may be parked in a lobby until a host admits it. Admit it like any guest. A run that ended in the lobby says so: awaiting_admission_timeout or awaiting_admission_rejected, and automatic_leave.max_wait_for_admission sets how long it waits.
  • Concurrency cap — a user can run at most max_concurrent_bots bots at once (set when the user was created). Stop an existing bot or raise the cap.

Authenticated bot fails or joins signed-out

Applies only to deployments with BOT_AUTHENTICATED=true (see Authenticated bots).
  • session-restore failure at spawn — the bot could not download the stored session (wrong BOT_S3_ENDPOINT, bad scoped credentials, or the aws CLI missing from a custom image). The bot log names the step and endpoint; the meeting fails rather than joining signed-out. Fix the config and respawn.
  • 503 on POST /botsBOT_AUTHENTICATED is set but BOT_USERDATA_S3_PATH / BOT_S3_ENDPOINT / BOT_S3_BUCKET are incomplete. The error names what is missing.
  • 409 “authenticated session in use” — one stored session runs one bot at a time; the message names the meeting holding it. Wait for it to finish, stop it, or provision another identity.
  • The session decayed (signed-out on join) — re-provision with make login; the next spawn picks the fresh session up automatically. If this fires soon after provisioning rather than after weeks of use, check write-back and the session-lifetime levers instead of re-provisioning in a loop.

A scheduled meeting didn’t auto-join

Auto-join sends the bot ~60 s before the scheduled time (see Configuration). If the bot never appeared, check the meeting’s row in the Terminal — auto-join failures are stamped onto the meeting as a visible auto_join_error, never swallowed. The usual causes:
  • No meeting link — a plan without a Meet/Zoom/Teams link has nothing to join. Attach the link in the meeting’s prep view.
  • Auto-join is off — the per-meeting toggle, or the source calendar’s Auto-join switch (dashboard Calendar page on hosted; PATCH /user/calendars/{calendar_id} anywhere).
  • Concurrency cap — you were already running max_concurrent_bots bots at start time. The row shows the cap error and retries after a backoff (default 5 min).
  • The start time is long past — a meeting more than the grace window (default 10 min) past its start is skipped deliberately; the bot never joins hours late.
  • STT not configured — like a manual spawn, an auto-spawn refuses to launch a bot that could never transcribe; the row shows the unset keys. See Bot joins but there’s no transcript.

Calendar isn’t syncing

Start with the connection’s status line — on hosted Vexa that is the Calendar page of the dashboard at dashboard.vexa.ai, which shows each feed’s last sync outcome, and every failure names itself (⚠ Last sync failed: …). Sync now re-runs that connection. Self-hosted deployments do not ship that dashboard, and the API carries the same two facts either way: GET /user/calendars/{calendar_id}/sync (last status), POST /user/calendars/{calendar_id}/sync (run now). The full message-by-message reference is in Calendar sync → Reading the sync status. The usual causes:
  • Wrong URL kind — you need the secret ICS address (Google: Secret address in iCal format, …/private-…/basic.ics; Outlook: the published ICS link). The public iCal address of a private calendar answers HTTP 401/404; a calendar page URL (embed/share) is rejected at save. Reads via GET /user/calendars show every connected feed masked.
  • The secret-address field is missing (Google Workspace) — a restrictive Workspace sharing policy hides it. An admin unlocks it: admin.google.com → Apps → Google Workspace → Calendar → Sharing settings → External sharing options for primary calendars → “Share all information…”. Details in Calendar sync.
  • The event has no meeting link — it still imports as an unarmed planned meeting with platform: "unknown"; Vexa cannot auto-join until the feed contains a recognizable Meet/Zoom/Teams link.
  • Self-host wiring — confirm ADMIN_API_URL and INTERNAL_API_SECRET are set for meeting-api; without them the sync loop is disabled (and POST /user/calendar/sync answers 503).
  • Feed too large / unreachable / redirecting — feeds over 2 MB, behind redirects, or resolving to blocked/internal addresses are refused; the status line records which.

POST /bots returns 503 (transcription required)

Default spawns resolve transcribe_enabled=true. When no STT backend is configured, meeting-api refuses before writing a meeting row:
That is intentional (refuse loud). Fixes:
  • Configure TRANSCRIPTION_SERVICE_URL + TRANSCRIPTION_SERVICE_TOKEN (see Configuration), or
  • Spawn capture-only with {"transcribe_enabled": false}, or set TRANSCRIBE_ENABLED=false for the deployment.
If STT is set but the live probe says the backend is wrong, the 503 detail names the probe reason instead — fix the URL/token and re-probe (GET /health?force=1).

Bot joins but there’s no transcript

The bot is capturing audio, but transcription isn’t configured — or the bot was spawned capture-only, which looks identical from the outside.
  • First, check whether the bot was spawned capture-only. GET /bots (or GET /meetings/{id}) reports the resolved flag: data.transcribe_enabled: false means the bot was never asked to transcribe, so no STT request was ever made and no error will appear anywhere. If it is false and you did not intend that, an explicit {"transcribe_enabled": true} on POST /bots overrides the deployment default and confirms the diagnosis in one call.
  • Confirm TRANSCRIBE_ENABLED is not set to a false value in your .env. An empty TRANSCRIBE_ENABLED= means true (the default) — but a trailing comment on that line does not: docker run --env-file keeps the comment as part of the value. Keep comments on their own line.
  • Confirm TRANSCRIPTION_SERVICE_URL and TRANSCRIPTION_SERVICE_TOKEN are set (see Configuration). Unset → audio is recorded, no text is produced.
  • Segments arrive draft-first (completed: false) then confirmed (completed: true) — a short delay is normal, not a failure.
  • A stale bot key shows up as native_resolve:{ok:false,kind:"unauthorized"} on GET /api/meeting/relay-health rather than as silent dead air.

What terminal errors look like — and where the technical details live

Terminal error surfaces speak user 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 when it sent one). The full technical string — proxied URL, HTTP status, backend exception detail, e.g. /api/vexa/user/calendars → 502: upstream unreachable: ConnectError — is never lost: it is written verbatim to the browser console (api failure … / meeting action failed … warnings) on every presented error. Support and self-hosters debug from the console line; the on-screen headline tells the user which layer to suspect.

A bot on a fresh k8s node fails instantly with exit code 3 (control_plane_unreachable)

On Kubernetes each bot is a bare Pod, and a freshly autoscaled node’s network can take up to a few minutes to converge after the node reports Ready. A bot scheduled into that window may find its control plane (the meeting-api callback and redis) unreachable. Rather than crashloop opaquely or report a misleading join_failure, the bot now fails fast: it refuses to join and exits with a dedicated code. What to check on the node: is the CNI fully programmed? can a canary pod on that node reach the meeting-api Service and redis? Deleting the pod to force a reschedule (onto a warmed node, or the same node after its window passes) is the manual remedy. The bot’s exit is fast (< a few seconds) and the retry budget on the first emit (≤ ~15s) already rides out brief transient programming lag. See Kubernetes deployment → autoscaling and bot bursts.

Authentication failures

Don’t have a key yet? Hosted: sign in at vexa.ai/signin with a Google account and copy your key from your account page — free credit, no card required. Self-hosted: make all prints a key when the stack comes up.

Agent chat says no model credentials are configured

POST /agent/chat streams a single error frame — “No model credentials are configured, so the agent cannot run…” — and no turn runs. This is the credential preflight: the deployment has no model credential and your user has no custom endpoint, so a worker could only fail. Fix either side:
  • Deployment-wide — set ONE of CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, VEXA_LLM_API_KEY or HOST_CLAUDE_CREDENTIALS in deploy/compose/.env, then make all (the frame names exactly the keys it found unset).
  • Per-user — add a custom endpoint under Settings → Models in the Terminal (a custom config needs its base_url; the API key is optional for keyless local gateways).
A turn that starts and then fails with “Model credentials are missing or expired for this deployment” (a done frame, ok:false) is the mid-flight variant: a credential that existed at dispatch went stale — on macOS typically the Keychain-exported HOST_CLAUDE_CREDENTIALS file (install the sync daemon); otherwise refresh/rotate the key. The raw provider text rides in the frame’s detail field.

Containers don’t spawn (self-host)

The runtime spawns bot and agent containers via the Docker socket.
  • The runtime needs access to the host Docker socket and the right group — set DOCKER_GID to the host’s docker group id.
  • The meeting bot (BROWSER_IMAGE) is built from source (make bot) and the runtime spawns it without pulling — it must exist locally first. Confirm it’s there (docker image inspect "$BROWSER_IMAGE"); the published vexaai/vexa-bot:dev is the old 0.10 line and is incompatible. AGENT_IMAGE is built by make all — confirm it resolves and IMAGE_TAG matches what you built.

Leftover chrome/node processes after a bot ends (Lite)

On Lite every bot and agent runs as a child process inside one shared container (the process backend). If a meeting ends and you still see stray chrome/node processes, check inside the container:
As of this release the backend reaps each workload’s whole process group when it exits or is stopped, so no strays should remain — a descendant that detached into its own process group (the debug-view x11vnc/websockify) is the one declared exception. On older Lite images the workaround is a container restart.

Agent write was rejected

A streamed turn can end with a rejected frame carrying violations. This is governance, not a bug: untrusted input (email, web) runs propose-only and cannot write directly — it emits proposal cards a human approves. Trusted input (your chat) may write. If a legitimate trusted write is rejected, check that the dispatch’s trigger is message/scheduled (which mount the workspace rw), not an untrusted event.

The stack won’t come up

  • make all brings the compose stack up health-gated; tail the logs with make logs or docker compose -p vexa-v012 logs -f to see which service is wedged.
  • Port already in use → override the host port (e.g. API_GATEWAY_HOST_PORT) in deploy/compose/.env.
  • Wipe and restart from clean: make down (or docker compose -p vexa-v012 down -v to drop the postgres + minio volumes too), then make all.

Scaling meeting-api to more than one replica

Running meetingApi.replicaCount > 1 (the Helm chart default is 2) is safe. Every replica starts the same background sweeps, but each sweep’s real work is single-flighted by a Postgres session-level advisory lock (one per loop): the replica that acquires the lock runs the tick, the others skip it that interval. So a sweep’s real work — notably the calendar-sync external ICS fetch — runs once per interval across the cluster, not once per replica. Sweep intervals (CALENDAR_SYNC_INTERVAL_S, etc.) are therefore per-cluster, not per-replica. A replica that dies mid-tick drops its lock on disconnect, so the next interval is picked up by another replica — no leader-election setup required. On a single replica the lock is always free, so behaviour is unchanged. Note that scheduler-tick is not a production loop and is no longer started.

Still stuck?

If nothing above matches, that is itself worth telling us — it means the failure has no published cause yet. Support is where to send it, and says what to include: the meeting id and platform (the join key between your account of the failure and our record of the same meeting), your deployment shape and version, the failing request, the response (status + detail), and the relevant docker logs.