# Agent API Source: https://docs.vexa.ai/api/agent The control plane — dispatch a unit, stream a chat turn, manage routines, read the workspace. The agent API is Vexa's **control plane**. It turns triggers into [dispatches](/core/agents) and streams their output; it does no execution itself — it validates and hands off to the [runtime](/core/runtime). Endpoints are fronted by the gateway, which carries authentication and per-route scopes; the service itself is internal. **Identity is server-derived.** Public clients reach these routes through the gateway under the canonical prefix **`/agent/*`**. The gateway resolves your `X-API-Key` → user and injects `X-User-Id`; agent-api derives the workspace/chat/quota **`subject`** from that header (P20). A `subject` in a request body or query string is **ignored** — never trust the client for identity. The `$AGENT_API/api/...` paths below address the internal service directly (which requires `X-User-Id`); through the gateway the same routes are `$API_BASE/agent/...` with `X-API-Key`. ## Health ```bash GET /health theme={null} curl "$AGENT_API/health" # → {"status":"ok","service":"agent-api","checks":{"dispatcher":true}} ``` ## Models `GET /api/models` → the resolved model names for this subject: ```json theme={null} { "chat_model": "...", "agent_model": "...", "streaming_model": "...", "meeting_model": "..." } ``` ## Dispatch a unit `POST /invocations` — the sink every trigger funnels through. Body is a conformant `unit.v1` Invocation (the same envelope `units.make_dispatch` builds); it is validated and dispatched to the runtime. `400` if the envelope is non-conformant. ```bash POST /invocations → 202 theme={null} curl -X POST "$AGENT_API/invocations" -H "Content-Type: application/json" -d '{ "identity":{"subject":"u_jane","launcher":"schedule:u_jane"}, "runner":"claude-code", "workspaces":[{"id":"u_jane","mode":"rw"}], "trigger":"scheduled", "start":{"entrypoint":{"inline":"Summarize overnight activity."}} }' # → {"workload_id":"agent-..."} ``` The shape is: `identity.subject` + `identity.launcher`, `runner`, a `workspaces` list (`mode` is `rw` for trusted triggers — `message`/`scheduled` — else `ro`), the `trigger`, and a `start` that is either `{"entrypoint":{"inline":"..."}}`, `{"entrypoint":{"path":"..."}}`, or `{"session":{"ref":"..."}}`. There is **no** top-level `subject`, `workspace_repo`, or `plan` field. ## Chat (streamed) `POST /api/chat` — body `{ prompt, session?, active? }` (`subject` is server-derived, not sent). Returns **Server-Sent Events**; each `data:` line is one frame: | frame | fields | | --------------- | --------------------------------------------------------------------------------------------------------------------- | | `message-delta` | `text` | | `tool-call` | `tool`, `args`, `callId` | | `tool-result` | `callId`, `ok`, `summary` | | `commit` | `sha` | | `rejected` | `violations` | | `done` | `reply`, `sessionId`, `ok` (+ `detail` when a failure's raw provider text was rewritten) | | `error` | `message` — the turn was refused before dispatch (e.g. no model credential configured); actionable, names what to set | ```bash POST /api/chat (SSE) theme={null} curl -N -X POST "$AGENT_API/api/chat" -H "Content-Type: application/json" \ -H "X-User-Id: u_jane" \ -d '{"prompt":"Record that Acme renewed."}' ``` `POST /api/chat/reset` `{ session? }` → `{ "ok": true }` · `GET /api/sessions` → `{ "sessions": [...] }` · `GET /api/sessions/{session}/history` → `{ "turns": [...] }` (tolerant — a missing transcript returns `{ "turns": [] }`). ## Routines ```bash POST /api/routines → 201 theme={null} curl -X POST "$AGENT_API/api/routines" -H "Content-Type: application/json" \ -H "X-User-Id: u_jane" -d '{ "name":"Morning brief","cron":"0 8 * * 1-5", "prompt":"Brief me from overnight activity.","run_now":true }' # → {"routine":{...},"job_id":"job_...","ran_now":true} ``` `GET /api/routines` → `{ "routines": [...] }` · `PATCH /api/routines/{name}/enabled` `{ enabled }` → `{ "ok": true, "name", "enabled", "reconcile" }` · `DELETE /api/routines/{routine_id}` → `{ "ok": true, "routine_id": "..." }`. ## Events `POST /events` — body is an `event.v1` Event (an integration firing). It maps to a `unit.v1` Invocation and dispatches. `400` if non-conformant; `422` if the event carries no plan. ```bash POST /events → 202 theme={null} curl -X POST "$AGENT_API/events" -H "Content-Type: application/json" -d '{ "name":"email.received","subject":"u_jane", "source":{"uri":"mailbox://u_jane/INBOX/AB12CD"}, "plan":{"prompt":"Triage this email into tasks."} }' # → {"workload_id":"agent-...","trigger":"event"} ``` ## Live meeting copilot A live-meeting copilot is itself driven through `make_dispatch`; the id invariant is `meeting_id == session_uid == native_id`. * `POST /api/meeting/start` `{ native_id, platform, title? }` → `202`, returns the live-meeting record. * `POST /api/meeting/process` `{ native_id, platform, on }` → toggle opt-in processing. `on:true` resumes from the per-meeting cursor (`{ ..., "processing":true, "resumed_from":"" }`); `on:false` clears the flag and freezes the cursor (`{ ..., "processing":false }`). * `GET /api/meeting/relay-health` → typed transcript-relay health (a stale bot key surfaces as `native_resolve:{ok:false,kind:"unauthorized"}` rather than silent dead air). * `GET /api/meeting/stream?meeting_id=&session_uid=` (SSE) → the merged transcript + copilot-output feed. Resumable: every event carries an SSE `id:`; on reconnect echo it as `Last-Event-ID` to resume gaplessly. Event types: `transcript`, `card`, `message-delta`, `tool-call`, `ping`, `meeting-end`. ## Workspace `GET /api/workspace/tree?hidden=` → `{ "files": ["kg/entities/..."] }` · `GET /api/workspace/file?path=` → `{ "path": "...", "content": "..." }` (`404` if absent) · `GET /api/workspace/git` → `{ branch, changes, commits }` · `POST /api/workspace/upload` (multipart `files`, ≤25 MB each) → `{ "files": [{ name, path }] }` · `POST /api/workspace/init` → `201` `{ workspace, seeded, already_initialized }` (idempotent) · `GET /api/workspace/attached` → `{ active, parked }` · `POST /api/workspace/swap` `{ repo?, ref?, token? }` → attach an external git repo as the active workspace (parks the current one; omit `repo` to swap back to the seed). (All workspace routes derive `subject` from `X-User-Id` — no `subject` query parameter.) # Errors Source: https://docs.vexa.ai/api/errors Status codes, error shape, and what each one means across the Meetings and Agent APIs. All public routes are fronted by the gateway and return conventional HTTP status codes. Error bodies follow FastAPI's convention — a JSON object with a `detail` field: ```json theme={null} { "detail": "Missing API key" } ``` `detail` is a human-readable string for client errors, or a list of field errors for request-validation failures (`422`). ## Status codes | Status | When | Example `detail` | | --------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `200` / `201` / `202` | success (`201` create, `202` accepted-for-dispatch) | — | | `204` | success, no body (e.g. token deleted, bot stopped) | — | | `400` | malformed request — non-conformant envelope on `POST /invocations` | `Invocation envelope is not conformant` | | `401` | missing or invalid API key — no/unknown/revoked `X-API-Key` | `Missing API key` · `Invalid API key` | | `403` | under-scoped key, or bad/missing admin token | `Token scope not authorized for this endpoint` · `Invalid or missing admin token.` | | `404` | resource not found — user, token, meeting, or workspace file | `User not found` · `not found` | | `422` | semantic validation failed — bad field, invalid scope, event with no plan | `Invalid scope(s): ['foo']. Valid: ['bot', 'browser', 'tx']` | | `429` | rate-limited — per-IP edge throttle or per-user limiter; honor the `Retry-After` header (seconds) | — | | `500` | unexpected server error | — | ## By surface **Authentication / admin** (`/admin/*`) * `403` on a missing **or** wrong admin token (`detail: "Invalid or missing admin token."`). * `404` minting a token for a non-existent user id. * `422` minting with an invalid scope (valid: `bot`, `tx`, `browser`). **Meetings API** (`/meetings`, `/bots`, `/transcripts/*`) * `401` on a missing or invalid `X-API-Key`; `403` when the key lacks the route's scope. * `404` reading a transcript for a meeting that was never started. * `POST /meetings` (plan) → `409` when a non-terminal meeting already exists for that link; `422` on an unrecognizable `meeting_url`. * `PATCH`/`DELETE /meetings/{id}` → `409` once the bot lifecycle owns the record (a planned meeting is only editable while it's still planned); `409` on `PATCH` when the new link collides with another active meeting. * `POST /bots` → `409` when a bot is already active on that link (a *planned* record on the link is fine — the spawn claims it); `429` past your concurrency limit. * A bot that fails to join surfaces in `GET /bots/status` rather than as an HTTP error on `POST /bots` — see [Troubleshooting](/troubleshooting#bot-wont-join). **Agent API** (`/agent/*`) * `POST /invocations` → `400` if the `unit.v1` envelope is non-conformant. * `POST /events` → `400` if the `event.v1` event is non-conformant; **`422` if the event carries no plan**. * Workspace reads → `404` (`detail: "not found"`) if the path is absent (`GET /agent/workspace/file?path=...`). ## Streaming errors Streamed endpoints (`POST /agent/chat`, `GET /agent/meeting/stream`) open with `200` and report problems **in-band** as SSE frames, not as a status code: * `rejected` — a write was blocked by governance; the frame carries `violations`. * `done` with `ok: false` — the turn ended unsuccessfully. * On a dropped meeting stream, reconnect with the last `id:` echoed as `Last-Event-ID` to resume gaplessly. ## Rate limits Two layers gate the gateway, in order: 1. **Per-IP edge throttle (pre-auth).** An optional fastapi-guard edge layer caps requests per client IP *before* the API key is validated — so an IP flooding invalid keys, or rotating many keys from one IP to defeat the per-user limiter, is answered with `429` at the edge and never reaches admin-api. An IP that keeps offending past a threshold is **auto-banned for a window**. Self-hosted default: **OFF** (`GUARD_ENABLED=false`); set `GUARD_ENABLED=true` + `GUARD_RATE_LIMIT_RPM` to turn it on. Hosted runs it on. See [Configuration → Gateway edge protection](/configuration#gateway-edge-protection). 2. **Per-user limiter (post-auth).** A token-bucket limiter keyed by user id fires *after* the key is resolved — it catches one token driving too much traffic across many IPs. Self-hosted: **ON** by default (`GATEWAY_RATE_LIMIT_RPS` / `GATEWAY_RATE_LIMIT_BURST`, see [Configuration](/configuration)). A throttled request returns `429` with a `Retry-After` header (seconds) — honor it before retrying. Beyond rate limits, throughput is bounded by your own infrastructure and per-user concurrency (`max_concurrent_bots`, set when the user is created). The hosted service additionally applies plan-based limits. # Meetings API Source: https://docs.vexa.ai/api/meetings Plan meetings ahead, sync your calendar, send a bot, get the real-time transcript, manage the bot, retrieve recordings. The Meetings API covers a meeting's whole life: **plan** it ahead (or import it from a calendar), **send a bot** into the call (or let auto-join do it), and read the **transcript in real time**. It is the public Vexa API — the same surface whether you use the hosted service or self-host. ## Base URL & auth | | | | ----------- | -------------------------------------------------------------------------------- | | Hosted | `https://api.cloud.vexa.ai` | | Self-hosted | `http://localhost:18056` (the gateway; `API_GATEWAY_HOST_PORT`, default `18056`) | Every request carries your key: ```bash theme={null} -H "X-API-Key: " ``` ## Platforms Pass one of these as `platform`: | Platform | `platform` value | | --------------- | ---------------- | | Google Meet | `google_meet` | | Zoom | `zoom` | | Microsoft Teams | `teams` | | Jitsi Meet | `jitsi` | The bot joins like any participant — no plugins, no host configuration. For `jitsi`, the full `meeting_url` is **required** (like Zoom): a Jitsi room name is scoped to a deployment — `MyRoom` exists on meet.jit.si *and* on every self-hosted instance — so only the URL says which one to join. The `native_meeting_id` reflects that scoping: the bare room name on meet.jit.si (`MyRoom`), and `room@host` on any other deployment (`MyRoom@video.example.org`) — so same-named rooms on different deployments never collide. A password-protected room is joined by passing the optional `passcode` field. To make **calendar sync** recognize a self-hosted deployment's links, list its hostnames in the `VEXA_JITSI_HOSTS` env (comma-separated). On Jitsi the transcript also carries the meeting's **chat**: each chat message arrives as a segment with `source: "chat"` (the sender is the `speaker`), and speaker names come from the conference's own dominant-speaker signal. ## Meeting statuses A meeting is **one record from plan to transcript**: | Phase | `status` values | Who owns it | | ---------------- | ------------------------------------------------------------------------------------- | --------------------- | | Planned (intent) | `idle` (no time) · `scheduled` (time set) | you — freely editable | | Live (bot FSM) | `requested` · `joining` · `awaiting_admission` · `needs_help` · `active` · `stopping` | the bot lifecycle | | Terminal | `completed` · `failed` | — | Sending a bot to a planned meeting (yours or auto-join's) **upgrades the same record in place** — its title, workspace binding, and transcript stay together. `PATCH`/`DELETE` work only while the record is still planned; once the bot lifecycle owns it they answer `409`. ## Plan a meeting Create a meeting **before it happens** — no bot is spawned. All fields are optional: a plan can be just a title, and the link can be attached later. What the meeting is about — shown in the Meetings list. ISO-8601 start time. Present → status `scheduled` (and [auto-join](#auto-join) arms); absent → `idle`. A Meet/Zoom/Teams link — parsed server-side into `platform` + `native_meeting_id`. Unrecognized links are rejected with `422`. Bind the meeting to a shared workspace — its members then see the meeting, its live feed, and the transcript. Send the bot automatically at start time. Default `true`. ```bash POST /meetings theme={null} curl -X POST "$API_BASE/meetings" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"title":"Q3 kickoff with Acme","scheduled_at":"2026-07-10T15:00:00Z", "meeting_url":"https://meet.google.com/abc-defg-hij","workspace_id":"ws-acme"}' ``` Returns `201` with the meeting record. `409` when a non-terminal meeting already exists for the same link. ### Edit or delete a plan Planned meetings are addressed **by record id** (a plan without a link has no platform/native path). Send only the fields you're changing; `null` clears a field (`scheduled_at: null` flips the status back to `idle`, `meeting_url: null` detaches the link, `workspace_id: null` unbinds). ```bash PATCH /meetings/{meeting_id} theme={null} curl -X PATCH "$API_BASE/meetings/12345" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"scheduled_at":"2026-07-10T16:00:00Z","auto_join":false}' ``` ```bash DELETE /meetings/{meeting_id} theme={null} curl -X DELETE "$API_BASE/meetings/12345" -H "X-API-Key: $API_KEY" ``` Both answer `404` for a record you don't own and `409` once the bot lifecycle owns the record. The same edit/delete is also reachable **by native key** — `PATCH`/`DELETE /meetings/{platform}/{native_meeting_id}` — which resolves `(platform, native_meeting_id)` to your newest owned row and applies the same rules (`404` unknown/unowned, `409` once FSM-owned). Use it when you address meetings by their join-link identity rather than the record id; `DELETE` answers `200` on this path (the by-record-id `DELETE` answers `204`). ## api.v1 native-id support (per route) `api.v1` is sealed as a **file hash** (`contracts.seal.json`), which pins the schema document, not the running implementation. This table states, per route, whether the `(platform, native_meeting_id)` keying a 0.10 client uses is served by the 0.12 core: | Route | Native-id keying in 0.12 | | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PATCH /meetings/{platform}/{native_meeting_id}` | **Yes** — resolves to your newest owned row. | | `DELETE /meetings/{platform}/{native_meeting_id}` | **Yes** — `200` on success (by-id path is `204`). | | `GET /bots/status` | **Yes** — body carries both `running_bots` (0.10) and `running`/`count` (0.12). | | `POST /transcripts/{platform}/{native_meeting_id}/share` | **Aliased** to the moved `POST /meetings/{platform}/{native_meeting_id}/share` mint. The response is the capability-token share (`{id, token, mode, expires_at}`), not the 0.10 public-URL `TranscriptShareResponse` — the public-URL-share backend is not in the 0.12 core. | | `GET /recordings/{recording_id}/media/{media_file_id}/download` | **Aliased** to the `.../raw` master byte stream (Range-capable). | | `GET /bots/{platform}/{native_meeting_id}/chat` | **Restored, empty** — the owner boundary is real; the message list is always empty (0.12 does not persist in-meeting chat server-side; chat flows live over the WS `va:…:chat` channel). | | `POST /bots/{platform}/{native_meeting_id}/chat` | **Not implemented** — no bot-command (send) backend in the 0.12 core. | | `POST /meetings/{meeting_id}/transcribe` | **Not implemented** — no on-demand re-transcribe backend in the 0.12 core. | **What the seal enforces now.** Because the file hash pins the *document* and not the *running implementation*, CI now also runs a **reverse** conformance check (`gate:contract-conformance`): for every `(path, method)` `api.v1` declares, the shipped gateway + meeting-api must serve it — otherwise the route must be recorded in the audited `core/gateway/contracts/api.v1/KNOWN_GAPS.json` ledger (with a reason + issue link), which the gate prints loudly on every run. A sealed endpoint renamed or dropped without a ledger entry fails CI. The frozen golden examples are also driven against the real responses, so a renamed response field (e.g. `running_bots` → `running`) fails too. The two **Not implemented** rows above and the share **response-shape** divergence are the current recorded gaps — a client can rely on the seal meaning "served, or explicitly listed as a gap", not merely "the schema file didn't change". ## Auto-join A `scheduled` meeting **with a link** is joined automatically: a background sweep sends the bot \~60 s before `scheduled_at` (never more than 10 min after — a stale plan is skipped, not joined late). Opt out per meeting with `auto_join: false`. Failures are loud: a concurrency-cap or spawn failure stamps `auto_join_error` into the meeting's `data` and retries after a backoff. Timing is tunable on a self-host — see [Configuration](/configuration). ## Calendar sync Connect a calendar's **secret ICS address** and upcoming meetings import as planned records automatically (only events carrying a recognizable Meet/Zoom/Teams link; one record per event — the next occurrence of a recurring series). The URL is a secret: read-backs return it **masked**. `auto_join` here is the **global default** stamped onto imported meetings. ```bash PUT /user/calendar theme={null} curl -X PUT "$API_BASE/user/calendar" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"ics_url":"https://calendar.google.com/calendar/ical/…/basic.ics","auto_join":true}' ``` ```bash GET /user/calendar theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/user/calendar" ``` ```json Response theme={null} { "ics_url_set": true, "ics_url_masked": "calendar.google.com/…s.ics", "auto_join": true } ``` Pasting the wrong kind of URL fails at save: a calendar *page* (e.g. Google's `/calendar/embed`) is rejected with a `422` that points at the right field — the **Secret address in iCal format** (on Google Workspace domains an admin policy can hide that field; see [Calendar sync](/how-to/calendar-sync#1-find-your-secret-ics-address) for the unlock). **Sync feedback** — connecting from the Terminal runs a sync immediately; over the API the same two edges are yours: ```bash POST /user/calendar/sync — run the sync NOW, get the result theme={null} curl -X POST "$API_BASE/user/calendar/sync" -H "X-API-Key: $API_KEY" ``` ```json Response theme={null} { "last_sync": "2026-07-08T15:30:00+00:00", "last_error": null, "counts": { "created": 3, "updated": 0, "cancelled": 0 } } ``` ```bash GET /user/calendar/sync — the last sync's status theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/user/calendar/sync" ``` `last_error`, when set, is a human-readable reason (wrong-URL kind, HTTP status, oversize, redirect, not-ICS content) — the same strings the Terminal panel shows. `404` on the POST means no feed is connected; `503` means the deployment has calendar sync disabled (see [Configuration](/configuration#auto-join--calendar-sync)). Disconnect with `{"ics_url": null}`. See [Calendar sync](/how-to/calendar-sync) for where to find the secret address. ## Send a bot to a meeting `google_meet` · `zoom` · `teams` The meeting id from the join URL (e.g. `abc-defg-hij`). Display name the bot uses in the call. Defaults to `Vexa`. ISO code (e.g. `en`). **Omitted** = auto mode: each STT window's language is detected independently and stamped on its segments' `language` field. **Set** = forced mode: every STT call is pinned to this code and every segment carries it. Granularity is the transcription window (a few seconds of one speaker's audio), not word-level; in auto mode a low-confidence detection on the Zoom/Teams (mixed-audio) lane discards the window rather than guessing. Undetermined windows yield `language: null`. (0.10's `allowed_languages` is not part of the 0.12 API.) `transcribe` (default) or `translate`. Whether this spawn should request transcription. **Resolution:** an explicit value wins; else the deployment env `TRANSCRIBE_ENABLED`; else `true`. Non-boolean values (other than the common string forms `true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`) are refused with `422` (`transcribe_enabled must be a boolean`). When the resolved value is `true` and no STT backend is configured, `POST /bots` answers **503** with a detail naming the unset keys, e.g. `no transcription backend configured — set it in Settings or environment variables TRANSCRIPTION_SERVICE_URL + TRANSCRIPTION_SERVICE_TOKEN`. Set `false` for **capture-only** (bot may still join; no STT client is constructed). Whether this spawn should persist an audio/video recording. Same resolution pattern as `transcribe_enabled` against env `RECORDING_ENABLED` (default `true` at the env resolver; the service default when callers omit it follows the request/env chain). Capture-only (`transcribe_enabled=false`) is **not** the same as recorded — recording is gated separately. ```bash POST /bots theme={null} curl -X POST "$API_BASE/bots" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"platform":"google_meet","native_meeting_id":"abc-defg-hij","bot_name":"Vexa","language":"en"}' ``` If a **planned** meeting exists for the same link, the spawn **claims it**: the same record moves to `requested`, keeping its title, `scheduled_at`, and workspace binding. `409` when a bot is already active on that link; `429` past your concurrency limit. With transcription requested (the default) and STT unconfigured, the spawn is **refused loud** — `503` naming `TRANSCRIPTION_SERVICE_URL` / `TRANSCRIPTION_SERVICE_TOKEN`. Capture-only: `{"transcribe_enabled": false}` (or `TRANSCRIBE_ENABLED=false` for the deployment). See [Configuration](/configuration#transcription-stt) and [Troubleshooting](/troubleshooting#bot-joins-but-theres-no-transcript). ### Completion service provenance The signed `meeting.completed` webhook includes `data.meeting.service_provenance` when the meeting has complete producer-owned lifecycle facts: ```json theme={null} { "bot_admitted_at": "2026-07-28T10:05:00.000Z", "bot_departed_at": "2026-07-28T10:30:00.000Z", "bot_outcome": "served", "transcription_provider": "customer", "transcription_outcome": "served", "lifecycle_contract_version": "2026-07-28" } ``` `transcription_provider` is frozen when that bot is created: `vexa`, `customer`, or `none`. `bot_admitted_at` and `bot_departed_at` are the bot-observed lifecycle transitions, not meeting wall-clock time or delayed webhook receipt time. A bot that never reached `active` reports `bot_outcome: "never_admitted"` with both timestamps absent. The block never contains an endpoint URL, hostname, token, credential, meeting title, or transcript. If a legacy or mixed-version run lacks provider ownership or producer timestamps, the block is `null` or absent. Consumers must treat that as unresolved provenance; do not reconstruct it from `transcribe_enabled`, current user settings, endpoint URLs, or meeting start/end time. ## Get the transcript ```bash GET /transcripts/{platform}/{native_meeting_id} theme={null} curl -H "X-API-Key: $API_KEY" \ "$API_BASE/transcripts/google_meet/abc-defg-hij" ``` ```json Response theme={null} { "segments": [ { "segment_id": "sess_9c2a:spk1:12400", "speaker": "Jane Liu", "text": "Let's lock the renewal pricing by July 1.", "start": 12.4, "end": 15.1, "language": "en", "completed": true, "confidence": 0.93, "words": [{ "word": "Let's", "start": 12.4, "end": 12.6, "probability": 0.98 }] } ] } ``` Segments stream in while the meeting runs — poll this endpoint, or subscribe over **WebSocket** for live, per-segment updates. Live drafts arrive as `completed: false` and are replaced by `completed: true` confirmations. ## Manage the bot ```bash Update config — PUT /bots/{platform}/{native_meeting_id}/config theme={null} curl -X PUT "$API_BASE/bots/google_meet/abc-defg-hij/config" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"language":"es","task":"translate"}' ``` ```bash Stop / leave — DELETE /bots/{platform}/{native_meeting_id} theme={null} curl -X DELETE "$API_BASE/bots/google_meet/abc-defg-hij" -H "X-API-Key: $API_KEY" ``` ```bash Running bots — GET /bots/status theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/bots/status" ``` ```bash Make the bot speak — POST /bots/{platform}/{native_meeting_id}/speak theme={null} curl -X POST "$API_BASE/bots/google_meet/abc-defg-hij/speak" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"text":"Thanks everyone, wrapping up."}' ``` **Config** (`PUT …/config`, change language/task mid-call) and **speak** (`POST …/speak`, TTS into the call) ride the live bot-control plane and are not yet wired in the v0.12 open-core stack — they currently return `404`. Send-a-bot, stop, **running bots** (`GET /bots/status`), list, and transcripts are live. ```bash List meetings — GET /meetings theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/meetings" ``` ```bash Single meeting — GET /meetings/{meeting_id} theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/meetings/12345" ``` ### List rows are slim; detail is full The two list endpoints — `GET /bots` and `GET /meetings` — return **lightweight rows**. Each row keeps its light metadata (id, status, times, `title`, connected docs) but **omits the heavy `data` detail keys**: `speaker_events`, `bot_logs`, `recordings`, `status_transition`, `chat_messages`, `error_details`, and `last_error`. Fetch a meeting's full `data` from `GET /meetings/{meeting_id}` — the detail endpoint is unaffected and still returns every key. The list is also **paged**: with no `limit`, a default page size of `50` applies (pass `limit`/`offset` to page explicitly). `GET /bots` returns `has_more: true` when more rows remain past the current page. ## Speaker-attributed transcripts Each segment is **diarized** — attributed to a speaker (a bound display name, or a provisional label until it binds) — with **word-level timestamps** (`words[]`) and a `confidence`. Speaker attribution is text-level (who said what), via speaker binding / clustering / captions — *not* separate audio tracks. Times are seconds from session start; `absolute_start_time` / `absolute_end_time` give wall-clock. The same segments arrive **live** (`completed: false`, a pending draft) and then **confirmed** (`completed: true`) — the gateway forwards the confirmed-plus-pending bundle to subscribers as the meeting runs. ## Recordings The meeting's **audio recording** is uploaded to object storage — on a self-host, your own MinIO bucket, so it never leaves your environment. This is the meeting audio, stored separately from the diarized transcript above (there is no "per-speaker audio" — speaker separation lives in the transcript as text). ```bash List recordings — GET /recordings theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/recordings" ``` ```bash Recording detail — GET /recordings/{recording_id} theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/recordings/42" ``` ```bash Master metadata (finalize-on-read) — GET /recordings/{recording_id}/master?type=audio theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/recordings/42/master?type=audio" ``` The master metadata returns a `raw_url` pointing at the byte stream `GET /recordings/{recording_id}/media/{media_file_id}/raw`, which the player loads. The `/raw` endpoint honours a `Range` header and returns `206 Partial Content` with `Content-Range` and `Accept-Ranges` — these are preserved **through the gateway**, not only on a direct hit, so browser playback and seeking work. *** In Vexa's [runtime](/core/runtime) terms, a bot is a browser [container](/concepts#container); the transcript it produces compiles into the [workspace](/concepts#workspace), where [agents](/core/agents) act on it. See [Meetings](/core/meetings). # Architecture as Code (CALM) Source: https://docs.vexa.ai/architecture/architecture-as-code The runtime architecture is a validated FINOS CALM model — nodes, data carriers, single-writer ownership, and a self-hostable meeting-intelligence pattern, enforced in CI. The Vexa runtime is modeled as code using **[FINOS CALM](https://calm.finos.org/)** (Common Architecture Language Model). There is **one chart** — the repo-root [`architecture.calm.json`](https://github.com/Vexa-ai/vexa/blob/main/architecture.calm.json) — the single source of truth; [`calm/`](https://github.com/Vexa-ai/vexa/tree/main/calm) holds the governance controls and the reusable pattern that validate it. Two CI gates keep it honest: `pnpm gate:calm` (FINOS pattern conformance) and `pnpm gate:dataflow` (model↔disk completeness, ownership enforcement, and a reality diff against the code) — so the architecture description can't silently drift from the system. ## What's modeled The chart carries both lenses at once: the complete **module / service / contract inventory** (every `core/*` service, module, and sealed contract is registered — adding one without registering it turns CI red) and the **runtime / data-flow view**: the standing services ([gateway](/architecture/identity-and-trust), `meeting-api`, `agent-api`, `admin-api`, [`runtime`](/architecture/execution)), the **runtime-spawned workers** (`bot`, `agent-worker` — [`deployed-in` the runtime kernel](/architecture/execution)), the redis transcript fabric, the durable stores (postgres, object storage), and the **first-party, self-hostable STT** boundary. | Layer | Nodes | | -------- | ----------------------------------------------------------------------------------------- | | Edge | `gateway` — the one authenticated door (auth · routing · WS fan-out) | | Services | `meeting-api` (collector hub) · `agent-api` (copilot) · `admin-api` (identity) | | Workers | `bot` · `agent-worker` — ephemeral, spawned per meeting / per dispatch | | Carriers | the redis [streams + pub/sub](/architecture/streaming) (transcript live + durable planes) | | Stores | postgres (transcripts · identity) · object storage (recordings) | | STT | `transcription` — first-party GPU service, **tenant-hosted by default** | ## Controls (governance, machine-checked) The model carries [governance](/architecture/governance) controls, each backed by a JSON-Schema requirement in `calm/controls/`: * **single-writer (P23)** — every data carrier declares exactly one producer; readers never re-derive a producer's data. * **render-only** — the terminal renders transcript and cards; it never re-derives or republishes. * **data-egress** — the `bot → transcription` edge declares its egress posture. Default is **tenant-hosted**, so meeting audio need not leave the tenant. ## The pattern `calm/patterns/meeting-intelligence.pattern.json` is a reusable CALM **pattern** a meeting-intelligence deployment must conform to: a single authenticated edge, a capture worker, a collector, a copilot, a render-only client, and a **declared STT egress boundary**. Conformance is **fail-closed** — a design that omits the egress control or the render-only client is rejected: ```bash theme={null} pnpm gate:calm # = calm validate -p calm/patterns/meeting-intelligence.pattern.json -a architecture.calm.json ``` Because the model is standard CALM, you can also `calm generate` a conformant starter architecture from the pattern and validate your own deployment against it with the FINOS [`calm-cli`](https://www.npmjs.com/package/@finos/calm-cli). ## Generated views Diagram views are **carved from the chart, never drawn by hand**. `pnpm arch:dsl --write` regenerates [`docs/views/`](https://github.com/Vexa-ai/vexa/tree/main/docs/views) deterministically, and `gate:dataflow` fails when they are stale: | View | Shows | | ------------------ | ------------------------------------------------------------------------------ | | `architecture.dsl` | compact text projection — the always-in-context LLM index | | `containers.mmd` | systems, services, clients, and the protocols between them | | `ownership.mmd` | every data carrier and its writers/readers (multi-writer carriers highlighted) | | `flow-*.mmd` | one sequence diagram per declared flow (live transcript · agent dispatch) | | `deployment.mmd` | the runtime.v1 spawn topology | | `egress.mmd` | the tenant trust boundary and every egress-controlled edge | The chart is also **sealed** (`architecture.seal.json`): any edit fails CI until deliberately re-sealed with `pnpm seal:arch`, so ownership or boundary changes are always a reviewed act. ## Why this exists A standard, validated model gives one diffable source of truth for the architecture, makes the [single-writer](/architecture/streaming) and [trust-boundary](/architecture/identity-and-trust) invariants machine-checkable, and lets the design be consumed by any CALM tooling — not a bespoke diagram that rots. # Control plane Source: https://docs.vexa.ai/architecture/control-plane The meeting/agent separation of concerns, the terminal jobs, and the critical paths. > Companion to [`ARCHITECTURE.md`](/governance/architecture). That file is the constitution; this one is the > applied separation-of-concerns for **live meetings + the agent copilot**, and the catalog of critical > paths we prove deterministically. Governed by **P2** (couple only through contracts), **P3** > (`meetings ⊥ agent`), **P23** (one writer per carrier; readers never re-derive). ## 1. The three layers | Layer | What it is | Owns | Never does | | ------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | | **Domain** | `meetings`, `agent` — each a bounded context with its own carriers/logic | meetings: bots, meeting rows, the transcript (single writer), status. agent: copilot lifecycle, chat, workspace, notes/cards, config | reach into the other domain's internals; re-derive the other's data | | **API** | each domain's published, gateway-fronted endpoints | meetings-control (`/bots`, `/meetings`, `/transcripts`, `/intent`, `/ws` status). agent-control (`/api/meeting/process`, `/api/chat`, `/api/workspace/*`, `/api/models`) | hold business logic in the gateway; let a client reach a domain backend directly | | **Top-level wiring (cookbook)** | composed operations + tool-authorization patterns that deliver *state* | "agent listening on a meeting" (compose bot-spawn + copilot-enable); per-turn meeting-scoped tool grants | live *inside* a domain (that re-merges `meetings ⊥ agent`); compose over anything but published contracts | **The rule:** the two domains never reach into each other's internals. They meet only through **published contracts** — the gateway's `api.v1` HTTP surface, or a `.v1`-governed bus carrier (`transcript.v1`, `tool.v1`). Composition that spans both domains lives **above** them in the cookbook layer — never folded into either domain. **Legal acquisition, not "never touch."** The boundary is about *write-ownership* and *how data is acquired*, not about forbidding possession. The agent **may hold, compose, and serve meeting data downstream** once it has acquired it **legally** through a published contract (e.g. reading `/transcripts` via the gateway, or the `transcript.v1` carrier). What's forbidden is **owning/writing** another domain's carrier, **re-deriving** a producer's data into a competing copy (P23), or reaching into **internals** (P3). So the agent's live-view composition and its chat-grounding tool are both fine — each legally acquires the transcript, then uses its own downstream copy. `send bot ≠ start copilot` — two toggles, two domains. The bot (meetings) makes the transcript *flow*; the copilot (agent, the `proc:on` toggle) *processes* it. The cookbook layer is where a single high-level op ("agent on this meeting") composes the two. ## 2. Terminal jobs → domain map **Meetings-domain control** (terminal → gateway → meeting-api): * send / stop / re-send bot — `POST /bots`, `DELETE /bots/{platform}/{native}` * schedule / set intent + cancel — `PUT /meetings/{platform}/{native}/intent` * list meetings (live + past) — `GET /meetings` * transcript history — `GET /transcripts/{platform}/{native}` * live status for ALL user meetings (left pane) — `WS /ws`, auto-subscribed to `u:{user_id}:meetings` at connect; meeting-api publishes every status change there (no polling). *Already built.* **Agent-domain control** (terminal → gateway → agent-api): * enable/disable copilot ("start agent listening") — `POST /api/meeting/process` (the `proc:on` toggle) * chat with copilot — `POST /api/chat` * read meeting doc/notes — `GET /api/workspace/file` (`kg/entities/meeting/{native}.md`) * browse workspace — `GET /api/workspace/tree` (the user's workspace git repo = durable agent memory) * configure copilot — edit workspace `agents/meeting.md` (its body is spliced into the live extraction prompt every turn — it *is* the real-time steering prompt) * model list — `GET /api/models` **Cross-domain (cookbook / composed at the edge):** * "agent on meeting" — one op = `POST /bots` + `POST /api/meeting/process` (cookbook entry #2) * chat grounded in a live meeting — agent-api folds the meeting's live transcript from its redis Stream (`tc:meeting:{native}`, the same wire the copilot tails) into the prompt, not a file (cookbook entry #1) * live view — the gateway *composes* the meetings transcript feed + the agent card feed (`unit:agent-meet-*:out`) into one client stream; neither domain merges the other's data ## 3. Chat grounding — fold the live transcript stream, not a file When the terminal's `active` tab is a meeting, agent-api grounds the chat turn by reading the meeting's live transcript directly from its redis Stream `tc:meeting:{native}` — the SAME wire the live copilot tails (`worker/meeting.py`) and the terminal renders — and folding the segments (refining drafts upserted by `segment_id`, arrival order preserved, bounded) into the prompt. This happens **fresh on every turn**, so a follow-up re-reads the latest lines. The transcript stays inside the trusted control plane and rides the prompt to the isolated worker: no notes-file dependency, no cross-domain HTTP, and no user key or scoped token in the worker (P15). The copilot still writes a durable `kg/entities/meeting/{native}.md` for the *finished* record, but live chat no longer depends on it. ## 4. Critical-path catalog (proven with deterministic fixtures) Each path: simplest perfect fixture in → frozen expected output, byte-identical across two runs (stubbed LLM/turn). Where an LLM reply is inherently non-deterministic, assert the *plumbing*, never the prose. | ID | Path | Owner | Fixture → output | | | ------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | **CP1** | raw `transcription_segments` → collector `ingest()` → `:mutable` + durable hash | meetings | 2 segments (1 confirmed, 1 pending) → exact bundle + stored hash | | | **CP2** | collector single-writer → native transcript feed (D7) | meetings | numeric segments → exact native-keyed, time-anchored entries | | | **CP3** | `proc:on` → watcher arms → worker reads from `:cursor` | agent | flag + 3-seg stream + cursor → dispatch with right `transcript_start_id`; cursor advances | | | **CP4** | `serve_meeting`: segments → gate → stub `card_turn` → notes/cards + doc | agent | 3 segs (speaker change) → exact cards/notes/doc | | | **CP5** | live view = transcript feed + card feed → one client stream, gapless resume by a dual-stream cursor | agent-api SSE today (reader-composes); gateway-composed eventually | canned transcript + cards → merged ordered stream, gapless resume | `_encode/_decode_sse_cursor` + `test_meeting_stream` (`test_api.py`) | | **CP6** | chat `active={meeting}` → agent-api folds `tc:meeting:{native}` into the prompt | agent (reads meetings' transcript Stream) | seeded stream → folded `speaker: text` prompt grounding; refining drafts deduped, empty stream → "no transcript yet" | | | **CP7** | status change → `u:{user}:meetings` → gateway fan-in → client | meetings | one status change → exact user-channel frame | | | **CP8** | cookbook "agent-on-meeting" = `POST /bots` + `POST /api/meeting/process` | wiring | meeting input → both calls (right args) + combined state + partial-failure surfaced | | ## 5. Cookbook — patterns, not yet a home We build the first two concrete entries (#1 context grounding, #2 composition) before deciding where the cookbook layer permanently lives (gateway-composed vs a thin orchestration surface). The patterns to extract once both exist: * **Composition over contracts** — a high-level op calls ≥2 domain APIs, owns partial-failure, returns combined state; lives above the domains. * **Per-turn context grounding** — when the turn's `active` context warrants, the trusted control plane reads the one in-focus resource (here the meeting's transcript Stream) and folds it into the prompt, fresh each turn — keeping the credential/data inside the control plane, never in the isolated worker. ## 6. Deferred (seam wired, implementation follows — P16) These are intentionally staged: the contract/seam is in place and tested; the runtime piece follows. * **Gateway-composed live view** (CP5) — today agent-api's SSE *reads* the meetings-owned transcript carrier and *composes* it with the agent's cards (a reader composing — no P23 violation). Relocating that compose to the gateway (transcript from meetings, cards from agent) is a user-invisible follow-up; the merge + gapless cursor are already pinned by the CP5 tests. * **Cookbook home** — the two concrete entries (#1 tool-authorization, #2 composition) exist; where the cookbook layer *permanently* lives (gateway vs a thin orchestration surface) is decided from these real instances, not up front. # The dispatch Source: https://docs.vexa.ai/architecture/dispatch One agent run — the composition of the seven primitives. A **dispatch** is one run of an agent. It is the [model](/concepts)'s single object. See the shape in the [primitives overview](/concepts). * **Lifecycle** = TTL-on-idle (the [Runtime](/core/runtime) reaps it when nothing is computing). * **subject vs launcher** — on-behalf-of vs who-triggered (see [Identity](/architecture/identity-and-trust)). * **`trust` and `output` are derived**, not stored: `trust` = (all workspaces `ro` && irreversible tools gated), set by the trigger→grant policy; `output` = `unit::out`. # Execution Source: https://docs.vexa.ai/architecture/execution The agent runs in a runtime-spawned container, over a mounted workspace folder, resuming from a session file. 1. The [Scheduler](/concepts#scheduler) fires; agent-api asks the [Runtime](/core/runtime) to spawn (it touches **no docker** itself). 2. The container mounts **only** the granted [workspaces](/concepts#workspace) — one bind per mount, no clone; another tenant's workspace is not in the filesystem at all — + brokers creds. 3. The agent **resumes from the session file** in the rw folder (continuity is a file), runs the [runner](/concepts#agent), and **commits freeform** changes. 4. It emits events on its [Stream](/architecture/streaming); the container is **reaped when idle**. The workspace folder (+ its session file) is the only durable state; the container is disposable. ## Implemented — the live path A dispatch **is** the in-container worker `agent_api.worker`. The Runtime injects everything it needs as env + a bind-mount, and the worker drives the [runner](/concepts#agent) over the mounted folder: ```text theme={null} trigger → Scheduler → Runtime spawns the `agent` container (CMD: python -m agent_api.worker) ├─ bind-mounts ONLY the granted workspaces, one per mount (ported in, not cloned; :ro roles enforced) ├─ injects the signed dispatch token + REDIS_URL + the unit::in / unit::out topics + `start` └─ brokers the model credential (never in the dispatch envelope) worker → one governed turn: runner → workspace.v1 re-validate → git commit ├─ XADD each UnitEvent to unit::out (the Stream) └─ block on unit::in for the next message; idle ⇒ exit ⇒ container reaped (TTL-on-idle) ``` **Continuity is files in the workspace.** Both the session id (`.claude/.session`) **and the chat transcript** are saved in the folder — claude-code's transcript dir is symlinked into `/.claude/projects` — so a fresh container [resumes](/concepts#workspace) the same conversation from the durable git folder, no warm container needed. Proven end-to-end on docker: a `unit.v1` dispatch → isolated `vexa-agent-` container → a real claude turn → a `workspace.v1`-governed commit → events on `unit::out`. Code: `core/agent/services/agent-api/src/agent_api/worker.py` (the harness), `core/runtime/src/runtime_kernel/docker_backend.py` (mount + credential brokering). ## The same run, any substrate The flow above is the **Docker** path (the open-core default). agent-api never spawns anything itself — it hands the [Runtime](/core/runtime) a `runtime.v1` workload, and the kernel's pluggable backend turns that *same* workload into a child process, a container, or a **Kubernetes Pod** (`RUNTIME_BACKEND=k8s`) — same lifecycle, same dispatch, same worker. Only *how* the container is created changes; the run does not. The per-backend mechanics — and what the k8s path does and does **not** wire up yet — are in [Runtime → Where it runs](/core/runtime#where-it-runs). # Governance Source: https://docs.vexa.ai/architecture/governance Two axes — input trust × effect reversibility. Untrusted or irreversible → propose, approve, apply. Governance is enforced at the **boundaries** (runtime, MCP Gateway, identity), never by the agent, and it turns on **two axes**: * **Input trust** (from the trigger): **trusted** (you, in [chat](/how-to/chat-workspace)) ⇒ the agent may write `rw`. **Untrusted** (email, web — attacker-controllable, prompt-injectable) ⇒ **propose-only** (`ro` workspaces). * **Effect reversibility**: **reversible** (a workspace commit) ⇒ auto (git is the undo). **Irreversible** (send, order) ⇒ **gated**. When propose-only or gated, the agent's output is **proposed actions** — `proactive-card.v1` frames on its [Stream](/architecture/streaming): `record` (a task/note — payload is the file), `draft`, `send` (external). **Untrusted agent proposes → human approves → trusted code applies**: a `record` is committed by a trusted applier; a `send` is executed by the [Integration](/how-to/email-triage). There is **no workspace-structure/schema check** anywhere — the workspace is [just files](/concepts#workspace). # Identity & trust Source: https://docs.vexa.ai/architecture/identity-and-trust Chain of custody — authenticity of the launcher, a signed token through every hop, verified at the boundaries. This is the cross-cutting **trust flow**. For the identity *domain* — accounts, the access-control model, what's built, and the roadmap — see [Identity](/core/identity). A dispatch is often launched by a **non-human** (a 3am schedule entry, a Gmail webhook). We cannot trust the launcher by virtue of it running — it must **prove** it was authorized, and that proof must survive every hop to the tool that finally sends an email. The agent is **untrusted** and carries proof but never enforces it. The flow — authenticate the launcher (OIDC session, or a **signed delegation grant**) → the identity service **mints a short-lived signed dispatch token** → the runtime **attests the workload** → every **boundary verifies**: * **Workspace store** mounts only granted workspaces, `rw` only where the token says. * **Tool calls** route through the **MCP Gateway**, which does an **RFC 8693 token exchange** (the agent's SPIFFE identity → a token scoped to that one tool's audience) and acts with brokered creds — the agent never holds the credential. * **Stream** tags every event with `dispatch_id`; audit resolves it to `(subject · launcher · scope)`. We adopt **[kagenti](https://github.com/kagenti/kagenti)**: **SPIRE** (workload identity) + **Keycloak** (RFC 8693) + Envoy **MCP Gateway**. Dev uses a token-bound secret behind the same interface; k8s uses SPIRE. The agent (LLM) is outside the trust boundary. Compromise it via prompt injection and it still cannot exceed the token's scope — the boundaries enforce, not the model. The same posture covers **user-supplied secrets and URLs**. A calendar's ICS address is a credential: it is stored server-side, every user-facing read returns it **masked**, and it crosses in the clear only on the internal, secret-gated hop the sync poller calls. And because the poller dereferences a user-controlled URL, the fetch runs on the same **IP-pinned, SSRF-guarded transport** as webhook delivery — re-resolved and validated at connect time, so a DNS-rebinding flip can never turn calendar sync into an internal-network probe. # Modules & Seams Source: https://docs.vexa.ai/architecture/modules The single map of the codebase: the module tree, the sealed contract registry (owner → consumers), and the four eval levels. This is the one-page index of the system: **which module owns what**, **which sealed contracts join them**, and **how each level is proven**. Every module entry links to its in-tree README — the README is the source of truth; this page is the map. Why the system has this shape: **a [module](/concepts#module) owns exactly one concern and is the single source of truth for it**; every service is a **modular monolith** built from such modules; modules join only through **[sealed contracts](/concepts#contract)** that CI refuses to let drift. The payoff is debuggability at every scale — a module alone against its fixtures, or a chain of modules at the scale a bug lives at — and a verify loop (code + adversarial tests + goldens, deterministic ports, fixtures from real meetings) simple enough for AI agents to own. The narrative: [What 0.12 is](/roadmap/status#what-012-is). ## The module tree Five core domains, a client, and the composition layer. Each domain owns its contracts + the services that honour them. | Module | Path | Purpose | | -------------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **core/agent** | [`core/agent`](https://github.com/Vexa-ai/vexa/tree/main/core/agent/README.md) | The **execution domain** — turns a trigger (chat turn · fired schedule · external event · live transcript beat) into a **governed agent action** committed to a user's `workspace.v1` git repo. Funnels every trigger through one `unit.v1` envelope (the one Dispatcher). | | ↳ agent-api | [`core/agent/services/agent-api`](https://github.com/Vexa-ai/vexa/tree/main/core/agent/services/agent-api) | The one Dispatcher + the in-container `agent_api.worker` (claude-in-container) it spawns via the runtime. | | **core/meetings** | [`core/meetings`](https://github.com/Vexa-ai/vexa/tree/main/core/meetings/README.md) | The **capture domain** — joins a meeting, captures + transcribes it, emits a speaker-attributed `transcript.v1`. The same `modules/` bricks composed three ways. | | ↳ bot | [`core/meetings/services/bot`](https://github.com/Vexa-ai/vexa/tree/main/core/meetings/services/bot) | TS realtime capture + browser automation — streams audio to the transcription service, relays the returned segments as `transcript.v1`. | | ↳ transcription | [`core/meetings/services/transcription`](https://github.com/Vexa-ai/vexa/tree/main/core/meetings/services/transcription) | The STT service — faster-whisper / CTranslate2 behind an OpenAI-compatible `/v1/audio/transcriptions`. The **GPU workload, deployed separately** (not in `make all`); the bot calls it via `TRANSCRIPTION_SERVICE_URL`. | | ↳ meeting-api | [`core/meetings/services/meeting-api`](https://github.com/Vexa-ai/vexa/tree/main/core/meetings/services/meeting-api) | Python control-plane: the REST surface + lifecycle sink; its `collector` ingests the bot's segments; background sweeps run [auto-join](/core/meetings#auto-join--scheduled-means-the-bot-comes) (due scheduled meetings → the same spawn flow) and `calendar_sync` (ICS feeds → planned meetings, SSRF-pinned fetch). | | ↳ desktop | [`core/meetings/services/desktop`](https://github.com/Vexa-ai/vexa/tree/main/core/meetings/services/desktop) | Single-process desktop host (same bricks, one process). | | **core/gateway** | [`core/gateway`](https://github.com/Vexa-ai/vexa/tree/main/core/gateway/README.md) | The world-facing **edge** — resolves `x-api-key` (fail-closed), enforces per-route scopes, proxies the CORE REST surface verbatim, runs the `/ws` multiplex (per-meeting redis channels → one socket). | | ↳ gateway | [`core/gateway/services/gateway`](https://github.com/Vexa-ai/vexa/tree/main/core/gateway/services/gateway) | The shipped FastAPI edge (`create_app`, hexagonal). | | ↳ conformance | [`core/gateway/services/conformance`](https://github.com/Vexa-ai/vexa/tree/main/core/gateway/services/conformance) | The O-API-1 conformance suite, driven against fakes. | | **core/runtime** | [`core/runtime`](https://github.com/Vexa-ai/vexa/tree/main/core/runtime/README.md) | The **kernel** — spawns + supervises isolated workloads (`runtime.v1`) over a pluggable Backend (process / Docker / K8s), and runs the redis-backed `Scheduler` for `schedule.v1` jobs. Mechanism, not policy (P11). | | ↳ runtime\_kernel | [`core/runtime/src`](https://github.com/Vexa-ai/vexa/tree/main/core/runtime/src) | The kernel + scheduler library; backends (process · docker · k8s). | | **core/identity** | [`core/identity`](https://github.com/Vexa-ai/vexa/tree/main/core/identity/README.md) | The **authN/authZ + accounts** lane — authenticates opaque tokens to a `User`, decides ownership/scope (default-deny, P20), brokers scoped creds. Exists twice on purpose. | | ↳ admin-api | [`core/identity/services/admin-api`](https://github.com/Vexa-ai/vexa/tree/main/core/identity/services/admin-api) | The live DB-backed auth oracle (`/internal/validate`). | | ↳ identity\_core | [`core/identity/src`](https://github.com/Vexa-ai/vexa/tree/main/core/identity/src) | The pure, DB-free reference library honouring `identity.v1`. | | **clients/terminal** | [`clients/terminal`](https://github.com/Vexa-ai/vexa/tree/main/clients/terminal/README.md) | The browser-CLI **workbench** (Next.js) — a dockview over a registry of surfaces (chat · meeting · workspace · routines · sessions · tasks). Owns no business logic; thin `/api/*` proxies to agent-api. | | **clients/slim** | [`clients/slim`](https://github.com/Vexa-ai/vexa/tree/main/clients/slim/README.md) | **`vexa-slim`** — a minimal **Python** client/SDK that drives the control plane through the gateway (`api.v1`) only: peer `slim.agent.*` / `slim.meetings.*` sub-clients + a high-level cookbook. Holds no redis URL or domain internals, so it doubles as a `meetings ⊥ agent` SoC validator. | | **deploy** | [`deploy`](https://github.com/Vexa-ai/vexa/tree/main/deploy/README.md) | The **composition** layer (Compose) — `deploy/compose` brings up the whole v0.12 control plane as one ordered, health-gated stack (postgres · redis · minio + the Python services), GPU-free. `deploy/transcription` is the **separate GPU unit** for the STT service (own compose, GPU or CPU). Owns `execution-targets.v1`. | ## Contract registry The sealed seam between modules. **16 `*.v1` contracts are frozen** in [`contracts.seal.json`](https://github.com/Vexa-ai/vexa/tree/main/contracts.seal.json) — a sha256 per contract; CI fails if a sealed schema drifts. Grouped by owning module, with `owner → consumers`. The agent domain also defines six **unsealed** control-plane contracts still in flight — `event.v1`, `unit.v1`, `invoke.v1` (UNSEALED), `task.v1`, `routine.v1`, `tool.v1`, `proactive-card.v1`. Only the 16 below carry a seal hash today. ### core/agent | Contract | Owner → Consumers | What it is | | -------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `invoke.v1` | agent ← meetings transcript bridge → agent-api | The meeting → agent trigger; an `Invocation` agent-api turns into a `runtime.v1` worker. (seal: UNSEALED marker in README; hashed in seal) | | `workspace.v1` | agent → agent-api worker, terminal | The agent's git-repo workspace convention — durable memory; data, not platform code. | ### core/meetings | Contract | Owner → Consumers | What it is | | -------------------- | ---------------------------------------- | --------------------------------------------------------------------------- | | `transcript.v1` | bot (TS) → collector (Py), gateway, eval | Speaker-attributed segments — the product's core output; the TS↔Py seam. | | `lifecycle.v1` | bot → meeting-api callback | The bot's domain status (distinct from container lifecycle). | | `invocation.v1` | meeting-api → bot (`VEXA_BOT_CONFIG`) | The bot's constructor, validated at boot (fail-fast). | | `acts.v1` | control-plane → bot (redis pub/sub) | The bot command bus; unknown actions ignored (forward-compatible). | | `webhook.v1` | control-plane → subscriber URLs | Outbound delivery envelope + signed-header scheme. | | `captured-signal.v1` | bot capture bridge → eval replay | Raw capture signal teed before the pipeline — replays offline (O-TEL-2). | | `flagged-issue.v1` | user / system → eval | A flagged transcript/attribution bug → reproducible offline test (O-TEL-3). | ### core/gateway | Contract | Owner → Consumers | What it is | | ------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------- | | `api.v1` | gateway → all clients (eval · terminal · SDKs) | The public REST + WS + MCP surface, OpenAPI 3.1, frozen to vexa `main`. | | `ws.v1` | gateway → clients | The live `/ws` multiplex (transcripts · bot status · chat), pinned to `main`'s G5 gate. | | `logevent.v1` | all control-plane services → observability | The structured log envelope + distributed `trace_id` — the observability SSOT. | ### core/identity | Contract | Owner → Consumers | What it is | | ------------- | ---------------------------------- | -------------------------------------------------------------- | | `identity.v1` | admin-api → gateway, every service | Scoped token + access decision; the one auth wire shape (P20). | ### core/runtime | Contract | Owner → Consumers | What it is | | ------------- | --------------------------------------- | --------------------------------------------------------------------------------- | | `runtime.v1` | runtime kernel ← meeting-api, agent-api | The workload lifecycle contract (mechanism, not policy — P11). | | `schedule.v1` | runtime scheduler ← agent-api, routines | HTTP-call job spec: one-shot (`execute_at`) or recurring (`cron`) + retry policy. | ### deploy | Contract | Owner → Consumers | What it is | | ---------------------- | ----------------- | ------------------------------------------------------------------------------------------------- | | `execution-targets.v1` | deploy → planner | "Where can work run, and what does it need?" — resolved in planning, before execution (ADR-0020). | ## Eval levels — the validation pyramid Every change is proven at the lowest level that can catch it. Governed by [the architecture constitution](/governance/architecture); the live gate lives at [`core/meetings/eval`](https://github.com/Vexa-ai/vexa/tree/main/core/meetings/eval/README.md). | Level | Name | What it proves | Where | | ------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | **L1** | Contract | A schema is honoured — every `*.v1` validates its golden fixtures (`validate.mjs`), and the seal catches drift. | each `*/contracts/.v1/` | | **L2** | Unit | A brick does its job in isolation, collaborators faked (ports/adapters). | per-service tests | | **L3** | Integration | Modules compose across a real seam (e.g. gateway → meeting-api, agent-api → runtime). | per-service / cross-module tests | | **L4** | Live + eval | A **real meeting, scored** — bots join, speak a known timeline, and the captured `transcript.v1` is scored (completeness · leakage · attribution) vs ground truth. 0.12 is "done" when live scores ≥ the 0.11 baseline. | [`core/meetings/eval`](https://github.com/Vexa-ai/vexa/tree/main/core/meetings/eval/README.md) | ## Gates, harness & fixtures The eval levels are *enforced*, not aspirational: **an artifact "exists" only when it is gate-green** (P9). Three terms separate the concern: * a **gate** is a runnable check that turns CI red when a rule is crossed; * the **harness** is the machinery that stands a real (or faked) system up so a gate can run; * **fixtures** are the inputs and lifecycle plumbing the harness runs against. ### The gate In this tree the runnable bar is `pnpm typecheck build test`, the compose stack-readiness proof — `make -C deploy/compose stack-test` (it stands the whole stack up, proves it, and tears it down) — and the gate suite (`scripts/gates.mjs`, invoked as `pnpm gates` / `pnpm gate:*`): readme · isolation · exports · graph · schema · contract-version · python · licenses in CI, plus the two architecture gates — `pnpm gate:dataflow` (model↔disk completeness, single-writer / render-only ownership, a reality diff against the code, and view staleness) and `pnpm gate:calm` ([Architecture as Code](/architecture/architecture-as-code)). Contracts **and** the architecture chart are sealed (`pnpm seal:contracts` / `pnpm seal:arch`) — drift from a seal is deliberate-only. ### The harness — three tiers | Tier | What it stands up | Where | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | **Stack proof** | the **real** v0.12 compose stack — `up --build`, wait every service healthy, prove health · auth · transcript dataflow · recording · control-plane invariants, then `down -v` in a guaranteed `finally` | [`deploy/compose/tests`](https://github.com/Vexa-ai/vexa/tree/main/deploy/compose/tests) | | **Replay** | drives the agent / meeting path with **no real meeting** — XADDs `transcript.v1` segments onto the same redis stream the bot produces, so a turn replays offline and deterministically | [`core/agent/eval/replay`](https://github.com/Vexa-ai/vexa/tree/main/core/agent/eval/replay) | | **Unit** | one brick in isolation, collaborators faked through its ports/adapters | each service's `tests/` | Two rules run through every tier: **poll with bounded timeouts, never sleep-and-hope**, and **green-or-skip** — when Docker is absent the stack proof *skips* rather than fails, so the gate stays honest on a machine that cannot run it. The slow, real-bot lanes (a \~7GB browser image) are opt-in behind `COMPOSE_BOT=1`; the always-on subset never spawns one. ### Fixtures * **Golden vectors** — committed example envelopes that *are the spec* (P8). Tests load them **by path from the published contract** (e.g. the `transcript.v1` and `lifecycle.v1` goldens), never by importing the producing domain's code — which preserves the same `meetings ⊥ agent` boundary the production code keeps. * **Lifecycle fixtures** — the session-scoped `stack` fixture owns the whole compose `up → healthy → down -v` cycle; the unit tier uses ephemeral testcontainers (Postgres / Redis), in-process fakes (`fakeredis`, a fake authorizer, a fake webhook receiver), and a `FakeClock` so time-based logic is deterministic. * **Replay fixtures** — captured transcript material (a scripted call) plus a VTT→fixture converter, so an agent turn can be re-driven offline without a live meeting. # Streaming Source: https://docs.vexa.ai/architecture/streaming Redis Streams from the agent container, relayed to the terminal over websocket. The agent runs in a different container than the thing talking to the browser, so output flows through a shared broker — **redis**: ``` Agent (container) ──XADD unit::out──▶ redis Stream ──XREAD──▶ agent-api/gateway ──ws──▶ terminal ``` Because it is a **Stream** (not pub/sub) it is durable + replayable — live for [chat](/how-to/chat-workspace) and [cards](/how-to/live-copilot), and after-the-fact for background dispatches. This reuses the exact mechanism Vexa already runs for [meeting transcripts](/core/meetings). # Authenticated bots Source: https://docs.vexa.ai/authenticated-bots Run every bot signed in to a real account — provision a session once, spawn through the stock API, keep the session alive through use. By default a Vexa bot joins **anonymously**: it knocks on the lobby under a guest name and a host admits it. Authenticated mode makes every bot join **signed in to a real account** instead — the participant list shows the account identity, and org-restricted meetings that refuse anonymous participants open to it. The flow is three parts: provision a session once, configure the deployment to spawn with it, and let normal use keep it alive. **Validated on Google Meet.** The mechanism is platform-agnostic (the session store never inspects a platform; the provisioner also knows Teams and Zoom sign-in surfaces), but Teams and Zoom authenticated joins ride the same setup and validate on their own waves. Zoom is expected to move to a server-side (RTMS) lane that would not need browser auth at all. ## 1. Provision the session — `make login` Provisioning opens a browser, a human signs in **once**, and the session's auth-essential subset (\~200 KB: cookies, local/session storage, login data, preferences — never cache or history) is uploaded to your deployment's userdata storage. ```bash theme={null} export BOT_USERDATA_S3_PATH=userdata/bot-identity-1 # a dedicated prefix per bot identity export BOT_S3_ENDPOINT=http://localhost:9000 # your deployment's MinIO/S3 export BOT_S3_BUCKET=vexa export BOT_S3_ACCESS_KEY=... # SCOPED userdata credentials — see security export BOT_S3_SECRET_KEY=... make login # AUTH_PLATFORM=google (default) | teams | zoom ``` The command opens the platform's sign-in page (headed on a desktop; inside a container, attach via the provisioning browser's VNC on `:6080`), waits for you to finish signing in, **confirms** the session (it only succeeds after a logged-in validation passes), then uploads. Aborting without signing in exits non-zero with the login verdict and leaves the storage prefix untouched. Prerequisite on the machine running it: the `aws` CLI (the bot image ships it; a desktop needs it installed). Use a **dedicated account** for the bot (a Workspace user like `notetaker@your-org.com`), never a personal one. ## 2. Configure the deployment to spawn signed-in Authenticated mode is a **deployment property**: set it once on `meeting-api` and every stock `POST /bots` spawns signed-in — no per-request field, no hand-crafted bot config. ```bash theme={null} # meeting-api environment (compose: deploy/compose .env; helm: meeting-api env values) BOT_AUTHENTICATED=true BOT_USERDATA_S3_PATH=userdata/bot-identity-1 BOT_S3_ENDPOINT=http://minio:9000 # as reachable FROM the bot containers BOT_S3_BUCKET=vexa BOT_S3_ACCESS_KEY=... BOT_S3_SECRET_KEY=... ``` With the knob set, each bot restores the stored session into its own ephemeral browser profile before launch and joins as the signed-in account — no guest-name entry, no lobby knock where the account has access. If the knob is set but the storage config is incomplete, `POST /bots` refuses with a 503 naming what's missing — a half-configured deployment never silently spawns anonymous bots. If the session store is unreachable at spawn time, the bot fails loud with a typed `session-restore` error naming the step and endpoint — it never joins signed-out on a failed restore. **One session, one bot at a time.** A second concurrent spawn against the same stored session is refused with a 409 naming the meeting that holds it. One live cookie jar used from N containers and IPs at once is a textbook account-risk signal to Google, and concurrent runs would race the write-back below. Need N concurrent authenticated bots? Provision N accounts under N `BOT_USERDATA_S3_PATH` prefixes (today that means N deployments of the spawn knob, one identity each). ## 3. Session lifetime — the session stays alive because it is used Google rotates session cookies continuously during use and re-challenges state that looks stale. A stored session that is only ever *read* decays with every restore. Vexa therefore closes the round trip: **restore freshest → use → write back**. On every clean bot teardown, the rotated session is uploaded back to the userdata prefix, so the next spawn restores the freshest state instead of a decaying snapshot. The boundary, stated honestly: write-back runs on **clean teardown only**. A hard-killed bot (SIGKILL, node crash) never reaches it — the durable copy simply stays at the last successful write-back, and the next clean meeting refreshes it. A write-back failure is an attributed warning in the bot log, never a hang on exit. The levers that make session lifetime a configured property instead of luck: * **Workspace session-duration policy** — the biggest lever, pure configuration. In Google Workspace Admin, set the bot account's OU session duration to the maximum (or "never expire"). * **Stable egress and browser** — keep each identity on a stable egress IP and a stable bot image version. An identity that hops IPs and browser fingerprints looks cloned. * **Keep-warm** — an idle identity decays on Google's clock. If the account doesn't meet regularly, run a periodic authenticated meeting (or simply schedule the bot into a recurring internal meeting) so rotation keeps happening. There is no packaged keep-warm job today; a cron'd `POST /bots` into a standing meeting is the recipe. * **N accounts for N concurrent bots** — follows from the one-session-one-bot rule above. ## When the session dies anyway — the recovery loop Eventually a session can decay past recovery (a Workspace policy change, a security challenge, a long idle gap). A bot restoring a signed-out session is reported as a failed meeting (the typed `auth_session_missing` verdict is landing with the signed-out detection change — until it merges, the failure surfaces as the join failing to proceed as the signed-in account; see [Troubleshooting](/troubleshooting#authenticated-bot-fails-or-joins-signed-out)). The fix is always the same one command: ```bash theme={null} make login # re-provision; the next spawn picks the fresh session up automatically ``` If sessions die **soon after provisioning** rather than after weeks, don't just re-provision in a loop — check the lifetime levers above (is write-back running? one bot per session? stable egress?). ## Storage layout and security ``` s3:////browser-data/ Local State Default/Cookies ← the live credential material Default/Login Data ... Default/Local Storage/ ... Default/Session Storage/ ... ``` * **The stored session IS a credential.** Anyone who can read the prefix can be the account. Create **dedicated S3 credentials scoped to the userdata prefix** for `BOT_S3_ACCESS_KEY` / `BOT_S3_SECRET_KEY` — never reuse the deployment's admin S3 credentials. * **The boundary, stated honestly:** the S3 credentials ride the bot's invocation into the bot container's environment (that is how the bot restores and writes back). Anyone with `docker inspect` on the bot host, or read access to the runtime API, can see them — which is exactly why they must be scoped to the userdata prefix and nothing else. Secret *values* are never printed in bot logs. * The session subset is not otherwise encrypted at rest beyond what your S3/MinIO deployment provides. ## Availability by deployment surface * **Compose** — supported as above; MinIO is in the stack. * **Kubernetes/Helm** — set the same `BOT_AUTHENTICATED` / `BOT_*` env on the meeting-api deployment (values → env); point the S3 vars at storage the bot pods can reach. * **Lite** — the single-container build does not package a userdata store or the provisioning flow; authenticated mode is not available on Lite today. # Authentication Source: https://docs.vexa.ai/authentication Mint an API key, send it on every request, scope it, and rotate it. Every public request carries an API key in the **`X-API-Key`** header. The gateway resolves the key to a user and injects identity downstream — you never pass a user id or subject yourself; the server derives it from the key. ```bash theme={null} -H "X-API-Key: " ``` ## Base URL | Deployment | API base | | ---------------------------------- | -------------------------------------------------- | | Self-hosted (`make all` / compose) | `http://localhost:18056` (`API_GATEWAY_HOST_PORT`) | | Hosted | `https://api.cloud.vexa.ai` | ## Getting a key `make all` mints a key as part of bring-up and **prints it** (along with the service URLs) when the stack is ready — copy it from the `make all` output and use it as your `X-API-Key`. ### Minting more keys To mint additional keys, use the `provision-token` make target with your `ADMIN_TOKEN` (it's set in `.env`, default `dev-admin-token` — **change it before exposing anything**): ```bash theme={null} make -s provision-token ADMIN_TOKEN=dev-admin-token # → prints a freshly minted vxa_... API key ``` Under the hood the **admin API** mints keys. It listens on `http://localhost:18057` by default (`ADMIN_API_PORT`) and is protected by `ADMIN_TOKEN` via the `X-Admin-API-Key` header — you can call it directly for finer control over users and scopes: ```bash theme={null} export ADMIN=http://localhost:18057 export ADMIN_TOKEN=dev-admin-token # your ADMIN_TOKEN from .env # 1. create (or find) a user curl -X POST "$ADMIN/admin/users" \ -H "X-Admin-API-Key: $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"email":"jane@acme.com","name":"Jane","max_concurrent_bots":2}' # → {"id":1,"email":"jane@acme.com","name":"Jane","max_concurrent_bots":2} # 2. mint a key for that user id (JSON body preferred) curl -X POST "$ADMIN/admin/users/1/tokens" \ -H "X-Admin-API-Key: $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"scopes":["bot","tx"]}' # → {"id":7,"token":"vxa_bot_...","user_id":1,"scopes":["bot","tx"]} # query form still works: ?scopes=bot,tx or ?scope=bot ``` The returned `token` is your `X-API-Key`. **It is shown once** — store it. ## Scopes A key carries one or more scopes. Pass them in the JSON body as `{"scopes":["bot","tx"]}`, or as query `scope=` / `scopes=,`. The body wins when both are present. An unknown body field is refused with `422` — never silently dropped. | Scope | Grants | | --------- | ---------------------------------------------- | | `bot` | send and manage meeting bots, read transcripts | | `tx` | transcription / transcript access | | `browser` | browser-tool capabilities | Keys are prefixed by their primary scope — `vxa_bot_…`, `vxa_tx_…`, `vxa_browser_…`. A key without a recognized scope is rejected. ## Rotating and revoking Mint a new key, switch your clients over, then delete the old one by its token id: ```bash theme={null} curl -X DELETE "$ADMIN/admin/tokens/7" -H "X-Admin-API-Key: $ADMIN_TOKEN" # → 204 ``` Set an expiry at mint time with `expires_in=`; expired keys are rejected automatically. ### Login-minted tokens (`terminal-login`) When a user signs in through the terminal (OAuth or email login), the terminal mints an API token named **`terminal-login`** to populate the auth cookie. These are **bounded per user**: after each sign-in the terminal keeps only the newest `VEXA_TERMINAL_LOGIN_TOKEN_CAP` (default `3`) and revokes the older ones. So when you audit a user's tokens — ```bash theme={null} curl "$ADMIN/admin/users/1/tokens" -H "X-Admin-API-Key: $ADMIN_TOKEN" ``` — the `terminal-login`-named entries are login sessions (capped, not unbounded), while any differently-named entries are self-serve keys the user minted and are never pruned by login. ## What can go wrong | Status | `detail` | Meaning | | ------ | ---------------------------- | ------------------------------------ | | `401` | `Missing API key` | no `X-API-Key` header | | `401` | `Invalid API key` | unknown or revoked key | | `403` | `Token scope not authorized` | key lacks the scope this route needs | See the full [error reference](/api/errors). # Changelog & migration Source: https://docs.vexa.ai/changelog Release notes and what changes between major versions. The authoritative, per-release changelog is on **GitHub Releases**: [github.com/Vexa-ai/vexa/releases](https://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](/core/meetings) and [Plan and share a meeting](/how-to/plan-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](/api/meetings#auto-join). * **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](/how-to/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](/authentication) and [Identity & trust](/architecture/identity-and-trust). * **Agent control plane.** Dispatch, chat, routines, events, and workspace reads are unified under the [Agent API](/api/agent) 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](/deployment). * **Open Knowledge Format workspaces.** The workspace knowledge graph (`kg/`) is an [OKF v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf) 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](/how-to/workspace-files#the-knowledge-graph-is-an-okf-bundle). ### 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](/configuration). ### 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 | Capability (0.10.x has it) | Status in 0.12 | Honest note | | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Mid-call bot config — `PUT /bots/{platform}/{id}/config` (change language/task live) | contract-sealed, not wired | The gateway mounts and forwards it, but the live bot-control plane isn't wired in the open-core meeting-api — authenticated calls return the downstream `404`. Flagged in the [Meetings API](/api/meetings) reference. | | Voice agent — `POST /bots/{platform}/{id}/speak` (TTS into the call) | contract-sealed, not wired | Proven at contract level (`acts.v1` + the mock-bot speak-ack gate); the real path returns `404` in the shipped stack. Contract-ready, not user-ready. | | Interactive bots beyond speak — in-meeting **chat**, **screen share**, **avatar** | contract-sealed, not wired | The `chat`/`screen`/`avatar` endpoints are in the sealed `api.v1`, but the 0.12 gateway does not mount them (edge probe: `404`). **Planned back in the 0.12.x line** — same honest framing as config/speak. | | Transcript **share links** — `POST /transcripts/…/share` + `GET /public/transcripts/{id}.txt` | contract-sealed, not mounted | Verified: both routes are in the sealed `api.v1`; neither is mounted at the 0.12 gateway (edge probe: `404`). The feature was dashboard-coupled in 0.10.x. | | `GET …/participants` — meeting participants endpoint (main PR #453) | post-seal | Merged into `main` after the 1.5.0 seal; **planned back in the 0.12.x line** with the next `api.v1` contract revision. | | **`discord`** as a non-bot ingest platform (main PR #452) | post-seal | The 0.12 platform enum is `google_meet` / `zoom` / `teams` — a contract test asserts `DELETE /bots/discord/…` → `422`. **Planned back in the 0.12.x line** with the next `api.v1` contract revision. | | Zoom **bring-your-own OBF/ZAK tokens** + native SDK path (main PR #320) | not carried — **planned back in the 0.12.x Zoom track** | Verified: the carved bot joins Zoom via the **web client only** (`buildZoomWebClientUrl` → `app.zoom.us/wc/…/join`); no ZAK/OBF token handling and no native-SDK branch exist yet. Authorized-join (tokens and/or SDK) returns with the 0.12.x Zoom work. | | **Segment-latency env knobs** (main PR #447: `MIN_AUDIO_DURATION_SEC`, `SUBMIT_INTERVAL_SEC`, `IDLE_TIMEOUT_SEC`) | partial | The three knobs exist as **programmatic** config (`SpeakerStreamManagerConfig`: `minAudioDuration` / `submitInterval` / `idleTimeoutSec`), but nothing reads them from the environment — a deployment cannot opt into low-latency mode without code. | | `max_concurrent_bots=0` treated as **depleted, not unlimited** (main PR #456) | **carried (fixed 2026-07-04)** | The gap was found while fact-checking this page and fixed the same day (0.12 PR #44, mirroring main's #456): a cap of `0` now rejects the spawn as depleted, with a regression test. | | Swagger/OpenAPI **auth-scheme fixes** (main PRs #319, #336) | not carried | The 0.12 admin-api declares both `APIKeyHeader`s **without `scheme_name`** — the exact collapsed-scheme Swagger bug #319 fixed; the 0.12 gateway declares no OpenAPI security schemes at all. Cosmetic-but-real DX gap in the *served* docs (the sealed contract is unaffected). | | Dashboard-authz + schedule-callback hardening (main PR #406) | superseded by architecture | 0.12 scheduling compiles `schedule.v1` jobs that the runtime scheduler fires internally — there is no exposed schedule-callback endpoint to harden. The vendored dashboard rides as an optional compose overlay behind the gateway. | | Hardened Python Docker builds (main PR #435) | superseded | 0.12 rebuilt all of its images. | #### 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 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__…` 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 contracts** — `api.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 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`), 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. ## 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. # Share a transcript into ChatGPT Source: https://docs.vexa.ai/chatgpt-transcript-share-links Public transcript share links were a 0.10.x capability — contract-sealed but not served in 0.12. What exists today, and what replaced them. **Honest status:** the public share-link flow this page once documented is **not available in 0.12**. The endpoint is contract-sealed but mounted nowhere. Tracking: [#541](https://github.com/Vexa-ai/vexa/issues/541) (sealed-vs-served reconcile) · demand record: [#1078](https://github.com/Vexa-ai/vexa/issues/1078). ## What this page used to describe (0.10.x) Vexa could mint a **public, unauthenticated, short-lived URL** for a transcript: 1. An authenticated call created a share id; the transcript was rendered as plain text and stored under a TTL (default 15 minutes). 2. The response returned a URL of the form `/public/transcripts/.txt`. 3. You handed that URL to ChatGPT — *"Read from this URL so I can ask questions about it"* — and ChatGPT fetched it directly, avoiding paste-length limits. ## What is true in 0.12 today * `GET /public/transcripts/{share_id}.txt` is **sealed in the API contract but not served** by any component. A URL of that shape will not resolve on a 0.12 deployment. * A share endpoint exists under the same name — `POST /meetings/{platform}/{native_meeting_id}/share` — but it does something different: it mints a token that **another authenticated Vexa user** redeems (`POST /transcripts/share/accept`). It is user-to-user sharing inside a Vexa deployment, not a public URL, and an external assistant cannot consume it. ## What you can do today To get a transcript into ChatGPT or any assistant right now: * **Fetch and paste/upload:** retrieve the transcript with an API key carrying the `tx` scope — `GET /transcripts/{platform}/{native_meeting_id}` — and paste or upload the text into your assistant. * **Share to a teammate inside Vexa:** use the 0.12 share/accept flow above (both sides authenticated). ## If you need the public link back Demand for this page is measured and sustained, a substantial share of it from AI agents. Whether to re-mount the public leg is a product and security decision — unauthenticated transcript URLs need deliberate TTL and identifier design — and it is being weighed on the release scope via [#541](https://github.com/Vexa-ai/vexa/issues/541). If this capability matters to you, say so there: demand comments on that issue are exactly the evidence the decision consumes. # How Vexa compares Source: https://docs.vexa.ai/comparison An honest map of the meeting-capture options — Vexa, Attendee, hosted APIs, local notetakers, DIY — and when each is the right choice. If you're evaluating self-hosted meeting intelligence, you'll find these options. Here is how they actually differ — including where an alternative is the better fit. ## The field **Meeting-bot APIs** put a bot *in* the call and give you a server-side API — the only shape that works org-wide (IT deploys it once; every meeting can be captured, governed, audited): * **Vexa** (this project) — Apache-2.0, self-hosted, bot + real-time STT + speaker attribution + the agent/knowledge layer, air-gappable end to end. * **[Attendee](https://github.com/attendee-labs/attendee)** — the other credible open-source meeting-bot API (Django/Postgres/Redis). A solid, conventional capture API you build on. * **Hosted bot APIs** (e.g. Recall.ai) — mature and convenient, but your meetings transit their cloud; nothing to self-host. **Local notetakers** (Meetily, Hyprnote, and similar) record the *user's own device audio* on a laptop. Genuinely private for an individual — but per-seat installs with no server-side fleet, no API for downstream systems, and speaker attribution limited to what mono device audio allows. A personal tool, not an infrastructure layer. **DIY** (Whisper + your own headless-browser bot) — full control, and an enormous, permanently maintained effort: four platforms' join flows, admission handling, audio capture, streaming STT, attribution, scaling. ## Vexa vs. the alternatives | Capability | **Vexa** | Attendee | Hosted APIs | Local notetakers | DIY | | -------------------------------------------------------------------------------------------------------------------------------- | :--------: | :-------------------: | :---------: | :----------------: | :---------: | | Self-hosted / data stays in your perimeter | ✅ | ✅ | ❌ | ✅ | ✅ | | Fully **air-gapped** (bundled self-hosted GPU STT unit) | ✅ | 🟡 BYO STT wiring | ❌ | 🟡 local models | 🟡 build it | | Bot joins **Meet + Teams + Zoom + Jitsi** | ✅ | ✅ | ✅ | ❌ device audio | 🟡 build ×3 | | Real-time transcript API, speaker-attributed | ✅ | ✅ | ✅ | 🟡 app-local | 🟡 build it | | 99+ languages: auto-detect or forced, per-window segment labels | ✅ | 🟡 provider-dependent | ✅ | 🟡 model-dependent | 🟡 build it | | **Bring your own models** (STT + LLM endpoints) | ✅ | 🟡 STT providers | ❌ | ✅ | ✅ | | Kubernetes / OpenShift scale-out (Helm, a Pod per workload) | ✅ | 🟡 compose-first | n/a | ❌ | 🟡 build it | | Knowledge layer: transcripts → git Markdown workspace + sandboxed agents | ✅ | ❌ | ❌ | 🟡 app notes | ❌ | | Architecture built for AI-assisted maintenance ([one-concern modules, sealed contracts, golden fixtures](/architecture/modules)) | ✅ | 🟡 conventional | n/a | ❌ | — | | License | Apache-2.0 | Apache-2.0 | proprietary | varies | — | *Bring your own models* covers both halves of the STT contract: the endpoint (`TRANSCRIPTION_SERVICE_URL`) **and** the model id (`TRANSCRIPTION_MODEL`) — so backends that validate the id (Groq, vLLM, OpenAI-compatible gateways) work end-to-end, not just backends that ignore it. See [Configuration](/configuration#transcription-stt). ## When to choose what — honestly * **Choose Attendee** if you want *only* a capture API with the most conventional stack possible, you're happy wiring your own transcription provider, and the knowledge/agent layer is something you'd rather build yourself. It's good software and the comparison keeps us honest. * **Choose a hosted API** if your compliance posture allows a vendor cloud and you want zero operations. That's a real trade — it's just not the one this project exists for. * **Choose a local notetaker** for personal note-taking on your own laptop with no IT involvement. * **Choose Vexa** when the requirement is *organizational and sovereign*: every meeting platform, real-time attributed transcripts through an API you host, **your** STT and LLM endpoints, scaling from one Linux box to an OpenShift cluster inside your walls — and, when you want it, the [agent layer](/core/agents) that compounds those transcripts into a knowledge base your team owns. The deeper positioning discussion — why capture sits upstream of every "chat with your docs" tool — is in [Concepts](/concepts). For the regulated-enterprise posture (air-gap, procurement artifacts, OSPS Baseline), see [Security & compliance](/security-compliance). # Concepts Source: https://docs.vexa.ai/concepts The primitives Vexa is built from — workspace, meeting, agent, container, identity, scheduler — and the two beneath them: module and contract. Everything in Vexa composes from a handful of primitives. A unit of work — a **dispatch** — is one [agent](#agent), in one [container](#container), over a person's [workspace](#workspace), authorized by an [identity](#identity) token, fired by the [scheduler](#scheduler). The data those agents grow on comes from [meetings](#meeting) and docs. ## Workspace A git **folder**, stored in an (encrypted) bucket, with an `id` and an access **mode** (`ro`/`rw`). A dispatch mounts a list — typically `system` (ro) + `company` (ro) + `user` (rw). It holds knowledge, plans, and the agent's session — all just files, with **no dictated structure**. git is the durable state and the undo. The knowledge itself lives in the `kg/` subtree as an [Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf) (OKF) v0.1 bundle — one markdown file per entity at `kg/entities//.md`, YAML frontmatter on top, `index.md` listings for navigation. Knowledge-as-code: portable, diffable, and readable by any OKF consumer, not just Vexa. See [Browse the workspace](/how-to/workspace-files#the-knowledge-graph-is-an-okf-bundle). ## Meeting **One record from plan to transcript.** A meeting is born in a user-owned *intent* status — planned ahead by hand or imported from a calendar (`idle`/`scheduled`) — is **claimed in place** by the bot lifecycle when the bot joins (`requested → … → completed`), and ends with its speaker-attributed transcript on the same record. Because it is one record, everything attached to the plan survives the meeting: the title, and the **workspace binding** — bind a meeting to a shared workspace and every member sees it (the plan, the live feed, the transcript). `scheduled` means the bot **joins on its own** at start time (the auto-join sweep; per-meeting opt-out). See [Meetings](/core/meetings). ## Agent A generic CLI coding agent selected by a **runner** (Claude Code is one; others and BYO-inference plug in the same way). It works the mounted workspace with a scoped toolbelt and commits. It is **untrusted** — outside the trust boundary — so it carries a signed token but enforces nothing. Its outbound capabilities (email, calendar, web) are **integrations**: cred-gated tools reached over MCP, never built into the backend. See [Agents](/core/agents). ## Container The isolated, ephemeral unit an agent runs in, spawned by the [runtime](/core/runtime). Sub-second to start, reaped on idle, no egress except through brokered tools — thousands run in parallel with no lateral movement. Its live output streams to the client over a per-dispatch channel. ## Identity The chain of custody. The launcher proves itself (a user session, or a signed delegation grant for a schedule/integration); the identity service mints a **short-lived signed dispatch token** (*subject* = on whose behalf · *launcher* = who triggered · scope); the runtime **attests the workload** (SPIFFE/SPIRE); and **every boundary verifies** the token — never the agent. Tool calls exchange it for an audience-scoped credential (Keycloak / RFC 8693) at an MCP gateway, so the agent never holds a raw key. ## Scheduler Redis. The one mechanism that **dispatches agents**, on a trigger — a schedule entry (cron), an integration event (e.g. new email), or *now* (chat). It holds the user-manageable schedule; a meeting ending is just another event that dispatches an agent. *** Two more primitives live one level down, in the **code itself** — they are how everything above is built: ## Module The unit of construction. **A module owns exactly one concern and is the single source of truth for that concern** — that's the rule, everywhere. Capture bricks compose into the meeting bot, modules compose into services (each service a **modular monolith**), services compose into deployments. Because its concern is exact and singular, a module is tested — and debugged — **fully in isolation** against fixtures. Every module ships three things: the running code, the adversarial code (tests that harness it), and the data (goldens that validate it). Fixtures are collected from real meetings — real audio, speaker activations, environment metadata — so the harness replays reality. The full tree: [Modules & Seams](/architecture/modules). ## Contract The only way modules join. A contract is **defined once, rarely changed, and sealed** — a sha256 per contract in `contracts.seal.json`, enforced in CI, so a seal can break loudly but a contract can never drift silently. Chains of modules joined by contracts test in integration with the same fixtures — a bug is reproduced at exactly the scale it lives at. All ports are deterministic for deterministic input, which keeps the verify loop simple enough for an AI agent to own. The registry (owner → consumers, all 16 seals): [Modules & Seams](/architecture/modules#contract-registry). # Configuration Source: https://docs.vexa.ai/configuration Every environment variable the stack reads — what it does and its default. Vexa is configured by environment variables, set in `deploy/compose/.env` for Docker Compose (seeded from `.env.example` on first `make all`). Defaults below are what the stack falls back to when the variable is unset — fine for local evaluation, **not** for anything exposed. Change every secret (`ADMIN_TOKEN`, `INTERNAL_API_SECRET`, DB and MinIO credentials, `VEXA_DISPATCH_SIGNING_KEY`, `NEXTAUTH_SECRET`) before putting the stack on a network. The defaults are public. ## Transcription (STT) | Variable | Default | Purpose | | --------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `TRANSCRIPTION_SERVICE_URL` | — | STT service URL, e.g. `http://:8083`. Give it the **base URL** (canonical) or the full `…/v1/audio/transcriptions` endpoint — every consumer appends the path only when it is missing, so both shapes behave identically. Point it at the bundled service you deploy separately (`deploy/transcription`) or any OpenAI-compatible endpoint. Unset + default `transcribe_enabled=true` → `POST /bots` answers **503** naming the missing keys (not a silent empty transcript). | | `TRANSCRIPTION_SERVICE_TOKEN` | — | STT auth token — must match the `API_TOKEN` of your `deploy/transcription` unit, or a token from `vexa.ai/account`. | | `TRANSCRIPTION_MODEL` | `whisper-1` | STT model id sent on every transcription request (the OpenAI-compatible `model` field). **Set it when the backend validates model ids** — Groq (`whisper-large-v3-turbo`), OpenAI (`whisper-1` / `gpt-4o-transcribe`), vLLM/LiteLLM (the served name) — or every request fails `model_not_found`. Ignored by the bundled `deploy/transcription` unit, whose model is its own `MODEL_SIZE`. | | `TRANSCRIBE_ENABLED` | `true` | Deployment-level default for a spawn's `transcribe_enabled` when `POST /bots` does not say. Resolution: **explicit request body wins** → else this env → else `true`. Set `false` for a deliberately no-STT deployment: bots spawn **capture-only** instead of answering 503. Leaving it **empty means `true`** — only an explicit `false`/`0`/`no`/`off` opts out. | | `RECORDING_ENABLED` | `true` | Deployment-level default for a spawn's recording when `POST /bots` does not say. Same empty-means-default rule as above. | | `BOT_ALONE_SILENCE_WINDOW_MS` | `600000` | Active-phase remote-audio silence window before the bot leaves with `completed(left_alone)`. Forwarded to spawned bots. A request's `automatic_leave.max_time_left_alone` overrides it. | | `BOT_SPEAKER_MIN_AUDIO_SEC` | `2` | Google Meet minimum audio window before submitting to STT. Lower values reduce first-transcript latency but increase request frequency. | | `BOT_SPEAKER_SUBMIT_INTERVAL_SEC` | `2` | Google Meet speaker-stream submission interval. | | `BOT_SPEAKER_CONFIRM_THRESHOLD` | `2` | Consecutive matching STT results required to confirm a segment. `1` lowers latency but reduces LocalAgreement protection against corrections. | | `BOT_SPEAKER_MAX_BUFFER_SEC` | `30` | Maximum buffered Google Meet audio before a forced submission. | | `BOT_SPEAKER_IDLE_TIMEOUT_SEC` | `15` | Flush/reset timeout after a speaker stops producing audio. | The STT service is the **GPU workload, deployed separately** from this stack — see [Deployment → Transcription](/deployment#transcription-the-separate-gpu-unit). The main stack stays GPU-free and reaches it over the network via the two variables above. ### When the backend is wrong, you learn at configure time A URL or token that does not work is refused where you set it, not by an empty transcript hours later. The same live check runs at three surfaces, and all three name the same cause: | Surface | What you see | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Setup wizard — **Save & test** | Red with the endpoint's own status: `Token REJECTED by … (HTTP 401)` or `No transcriptions endpoint at … (HTTP 404)`. A green here means a bot will transcribe — the wizard verifies with the exact request a bot's first audio chunk makes. | | Boot log + `GET /health` | The `stt` capability row reads `misconfigured` with a `probe.reason`, instead of `configured`. A failing probe warns loudly but never blocks boot. | | `POST /bots` | `503` carrying that same reason, **before** any meeting row is written — never a bot that joins and silently transcribes nothing. | The verdict is cached for 60s, so after fixing the value either wait a minute or call `GET /health?force=1` to re-probe immediately. A `404` from the transcriptions path is treated as a **wrong URL**, not as proof of life: a real OpenAI-compatible endpoint answers `400` or `401` to an empty request body and never `404`. Some gateways also answer `404` for a rejected credential — the error text names both possibilities. **Never put a comment on the same line as a value in `.env`.** Write it on its own line above the key. Compose tolerates a trailing `# comment`, but `docker run --env-file` (which Vexa Lite uses) does **not** strip it — the comment becomes part of the value, so `TERMINAL_PORT=13000 # the UI` sets the port to the literal string `13000 # the UI`. ## Optional service authority Stock self-hosted Vexa has no external service authority and continues to admit bots exactly as before. Operators who need an independently owned admission/active-service policy can opt into the sealed `service-authority.v1` boundary: | Variable | Default | Purpose | | ------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `VEXA_SERVICE_AUTHORITY_CONFIG` | — | Compact JSON containing a credential-free `url`, `contract_version: "service-authority.v1"`, `timeout_ms`, `response_max_age_seconds`, `failure_policy: "closed"`, and `mode: "enforce"` or `"observe"`. HTTPS is required except for loopback/in-cluster HTTP. | | `VEXA_SERVICE_AUTHORITY_SECRET` | — | HMAC-SHA256 secret for the exact timestamped request bytes. Required only when the config is set; keep it in the deployment secret store. | | `SERVICE_AUTHORITY_SWEEP_INTERVAL_S` | `15` | Poll cadence for discovering due active-service boundaries. Decisions remain anchored to admitted time plus whole minutes, so changing the poll cadence does not change the billable boundary. | The request contains service identity, authoritative user ID, service mode, frozen transcription provider, concurrency, and lifecycle timestamps. It deliberately contains no email, customer or payment-provider identifier, price, balance, transcription endpoint URL, or credential. A configured authority that is unavailable, malformed, stale, or bound to another request fails closed before a new bot is spawned. During active service, its stop intent is committed before runtime teardown and resumed after restart. `mode: "observe"` records decisions without refusing or stopping service. It is useful for rollout, but it is not evidence that a hard service control is enforced. A session admitted in one mode is never reinterpreted under another mode after a deployment change. ## Operator terminal callback An operator can send terminal service facts to one deployment-owned system endpoint. This is separate from a user's webhook configuration: the URL is frozen at boot, cannot be supplied by an API caller or meeting payload, and receives only `meeting.completed` and `bot.failed`. | Variable | Default | Purpose | | ---------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `VEXA_SYSTEM_WEBHOOK_URL` | — | Absolute operator-owned endpoint for terminal `webhook.v1` envelopes. HTTPS is required unless private HTTP is explicitly enabled. URLs with credentials, query strings or fragments are rejected. | | `VEXA_SYSTEM_WEBHOOK_SECRET` | — | HMAC-SHA256 secret for the exact timestamped envelope bytes. Required with the URL and stored only in the deployment secret store. | | `VEXA_SYSTEM_WEBHOOK_ALLOW_PRIVATE_HTTP` | `false` | Explicitly permits a private/in-cluster HTTP destination such as a billing service on the Compose/Kubernetes network. It does not change customer webhook SSRF protection. | | `VEXA_SYSTEM_WEBHOOK_TIMEOUT_S` | `10` | Bounded delivery timeout, greater than zero and at most 60 seconds. | Transient delivery failures enter a dedicated retry/dead-letter queue and retain the same deterministic event ID. Customer webhook destinations continue through DNS/IP validation and cannot redirect this operator callback. Leave all four values unset for the stock OSS behavior. ## Secrets & identity | Variable | Default | Purpose | | --------------------------- | -------------------------- | ----------------------------------------------------------- | | `ADMIN_TOKEN` | `changeme` | Admin API key (`X-Admin-API-Key`) — mints users and tokens. | | `INTERNAL_API_SECRET` | `vexa-internal-secret` | Shared secret for service-to-service calls. | | `VEXA_DISPATCH_SIGNING_KEY` | `dev-dispatch-signing-key` | Signs dispatch tokens (the identity chain of custody). | | `NEXTAUTH_SECRET` | `dev-nextauth-secret` | Session secret for the web UI. | | `VEXA_BOT_API_KEY` | — | Pre-shared key a bot uses to call back into the stack. | ## Bot participant name The terminal client uses `NEXT_PUBLIC_DEFAULT_BOT_NAME` at build time and falls back to `Vexa`. The meeting-api uses `DEFAULT_BOT_NAME` for raw `POST /bots` callers and falls back to `VexaBot-{random}`. Direct `joinMeeting` callers use the same variable and fall back to `Vexa Join Layer`. An explicit `bot_name` in the request takes precedence over these defaults. ## Database & storage | Variable | Default | Purpose | | ------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `vexa` / `postgres` / `postgres` | Postgres credentials (metadata). | | `POSTGRES_HOST_PORT` | `5458` | Host port mapped to Postgres. | | `DB_POOL_SIZE` | `5` | SQLAlchemy async-engine `pool_size` — persistent connections **per pod** — read by admin-api and meeting-api and applied to each service's engine. | | `DB_MAX_OVERFLOW` | `10` | SQLAlchemy async-engine `max_overflow` — burst connections above `pool_size`, per pod. Ceiling per pod is `pool_size + max_overflow` (default `15`). | `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` are pure runtime overrides. On managed Postgres with a hard `max_connections`, reconcile them with the per-service connection budget in `deploy/db-budget.json`, whose accounting is `Σ (replicas × (pool_size + max_overflow)) + reserved ≤ max_connections`. The defaults (`5` / `10`) match that budget. \| `MINIO_ENDPOINT` | `minio:9000` | Object storage endpoint (recordings + workspaces). | \| `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | `vexa-access-key` / `vexa-secret-key` | MinIO credentials. | \| `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` | `vexa-access-key` / `vexa-secret-key` | MinIO root credentials. | \| `MINIO_BUCKET` | `vexa` | Bucket holding recordings and agent workspaces. | \| `MINIO_SECURE` | `false` | Use TLS to reach MinIO. | ## Agent inference (bring your own) Point the agent at your own model so no inference leaves the network. | Variable | Default | Purpose | | --------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- | | `VEXA_AGENT_MODEL` | — | Model the agent runner uses. | | `VEXA_MEETING_MODEL` | — | Model for meeting-time processing. | | `ANTHROPIC_API_KEY` | — | Key for Anthropic-backed runners. | | `ANTHROPIC_MODEL` · `ANTHROPIC_DEFAULT_OPUS_MODEL` · `…_SONNET_MODEL` · `…_HAIKU_MODEL` | — | Per-tier model overrides. | | `HOST_CLAUDE_CREDENTIALS` | — | Host path to Claude credentials mounted into agent containers (see below). | | `VEXA_AGENT_DEFAULT_SUBJECT` | `u_live` | Fallback subject before the gateway fronts agent-api. | ### Claude subscription credentials (`HOST_CLAUDE_CREDENTIALS`) Setting `HOST_CLAUDE_CREDENTIALS=~/.claude/.credentials.json` mounts your Claude Code sign-in into the agent containers (read-only), so the agent runs on your subscription instead of an API key. Whether that file stays valid depends on the host OS: * **Linux** (and **Windows via WSL2** — run the stack and the `claude` CLI inside WSL): the file is Claude Code's own store; the CLI refreshes it in place. Nothing to do. * **macOS**: the CLI's source of truth is the login **Keychain** — the file is a one-time export whose token expires every \~8–12 hours. Symptom: agent chat fails with `401 Invalid authentication credentials` while `claude` works fine in your terminal. Install the bundled sync daemon once: ```bash theme={null} deploy/bin/claude-creds-sync/install.sh ``` It registers a launchd user agent (`ai.vexa.claude-creds-sync`) that re-exports the Keychain into the file every 5 minutes, write-only-on-change, preserving the inode the containers mount. `install.sh uninstall` removes it. Details: `deploy/bin/claude-creds-sync/README.md`. The credential-file mount is a single-developer convenience. For a portable setup that survives token rotation on any OS, configure an API key or a custom endpoint in **Settings → Models** (stored per-user/global in the database) — see the setup wizard or the table above. ## Auto-join & calendar sync Timing knobs for the two meeting-api background sweeps ([auto-join](/core/meetings#auto-join--scheduled-means-the-bot-comes) and [calendar sync](/how-to/calendar-sync)). Both degrade gracefully: without `ADMIN_API_URL` + `INTERNAL_API_SECRET`, calendar sync no-ops and auto-join spawns without per-user context — the stack still boots. | Variable | Default | Purpose | | ---------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------- | | `DEFAULT_BOT_NAME` | — | Bot participant name used when the caller omits `bot_name` in `POST /bots`. Falls back to `VexaBot-{random}` if unset. | | `AUTO_JOIN_SWEEP_INTERVAL_S` | `30` | How often scheduled meetings are checked for a due start. | | `AUTO_JOIN_LEAD_S` | `60` | The bot is sent this many seconds *before* the scheduled time. | | `AUTO_JOIN_GRACE_S` | `600` | A meeting whose start passed longer ago than this is skipped — never joined hours late. | | `AUTO_JOIN_RETRY_BACKOFF_S` | `300` | Wait after a loud auto-join failure (cap/quota/spawn) before retrying that meeting. | | `CALENDAR_SYNC_INTERVAL_S` | `300` | How often connected ICS feeds are re-fetched and upserted. | | `ADMIN_API_URL` | — | admin-api base URL for the sweeps' internal lookups (spawn context, calendar configs). | ## Images & runtime | Variable | Default | Purpose | | ------------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `IMAGE_TAG` | `dev` | Tag for the built service images. | | `BROWSER_IMAGE` | `vexaai/vexa-bot:v012` | Browser/bot container the runtime spawns per meeting. **Built from source** (`make bot`), spawned without pulling — the published `vexaai/vexa-bot:dev` is the old 0.10 line and is incompatible. | | `AGENT_IMAGE` / `AGENT_WORKER_IMAGE` | `vexaai/v012-agent-*` | Agent container the runtime spawns per dispatch. | | `DOCKER_GID` | `0` | Host docker group id, so the runtime can use the Docker socket. | | `LOG_LEVEL` | `info` | Log verbosity. | ## Ports Host ports the compose stack publishes on `127.0.0.1` (override any of them in `.env`): | Variable | Default | Service | | --------------------------------------------- | --------------- | ---------------------------- | | `API_GATEWAY_HOST_PORT` | `18056` | gateway (the one front door) | | `ADMIN_API_PORT` | `18057` | admin-api | | `MEETING_API_PORT` | `18080` | meeting-api | | `RUNTIME_API_PORT` | `18090` | runtime | | `AGENT_API_PORT` | `18100` | agent-api | | `TERMINAL_PORT` | `13000` | web UI / terminal | | `MINIO_HOST_PORT` / `MINIO_CONSOLE_HOST_PORT` | `9000` / `9001` | MinIO API / console | The gateway (`:18056`) is the one front door; the terminal web workbench is at `:13000`. The other host ports above are bound to `127.0.0.1` for local inspection and aren't needed for day-to-day use. ## Gateway edge protection The gateway carries two independent abuse layers. Both are env-driven; both default sensibly for self-hosted and can be left untouched. ### Per-user limiter (post-auth, on by default) A token-bucket limiter keyed by user id fires *after* the API key is resolved — it catches one token driving too much traffic. On for self-hosted by default. | Variable | Default | Purpose | | ----------------------------- | ------- | ------------------------------------------------------------------------------------ | | `GATEWAY_RATE_LIMIT_DISABLED` | — | `1` / `true` / `yes` / `on` disables the per-user limiter entirely. | | `GATEWAY_RATE_LIMIT_BURST` | `120` | Bucket size (max burst). | | `GATEWAY_RATE_LIMIT_RPS` | `40` | Refill rate (tokens/sec). High enough for normal use; lower for stricter throttling. | ### Edge guard (pre-auth, off by default for self-hosted) An optional fastapi-guard edge layer caps requests per **client IP** *before* the API key is validated — so an IP flooding invalid keys, or rotating many keys from one IP to defeat the per-user limiter, is answered with `429` at the edge and never reaches admin-api. An IP that keeps offending past a threshold is **auto-banned for a window**; sustained abuse costs the abuser, not the operator. Hosted runs this on; self-hosted defaults OFF. The code default is `GUARD_ENABLED=true`; the deploy surfaces set `GUARD_ENABLED=false` for self-hosted, so you opt in by overriding it. `GUARD_ENABLED=false` is the kill switch — flip it to `true` to turn the whole edge layer on. When behind a reverse proxy you must also set `GUARD_TRUSTED_PROXIES`, or every request keys to the proxy IP → one global bucket shared by all clients (one abuser throttles everyone). See [Deployment → Publishing behind a reverse proxy](/deployment#publishing-behind-a-reverse-proxy). | Variable | Default | Purpose | | ------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GUARD_ENABLED` | `true` (code) / `false` (self-hosted deploy) | Kill switch for the entire edge layer. | | `GUARD_ENABLE_REDIS` | `true` | Share rate/ban state across processes via the existing `REDIS_URL` (`false` → in-memory only, per-process). | | `GUARD_RATE_LIMIT_RPM` | `600` | Per-IP request cap per minute; `0` disables rate limiting. | | `GUARD_RATE_LIMIT_WINDOW` | `60` | Rate-limit window in seconds. | | `GUARD_AUTO_BAN_THRESHOLD` | `10` | Over-limit events from one IP before it is auto-banned. | | `GUARD_AUTO_BAN_DURATION` | `3600` | Auto-ban window in seconds; after expiry the offender gets a fresh budget. | | `GUARD_IP_WHITELIST` | — | CSV of IPs that bypass every guard check. | | `GUARD_IP_BLACKLIST` | — | CSV of IPs rejected on the first request (`403`). | | `GUARD_BLOCKED_COUNTRIES` | — | CSV of ISO country codes to block (geo, opt-in). | | `GUARD_BLOCK_CLOUD_PROVIDERS` | — | CSV of cloud providers to block (e.g. `aws,gcp`), opt-in. | | `GUARD_TRUSTED_PROXIES` | — | CSV of trusted proxy IPs whose `X-Forwarded-For` is honored for client-IP resolution. **Set this when behind a reverse proxy.** | | `GUARD_TRUST_X_FORWARDED_PROTO` | `false` | Trust `X-Forwarded-Proto` for scheme enforcement. | | `GUARD_REDIS_PREFIX` | `vexa:guard:` | Redis key namespace (avoids colliding with Vexa's own keys). | | `GUARD_WS_ENABLED` | `false` | Opt-in `/ws` connect guard. In-memory per-process — does **not** share ban/rate state with the Redis-backed HTTP layer, and under `uvicorn --workers N>1` each worker keeps an independent WS ban set. | With `GUARD_ENABLE_REDIS=false` AND `uvicorn --workers N>1`, the HTTP rate-limit buckets and auto-bans are also per-process: the effective limit becomes `N × GUARD_RATE_LIMIT_RPM` and bans do not propagate across workers (the WS per-process ceiling above applies to the WS path independently). The same applies across multiple gateway **replicas** without Redis — each is its own process. **Safe configurations:** the shipped gateway runs a **single** uvicorn worker, so the default (`GUARD_ENABLE_REDIS=true`, one worker) enforces the limit globally. If you scale to multiple workers **or** replicas, keep `GUARD_ENABLE_REDIS=true` — Redis-backed state is what shares the buckets and bans across processes. `GUARD_ENABLE_REDIS=false` is only safe with a single worker **and** a single replica; if you must run without Redis at higher concurrency, divide `GUARD_RATE_LIMIT_RPM` by the process count to hold the aggregate cap (note this still will not propagate bans). The guard fails open (`fail_secure=false`): a guard-check bug or a redis outage returns the request to the app rather than taking the gateway down. Request-body WAF scanning is intentionally off — the gateway proxies arbitrary user text (chat, meeting `data`, transcript shares), so signature scanning would false-positive on legitimate content. # Agents Source: https://docs.vexa.ai/core/agents A sandboxed CLI agent that works a person's knowledge like a developer works a codebase. An **agent** is a CLI coding agent (Claude Code, Codex, …) given a [workspace](/concepts#workspace) and a job. It reads and writes Markdown files, runs tools, and commits — the same loop that disrupted software development, pointed at knowledge instead of code. The domain itself is built the way everything in Vexa is: [one-concern modules](/concepts#module) (dispatcher, worker, workspace store, LLM runners) joined by [sealed contracts](/concepts#contract) (`unit.v1`, `workspace.v1`, `invoke.v1`), each testable in isolation against fixtures. Agents are a **standalone domain** — they work any [workspace](/concepts#workspace) of knowledge, **with or without meetings**. A meeting is just one of the triggers and sources ([message, schedule, event](/concepts#scheduler)), never a requirement. ## Why this works A CLI coding agent is just a **process on Linux** — it reads files, runs commands, commits. That bare simplicity is exactly why it reshaped software work: no special runtime, nothing to integrate. We change two things, and nothing else: * **Put it in a container** — now it is *safe* (isolated, no egress except through brokered tools, no lateral movement) and *scalable* (ephemeral, thousands in parallel). * **Point it at business data** instead of dev prompts — give it a [workspace](/concepts#workspace) of Markdown instead of a code repo, and the same loop treats **knowledge as code**. Nothing exotic: the proven coding-agent loop, made safe and scalable, aimed at your knowledge. ## How an agent runs A trigger ([message, schedule, event, or a meeting](/concepts#scheduler)) dispatches an agent. The [runtime](/core/runtime) spawns it in an isolated [container](/concepts#container) with the workspace mounted; it works, commits, streams its output, and is reaped when idle. Continuity is a session **file** in the workspace, so a fresh container resumes instantly — nothing stays warm. The agent is **untrusted** by design (it is prompt-injectable). It carries a signed [identity](/concepts#identity) token that every boundary verifies; it never enforces anything itself, and never holds a raw credential. ## Trusted vs untrusted input What an agent may do is set by where its input came from, enforced at the boundaries — never by the model: * **Trusted** (you, in chat) → the agent writes to the workspace directly; git is the undo. * **Untrusted** (an email, a web page) → the agent runs **propose-only**: it suggests actions (record a task, draft a reply, send) as cards; a human approves, and trusted code applies them. Irreversible effects (send, order) are always gated. # Identity Source: https://docs.vexa.ai/core/identity The one place that decides who is asking, what they own, and whether they are allowed — for every request in the system. Identity is the platform checkpoint: accounts, keys, and the rule that you only touch what you own. Every other domain assumes a request already passed through it. It exists twice on purpose — the live DB-backed oracle and the pure DB-free reference library — both honouring the same [sealed contract](/concepts#contract) (`identity.v1`): the [one-concern-per-module](/concepts#module) rule applied to trust itself, so authorization logic is testable in full isolation from the database. On every request it resolves three things: 1. **Who is asking** — resolved once at the edge, never from the client's word. 2. **What they touch, and who owns it** — every meeting, recording, and workspace has an owner. 3. **Whether they are allowed** — a default-deny decision. It also mints **short-lived, single-purpose tokens** so an agent acts on your behalf without holding your API key (see [zero-trust](#where-we-are-going-zero-trust)). Two forms, on purpose: * **`admin-api`** (`core/identity/services/admin-api`) — the live, database-backed service: user accounts, API tokens, and the `/internal/validate` oracle the gateway calls. * **`identity_core`** (`core/identity/src/identity_core`) — a pure, dependency-free library of the rules (mint a token, check access, mint a dispatch token, broker a secret), sealed as the [`identity.v1`](#building-blocks) contract so every service decides the same way. This page is the identity **domain** — accounts, the access model, what's built, and the roadmap. The cross-cutting **trust flow** — how a dispatch's authorization is proven and verified at every hop — is its own document: [Identity & trust](/architecture/identity-and-trust). *** ## The model Six rules — the reasoning, the mechanism, and the established practice each comes from. | Rule | Why | Mechanism | Maps to | | ---------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | **Verified caller** | A claimed identity can be faked; verify it once at the edge, then trust it. | Gateway resolves the key to a user, stamps `X-User-Id`, strips any client-supplied identity. | Zero-trust edge / policy enforcement point (Google BeyondCorp) | | **Owned resources** | Being logged in isn't being allowed; check the object's owner, not just the session. | `meetings.user_id`; a workspace is named for its owner. | Least privilege; prevents IDOR / broken object-level authZ (OWASP API1:2023 BOLA) | | **Check per request** | A check you can skip isn't a check; decide on every access, never in the UI. | [`OwnerOnlyPolicy`](#building-blocks) on each read. | Complete mediation (Saltzer & Schroeder) | | **One decision point** | Rules copied across services drift; keep one decider and have services call it. | `identity_core` decides; services enforce. | PDP/PEP separation (XACML lineage; OPA · AWS Cedar) | | **Default deny** | A forgotten check should fail closed; deny unless explicitly allowed. | `OwnerOnlyPolicy` allows the owner, denies the rest with a reason. | Fail-safe defaults (Saltzer & Schroeder) | | **No shared keys** | A master key can do everything; hand out a token good for one thing, briefly. | Signed [dispatch tokens](/architecture/identity-and-trust); credentials are brokered, never handed over. | OAuth 2.0 token exchange (RFC 8693); avoids the confused-deputy problem | The agent (the LLM) is **outside** the trust boundary — it carries proof but never enforces it. Compromise it via prompt injection ([OWASP LLM01](https://genai.owasp.org/)) and it still cannot exceed its token's scope; the boundaries enforce, not the model. See [Identity & trust](/architecture/identity-and-trust). *** ## The request path (grounded) Everything reaches the domains through the [gateway](/architecture/modules); the [terminal](/concepts) is one client. For every call the gateway resolves the key to a user and forwards to the owning domain with `X-User-Id` stamped on. ``` terminal ──(API key, from your login)──▶ GATEWAY ──(X-User-Id, verified)──▶ meetings / agent domain │ resolve key → user (fail-closed) │ strip any client-supplied identity (anti-spoof) │ check the route's coarse scope ``` The full public surface, grouped by **what it protects** — this is the real attack surface, and what the security model has to cover (nothing more): | Group | Endpoints | Owner rule | Scope | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | ------------------------ | | **Front door** | `GET /health`, `GET /auth/me`, `WS /ws` | n/a / "who am I" / per-meeting subscribe | — | | **Meetings** (you must own the meeting) | `GET·POST /bots`, `…/bots/{platform}/{native}` (delete · config · speak), `GET /transcripts/{platform}/{native}`, `GET /recordings…`, `GET /meetings…` | owner = `meetings.user_id` | `bot` · `browser` · `tx` | | **Your agent space** (yours by construction) | `POST /agent/chat`, `GET /agent/meeting/stream`, and `/agent/*` → `chat`, `workspace/{tree,file,init,swap,git,upload}`, `sessions`, `routines`, `models`, `meeting/{start,process,stream}` | owner = you, **by partition** (you can only name your own workspace/session) | none today | Two safety shapes result: * **Agent space** is safe by partition — a workspace, session, or routine is addressed by your id, so no explicit check is needed. * **Meetings** are owned in the meetings domain, so the owner-checked path runs there (`GET /transcripts`, `/ws` subscribe authorization). The leak is where the agent domain reads a meeting directly instead of via the meetings domain — see [adoption gaps](#where-we-are). *** ## Building blocks What actually exists today, with the real names. **`identity_core`** — the pure rule library, sealed as `identity.v1` (`core/identity/contracts/identity.v1`): * **Tokens** (`tokens.py`) — `ScopedToken(subject, scopes, expires_at)`; scopes are `bot · tx · browser`; `mint_token` / `validate_token`. * **Access** (`access.py`) — `Resource(kind, id, owner)` for kinds `meeting_transcript · recording · ws_subscribe`; `AccessDecision(allow, subject, …, reason)`; `OwnerOnlyPolicy` (default-deny, allow iff `subject == owner`); the front-door function `can_access(subject, resource, action)`. * **Dispatch tokens** (`dispatch_tokens.py`) — a JWT-style, audience-scoped bearer token signed HS256 in dev (SPIFFE [SVIDs](https://spiffe.io/) in production): `DispatchClaims(subject, launcher, workspaces[], tools[], iat, exp)`; `mint_dispatch_token` / `verify_dispatch_token`. `subject` is *who you act for*; `launcher` is *what triggered it*. `may_mount(id, mode)` and `may_call(tool)` are the limits a boundary checks. This is the "single-purpose pass." * **Secrets** (`secrets.py`) — the brokered-secrets pattern ([HashiCorp Vault](https://www.vaultproject.io/)): `SecretsPort` returns a redacted `BrokeredSecret` (its `repr` never prints the value) and writes an audit log; the value is fetched on demand, never logged. The real vault (lease, rotation) is deferred — see [encryption](#data-at-rest-encryption). **`admin-api`** — accounts and the live oracle: * Tables: `User` and `APIToken(token, user_id, scopes[], expires_at, …)`; tokens look like `vxa__`. * `POST /internal/validate` — the gateway calls this (behind an internal secret, fail-closed) to turn an API key into `{user_id, scopes, …}`. * Three tiers: **admin** (`X-Admin-API-Key`, create users/tokens), **user** (`X-API-Key`, e.g. set a webhook), **internal** (`X-Internal-Secret`, the validate oracle). **Gateway** — the edge that resolves the key, stamps `X-User-Id`, enforces coarse route scopes, and runs the `/ws` multiplex. *** ## Where we are Honest status. The **design is frozen and the primitives are built** — the gap is *adoption*: the rules exist as a library but are not yet wired into every path that needs them. The constitution names this risk directly: **P20** (complete mediation) records that the `canAccess` seam "was designed but never wired, so it **rotted**," and **P9** holds that an unenforced rule is only aspirational — a rule that does not turn CI red can be crossed. | Piece | State | | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Per-dispatch signed token (`DispatchClaims`, HS256) | ✅ **built, proven live** | | `identity_core` library + sealed `identity.v1` | ✅ **built** | | `admin-api` (users, tokens, `/internal/validate`) | ✅ **built** | | Gateway: key → user, `X-User-Id`, route scopes, `/ws` | ✅ **built** | | Agent-api derives `subject` from `X-User-Id`, ignores the client body | ✅ **built** (P20) | | Client identity is real (terminal sends the logged-in user's key via OAuth) | ✅ **fixed** — the old hardcoded `u_live` is gone from the client | | `can_access` / `OwnerOnlyPolicy` **adopted on every meeting path** | ⚠️ **partial** — meeting-api owner-checks its own way; the agent domain's `chat` / `meeting/stream` / `meeting/start` / `meeting/process` do **not** call it yet | | Server-side live-meeting **owner attribution** | ⚠️ **pre-M2** — the live-meeting watcher is started with the default `subject = "u_live"` (`control_plane/api.py:880` → `transcription_watcher.py:203`); live-meeting dispatch (M2) is not yet delivered, so this default must be replaced with the real owner before it ships | | **Auth spine** end-to-end (opaque token → user → scoped access, everywhere) | ⬜ **planned** (Stage 2) | | Workload identity in production (SPIRE), Keycloak, RFC 8693, MCP Gateway | ⬜ **planned** (Stage 2) | | SSO (Okta/Entra) + SCIM | ⬜ **planned** (Stage 5) | The front door is real; a few agent-domain endpoints read a meeting's data directly instead of via the meetings domain, skipping the owner check that already exists in the library. Closing it is wiring, not design: route those reads through the owning domain and decide with `OwnerOnlyPolicy`. *** ## Where we are going (zero-trust) The target — a **chain of custody** where a dispatch's authorization is proven and verified at every hop (signed tokens, [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) exchange at the tool boundary, SPIRE/Keycloak via [kagenti](https://github.com/kagenti/kagenti)) — is one document: [Identity & trust](/architecture/identity-and-trust). It lands as [Stage 2 — Trust](/roadmap/stages). The point for this domain is narrow: we run an **untrusted, prompt-injectable agent on private data**, so safety must come from boundaries that verify, not from trusting the model. *** ## Roadmap — by principle Each item ties a declared principle to its code reality and the concrete next step, plus the gate or stage that makes it real (P9: an ungated rule is aspirational). Foundations: [the agent is untrusted](/concepts), [identity is a chain of custody](/concepts), [self-host & air-gap by default](/concepts). | Principle | Today (in code) | Next step | Lands as | | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | **P20 — complete mediation**: authorize every access, default-deny `canAccess` on API · WS · agent | deny test on the library only (`core/identity/tests/test_access.py`, `gate:access`); meeting-api owner-checks **implicitly per query**; agent meeting paths unchecked (`can_access` count = 0 in `control_plane/api.py`) | wire `can_access(subject, resource, action)` onto every meeting path (`chat` · `meeting/stream` · `meeting/start` · `meeting/process`); extend `gate:access` to the wired paths | ADR-0012 · `gate:access` | | **Identity is a chain of custody** | `x-user-id` is plaintext, trusted by network position (`gateway/app.py:192`); the per-dispatch token is minted (`control_plane/dispatch.py:36`) but not yet verified at a boundary | a **signed** user-assertion verified at each hop; verify the dispatch token at the workspace/tool boundaries | Stage 2 — SPIRE · Keycloak · RFC 8693 · MCP Gateway | | **Scoped agent domain** | `/api/*` carries **no** scope — any valid key reaches any agent route (`gateway/app.py:153-156`) | per-route scopes for the agent domain | Stage 3 | | **P15 — secrets behind a port** | broker pattern **wired** for the per-user git token (`agent/shared/adapters.py:135-162`); the store is the stand-in `PassthroughSecretsBroker` | real vault — lease · rotation · BYOK — behind `SecretsPort` | P16 · ADR-0003 | | **P15 — data encrypted at rest** | buckets bind-mounted; transcripts and tokens in cleartext | the three at-rest stores → see [Data at rest](#data-at-rest-encryption) | Stage 0 contract · planned | | **Multi-tenant attribution** | the live-meeting watcher is started with the default `subject = "u_live"` (`control_plane/api.py:880` → `transcription_watcher.py:203,339`); live-meeting dispatch (M2) is not yet delivered | arm the copilot as the real meeting owner before M2 ships | Auth spine · M2 | | **P9 / P6 — one front door** | `_resolve_user_id` is duplicated across four meeting-api routers (`collector` · `bot_spawn` · `lifecycle` · `recordings`) | fold identity resolution into the shared `identity` front door | cleanup | `subject_of` (`control_plane/api.py`) has a `VEXA_AGENT_DEFAULT_SUBJECT` fallback for a gateway-less single-user self-host. It is fail-closed (401) when unset; it **must stay unset** in any multi-tenant deployment, or every caller collapses to one subject. ## Data at rest (encryption) Three stores hold sensitive data at rest (data **in transit** is encrypted with TLS — the standard that secures HTTPS connections). State and plan: | Store | What is in it | Today | Plan | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Workspace buckets** | Your whole workspace — notes, meeting docs, chat transcripts — a git folder in object storage (minio/S3) | bind-mounted local dirs (M1); contract reserves an `encryption` field on `workspace.v1` | **Per-workspace envelope encryption** (the KMS data-key pattern): a data key per workspace, wrapped by a master key the identity layer brokers; decrypted **only inside the sandboxed container**; keys stay in-VPC for air-gap | | **Transcripts** | The durable source of truth: the meetings **Postgres** `transcriptions` table (what `GET /transcripts` reads). The redis `transcription_segments` / `tc:…:mutable` stream is the live carrier; the workspace Markdown is a derived copy | persisted in Postgres; carried in redis; derived copy in the workspace bucket | Encrypt at rest in the **meetings database** (the SSOT); protect the redis carrier (auth/TLS, restricted ACLs); the workspace copy is covered by bucket encryption above | | **User API tokens** | The keys in `api_tokens.token` that unlock an account | stored and matched as **cleartext** | Store only a **hash** and verify by hashing the presented key — the same practice as passwords (argon2 / bcrypt) — or encrypt at rest, so a database leak yields no usable keys | These are tracked in the [roadmap status](/roadmap/status); workspace/bucket encryption is already part of the `workspace.v1` contract shape in [Stage 0](/roadmap/stages). *** ## Why this much, and not more Measures are sized to the system: a multi-user product where each person owns meetings and a private workspace, agents run untrusted in isolated containers, all driven from the terminal. The API reduces to "is this yours?", so the priorities are: * owner checks on meetings and workspaces (cross-user exposure is the real risk); * credentials out of the agent (the one untrusted, injectable component); * encryption of the three at-rest stores. Heavier machinery — SPIRE everywhere, SSO/SCIM, full air-gap — is staged ([Stage 5](/roadmap/stages)) for self-host and regulated verticals. The interfaces (`can_access`, the token shapes) do not change when it lands; only what sits behind them does. # Meetings Source: https://docs.vexa.ai/core/meetings A standalone service that plans, captures, stores, and serves meeting data in real time — usable on its own or with agents. **Meetings is a separate, self-contained service** — a detached domain with its own [API](/api/meetings). It covers a meeting's **whole life**: plan it (or import it from your calendar), auto-join it when it starts, capture it natively in real time, and share it — the transcript stored and served over the API as it happens. Run it entirely on its own as a meeting-capture backend — no agents required. Like every Vexa domain it is built as [modules of one concern](/concepts#module) — the same capture bricks compose into the bot, the desktop host, and the eval harness — joined by [sealed contracts](/concepts#contract) (`transcript.v1`, `lifecycle.v1`, `invocation.v1`, …), with fixtures collected from real meetings driving the tests at every scale. It also **composes** with the [agent domain](/core/agents) — the transcript becomes knowledge agents act on — but that is a composition, not a dependency: meetings stands alone. How the two connect (the `transcript.v1` → agent bridge) lives in one place: [Modules & seams](/architecture/modules). ## The lifecycle — one meeting, one row A meeting is **one record from plan to transcript**. It is born in a user-owned *intent* status, is claimed by the bot lifecycle when the bot joins, and ends terminal — the plan's title, its workspace binding, and the eventual transcript all live together: | Phase | Statuses | Owned by | | ----------- | -------------------------------------------------------------- | ------------------------------- | | **Planned** | `idle` (no time) · `scheduled` (time set) | you — create/edit/delete freely | | **Live** | `requested → joining → awaiting_admission → active → stopping` | the bot lifecycle FSM | | **Done** | `completed` · `failed` | terminal | Terminal lifecycle events carry a completion reason. `left_alone` means the active meeting produced no remote microphone audio for its configured silence window (10 minutes by default), covering both an empty room and a silent bots-only room with one platform-agnostic rule. Qualifying remote speech resets the full window; local bot speech does not. If capture is unavailable, the detector fails closed and keeps the bot seated. The deliberate trade is that a human who remains silent for the whole window is also treated as an ended meeting; callers can raise the per-meeting window with `automatic_leave.max_time_left_alone`. Sending a bot to a planned meeting (manually or via auto-join) **upgrades the same record in place** — it is never duplicated, and a planned record can be edited or deleted only while it is still planned (the FSM is never fought). ### Plan Create a meeting before it happens with [`POST /meetings`](/api/meetings#plan-a-meeting): a title, an optional start time, an optional meeting link (Meet/Zoom/Teams/Jitsi — parsed server-side), an optional [workspace](/concepts#workspace) binding. A plan without a link is fine — attach the link later; a plan without a time sits in `idle` until you schedule it. ### Import — calendar sync Connect your calendar with its **secret ICS address** (Google Calendar and Outlook both provide one — see [Calendar sync](/how-to/calendar-sync)); no OAuth needed. Connecting syncs immediately and answers with the result — every sync failure is stamped as a human-readable status (`GET /user/calendar/sync`, shown in the Terminal's calendar panel), never swallowed — failures are loud here, like everywhere in the lifecycle. A background sweep re-fetches the feed (default every 5 minutes) and upserts planned meetings: * **Only events with a recognizable meeting link** import — a dentist appointment is not a joinable meeting. * **One meeting per calendar event, next occurrence only** — a weekly meeting reuses one link, so the importer tracks the next upcoming occurrence; the following one imports after the current completes. * Moves and cancellations follow the feed; a meeting the bot already joined is never touched. * A meeting you planned manually on the same link is **adopted** (linked to the calendar event), not duplicated. ### Auto-join — "scheduled" means the bot comes A `scheduled` meeting with a link is joined **automatically**: a sweep sends the bot shortly before the start time (default 60 s lead). Control it per meeting with the **auto-join toggle** (default on) and globally for imported meetings with the calendar's **auto-join switch**. Two guarantees: * **Never hours late** — a meeting whose start passed the grace window (default 10 min) is skipped, not joined absurdly late. * **Failures are loud** — a concurrency-cap or spawn failure stamps a visible error on the meeting (`auto_join_error`) instead of silently not showing up. See [Troubleshooting](/troubleshooting#a-scheduled-meeting-didnt-auto-join). ### Capture The bot joins natively, in real time, with no plugins or host configuration — it attends like any participant, on [**Google Meet**, **Zoom**, **Microsoft Teams**, and **Jitsi Meet**](/api/meetings#platforms). Mechanically it is a **browser [container](/concepts#container) spawned by the [runtime](/core/runtime)** — the same runtime an agent runs in. The speaker-attributed (diarized) transcript streams live over the [API](/api/meetings) and WebSocket. ### Share — workspaces carry the meeting Bind a meeting to a shared [workspace](/core/agents) and **every member of that workspace sees it**: the upcoming plan, the live transcript feed, and the finished transcript. That makes the prep workflow one motion — prepare context in a workspace, invite the people you're meeting, and the meeting itself rides along. See [Plan and share a meeting](/how-to/plan-a-meeting). (Independent one-off transcript share links exist too — no workspace required.) ## From transcript to knowledge 1. The bot captures audio; transcription produces a real-time `transcript.v1` stream. 2. The transcript compiles into the person's [workspace](/concepts#workspace) as Markdown. 3. Agents read it like any other file — and act on it: * **Before the meeting** — prepare a briefing in the bound workspace; attendees see it live. * **After the meeting** — a dispatch writes notes, decisions, and action items as workspace files. * **During the meeting** — a live dispatch surfaces proactive cards (new person, action item, decision); see [Meeting copilot](/how-to/live-copilot). # Runtime Source: https://docs.vexa.ai/core/runtime Isolated, ephemeral containers — the execution layer. The **runtime** spawns, reuses, and reaps the [containers](/concepts#container) that agents (and meeting bots) run in. It is the only thing that touches the orchestrator; the control plane just asks it to run a dispatch. It is one [one-concern module](/concepts#module) — mechanism, not policy — behind one [sealed contract](/concepts#contract) (`runtime.v1`), with pluggable backends (process · Docker · K8s) proven by the same fixture suite against each. ## Why it exists: safety An agent is an untrusted, tool-using process operating on sensitive data. So every dispatch runs **isolated** (its own container), **sandboxed** (no egress except through brokered tools), and scoped to **only the workspaces and tools it was granted**. Isolation is what makes the governance real rather than advisory — which is why agents never run in the control plane. The workspace grant is **enforced by the substrate**, not by instructions to the model: docker binds one volume subpath per granted mount (read-only roles bind `:ro`; requires engine ≥ v26 for named-volume stores); Kubernetes emits one `subPath` + `readOnly` volumeMount per mount against the store PVC; the lite process backend drops each worker to a **per-subject uid** with `0700` private tiers and per-shared-workspace groups. Another tenant's workspace isn't merely off-limits — it is **not in the worker's filesystem at all**, so even a prompt-injected agent cannot read or write it. ## One lifecycle, one substrate * **TTL-on-idle** — a container lives while it works and is reaped when idle. No warm/oneshot bookkeeping; continuity is the [session file in the workspace](/concepts#workspace). * **Sub-second, ephemeral, thousands in parallel** — the single-machine coding-agent model, made multi-tenant and cheap. ## Where it runs The runtime is **orchestration-agnostic**. The kernel owns the [`runtime.v1`](https://github.com/Vexa-ai/vexa/tree/main/core/runtime/contracts/runtime.v1) lifecycle — `starting → running → stopping → stopped → destroyed`, emitting an event on every transition — and delegates the one substrate-specific question (*how* do I start, observe, and stop a workload?) to a pluggable **Backend** with a five-method port: `start` · `exit_code` · `terminate` · `kill` · `cleanup`. The same control plane and the same `unit.v1` dispatch drive every backend; only the implementation behind that port differs: * **Process** — agents and bots are spawned as **child processes**, no Docker socket required. Each workload leads its own process group, and the backend owns that group: an observed exit or a stop reaps every descendant, so a self-exiting or stopped bot never strands its child tree (e.g. Chromium) on the shared host. Declared limitation — descendants that detach into their own process group (the debug-view x11vnc/websockify) are out of the group signal's reach. * **Docker** — each workload is its own container via the Docker socket. This is what the open core ships, brought up with **Docker Compose** (`make all`). * **Kubernetes** — the same workload model scheduled as a **Pod** across a cluster. The backend is selected per deployment by `RUNTIME_BACKEND` (default `docker`). Because all three honour the same port, the lifecycle a caller observes is **identical** across substrates — a bot and an agent are the same `runtime.v1` workload, differing only by [profile](/concepts#container) and env. ### On Kubernetes With `RUNTIME_BACKEND=k8s`, a workload is a **bare Pod**, created with `kubectl run … --restart=Never` (the kernel shells out to `kubectl` — no client library, mirroring the Docker backend). Two choices are deliberate: * **`--restart=Never`** — the *kernel* owns restart and reaping (TTL-on-idle, max-lifetime, per-owner quotas). A Pod that resurrected itself would defeat the kernel's "has it stopped?" detection, so the Pod must stay dead once it exits. * **A bare Pod, not a Deployment/Job** — a dispatch is a single ephemeral run, not a replicated service. The Pod is named `vexa-` (DNS-1123) in the namespace the runtime reads from the downward API (`POD_NAMESPACE`). Pod phase drives the backend's exit check (`Pending`/`Running` → still running; `Succeeded` → exit 0; `Failed` → the container's terminated exit code), which the kernel turns into the terminal state `stopped` with reason `completed` (exit 0) or `failed` (nonzero). **Current state (open core):** the Kubernetes backend implements the lifecycle **and the workspace mount** — worker Pods get one `subPath` + `readOnly` volumeMount per granted workspace against the store PVC (the same per-mount isolation the Docker backend enforces with volume-subpath binds). The in-cluster substrate — ServiceAccount + RBAC to create Pods, the store PVC — ships as the **Helm chart** in `deploy/helm`. The simplest self-hosted paths remain **Docker Compose** (`make all`) — see [Deployment](/deployment) — and the one-container **lite** (`make lite`) — see [Vexa Lite](/deployment-lite). Code: `core/runtime/src/runtime_kernel/k8s_backend.py` + `mounts.py:k8s_volume_mounts` (Pod spec) vs `docker_backend.py` (the reference bind + credential path). ## Already in production Vexa's [meeting bots](/core/meetings) are browser containers spawned by this exact runtime. Running agents this way is the same machinery, a different workload type — not a new system to stand up. # Workspaces & live collaboration Source: https://docs.vexa.ai/core/workspaces The workspace model an agent turn sees, sharing, membership, and live collaboration during meetings. The workspace model an agent turn sees, how workspaces are shared, how a user joins and manages them, and how members collaborate live during a meeting. This is the canonical explainer; the code lives in `core/agent/control_plane/` (membership, invites, mounts, purpose, git-sync, git-credentials), `core/agent/worker/` (the turn + mount preamble), `core/runtime/` (the binds), and `clients/terminal/src/` — `surfaces/workspace.tsx` (sidebar), `surfaces/workspaceManage.tsx` (the manage panel), `app/App.tsx` (invite consent), `surfaces/tokens.tsx` (the GitHub token). Status is marked ✅ done / 🟡 partial / ⬜ planned throughout. ## The mount model — three tiers Every agent turn mounts an ordered set `[_global?, *normal, _system]`: | Tier | Slug | Access | Always mounted | Purpose | | ------------------ | ------------------------------------- | ------------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | **Global system** | `_global` | **read-only** (fs `:ro`) | yes (when configured) | Platform-owned self-awareness: a synced **Vexa branch** (code + docs) + behaviour/skills. One copy, central. | | **Normal** | `` / `.attached//` | read-write | opt-in (flat, equal rank) | The user's own + shared knowledge workspaces — all the **same rank** (see below). | | **Private system** | `_system` | **read-write** | yes | Per-user private store — **who you're helping** (`identity.md`), chats/sessions, settings, routines, membership/attachment records. Never shared. | * **There is NO "baseline"/"primary" rank** (2026-07-07 flat model). The normal tier is a **flat, equal-rank list** (`active_set`) — every workspace activates/deactivates the same way. The mount set's `primary` flag marks only the **dynamic default HOME** (the *first active* normal workspace = the turn's cwd, whose `CLAUDE.md` auto-loads); it moves as workspaces are switched on/off, and is absent when nothing normal is active. Code: `workspace_attach._normalized_active_set` (flat, with a one-time `_flat_v1` migration off the legacy baseline state), `dispatch._worker_cwd`. * **`_system` carries the light self-identity** (`identity.md`): the user's **name** + a pointer to the full `self: true` profile in their Personal workspace. Always mounted, so the agent knows who it's helping even when Personal is switched off; the worker preamble routes identity here and **asks for the name until it's set** (✅). Full profile (company/role/relationships) stays in Personal. * `_global` and `_system` are **"system possessions" always attached** to a user's agent. They are **invisible** in the workspace lists and **non-sharable** — both are in `RESERVED_SLUGS` and `ensure_workspace_shareable` refuses them (✅). `_system` can be **surfaced read-only in the files panel via a toggle, hidden by default** (the key icon in the KNOWLEDGE header) (✅). * `_global` is provisioned from `GLOBAL_SYSTEM_WORKSPACE_PATH` (a host dir / synced branch); the runtime gives it its own `:ro` bind. Skips gracefully (logs) when unset/absent. (✅ wired; auto-sync of the branch is a ⬜ follow-up.) * Code: `core/agent/control_plane/system_mounts.py` (`GLOBAL_SLUG`, `SYSTEM_SLUG`, `global_mount`, `system_mount`, `system_store_path`), the mount stack in `dispatch.py`, binds in `core/runtime/src/runtime_kernel/mounts.py:workspace_binds`. ## Tenant isolation — enforced by the substrate (✅ all three backends) The mount set is not just a declaration to the model — it is **the enforced boundary**. A worker's filesystem physically contains ONLY its dispatch's declared mounts; another tenant's workspace is not reachable, so a prompt injection cannot read or write it. Read-only roles (viewer shares, `_global`) are enforced at the mount, not just at the commit token. | Backend | Mechanism | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **docker** | One bind per mount: a named-volume store rides the Mounts API's `VolumeOptions.Subpath` (**requires engine ≥ v26** — older engines fail the container create loudly); a host-path store joins the subpath (no version requirement). The whole-store root bind is never emitted. | | **k8s** | One `volumeMount` per mount against the single store PVC — native `subPath` + `readOnly` (no version caveat). | | **lite (process)** | POSIX: workers drop to a **per-subject uid** (`100000+id`), private/system tiers are `0700`-owned, each shared workspace gets its own **gid** (persisted registry, joined as a supplementary group), per-subject `HOME`/`TMPDIR`, and a **default-deny sweep** seals never-dispatched tenants' dirs on every apply. Unavailable conditions (non-root runtime, non-numeric subject) degrade **loudly** to shared-trust. | There is **no opt-out knob** — strict is the only mode. Known lite limitation: within a shared workspace, viewer-vs-contributor *write* gating stays at the commit layer (a POSIX group can't split read from write per member without ACLs); cross-tenant isolation is fully kernel-enforced. Code: `runtime_kernel/mounts.py` (`workspace_binds`, `k8s_volume_mounts`), `runtime_kernel/isolation.py` (the POSIX plan/apply), tests `test_mounts.py` + `test_isolation.py`. ## Personal + normal workspaces * **Personal is just a normal workspace** — no special rank. It's the workspace **auto-seeded at account creation** and shown as **"Personal"** in the UI; beyond that it activates/deactivates, reads/writes, and behaves exactly like any other normal workspace (✅). Switch it off and it leaves the active set — **its files drop from the finder** and the agent stops working in it, same as any workspace. Its tree still physically lives at `/` (the "seed slot" — a **storage detail, not a rank**); code resolves that path via `_seed_slot_slug` / `_slug_dir`. * 🟡 residual: **share/archive/delete of the seed slot are still refused** (you can't yet delete/ share the exact slot whose tree sits at `/`). Making Personal *fully* like others means relocating that tree out of the seed slot — a ⬜ follow-up (see Deferred). * **Provisioned eagerly on account creation** (✅): first login provisions BOTH Personal (light seed) and `_system` via `POST /api/workspace/init` (idempotent; the lazy first-dispatch seed remains a fallback). Wired from the terminal's `findOrCreateUserToken`. * **Light default seed** (✅): a new workspace seeds from the light `default` template (**not** FINOS — `DEFAULT_TEMPLATE`/`default_template` flipped), whose **`README.md` is a human onboarding-dashboard** ("what's in here and what matters"), not developer docs. Convention: **the README is every workspace's dashboard**, kept current by the agent (stated in the seed `CLAUDE.md`). FINOS stays an opt-in template (`VEXA_DEFAULT_TEMPLATE=finos`). * **First-view / landing** (✅, `firstView.ts` + `Workbench.resolveFirstView`) — on login the pinned first tab is chosen by what's shared: **nothing → own README-onboarding**, **shared workspace → that workspace's README**, **shared meeting → the meeting**, \*\*meeting + workspace → the workspace README * the meeting\*\* (its live badge). One resolver, replacing the old racing auto-opens. - A **just-accepted invite is explicit** and outranks a saved layout: it **pins the shared workspace's README AND forces the Knowledge section open** so the joiner actually sees the shared workspace's tree (`pinReadme(slug, forceKnowledge)`). **Gotcha handled:** the default Sessions view is *chat-only* (the dockview grid isn't mounted, so the resolver's `onReady` never fires) — an early effect un-chat-onlys the layout when a pending landing (`vexa.openWorkspace` / `vexa.openMeeting`) exists, so the grid mounts and the resolver runs. Without it an accepted invite silently dropped onto the session chat instead of the workspace. * **Normal workspaces are single rank** (sharing roles — see below). Created blank + additive; every workspace has a root `README.md`. * **Per-workspace PURPOSE** (✅, `workspace_purpose.py`) — the capability that makes the flat model *usable*. Each workspace carries a one-line statement of what it's **for / what the agent should write there**, stored as a plain `PURPOSE` file at the workspace root (committed to its git, so it **travels when the workspace is shared** — a member who mounts a `customer-deal` workspace inherits its purpose). The dispatcher reads each mount's purpose (`dispatch.py`) and `engine.mounts_preamble` declares it to the model with a routing instruction — so an agent with a *composition* mounted (Personal + a customer-deal shared ws + a sales-dept ws) **knows what belongs where** instead of dumping everything in one place. Editable in the manage panel (below); capped to a one-liner (`MAX_PURPOSE_LEN`) so it stays a cheap preamble line, not a document. **Why:** flat mounting is the *mechanism*; purpose is what keeps a multi-workspace mount set from becoming a junk drawer. ## Sharing model (Lane M — `workspace_membership.py`) **Single rank + creator (owner ruling 2026-07-07).** A shared workspace has one member rank; the `owner` is just the **creator**. Roles in the git store are still `owner`/`contributor`, but: * Invites mint a **read/write MEMBER** only — `INVITABLE_ROLES = ("contributor",)`; the read-only `viewer` tier is retained in the lattice for back-compat but is **not invitable** (✅). * **Any member can share** (mint/revoke invites) and read/write — `POST /api/workspace/invites` + `DELETE /api/workspace/invites/{id}` require `contributor`. * **Only the creator** (`owner`) can **unshare / remove members / change role** (`require_role("owner")`). → creator-only unshare/delete. * **DEFERRED DECISION:** whether to *also* offer an **owner-restricted invite mode** (only the creator invites) vs. keeping invites purely single-rank. Not decided; see `vexa-ops` handoff. **Two stores, written together** (git authoritative, index derived): * Authoritative: the workspace's own git repo — `policy/members.json` (`[{subject, role, added_by, added_at, email?}]`) + `policy/invites.json` (sha256 **hash** of each token * `{id, role, mode, allowed_emails, expires_at, max_uses, uses, revoked}`). * Index: `users.data.memberships[]` for "shared with me" — via the injected `MembershipIndex` (admin-api `/internal/users/{id}/memberships`; in-memory fake in tests). **Human roster — members show emails, not opaque ids** (✅). The participant list needs a human label, but `agent-api` has no user directory. Solution: **persist the gateway-verified `X-User-Email` into the member record at every grant point** — owner bootstrap (`ensure_owner`) and invite redeem (`accept_invite` → `grant_membership`). For **legacy** rows granted before this, `ws_members_list` **self-heals** by stamping the *requesting* member's own email onto their row the first time they open the manage panel (each member's email fills in on their next view). The email is committed to `members.json`, so it **travels with the workspace** to every member. **Why here (not `admin-api`/user.data):** the email is already in-hand on every request and only this store needs it — no cross-service directory lookup. **Access modes:** `open` (anyone-with-link, authenticated) or `restricted` (verified `X-User-Email` in `allowed_emails`). Redeem is **post-auth, no guest** — `POST /api/workspace/invites/accept`. **`policy/` is PLATFORM-WRITE-ONLY:** an agent turn may never write `policy/`; the worker's turn-commit reverts any `policy/` change (`_revert_policy_writes` → `{"type":"policy-reverted"}`). Membership is written only by `workspace_membership.policy_commit` (platform git identity). ## Joining a workspace — invite link → preview → consent → land (✅) The full path from an invite link to seeing the shared workspace. **Why a consent step:** joining mounts someone else's workspace into your agent — you should see *what it is* and *how you're joining* before committing, not silently get opted in. 1. **Mint** — a member creates an invite in the manage panel; the link is `…/?invite=`. 2. **Login** — `AuthGate` signs the user in first (the `?invite=` query survives the OAuth round-trip via the callback URL). **Consent is placed AFTER login** — the terminal's only path to `agent-api` is the **fail-closed gateway**, which 401s anonymous calls (the terminal has no service key), so a *pre-login* preview can't authenticate. `InviteGate` renders **inside** `AuthGate`'s authed subtree. 3. **Preview (no grant)** — `GET /api/workspace/invites/preview?token=` → `preview_invite` resolves the token to `{workspace_id, purpose, role, shared_by, mode, expires_at, valid}` **without** granting, consuming a use, or checking membership (capability-gated by the token; 404 if it matches nothing, so it never enumerates workspaces). Powers the consent card: **workspace name · purpose · your access · shared-by**. 4. **Consent → redeem** — "Continue to join" calls `POST …/invites/accept`, stashes `vexa.openWorkspace`, and reloads to a clean URL. "Not now" drops the invite. 5. **Land** — the first-view resolver pins the shared workspace's **README** and forces the **Knowledge** section open (see First-view above). Restricted invites still enforce `allowed_emails` at *accept* time — the preview only reveals name/purpose/ role to whoever already holds the token (the same trust boundary as the link itself). Code: `workspace_membership.preview_invite`, `api.py` (`ws_invite_preview`), `App.tsx` (`InviteGate`, `InviteConsent`). ## Managing a workspace — the manage panel (✅) Every workspace opens a **center-tab manage hub** (click the workspace name in the WORKSPACES sidebar). One place for everything about that workspace, so the sidebar row stays minimal (just a **checkbox** = mount/park + the **name** = open panel; the old per-row action icons were removed). `workspaceManage.tsx`: * **Rename** (display label) · **on/off** toggle (mount into the agent or park). * **PURPOSE** — view/edit the one-liner that steers what the agent writes here (see Per-workspace PURPOSE). * **GitHub** — publish (create repo + push), **push** / **pull** (fast-forward only, ahead/behind counts), Open-on-GitHub. Uses the saved GitHub token (below) so it doesn't re-prompt. * **Participants** — the roster (emails), **invite link**, **add by email**, remove member, **leave** (self), **unshare** (creator), **archive** / **delete**. Create flows are also panel/modal-based, not inline: **Attach repo** opens a portaled `Modal` (`ui-kit/Modal.tsx`); **New workspace** creates a blank additive workspace. Code: `clients/terminal/src/surfaces/workspaceManage.tsx`, `workspace.tsx` (sidebar rows), `ui-kit/Modal.tsx`. ## Reusable GitHub token (✅) Save a GitHub PAT **once** and reuse it for every git op across **all** repos, instead of re-entering it per push/pull/publish/attach. Set it in **API Tokens → GitHub**; the git-op forms then don't prompt. **Security model** (parity with the webhook secret — access-controlled, not encrypted at rest): * **Server-side only** — stored under the workspaces store root at `.secrets/.ghtoken` (`0600`, a dot-dir the workspace scanners skip, **outside any git tree** so it never lands in a commit). * **Browser-isolated** — never returned to the client; a read yields only a `••••abcd` mask. * **Never transits the gateway as a header** and never leaves the one service that uses it (smaller blast radius than routing it through `admin-api`/user.data — and no governed-service change). * **Applied then scrubbed** — git ops fall back to it when no per-call token is given; it rides the push URL for that op only, is **redacted from every error/log** (P15), and is never written to `.git/config`. * **Plaintext at rest** (the chosen level) → use a **minimally-scoped, revocable fine-grained PAT**; a stored PAT is password-equivalent and a full server compromise can use it. Code: `git_credentials.py` (`set`/`read`/`masked_github_token`), `api.py` (`GET`/`POST /api/workspace/git-token`; push/pull/publish/attach fallback), `tokens.tsx` (the card). ## Live collaboration (during a meeting) — all ✅ Meetings BIND to workspaces (`meetings.data.workspace_id`): a member of the bound workspace sees the meeting — the upcoming PLAN (planned meetings + auto-join + ICS calendar sync, 2026-07-08), the live feed, and the transcript. The user-facing story is `docs/docs/core/meetings.mdx` + `docs/docs/how-to/plan-a-meeting.mdx`; the prep tab is `clients/terminal/src/surfaces/meetingPrep.tsx`. A member's edits in a shared workspace surface live to the other members: * **One aggregated activity feed** — the SOURCE CONTROL panel merges commits across ALL active workspaces, recency-sorted, each labeled with its workspace (no per-workspace strips). Changed files are **clickable links** that open the doc. * **"New updates" badge on the Knowledge nav** — counts OTHER members' commits since Knowledge was last opened; polled always (even on Meetings/Sessions); clears on opening Knowledge. (`clients/terminal/src/surfaces/updatesBadge.ts` + the Workbench poll.) * **Live doc auto-reload** — an OPEN doc reloads (5 s poll) when a member edits it; an "Updated just now" banner + one-click **Changes** panel showing that file's latest highlighted diff. * **Attribution by EMAIL** — commits are authored as the human editor's email (`X-User-Email` stamped as the git author name; the synthetic `@vexa.local` stays for the you/member classification). `git_state_at(viewer)` classifies each commit `you` / `member` / `system`. * **Highlighted diffs** — `GET /api/workspace/git/show` returns a commit's unified diff; the UI renders `+`/`−` line highlighting. * **Cross-workspace file search** — Find-file spans every active workspace, not just the primary; hits are tagged with their workspace and open against the right mount. * **README auto-pinned** when a shared workspace connects — collaborators land on the doc. * **The 6 s poll is the accepted change-feed** (owner ruling) — no SSE push needed. Delivery mechanics: **Lane W** serialises the attributed writer per shared repo (`core/agent/shared/adapters.py workspace_write_lock`); note the flock is not yet on the live commit path (`dispatch.py` comment) — drive concurrent shared writes **sequentially** for now (⬜ to wire). ## Deferred / planned (documented, not built) * ⬜ **`_global` branch auto-sync** (currently a one-time clone). * ⬜ **Agent-proposed GitHub issues** — since `_global` carries the real repo, an agent that notices a user's feature request / bug should be able to **propose an issue** to the main repo (governed propose→approve→submit, author = principal). * ⬜ **Filesystem isolation** — today the whole store is bound once at `/workspaces`, so a turn can `cd ..` to other workspaces; per-mount binds are the fix (`workspace_binds`), decoupled as a presentation remap. (Security hardening for multi-tenant ship.) * ⬜ **Full seed-slot de-specialization** — relocate Personal's tree out of the fixed `/` seed slot into a normal store slot, so share/archive/delete work on it like any workspace (removes the last residual specialness; the rank is already gone). * ⬜ **Owner-restricted invite mode (deferred decision)** — whether to also offer creator-only invites. * ⬜ **At-rest encryption for the saved GitHub token** — today it's plaintext-at-rest (webhook-secret parity); envelope encryption with a server-held key would harden against DB/disk/backup leaks. * ⬜ **Routine can send a bot** (`routine.v1` gains a `target: agent|meeting`). * ⬜ **Chat migration (M1)** into `_system` (today `_system` holds `identity.md` + a README marker). * ✅ done since the last revision: **flat equal-rank model** (no baseline), **cwd follows the active set**, **light default seed + onboarding-dashboard README**, **first-view landing resolver**, **`_system` light identity**, **eager provision on account creation**, **per-workspace PURPOSE → mount preamble**, **the manage panel** (rename · on/off · GitHub · purpose · participants), **invite preview + post-login consent screen**, **participant roster shows emails (+ self-heal)**, **accepted invite lands on the shared README + Knowledge**, **reusable save-once GitHub token**, **minimal sidebar rows + attach-repo modal**. ## Related docs * `core/agent/control_plane/README.md` — Lane M membership/invites + the policy write-guard. * `core/agent/README.md` — the execution domain (dispatch, worker, contracts). * [Control plane](/architecture/control-plane) — control-plane boundary. * `core/agent/contracts/workspace.v1/` — the workspace git-repo contract. # Deployment Source: https://docs.vexa.ai/deployment Self-host Vexa with Docker Compose — air-gapped, with bring-your-own inference. Vexa runs **in your own environment** — open-source, self-hostable, air-gappable. Data, recordings, and agent state stay on infrastructure you control. This page is the Docker Compose path; the other two supported shapes are [Vexa Lite](/deployment-lite) (one container, no Docker socket) and [Kubernetes](/deployment-kubernetes) (Helm, a Pod per bot). ## Quick start (Docker Compose) Prerequisites: a Linux host (Ubuntu 24.04) for production, **Docker engine ≥ v26** (agent workers mount each granted workspace as an isolated volume subpath — older engines fail worker creation; `make all` checks and refuses), `git`, `curl`. A Mac with Docker Desktop works for a local evaluation — everything runs in containers either way. Published `vexaai/v012-*` images include `linux/arm64` variants alongside `linux/amd64`, so Docker on Apple Silicon pulls arm64 where available — but the arm64 images are published **best-effort**: release CI currently validates only the amd64 images (no arm64 execution leg yet), so treat Apple Silicon as experimental. `vexaai/vexa-bot` remains amd64-only (the install pulls the published image; `make bot` builds a local `vexa/vexa-bot:dev` for development). For GPU transcription on Mac, point `TRANSCRIPTION_SERVICE_URL` at any OpenAI-compatible local endpoint (see [Configuration](/configuration)). ```bash theme={null} curl -fsSL https://get.docker.com | sh git clone https://github.com/Vexa-ai/vexa.git && cd vexa make all # full stack via Docker Compose — each service in its own container make bot # build the meeting bot FROM SOURCE — required before a bot can join a meeting ``` `make all` seeds `.env` from `.env.example`, brings the stack up, and **prints an API key plus the service URLs** when it's done. The **meeting bot is built from source** (`make bot`), **not pulled** — the published `vexaai/vexa-bot:dev` on Docker Hub is the older 0.10 line and is **not compatible** with this stack's `lifecycle.v1` (bots reach `joining` then fail). `make all` warns loudly if the bot image is missing. For a transcript, set a **transcription (STT) token** in `.env` (`TRANSCRIPTION_SERVICE_TOKEN`) — get one at `vexa.ai/account`, or self-host the transcription service on a GPU for a fully air-gapped install. The API is then at `http://localhost:18056` (the gateway) and the terminal web workbench at `http://localhost:13000`. ## The stack | Service | Role | | ------------------------------------- | -------------------------------------------------------------------------------- | | **gateway** (`:18056`) | the one front door — auth, scopes, routing | | **admin-api** | users + API keys | | **meeting-api** | bots, transcripts, **recordings** (to object storage) | | **runtime** | spawns bot + agent **containers** on demand (via the Docker socket) | | **agent-api** | the [agent control plane](/api/agent) — dispatch, chat, routines, events | | **terminal** (`:13000`) | the web workbench — proxies `/ws` → gateway and REST/login → agent-api/admin-api | | redis (Valkey) · postgres · **minio** | bus + scheduler · metadata · object storage (recordings + workspaces) | The **bot is not a long-running service** — the [runtime](/core/runtime) spawns a browser container per meeting (`BROWSER_IMAGE`) and an agent container per dispatch (`AGENT_IMAGE`), then reaps them. The `BROWSER_IMAGE` is **built from source** here (`make bot`) and the runtime spawns it **without pulling** — so it must exist locally before any bot can join (build it once; `make all` checks and warns if it's absent). ## Configuration * **Transcription (STT)** — `TRANSCRIPTION_SERVICE_URL` / `TRANSCRIPTION_SERVICE_TOKEN`. Unset → default `POST /bots` answers **503** (refuse loud). Capture-only: set `TRANSCRIBE_ENABLED=false` or pass `{"transcribe_enabled": false}` on the spawn. See [Configuration](/configuration#transcription-stt). * **Object storage** — MinIO (`MINIO_*`): meeting recordings and agent workspaces live in your bucket. The default `MINIO_HOST_PORT=9000` is a common port — if it's already taken on your host (`make all` fails with `bind … 127.0.0.1:9000 … address already in use`), set a free port in `.env`. * **Agent inference** — bring your own: point the agent at your endpoint so no inference leaves the network (`VEXA_AGENT_MODEL` / mounted credentials). * **Secrets** — `ADMIN_TOKEN`, `INTERNAL_API_SECRET`, DB credentials. Set real values before exposing. ## Transcription (the separate GPU unit) Speech-to-text is the one **GPU workload**, so it is **carved out** of the main stack: `make all` runs GPU-free and anywhere, and the STT service is its own deploy unit at [`deploy/transcription`](https://github.com/Vexa-ai/vexa/tree/main/deploy/transcription) ([`core/meetings/services/transcription`](https://github.com/Vexa-ai/vexa/tree/main/core/meetings/services/transcription) is the brick — faster-whisper / CTranslate2 behind an OpenAI-compatible `/v1/audio/transcriptions`). Language is detected per transcription window and stamped on each segment; force a single language via the bot request's `language` (see [Send a bot](/how-to/send-a-bot)). Stand it up wherever a GPU lives (the same host or a dedicated GPU box): ```bash theme={null} cd deploy/transcription cp .env.example .env # set MODEL_SIZE, API_TOKEN, TRANSCRIPTION_LB_PORT docker compose up -d # GPU (needs nvidia-container-toolkit) # no GPU? CPU variant (slower, use a smaller model): docker compose -f docker-compose.cpu.yml up -d curl http://localhost:8083/health # waits on the model load ``` Then point the main stack at it in `deploy/compose/.env`: ```bash theme={null} TRANSCRIPTION_SERVICE_URL=http://:8083 # base URL; client appends /v1/audio/transcriptions TRANSCRIPTION_SERVICE_TOKEN= ``` **Which model id goes where:** the stack sends `TRANSCRIPTION_MODEL` as the OpenAI-compatible `model` field on every request (unset → `whisper-1`). The **bundled unit ignores it** — pick its model with the unit's own `MODEL_SIZE`. Backends that **validate** the field need the right id: **Groq** → `whisper-large-v3-turbo`, **OpenAI** → `whisper-1` / `gpt-4o-transcribe`, **vLLM/LiteLLM** → the exact served model name. Any OpenAI-compatible `/v1/audio/transcriptions` endpoint works: set `TRANSCRIPTION_SERVICE_URL` to its base URL, `TRANSCRIPTION_SERVICE_TOKEN` to its key, and `TRANSCRIPTION_MODEL` to the id it expects. Now bots transcribe end-to-end: **bot → transcription service → segments → meeting-api `collector` → live fan-out**. Scale by adding workers (one GPU each) in the unit's `docker-compose.yml` + `nginx.conf`. ## Publishing behind a reverse proxy `make all` binds every service to `127.0.0.1` (loopback only). To expose the **terminal** at a public hostname, put a TLS-terminating reverse proxy in front of the terminal port (`TERMINAL_PORT`, default `13000`) and tell the terminal its public origin so auth cookies and OAuth callbacks are correct: ```bash theme={null} # deploy/compose/.env NEXTAUTH_URL=https://your-host.example.com NEXTAUTH_SECRET= # don't ship the dev default ``` An nginx vhost (the terminal proxies `/ws` to the gateway itself, so the proxy only needs standard WebSocket-upgrade headers): ```nginx theme={null} server { listen 443 ssl; server_name your-host.example.com; ssl_certificate /etc/letsencrypt/live/your-host.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/your-host.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:13000; # TERMINAL_PORT proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; # terminal /ws → gateway proxy_set_header Connection "upgrade"; proxy_read_timeout 86400; } } ``` If you publish the **gateway** (`:18056`) through a reverse proxy and turn on the edge guard (`GUARD_ENABLED=true`), set `GUARD_TRUSTED_PROXIES` to the proxy's IP and have the proxy forward the real client IP: ```nginx theme={null} proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ``` Without it, every request keys to the proxy IP — one global rate-limit/ban bucket shared by all clients, so one abuser throttles everyone. The gateway honors the `X-Forwarded-For` chain only from an IP named in `GUARD_TRUSTED_PROXIES` (it reads the rightmost entry); a spoofed header from any other source is ignored, so it cannot rotate an abuser's budget. See [Configuration → Gateway edge protection](/configuration#gateway-edge-protection). The terminal carries its own Google/Microsoft OAuth login, so the proxy needs no auth of its own. ## Air-gapped Everything runs in-VPC: gateway + services + redis/postgres/minio on your host, the [transcription unit](#transcription-the-separate-gpu-unit) on your own GPU, **BYO inference**, recordings in your object storage. **Zero egress** — the posture the regulated verticals require. # Kubernetes (Helm) Source: https://docs.vexa.ai/deployment-kubernetes Deploy Vexa on Kubernetes or OpenShift with the Helm chart — a Pod per bot and per agent, scalable to thousands of users. The Helm chart at [`deploy/helm/charts/vexa`](https://github.com/Vexa-ai/vexa/tree/main/deploy/helm) deploys the same control plane as the Compose stack, with the runtime pointed at the cluster: `RUNTIME_BACKEND=k8s` makes **every bot and every agent dispatch its own bare Pod** (`restart: Never`), so capacity is your cluster's scheduler — not a bigger box. ## What the chart deploys | Component | Form | | ------------------------------------------------------- | -------------------------------------------------- | | gateway · admin-api · meeting-api · agent-api · runtime | Deployments (RollingUpdate, `maxUnavailable: 0`) | | terminal (web workbench) | Deployment + ingress | | postgres | StatefulSet (or point values at your managed DB) | | redis (Valkey) · minio | Deployments (+ minio init Job) | | DB migrations | Job | | pgbouncer | optional connection pooling | | dashboard (deprecated 0.10 UI) | optional Deployment + Service, **off by default** | | RBAC for the k8s spawn backend | Role/RoleBinding scoped to Pod create/watch/delete | Hardened defaults throughout: containers run non-root with all capabilities dropped, PodDisruptionBudgets on the control plane, and rolling updates that never take the last replica down. ## Install ```bash theme={null} git clone https://github.com/Vexa-ai/vexa.git && cd vexa helm install vexa deploy/helm/charts/vexa -f deploy/helm/charts/vexa/values.yaml ``` `values-staging.yaml` and `values-test.yaml` show environment overlays. Chart tests (`helm lint` + template tests) live under `deploy/helm/tests/`. ### Is this install actually working? — `make probe SURFACE=helm` The standing full-journey smoke probe drives spawn → schedule → boot → join → transcribe → live-view → stop through the gateway front door, then sweeps every deployment's logs once. Each stage prints Expected / Actual / Verdict; a red stage names where the journey broke and fails the command, so you get a truthful install verdict in minutes with no meeting and no humans. ```bash theme={null} make probe SURFACE=helm # port-forwards gateway + admin-api GATEWAY_URL=http://: make probe SURFACE=helm # or drive a NodePort directly ``` It mints its API key from the release secret's `ADMIN_API_TOKEN` (or pass `VEXA_API_KEY`). The same journey runs on the other surfaces: `make probe` (compose, the fast default) and `make probe SURFACE=lite`. ## The runtime on Kubernetes The runtime kernel owns one `runtime.v1` lifecycle (`starting → running → stopping → stopped → destroyed`) and delegates only the substrate question — *how do I start, observe, and stop a workload?* — to the backend. On `k8s`, a workload is a bare Pod; the same dispatch, contracts, and worker run identically to the Docker backend. See [Execution](/architecture/execution). ### Scheduling spawned Pods on a tainted / dedicated pool A spawned bot or agent Pod is created with `kubectl run` — it is **not** a child of the runtime Deployment, so it does **not** inherit `global.nodeSelector` / `global.tolerations`. On a cluster whose nodes are all tainted (the standard pool-segregation pattern — dedicated, spot/temp, or GPU node pools), a spawned Pod with no tolerations sits `Pending` forever (`untolerated taint(s)`) and the meeting silently fails while the runtime itself — a Deployment that *does* apply those constraints — looks healthy. The chart closes this by handing the runtime its **own** scheduling constraints as env, which the spawn backend stamps onto every Pod it creates: | Env on the runtime | Chart source | Applied to | | --------------------------- | ---------------------------------------------------------- | --------------------------------------- | | `RUNTIME_K8S_TOLERATIONS` | `runtime.tolerations` ⟶ defaults to `global.tolerations` | every spawned Pod's `spec.tolerations` | | `RUNTIME_K8S_NODE_SELECTOR` | `runtime.nodeSelector` ⟶ defaults to `global.nodeSelector` | every spawned Pod's `spec.nodeSelector` | By default a spawned Pod schedules **wherever the runtime itself can** — set `global.tolerations` / `global.nodeSelector` for your pool and both the runtime and everything it spawns land there, with no extra action: ```yaml theme={null} global: tolerations: - key: vexa.ai/pool operator: Equal value: main effect: NoSchedule nodeSelector: vexa.ai/pool: main ``` Set `runtime.tolerations` / `runtime.nodeSelector` only to send spawned workloads to a **different** pool than the kernel (e.g. GPU bots). Both values are serialized to JSON; malformed JSON fails the spawn loudly rather than dropping the constraint (a dropped constraint is the stranded-Pod bug). **Spawned-workload commands must exist in the target image under k8s (entrypoint-replace) semantics.** A runtime profile may carry a `command`; the k8s backend passes it as `kubectl run --command -- …`, which **replaces** the image `ENTRYPOINT` (it becomes the Pod's `argv[0]`). So a command path that is not present in the image `StartError`s **every** spawn on k8s — even though the Docker backend, which *appends* the command to the entrypoint as arguments, silently ignores the same bad path. The meeting-bot profile therefore carries **no** command: the bot image's own `ENTRYPOINT` (`/app/entrypoint.sh`) boots it on every backend. At release time an image↔profile conformance gate (`image-identity`) asserts each profile command is either empty or an in-image executable against the published images, so a profile that diverges from its image fails the release rather than a customer's install. ### Autoscaling and bot bursts — the fresh-node reachability gate Because every bot is its own bare Pod, a burst of meeting requests can push the cluster autoscaler to add nodes, and bots get scheduled onto them the moment they report `Ready`. But a brand-new node's network can take up to a few minutes to fully converge *after* `Ready` — the CNI/NetworkPolicy programming, kube-proxy, and DNS warmup all lag node readiness. A bot scheduled into that window can find its control plane (its meeting-api callback URL and redis) unreachable on its first outbound hop. The bot handles this with a **pre-join reachability gate**: it makes its first `joining` lifecycle emit load-bearing. If the meeting-api callback is reachable (any HTTP response), it joins immediately — zero added latency. If not, it probes redis; **if either channel is up it proceeds** (it can still report), and **only if both are down does it refuse to join**, terminating fast (\< a few seconds) with: * **Pod exit code `3`** — the attributable terminal signal (`kubectl describe pod ` → `Last State: Terminated, Exit Code: 3`). Distinct from a real join failure (exit `1`). * a `failed` lifecycle event carrying `failure_stage: requested` and `infra_fault: control_plane_unreachable` on whichever channel recovers (often none — the exit code is the signal). This converts an opaque CrashLoop / stuck-`requested` meeting into a fast, attributed failure an operator can read in one `kubectl describe`. See [Troubleshooting → exit code 3](/troubleshooting). **What stays cluster-side (not solved by the gate):** the node-readiness window itself. Consider pre-pulling the bot image onto new nodes (a cold pull can itself take minutes and mask the network window), and treat node `Ready` as insufficient for scheduling network-dependent work — the gate fails fast and, on k8s, the exit lets the operator (or an autoscaler policy) reschedule onto a warmed node. ## Scaling meeting-api `meetingApi.replicaCount` defaults to `2`, and that is safe for the segment consumer. Each replica now joins the `transcription_segments` consumer group under a **per-pod identity** (`collector-`, overridable with `COLLECTOR_CONSUMER_NAME`), and a surviving replica periodically `XAUTOCLAIM`s a crashed replica's un-acked segment batch and drains it through the normal persist path — so no transcript segments are orphaned when a pod dies mid-batch. The reclaim only fires once a batch has idled past `COLLECTOR_RECLAIM_MIN_IDLE_MS` (default `60000`), so a live peer's in-flight batch is never stolen. `/health` exposes `pipeline.pending_depth` (the group's delivered-but-un-acked count) alongside `pipeline.consumer_lag`, and degrades to `503` once it exceeds `PIPELINE_PENDING_ALARM` (default `100`) — a stuck batch is a reportable state, not a silent stall. A pod's hostname changes on **every recreate** (a rolling deploy, a pod restart, a compose `--force-recreate`), so each new container joins `collector_group` under a **new** `collector-` and the old name is left behind. Those abandoned consumers hold no pending entries (they read their last message and acked it), so they are harmless to correctness — but left unchecked the group fills with dead names that inflate operator `XINFO CONSUMERS` reads and muddy `/health`. The same reclaim sweep now **prunes** them: any consumer with `pending == 0` that has idled past `COLLECTOR_CONSUMER_TTL_MS` (default `1800000`, 30 min — well above the reclaim idle gate, so a briefly-quiet live replica is never touched) is removed with `XGROUP DELCONSUMER`. A consumer holding an in-flight batch (`pending > 0`) is **never** pruned, and the running replica never prunes itself; a live consumer re-registers on its next `XREADGROUP`, so the prune is idempotent and self-healing. Reading `XINFO CONSUMERS collector_group` should therefore list only the replicas that actually exist — dead `collector-` names are expected to disappear within a sweep of the TTL. For k8s you can eliminate the churn at the source by pinning a stable per-replica identity via `COLLECTOR_CONSUMER_NAME` (e.g. a StatefulSet ordinal) so a restart re-uses the same consumer. **Every surface now ships Valkey 8.x**, which has `XAUTOCLAIM` — so orphan reclaim is active everywhere Vexa ships, including Vexa Lite (compose, helm, and Lite all run Valkey, the Linux Foundation BSD-3 fork of Redis 7.2.4; see [the changelog](/changelog) and #653). Reclaim, per-pod identity, and ghost-consumer pruning all work on the shipped stack. Check yours with `valkey-server --version`. The degradation path below still exists as a safety net for a *bring-your-own* backing store older than the floor: on a Redis/Valkey without `XAUTOCLAIM` the reclaim disables itself and logs once at startup; everything else (normal `XREADGROUP` consumption, `pending_depth`) is unaffected. The cost is narrow but real — if a replica dies mid-batch on such a store, that batch stays pending instead of being drained by a peer, so run a single replica or move to a store with `XAUTOCLAIM` (Redis ≥ 6.2 / Valkey ≥ 7.2). Ghost-consumer pruning degrades the same way: if the backing store rejects `XINFO CONSUMERS`, the prune logs once and no-ops, leaving the consume path untouched. ## In-cluster self-addressing Under Helm the meeting-api Service is release-qualified (`-vexa-meeting-api`), not the bare `meeting-api` that the Compose stack uses. So the chart sets `MEETING_API_URL` explicitly on the meeting-api deployment to `http://-vexa-meeting-api:8080` — the address a spawned bot calls back on for its lifecycle callback and recording upload. Left unset it would fall back to the compose-only default `http://meeting-api:8080`, which does not resolve in-cluster. The chart sets the same address as `VEXA_MEETING_API_URL` on the **agent-api** deployment. Before opening a live-transcript SSE stream (`GET /api/meeting/stream`), agent-api verifies the caller owns the meeting by calling `GET /meetings/{id}` on meeting-api, and **fails closed** — an unreachable meeting-api returns `403 "not authorized for this meeting"` for the meeting's own owner. Left unset it would fall back to the compose-only `http://meeting-api:8080`, so the owner sees a permanent "Reconnecting to live stream…" instead of streaming words. The chart likewise sets `ADMIN_API_URL` on the meeting-api deployment to `http://-vexa-admin-api:8001` (mirroring Compose's `ADMIN_API_URL=http://admin-api:8001`). This is **required** for two background loops: calendar sync (discovering each user's connected ICS feed through admin-api's internal edge) and capped auto-join (fetching the per-user max-bots cap for every scheduled-meeting spawn). With `ADMIN_API_URL` unset, calendar sync no-ops and the auto-join sweep cannot resolve the per-user cap. The meeting-api then **fails closed and refuses to spawn** rather than spawn uncapped; set `AUTO_JOIN_ALLOW_UNCAPPED=1` only if you deliberately want uncapped auto-join spawns on a self-host. ## The deprecated dashboard (optional) The 0.10 dashboard — the multi-user web UI that predates the Terminal — ships as an **off-by-default** component while it is still load-bearing: hosted production runs it against the 0.12 core, and the authenticated-session flows are only walkable through it today. Enable it explicitly: ```bash theme={null} helm upgrade --install vexa deploy/helm/charts/vexa -n vexa \ --set dashboard.enabled=true \ --set dashboard.publicUrl=https://dashboard.example.com \ --set dashboard.publicApiUrl=https://api.example.com ``` It runs the pinned external image `vexaai/dashboard` on its own tag — `global.imageTag` does not apply to it — and talks to the gateway's hosted-compat surface. It is **deprecated**: when the Terminal covers its remaining flows, the component is deleted, not ported. On Compose the same option is `docker compose --profile dashboard up -d` (port 13001). ## Honest status The k8s backend **lifecycle and the workspace mount are implemented** (the workspace store binds into each worker Pod as a PVC, scoped per-mount). The Compose stack is the path with the most production mileage today; the chart is the right starting point for a cluster evaluation — track the [status page](/roadmap/status). ## Air-gapped clusters Everything the chart deploys pulls from images you build and host in your own registry; pair it with the [self-hosted transcription unit](/deployment#transcription-the-separate-gpu-unit) and your own LLM endpoint ([Configuration](/configuration)) for a zero-egress posture. See [Security & compliance](/security-compliance). # Vexa Lite (single container) Source: https://docs.vexa.ai/deployment-lite The whole control plane in one container — process runtime backend, no Docker socket, two datastore sidecars. Vexa Lite packs every control-plane service into a single image (`vexaai/vexa-lite`) and runs bots and agent workers as **in-container processes** (`RUNTIME_BACKEND=process`) — no Docker socket, no per-bot containers. Two sidecars carry state: Postgres and MinIO. Lite is one of the three supported deploy paths (lite · [compose](/deployment) · [Kubernetes](/deployment-kubernetes)) and ships from the same images and contracts. ## Quick start ```bash theme={null} git clone https://github.com/Vexa-ai/vexa.git && cd vexa make lite ``` `make lite` (= `make -C deploy/lite all`) writes a minimal `.env` if you don't have one, pulls `vexaai/vexa-lite:$IMAGE_TAG` (default `v012`; a locally built `vexa-lite:dev` wins when present), starts the `vexa-lite-postgres` and `vexa-lite-minio` sidecars on the `vexa-lite-net` Docker network, boots the app container, waits on the gateway health endpoint, and probes the three front doors: | Front door | Port | | ------------------------------------------------------------------------------- | ------ | | **gateway** — the public REST API | `8056` | | **terminal** — the workbench UI | `3001` | | **agent-api** — the agent control plane (reached directly, not via the gateway) | `8100` | On first boot Lite mints its own credentials — a `self-host@vexa.ai` user and `bot,tx`-scoped API keys — and hands them to the terminal, so `http://localhost:3001` opens signed in. Set `VEXA_API_KEY` in `.env` to bring your own key and skip the minting. Transcription needs a backend: point `TRANSCRIPTION_SERVICE_URL` / `TRANSCRIPTION_SERVICE_TOKEN` at a [transcription unit](/deployment#transcription-the-separate-gpu-unit), or run the bundled CPU sidecar: ```bash theme={null} # CPU Whisper (faster-whisper tiny.en, English-only) on :8083 — for trying it, not for real meetings make -C deploy/lite up LOCAL_STT=1 make -C deploy/lite stt-smoke # synthesizes speech in a container, asserts a transcript comes back ``` Without a transcription backend, bots join and capture but produce no text — a default `POST /bots` answers `503` unless the spawn opts out ([capture-only](/how-to/send-a-bot#capture-only-no-stt)). ## What's in the container One supervisord tree runs the services a compose deployment spreads across containers: | Program | Role | | ---------------------------------------------------------------------- | ----------------------------------------------------------- | | **gateway** (`:8056`), **terminal** (`:3001`), **agent-api** (`:8100`) | the three published front doors | | **meeting-api, admin-api, runtime** | loopback-only (`127.0.0.1`) — reach them with `docker exec` | | **valkey** | in-container Redis-compatible store, loopback-only | | **Xvfb · fluxbox · pulseaudio · x11vnc · noVNC** | the shared virtual display and audio graph bots run on | Bots and agent workers are **not** supervisord programs — the runtime spawns them as child processes, one per meeting and per agent dispatch. Inspect a running deployment with: ```bash theme={null} docker logs -f vexa-lite # all service logs go to stdout docker exec vexa-lite supervisorctl status # the service tree docker exec vexa-lite ps aux | grep dist/index.js # live bot processes ``` ## Configuration Lite reads the repo-root `.env` (via `docker run --env-file`) plus the flags the Makefile sets explicitly. Every key in the [configuration reference](/configuration) applies; mind its warning about inline `# comments` in `.env` values — it exists because of Lite's `--env-file` semantics. If `~/.claude/.credentials.json` exists on the host, `make lite` mounts it read-only into the container so agents run on your Claude subscription without copying credentials into `.env`. ## Persistence and upgrade State lives in the sidecars' named volumes and survives app-container replacement: | Data | Where | | --------------------------------------- | --------------------------------------------------------------------------------- | | Users, meetings, transcripts (Postgres) | volume `vexa-lite-pgdata` | | Recordings + agent workspaces (MinIO) | volume `vexa-lite-miniodata` | | Valkey state and `/workspaces` scratch | **in-container, ephemeral** — mount `/var/lib/redis` yourself if you need it kept | Upgrading is re-running with a newer image: set `IMAGE_TAG` in `.env` (pin a specific release so deploys are reproducible), then `make lite` again — it replaces the app container and leaves the sidecars and their volumes untouched. Schema converges in-process on service startup; there is no separate migration step. ```bash theme={null} make -C deploy/lite down # containers go, volumes stay docker volume rm vexa-lite-pgdata vexa-lite-miniodata # full wipe ``` ## Is this install actually working? — `make probe SURFACE=lite` ```bash theme={null} make probe SURFACE=lite ``` The probe mints a key and drives the whole journey — spawn → boot → join → transcribe → live-view → stop. Lite runs the **real** bot in-process, so a dead meeting URL ends in a truthful named failure, never a fake green. The container also carries a Docker `HEALTHCHECK` on the gateway's `/health` (30s interval, 120s start period). ## Limits vs compose and Kubernetes * **One shared display.** Bots share a single Xvfb — best for one browser session at a time. Compose isolates bots in containers; the Helm chart gives each bot its own Pod. * **Ephemeral Valkey.** In-container with no volume by default. * **Agent API is not gateway-fronted.** Clients reach `:8100` directly; gateway-fronting is roadmap. * **No MCP service.** The compose stack's `/mcp` surface does not exist in Lite. * **Sidecar ports are unpublished.** Postgres and the MinIO console are reachable only on the Docker network, not from the host. Outgrown it? Switch to [compose](/deployment) — same images, same contracts. Per-feature honest status: [Roadmap → Status](/roadmap/status). # Architecture compliance (generated) Source: https://docs.vexa.ai/governance/arch-compliance Generated map: every modularity principle and the gate that enforces it. > Generated by `scripts/arch-report.mjs`. The constitution promises every principle names the practice > it comes from **and the gate that enforces it** ([Architecture](/governance/architecture) §2/§4, **P9**: an > ungated rule is aspirational). This is that map, live — each modularity principle → its CI gate(s) → > the current evidence. **P9 itself is the table**: every row is a green gate, not a README rule, so > "fully modular" is a claim backed by mechanical evidence. Regenerate with `node scripts/arch-report.mjs`; > `gate:arch-report` keeps it honest in CI. | Principle | Rule | Gate(s) | Evidence | Status | | --------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | | **P2** | Couple only through contracts — no import reaches around a contract into a sibling module's internals (prod AND tests). | `gate:isolation` · `gate:isolation-py` · `gate:test-isolation` | 17 brick(s) checked; every Python sibling import is own-module, declared, or an allowed edge; no Python test imports a sibling module's internals (test lane gated, P2) | ✅ | | **P3** | Dependencies point inward to the kernel; the graph is acyclic (runtime depends on nothing above). | `gate:graph` · `gate:graph-py` | acyclic + allowed-edges; Python cross-package edges acyclic + allow-listed | ✅ | | **P4** | Cross a process/network boundary → a sealed, versioned, golden-pinned schema. | `gate:schema` · `gate:contract-version` | 24 contract(s) conform (goldens ≡ schema); 24 sealed contract(s) frozen | ✅ | | **P6** | One public front door per module; internals are private. | `gate:exports` | 13 library package(s) lock their front door | ✅ | | **P12** | Every folder self-documents (README: concern · surface · deps). | `gate:readme` | 299 dirs each carry a README | ✅ | | **P23** | One writer per data carrier; a reader never re-derives a producer's data (data-flow ownership, architecture.calm.json). | `gate:dataflow` | 81 nodes · 52 edges · 11 carriers · complete + sealed (P23) | ✅ | **Modularity verdict: all gates green — the v0.12 backend is fully modular by the constitution's own definition.** Per-service module structure (each a front-doored brick, independently testable): `meeting-api` = `{lifecycle · bot_spawn · collector · recordings · webhooks · scheduling · sessions · obs}`; `runtime` = the kernel (imports nothing above); `gateway`/`identity`/`agent-api` per their domains. The test lane is gated too (`gate:test-isolation`) — a test cannot reach across a module boundary where prod imports can't. # Architecture — the governing reference Source: https://docs.vexa.ai/governance/architecture The build constitution: principles P1–P23, each naming the practice it comes from and the gate that enforces it. > The constitution. Before you add a file, move a module, or define a contract, it is > governed by something here. **Every principle names the established practice it comes from > *and* the CI gate that enforces it** — so no rule lives by convention alone. If a rule is not > gated, it is aspirational; treat closing that gap as work. > > Scope note: `crm` and `retrieval` are **deferred**, and there is no `memory` domain. A workspace > is a *user-owned git repo* (data, not platform code); `crm` is an *application* — one entity schema > over a workspace (see **P11**) — not a platform domain. This doc governs what we build now. *** ## 0. The shape, in one sentence Vexa is **contract-bounded at two scales** — a handful of **microservices** coupled only by published schemas, each internally a **modular monolith** of modules coupled only by ports — all over a shared **runtime kernel**. The construction discipline is modular-monolith; the deployment shape is microservices, carved where a real force requires it (runtime, scale, data, ephemerality). *** ## 1. Concepts — the vocabulary (use these words precisely) | Term | What it is | In Vexa | Canonical source | | -------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | **Module** | unit of *composition* — a library behind a contract; compile-time, no runtime of its own | `@vexa/*` bricks under a domain's `modules/` | Parnas (*information hiding*, 1972); Szyperski (*Component Software*) | | **Service** | unit of *deployment* — a process with a lifecycle and an address; runtime | a dir with an entrypoint (bot, meeting-api, agent-api) | C4 "container" (Brown) | | **Domain** | a *bounded context* — one cohesive concern with its own language | `runtime/ meetings/ agent/ identity/ …` | DDD (Evans) | | **Contract** | the *only* sanctioned coupling between two parties | ports (in-process) + schemas (at boundaries) | Design by Contract (Meyer); Published Language (DDD) | | **Port** | an interface the core depends on — a "hole" an adapter fills | `JoinDriver`, `Pipeline`, `TranscriptSink` | Hexagonal / Ports & Adapters (Cockburn) | | **Adapter** | binds a port to a real transport or external brick | `join-vexa`, `transcript-redis`, `lifecycle-http` | Hexagonal; Anti-Corruption Layer (DDD) | | **Published schema** | a *language-neutral* contract at a boundary | `contracts/*.v1` (JSON Schema / OpenAPI + golden vectors) | schema-first / consumer-driven contract testing | | **Kernel (runtime)** | the domain-agnostic execution substrate everything sits on | `runtime/` (spawn/execute, now or scheduled; mounts the workspace) | platform substrate | | **Workspace** | a *user-owned git repo* — durable memory the agent reads/writes; **data, not platform code** | the user's repo; template = `core/agent/contracts/workspace.v1`; mount = a `runtime` capability | git-as-database; mechanism-not-policy | | **Composition root** | the one place wiring happens; the only place adapters meet the core | a service's `index.ts` / `main` | DI composition root (Seemann) | | **Worker** | an ephemeral, stateless service spawned on demand and disposed | `bot` (per meeting), `agent` (per run) | 12-Factor (disposability) | *** ## 2. Principles — the rules (each has a *why*, a *source*, and a *gate*) | # | Principle | Why | Source | Enforced by | | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **P1** | **Package by domain, not by layer.** Top-level dirs name the business (`meetings`, `agent`), never the framework (`controllers`, `utils`). | the structure should scream what the system *does* | Screaming Architecture (Martin) | review + structure | | **P2** | **Couple only through contracts.** No import reaches around a contract into another module's internals. | a boundary you can't reach around can't rot | Information hiding (Parnas); Bounded Context (DDD) | `gate:isolation` | | **P3** | **Dependencies point inward to the kernel; the graph is acyclic.** `runtime` depends on nothing above it. No cycles, ever. | the core must never depend on the edges; a cycle is mud | Clean / Onion architecture (Martin, Palermo) | `gate:graph` | | **P4** | **Cross a language or domain boundary → a published schema. Stay intra-domain and in-process → nest the contract with its owner.** A contract that crosses a **process / network / independently-deployable boundary is sealed + versioned + golden-pinned even when both sides are the same language and domain** — only a truly in-process, same-artifact call nests as a bare port. | the Python consumer can't `import` a TS type; *and* two sides that deploy independently (extension↔desktop, bot↔desktop) drift silently across a same-language wire unless it's pinned — `capture.v1` is the busiest such wire | schema-first / contract testing; independent deployability (Newman) | `gate:schema` · `gate:contract-version` (every cross-process `.v1`) | | **P5** | **Adapt at every boundary someone else owns.** Their vocabulary is translated at the edge, never leaked into your core. | one brick's churn must not ripple into your logic | Hexagonal + Anti-Corruption Layer (Cockburn, Evans) | review | | **P6** | **One public front door per module; internals are private.** Consumers import the `index`, never a deep path. | you can refactor freely behind a stable surface | encapsulation / information hiding | `gate:exports` | | **P7** | **Workers are stateless and ephemeral; all config arrives by env.** They spawn, work, emit, and die. | horizontal fan-out (one bot per meeting) + disposability | 12-Factor (Wiggins) | review | | **P8** | **The goldens are the spec.** A contract's truth is its committed example vectors, not the current output of any implementation. | stops "fix the test to match the bug" | golden/contract testing; fitness functions | `gate:schema-conformance`, `gate:unit` | | **P9** | **Every boundary is mechanically enforced, not aspirational.** A rule in a README rots; a rule that turns CI red cannot be crossed. | this is the meta-principle that keeps a modular monolith from decaying | Evolutionary Architecture / fitness functions (Ford, Parsons, Kua) | all gates | | **P10** | **Default to a module; carve a service only when a force requires it** (independent scale, different runtime, separate team, hard fault isolation). | distribution is a tax — pay it on purpose, not by reflex | Modular Monolith (Brown) | review | | **P11** | **Mechanism, not policy.** The platform owns *mechanism* (`runtime`, contracts, the workspace primitive); a *specific* entity schema, integration, or a customer's workspace is **config at the edge — never a platform domain.** | a sales CRM schema and a bank's control catalog are both just schemas; freeze one into the platform and it fits no one else | mechanism-not-policy (microkernel tradition); Open/Closed | review + structure | | **P12** | **Every folder self-documents.** A directory at any level carries a `README.md` stating its one concern, its public surface (the `index`/contract it exposes, or the children it groups), and what it may depend on. Trivial leaves get a one-liner — the rule is *existence*, not length. | a modular tree must be navigable; the README is the front-door *doc* beside the front-door *code* (P6) | self-documenting systems; the `modules/README` symptom→brick router | `gate:readme` | | **P13** | **Language minimalism.** Add a language only when an ecosystem forces it (browser→TS, ML→Python); the control plane's language is a deliberate choice. Align every language boundary with a service+contract boundary — never mix languages within a module. | each language multiplies the schema surface | mechanism-not-policy; P10 for languages | review · ADR-0001 | | **P14** | **Config is a validated contract, delivered by env.** App vars are `VEXA_*`; structured config travels as one JSON env var validated against a `*.v1` schema; secrets are a class (`*_TOKEN`/`_SECRET`/`_KEY`) — never logged, committed, or in goldens; validate at boot, fail fast. | env is where config discipline usually leaks | 12-Factor; schema-first | `.env.example` · ADR-0002 | | **P15** | **User data & secrets are protected by default.** Data → per-tenant envelope encryption (crypto-shreddable, BYOK); secrets → a vault behind a port; the agent gets scoped, brokered, audited access — never raw keys in its workspace or logs. | a meeting product's data is its liability | data-protection; least privilege | ports now, impl deferred · ADR-0003 | | **P16** | **Defer the implementation, not the seam.** A deferred capability is a port with a default (passthrough) adapter, wired through now; the contract *fields* it needs are added **additively** when it lands (optional fields are back-compatible — so early threading buys nothing). | "plug-and-play later" only works if the socket exists now; but unused fields are noise | open/closed; ports & adapters; YAGNI | review · ADR-0003 | | **P17** | **Every dependency is OSS-licence-clean.** Direct *and transitive* deps carry an OSI-approved permissive licence (Cat A: Apache-2.0 / MIT / BSD / ISC / …); weak-copyleft (Cat B: MPL / EPL / LGPL) only when isolated (unmodified, not statically bundled) and exception-logged; strong-copyleft (GPL / AGPL) and source-available/proprietary (BSL / SSPL / Elastic / Commons-Clause) are **forbidden**. The platform must drop into a regulated org with zero licence encumbrance. | one GPL/AGPL or source-available dep *anywhere* in the tree blocks deployment in a bank — the licence tree is a hard deployment constraint, not a footnote | FINOS OSS governance; ASF licence categories A/B/X; SBOM/SPDX | `gate:licenses` · ADR-0004 | | **P18** | **Fail loud and attributable.** A dependency's failure is translated at its adapter into a **typed fault** (`source` + `kind`) and surfaced on an **observable channel** (log · telemetry · a health frame · a lifecycle event) — never swallowed into silent degradation. A running component also exposes its **health** (can it reach its dependencies?) and **liveness** (is the expected signal actually flowing?) — **absence of an expected signal is itself a reportable state.** "No output" must be distinguishable from "the dependency is down / unpaid / unauthorized" *and* from "nothing is arriving." | a silent *fault* read as "the extension is broken" (STT `402`); *and* a silent *absence* — "session active, zero audio frames" (the YouTube stream-not-minted case) — throws nothing yet looks identical to "no speech" | crash-only / fail-fast (Candea–Fox); observability (Majors); health checks + absence-of-signal (Google SRE) | `onError` seam + fault-surfacing gate · `/health` + no-frames watchdog (ADD) · ADR-0010 | | **P19** | **Prove at the altitude of the claim.** A capability is "done" only when proven at the level it operates: a user-facing *behavior* needs live evidence (L4), not just structural/contract green (L1–L3). State *which* level a "green" claim rests on; the proof obligation scales with the claim's blast radius. | the costliest failures hide behind L1–L3 green read as "works" — a lane marked done while gmeet was untested, YouTube intermittent, STT dead; the L1–L4 pyramid (§5) is the *mechanism*, this is the *obligation* that binds a claim to it | test pyramid (Cohn); risk-based verification; DORA | a recorded **L4 eval baseline** per user-facing lane (the `eval/` harness; `gate:eval-baseline` ADD) · ADR-0011 | | **P20** | **Complete mediation — authorize every access, default-deny.** Every read/write of a user-owned resource passes a `canAccess(subject, resource, action)` check at **every** path (API · live subscribe · agent), defaulting to owner-only. | P15 protects data *at rest* (encryption, secrets-as-a-class) but not *who may read it* — the desktop's `/transcripts`·`/recordings`·`/ws` are wide open, and ADR-0003's `canAccess` seam was designed but never wired, so it rotted (P9) | complete mediation + least privilege (Saltzer–Schroeder); default-deny | `canAccess` port on the three read paths + a deny test (`gate:access` ADD) · ADR-0012 | | **P21** | **Report state from evidence, not intent.** A component's displayed/reported state reflects **observed reality, not the action attempted** — a success/active status is *earned* by the confirming signal (capture is "Listening" only once frames are observed flowing; "started, no signal" is its own state, never "working"). `started ≠ working`. **Principles & gates extend to the clients** — the extension/desktop UI is where the user meets failure, so it is in scope, not exempt. | "Listening — capturing 0 stream(s)" flips to success on the Start *command* while no audio flows — an unearned positive that hides the commonest failure exactly where the user sees it; the client was the least-governed code (zero tests) precisely where it matters most | runtime dual of P19; positive complement of P18 (don't fake success); make-illegal-states-unrepresentable (Wlaschin) | first-frame-observed transition + no-frames watchdog + client state-machine tests (`gate:client-liveness` ADD) · ADR-0013 | | **P22** | **Guarantee teardown, don't request it.** A destructive lifecycle effect (stop/kill) is **guaranteed at the boundary**, never delegated to a fire-and-forget message a not-yet-subscribed consumer can miss. Pair a graceful command for a **confirmed-listening** consumer (so it finalizes cleanly) with a **hard guarantee** when it can't be confirmed listening (directly kill the workload), and **reconcile the create/destroy race** (the spawn re-checks for a stop that landed mid-boot). | a `POST` then immediate `DELETE` left an **orphan** bot live in the meeting (DB `stopping`, bot joined) — the stop only PUBLISHED `{action:leave}` over redis pub/sub, which has **no buffering for late/booting subscribers**, so a booting bot never received it; the teardown *requested* an effect over an unreliable channel instead of *guaranteeing* it | crash-only / fail-fast teardown (Candea–Fox); reliable-delivery vs fire-and-forget (pub/sub has no buffering); compensating action (Sagas) | direct `runtime.delete_workload` for a booting bot + spawn race-reconcile + reconcile loop; orphan regressions in `test_robustness_seam.py` (`gate:eval` *meeting-lifecycle* path, under `gate:python`) · ADR-0024 | | **P23** | **Data-flow ownership — one writer per data carrier; a reader never re-derives a producer's data.** Every redis stream · pubsub · table · blob (a *data carrier*) has exactly **one writer**; consumers read, they never re-transform a producer's data into a competing copy. The whole service/module/contract/client inventory + carriers + `connects`/`flows` live in ONE registry — `architecture.calm.json` (FINOS CALM) — which renders the picture (`pnpm arch:viz`) and is gated against drift. | the same transcript was reshaped by **three** owners (bot confirm · agent-api rewrite · terminal `buildMeetingNotes`) — invisible to a suite that modeled code coupling but not data-flow, found only by hand-tracing | data-flow / lineage modeling; single-writer; architecture-as-code (FINOS CALM) | `gate:dataflow` (+ chart seal `architecture.seal.json`) · learnings ledger | *** ## 3. The structure — render the chart, don't snapshot it The module / service / contract inventory **and** the runtime data-flow ARE the chart — [`architecture.calm.json`](https://github.com/Vexa-ai/vexa/blob/main/architecture.calm.json), the single source of truth, gated against drift (P23, `gate:dataflow`). A hand-written tree here would drift (it already had), so structure lives nowhere but the chart. Render any slice instead of restating it: ``` pnpm arch:viz cluster: # a bundle's services/modules/contracts + the carriers it touches pnpm arch:viz flow: | path: # a data path | a carrier's writers -> readers (contract per hop) ``` **Code-dependency rule** — the acyclic seam, enforced by `gate:graph` / `gate:graph-py` (spec in `.dependency-cruiser.cjs` + `scripts/check-isolation-py.mjs`), so it is doctrine, not a snapshot: a domain's internals (`services/`, `modules/`) may import only **its own code · another domain's `contracts/` · `core/runtime/contracts`** — never another domain's internals. **meetings ⊥ agent** at the internals level: agent may reference `core/meetings/contracts/transcript.v1` (that IS the seam), never `core/meetings/services` / `modules`. Contracts **nest with their owner domain** as JSON Schema (P4); the chart lists which contract each domain exposes. *** ## 4. The gates (CI — the teeth) Each gate enforces one or more principles. **An artifact "exists" only when it is gate-green** — *"verified-compliant" = passing this suite.* Admit nothing on trust. The rows below are kept in sync with `scripts/gates.mjs`'s `GATES` map — **every gate the runner exposes appears here** (`node scripts/gates.mjs all` runs them). ("ADD" = gap still open; "retire" = scheduled for removal.) | Gate | Checks | Enforces | Tool | Status | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `gate:isolation` | every import is intra-module, builtin, or a declared dep | P2 | `check-isolation.js` | **have** | | `gate:isolation-py` | every Python sibling-package import is own-module, a declared pyproject dep, or an allowed edge | P2 | `check-isolation-py.mjs --mode=isolation` | **have** (green-on-empty) | | `gate:graph` | module graph is acyclic + matches the allowed-edges spec | P3 | `dependency-cruiser` | **have** (green-on-empty; bites as packages land) | | `gate:graph-py` | Python cross-package edges are acyclic + allow-listed (the Python twin of the depcruise DAG) | P3 | `check-isolation-py.mjs --mode=graph` | **have** (green-on-empty) | | `gate:test-isolation` | no Python test imports a sibling module's internals (the test lane obeys the same P2 boundary as prod) | P2 | `check-isolation-py.mjs --mode=test-isolation` | **have** (green-on-empty) | | `gate:exports` | no consumer deep-imports past a module's `index` | P6 | `package.json` `"exports"` + scan | **have** (locks land per-package) | | `gate:readme` | every non-ignored directory has a non-empty `README.md` | P12 | tree-walk | **have** | | `gate:schema` | goldens ≡ schema (ajv) | P4, P8 | `validate.mjs` (ajv) | **have** (both-language conformance per-consumer in Stage 3/4) | | `gate:contract-version` | a sealed `.vN` schema is frozen; any change routes to human re-seal (back-compat) or a vN+1 dir (breaking) | P4 | seal hash (`contracts.seal.json`) | **have** | | `gate:contract-conformance` | the SHIPPED meeting-api conforms to the sealed `api.v1` — registered routes ≡ declared (path, method); known drift is bounded by an explicit, reasoned waiver list, NEW drift turns RED | P8 | `tests/test_contract_conformance.py` (real `create_app()` route enumeration) | **have** (offline structural half; live input-fuzzing via schemathesis is the L4 extension on the dev host) | | `gate:python` | pytest green in every Python package (pyproject + tests/) — the L1–L2 Python pyramid | P8 | `uv run pytest` per package | **have** | | `gate:node` | build + unit-test every workspace TS package (the L1–L2 TS pyramid; carries `gate:fault-surfacing` + `gate:client-liveness` proofs) | P8 | `turbo run build test` | **have** | | `gate:stack` | the Group-1 backing-stack evals (postgres·redis·admin-api) pass on ephemeral testcontainers | P5, P8 | `test_stack_*.py` (testcontainers) | **have** (green-or-skip without docker) | | `gate:fault-surfacing` | a forced dependency fault (e.g. STT `402`) is surfaced + attributed via `onError`, never swallowed | P18 | failure-injection tests (under `gate:node`) | **have** (rides `gate:node`) | | `gate:health` | each long-running HTTP service exposes a conforming `/health` (status·service) | P18 | per-service `tests/test_health.py` (gateway · conformance · runtime · meeting-api · admin-api · agent-api) | **have** | | `gate:eval-baseline` | the worker-L4 eval oracle self-test passes offline + the recorded L4 ground truth exists (a reusable, calibrated instrument) | P19 | `core/meetings/services/bot/eval/verify.sh` + `core/meetings/eval/BASELINE.md` | **have** (instrument ready; live L4 score is B:V1) | | `gate:eval` | every essential path (Groups 2–8) ships an offline eval harness — the completeness/presence umbrella | P19 | filename discovery of per-path harnesses | **have** | | `gate:access` | each read path (API · WS subscribe · agent) denies an unauthorized `canAccess` request | P20 | `core/identity/tests/test_access.py` (deny test) | **have** | | `gate:client-liveness` | the extension's capture state is **evidence-driven** — "active" only after first-frame-observed; a no-frames watchdog flips to "no-signal"; the state machine is unit-tested | P21 | `clients/extension/src/capture-liveness.test.ts` (extension L2, under `gate:node`) | **have** (rides `gate:node`) | | `gate:tracing` | one synthetic multi-service request threads ONE `trace_id` through every hop's structured log; every line conforms to `logevent.v1` | P18 (O-OBS-1) | `tests/test_tracing.py` (conformance) | **have** | | `gate:telemetry` | `captured-signal.v1` + `flagged-issue.v1` exist; the capture-bridge `TelemetrySink` tap is proven (fed frame reaches the sink, round-trips the codec) | P18 (O-TEL-1/3) | `src/telemetry.test.ts` (capture-bridge) | **have** | | `gate:replay` | a stored captured-signal/tape replays through the EXACT pipeline to its expected transcript, deterministically (same in ⇒ same out) | P8 (O-TEL-2) | `pnpm run replay` (per package) | **have** | | `gate:licenses` | every direct+transitive dep licence is on the allowlist (Cat A; B by logged exception); no GPL/AGPL/source-available. The per-release SPDX SBOM (`scripts/sbom.mjs`, emitted + gated by `release-images`) records the npm/pip tree **and** baked model weights | P17 | `pnpm licenses` (FINOS Cat A/B/X) · `scripts/sbom.mjs` | **have** | | `gate:compose` | the REAL deploy/compose stack comes up bot-ready (health · auth · transcript dataflow · recording→minio · control-plane); `MOCK_BOT=1` runs the L3 backend↔bot seam | P5, P9 | `deploy/compose/bin/stack-test` | **have** (green-or-skip without docker) | | `gate:compose-stress` | the control plane under CONCURRENT load (N mock bots): max-bots never overspills, every FSM reaches terminal under contention | A:V2 | `deploy/compose/tests/stress_test.py` | **have** (opt-in `COMPOSE_STRESS=1`; `all` skips green) | | `gate:compose-chaos` | the control plane RECOVERS from injected dependency faults (redis/meeting-api pause) — FSM reaches a clean terminal, never a silent stall | P18 (A:V3) | `deploy/compose/tests/chaos_test.py` | **have** (opt-in `COMPOSE_CHAOS=1`; `all` skips green) | | `gate:execution-env` | the execution-target registry conforms to `execution-targets.v1` (committed template always; the gitignored real file when present); secrets are references only | P14 (ADR-0020) | `execution-targets.v1/validate.mjs` | **have** (green-on-empty before the contract lands) | | `gate:parity` | every capability + `api.v1` endpoint row in the parity matrix maps to a green proof (no unmapped/TODO on-par row) — "0.12 ≡ main" is a checked claim | P4, P9 | parity-matrix scan (maintainer workspace; green-on-empty in-repo) | **have** (green-on-empty before the matrix lands) | | `gate:arch-report` | every modularity principle (P2·P3·P4·P6·P12·P23) resolves to a passing gate — the compliance map is regenerable + current | P9 | `scripts/arch-report.mjs --check` | **have** (green-on-empty before the generator lands) | | `gate:dataflow` | the chart (`architecture.calm.json`) covers every service/module/contract/client (completeness, model↔disk — no drift); each data carrier has one writer + no reader re-derives a producer's data (`render-only`); the committed chart matches its seal | P23 | `scripts/gates.mjs` (CALM model · reality-diff · `architecture.seal.json`) | **have** | | `gate:unit` | per-module tests pass (the L1–L2 pyramid) | P8 | subsumed by `gate:node` + `gate:python` | **have** (alias — the per-language gates above) | | `gate:e2e` | offline lane/wire integration (L3) | P8 | subsumed by `gate:compose` (`MOCK_BOT`) + per-package e2e | **have** (alias) | | `typecheck` / `gate:standalone` | `tsc` clean against own declared deps | P2 | `tsc --noEmit` (under `gate:node`) | **have** (rides `gate:node`) | | `gate:dist-in-sync` | committed `dist/` ≡ clean rebuild of `src/` | — | — | **retire** (workspace tool builds on demand → delete committed `dist/`) | **Two-layer enforcement.** Locally, a `pre-push` hook (`.githooks/pre-push`, wired by `core.hooksPath` via the root `prepare` script — zero-dependency, per P17) runs `pnpm gates` and blocks any push that isn't green. In CI, `gates.yml` re-runs each gate as its own step so a failure is unambiguous. `git commit` itself runs nothing — the bar is at **push**, not every commit. **The architecture chart.** [`architecture.calm.json`](https://github.com/Vexa-ai/vexa/blob/main/architecture.calm.json) (FINOS CALM) is the runtime data-flow + ownership model and the single inventory of every node — *the index for AI, the mental model for humans* (P23). `pnpm arch:viz [--lod=0..3] [--scale]` renders **deterministic** perspectives to `docs/views/*.svg` — you read one cluster or path at a time, never the whole graph. `gate:dataflow` keeps it true (completeness · single-writer · render-only · seal); `pnpm seal:arch` re-stamps the asserted-true baseline after a *reviewed* change. *** ## 5. How we prove a change — the validation pyramid Build downward from the cheapest, most-isolated proof. The bot is the worked example (71 checks). | Level | Proves | How | Speed | | -------------------- | ----------------------------------------------------------------- | ------------------------------------------------- | ------- | | **L1 — contract** | the contract is self-consistent + the goldens conform | schema + golden vectors | ms | | **L2 — unit** | the core logic, with every port mocked | in-memory fakes for ports | ms | | **L3 — integration** | the real engine wired to mock externals | real lane/module + mock STT/redis | \~1s | | **L4 — live + eval** | the whole thing against reality, **plus quality vs ground truth** | hot container, real meeting + the `eval/` harness | minutes | Rule: a port (P5) is what lets L2 exist. If you can't unit-test the core without a browser/redis, you're missing an adapter seam. The **eval harness** (L4 quality) is first-class — it's how a domain proves its *output is correct*, not merely that it ran. *** ## 6. Reference shelf (read these to pressure-test a decision) | Practice | What it governs here | Source | | -------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------- | | **Microservices** | the system shape: services over REST/Redis, carved by force (P10) | Newman; Fowler | | **Modular Monolith** | each service's internal shape + the movable module↔service boundary | Simon Brown; Spring Modulith (Drotbohm) | | **Hexagonal / Ports & Adapters** | every service's internal shape | Cockburn | | **Clean / Onion** | dependency direction (P3) | Martin; Palermo | | **DDD — Bounded Context, Published Language, ACL** | domains, schemas, adapters | Evans | | **Information hiding** | why a module hides one decision behind one front door (P2, P6) | Parnas (1972) | | **Component Software** | the module definition | Szyperski | | **C4 model** | module (component) vs service (container) vs deploy node | Brown | | **12-Factor** | workers + config-by-env (P7) | Wiggins | | **Mechanism not policy** | the platform stays generic; schemas are config (P11) | microkernel / Open-Closed | | **Evolutionary Architecture / fitness functions** | gates as executable architecture (P9) | Ford, Parsons, Kua | | **Screaming Architecture** | package-by-domain (P1) | Martin | *** ## 7. When the rules bend (be honest, not dogmatic) * **An adapter is ceremony** if it does no vocabulary translation, no dispatch, and unlocks no test seam, over a stable leaf — inline it (P5 has a cost). * **Default to a module.** A new service must justify its distribution tax against P10's forces. * **A README boundary is acceptable for a leaf with no consumers.** The moment it has two, gate it (P9). * **`shared/`-style kernels are allowed but strict** — smallest, most stable, most reviewed code; never a junk drawer. If you can't name the one concern it hides, it's not a module. *** ## 8. Development process — how we build (and contribute) The sections above say *what the system is*. This says *how you change it*. Same discipline — every step names its practice and ends at a gate. **The inner loop (one change):** 1. **Contract first** — define/change the port or `contracts/*.v1` *before* the code; the contract is the unit of agreement. *(API-first / consumer-driven contracts)* 2. **Implement behind a port** — transports are adapters; the core stays offline-provable. *(hexagonal)* 3. **Prove down the pyramid** — L1 golden → L2 unit (mock ports) → L3 integration → L4 live + eval. Cheapest proof first. *(test pyramid)* 4. **Green under the gates is "done"** — isolation · graph · exports · schema-conformance · unit. *Green or it didn't happen.* *(fitness functions)* 5. **Small PR on trunk** — short-lived branch, small diff, gates required to merge. *(trunk-based dev / DORA)* **Special rules:** | Rule | Why | Practice | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | **`lane:contract` PRs are human-gated** — a PR label that routes any change touching a `contracts/*.v1` to *required human review* (it can break consumers across languages); ordinary changes merge on green gates alone | published contracts have a wide blast radius | published-language governance; semver (add-optional = v1, breaking = bump to v2) | | **Fix in the brick that owns the symptom** | never mask a brick's bug in a consumer | symptom→brick router (`modules/README`) | | **Reproduce with no live meeting before you fix** | the brick's own logs are a claim, not proof | brick debug discipline | | **Record decisions as an ADR** — *Architecture Decision Record*: a short, dated, numbered `docs/adr/NNNN.md` capturing **one** decision = context · the decision · the trade-off accepted | the durable "why," so a boundary isn't relitigated later | Nygard ADRs | | **Big work is staged with per-stage validation gates** | each stage is specific and ends at a *runnable* proof; never advance on red | staged migration | | **One worktree per chat** — concurrent work is isolated in its own `git worktree` on a short-lived branch, integrated via PR; **never two chats on one working tree**, and you never touch another chat's uncommitted files | shared-tree collisions cost us (the `meeting-api` appeared mid-session; the `bot/` brick was destroyed) — uncommitted state must be isolated | git worktrees; trunk-based + PR · ADR-0019 | **Planning mode ⇄ execution mode — never build without a current plan:** * **A current plan always exists** — the *full path* from where we are to the goal: the staged route to "done", each stage with a runnable proof, plus the critical path and what's parallelizable. It is a **maintained doc** (kept in the maintainer workspace), not a memory. *The plan is the macro expectation; the expectation–reality loop below executes against it.* * **Goal vs objective.** The **goal** is the destination — the *end of the plan* (the release). An **objective** is the *current go* — the one waypoint you execute toward now; the plan is the ordered path of objectives to the goal. The objective is the **unit of execution and assessment**: you are always executing toward exactly **one open objective** (never drifting), and it closes either **expected** (result met it → **continue with the next *planned* objective** autonomously — do not stop to ask) or **unexpected** (result diverged → **stop, interpret it *with the human* as learning**, root-cause + codify, **and update the plan** — an unexpected result is exactly what triggers a re-plan). Expected closure flows on its own through the plan; unexpected closure pulls in the human *and* revises the plan (ADR-0017). * **Planning mode** produces or revises that plan *before* building — decompose the objective into staged proofs, mark the parallel workstreams, set each stage's definition-of-done. Output = an approved plan. *(design-before-build; staged migration)* * **Execution mode** runs the plan one stage at a time under the expectation–reality loop — instrument-validated, stop-on-surprise. * **The modes interlock.** A surprise that root-causes to a principle gap — or a changed objective — loops **back to planning**: revise the plan, then resume. The plan is *living* and always current; you are always in one mode or the other, never improvising without a path. > **Collapsed:** *Plan the full path (planning mode) → execute a stage under the loop (execution mode) → on a gap/surprise, re-plan. Never build without a current plan.* **The expectation–reality loop — how execution mode runs, and how the principle-set grows:** 1. **State the expected behaviour first.** Before acting, name what the system *should* do and what "done" looks like for the current objective — the contract for the work in front of you. *You can't detect a divergence you never defined.* *(expectation-first; P19/P21 applied to the work itself)* 2. **Validate cheaply by instrument — but know it's approximate.** Gates, unit/integration tests, the `eval/` `replay`·`analyze`·`benchmark` path are fast, reproducible, and run with no human — so they do the **broad, cheap filtering**. But their pass/fail is a *proxy* for "actually working": the proxy can mis-*define* success (green while broken, red while fine) or mis-*interpret* the signal (the `capture` tool once called a healthy gmeet "unhealthy"). **Cheap, not definitive** — green is necessary, never sufficient (P19). 3. **The human is the ground-truth oracle — scarce, and fallible.** At the end of the day only a human can finally tell if the thing *actually works* — deploy the human precisely where cheap tests can't correctly **define** success or **interpret** the signal (real browser behaviour, real-meeting quality). Because the human is the scarcest resource *and* errs: spend it **last and least**, hand a **minimal, fully-instructed surface** (the exact `🧑` step), and **cross-validate both directions** — a green instrument is provisional until it correlates with real success; a human "it works" / "I topped up the balance" is checked against an instrument (ping the service, census the tape). A human↔instrument disagreement is a *signal*: usually the instrument's success-definition is the gap — fix it. * **Instrumentalise the human's verdict.** Each human judgement is *captured* as ground truth — a golden (P8), an eval baseline, a recorded expected-vs-actual — so the cheap test is **calibrated to the human** and the human is needed less next time. (The `eval/` completeness·leakage·attribution scores *are* "is the transcript right?" turned reproducible.) 4. **An unexpected error is a STOP.** Reality ≠ expectation ⇒ stop. Don't paper over it, blind-retry, or push past — an unpredicted behaviour is a *signal*, not a nuisance. 5. **Root-cause every surprise — earn the learning with the human, and promote it twice.** Each unexpected error is a *symptom* of a missing/violated principle. Interpret it **with the human** (the ground-truth interpreter — a learning is never minted from an instrument alone), trace it to the gap, fix the instance, and **promote the learning to BOTH (a) the architecture** — a principle + its gate + an ADR, so it bites (P9) — **and (b) the learnings log** (the running ledger, kept in the maintainer workspace of *surprise → root-cause → promotion*; the log always, even for a *practice*/*candidate* with no P-number). *This is how the principle-set grows* — P18–P21 were each born from one such surprise. *(blameless root-cause; evolutionary architecture; ADR-0018)* **State the objective, then report facts — not evaluations.** A report is the actual result-state assessed against the **current objective** (*result vs objective*, ADR-0017), so it **names the objective first**, then ships the **raw evidence** that produced the result — the command + its actual output, the counts and names of what was checked, and (crucially) **what was *not*** (fakes vs real, type-checked vs executed, instrument vs human, the boundary of the claim). "Done" / "validated" / "works" is *your* interpretation — state it separately and *labelled*, downstream of the facts, so the human (the ground-truth interpreter) assesses result-vs-objective themselves and can overturn yours. *An interpretation without its evidence is an unbacked claim (P21); surface the facts.* See ADR-0016. > **Collapsed:** *Expect → instrument → (human: minimal, cross-validated) → stop on surprise → root-cause to a principle → codify. Report facts, not evaluations.* **The brick lifecycle (how a module is born):** scaffold (one template, incl. its `README.md`: *what · surface · deps*) → define its contract (a nested port, or `contracts/*.v1` if it crosses a boundary) → implement behind the port → pass the gate suite → *admit* it (consumers may now import its `index`). **A brick that isn't gate-green doesn't exist yet.** **Collapsed:** *Contract → pyramid → gates → small PR. Contracts (`lane:contract`) are human-gated. Fix in the owning brick. Decisions get an ADR. Big work is staged to runnable proofs.* *** *This file is the source of truth for "how we build." Changes to a principle or a gate ride a `lane:contract`, human-reviewed PR and are recorded as an ADR under `docs/adr/`.* # Contributor rights Source: https://docs.vexa.ai/governance/contributor-rights Vexa's low-friction DCO path for individuals and head-bound authorization path for employer-owned contributions. Vexa uses one conscious rights choice at pull-request intake and automates the remaining mechanics. The canonical policy and operational commands live in [`CONTRIBUTOR_RIGHTS.md`](https://github.com/Vexa-ai/vexa/blob/main/CONTRIBUTOR_RIGHTS.md). | Path | Contributor action | Merge evidence | | -------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Independent | Select the declaration and sign each commit with `git commit --signoff` | Declaration plus the required DCO check; no individual CLA | | Employer/client-controlled | Select the corporate path and provide a legal/IP contact privately | Individual DCO plus a private authorization receipt bound to the current PR head | | Unsure | Select unsure as early as possible | Rights review resolves the path before merge | Technical review may continue during rights review. A later push invalidates a corporate verification, and only a designated verifier can bind a private receipt to the new head. Executed agreements, signatures, addresses, and employment information never belong in public PR comments. ## Agent-assisted flow Agents automate Git, not legal judgment. They ask the human to choose the path, check the repository-local Git identity, use `--signoff` after authorization, identify failing commits by SHA, and prepare safe remediation. They never select the declaration, sign for another person, or rewrite/push history without explicit approval. For the latest unsigned commit: ```bash theme={null} git commit --amend --no-edit --signoff git push --force-with-lease ``` Shared branches use the DCO App's individual remediation flow rather than third-party sign-off. ## Historical work The gate is prospective. Earlier contributions are triaged by concrete risk instead of rewritten or subjected to a blanket CLA campaign. Significant ambiguous work may receive a retrospective attestation; corporate-directed work receives a contribution-specific corporate authorization; material work that cannot be cleared is replaced or removed. # Delivery — the governing reference Source: https://docs.vexa.ai/governance/delivery The delivery constitution D0–D17: how work is chosen, prepared, proven, and shipped. > The delivery constitution — sibling of [`ARCHITECTURE.md`](/governance/architecture). That book governs > **how the software is built**; this one governs **how work is chosen, prepared, proven, and > shipped** — roadmap, issues, PRs, release. Same discipline: every principle names the practice > it comes from *and* the gate that enforces it. **A process rule that isn't enforced is a > comment, not a rule** (D1). Gates not yet built are marked **TO BUILD** — honestly, the same > way the architecture book ships green-on-empty gates — and tracked in > [the enforcement map](#enforcement-map). *** ## 0. The axiom **D0 — The scarce input is verified truth about value, not code.** Coding agents made writing code cheap; what remains scarce — and therefore what this whole system optimizes for — is *evidence that a change delivers its intended value in the real world*. We spend our effort preparing work so that the scarce thing (human, instrumented validation) is the only thing we ask for. · *Source:* the economics shift of agentic coding; Lean "build quality in" (Poppendieck). · *Gate:* this constitution is the mechanism. ## 1. The Loop — the shape of the whole system Everything below is one closed loop. Read it once and you know the system; each principle governs one arc, and each arc has a gate. ``` ┌──────────────────────── THE WORLD ────────────────────────┐ │ reports · incidents · Discord · support · probe results │ └───────────────────────────┬────────────────────────────────┘ │ raw signal ┌───────────────▼───────────────┐ │ 1. INTAKE (D2b) │ ≤3 days: no signal dead-ends └───────────────┬───────────────┘ ┌───────────────▼───────────────┐ │ 2. PREPARE (D-R1/2 · D5/6 · │ isolate → a ready, harnessed, │ D10 acceptance floor) │ merge-guaranteed issue └───────────────┬───────────────┘ │ seeded, in priority order ┌───────────────▼───────────────┐ │ 3. ROADMAP (D2 · D3 · D-R3)│ one ordered queue, three tiers └───────────────┬───────────────┘ │ pick from the top / self-propose ┌───────────────▼───────────────┐ │ 4. CLAIM (D14b) │ heartbeat lease: 4h, renew or release └───────────────┬───────────────┘ ┌───────────────▼───────────────┐ │ 5. DELIVER (D-A · D6b · D-A2 │ human + agent: debug the hot loop, │ · D-L the two loops) │ package only the proven diff └───────────────┬───────────────┘ │ a PR: value bundle + diff ┌───────────────▼───────────────┐ │ 6. PROVE (D8 · D9 · D10 · D12│ non-author witnesses value; channels │ ) + SECURE (D-S) │ corroborate; diff passes review+security └───────────────┬───────────────┘ │ value-signed + acceptance floor met ┌───────────────▼───────────────┐ │ 7. RELEASE (D15) │ batch signed PRs → ship → credit signers └───────────────┬───────────────┘ │ shipped value ┌───────────────────────────▼────────────────────────────────┐ │ 8. CLOSE BACK (D16): your report → this fix → this │ │ release; come validate │ └───────────────────────────┬────────────────────────────────┘ └──► back to THE WORLD Off-loop but first-class: INVALIDATED (D11) — honest-negative of a delivery · DECLINE (D17) — honest-negative of a proposal or stale item · both are results, recorded. ``` **In one sentence:** the world's feedback becomes a prepared, harnessed, merge-guaranteed issue on an ordered roadmap; a human takes it on a heartbeat lease, delivers it with an agent in the harnessed loop, and a *different* human witnesses the value — corroborated by instruments, never on authority alone — after which it ships in a batch and the loop closes back to whoever raised it. ## 2. Meta **D1 — Enforced, not aspirational.** Every rule here is machine-checked (a required status, a checks bot, a template gate) or it does not exist; a rule that lives only in prose rots exactly as an architecture boundary does. Unbuilt checks are marked TO BUILD and tracked — never silently assumed. · *Source:* fitness functions (Ford/Parsons/Kua); the mirror of architecture P9. · *Gate:* [the enforcement map](#enforcement-map) — the live principle→gate→status map. ## 3. The bridge to architecture **D-A — Delivery inherits architecture; the issue reuses the modular software.** The architecture constitution is the *substrate* every issue is built on. An issue's atoms ARE the architecture's isolated units (a module behind a contract, a seam), and their early validation IS that unit's existing harness + fixtures. We do not invent test scaffolding per issue; we compose the harnesses architecture already mandates. When an issue needs a harness that doesn't exist, building it is architecture debt surfaced by delivery (feeds D7). · *Source:* build discipline (P-book) and delivery discipline (D-book) share one modular substrate; testability-as-architecture (Feathers; Humble–Farley). · *Gate:* every component's `Target:` resolves to a real module/seam and its early validation runs in that module's own harness lane — not a bespoke script. **D-A2 — Fixtures are first-class: every seam carries a range to play with.** Validation is built on fixtures, produced and reused wherever possible, so every seam has a *range* of inputs — not one happy path. Three kinds, all welcome: **deterministic producers** where raw input must be simulated (a generator with a known oracle — e.g. a 1..500 counting fixture, where any drop, dup, or misattribution is arithmetic); **captured live output treated as a dataset** (real DOM snapshots, real transcripts — sanitized, versioned; a live validation that isn't captured is a fixture wasted); **hand-authored edge fixtures** (the broken states a report described). A seam without a fixture range is under-harnessed; adding the range is part of the work. · *Source:* golden/contract testing (architecture P8); property-based + example-based testing. · *Gate:* each component's early validation names its fixture(s); new seams ship a range, not a single case. ## 4. Roles and phases **D-R0 — Two species: CONTRIBUTOR and MAINTAINER; the maintainer holds exactly two exclusive authorities.** A **maintainer** exclusively (1) **approves an issue as `ready`** — the stamp that puts it on the guaranteed path — and (2) **merges PRs** — checks the bundle against the acceptance table + the closing security bundle and honors the promise. **Everything else is species-neutral**: preparing, proposing, claiming, delivering, heartbeating, validating as the non-author, signing, authoring probes — contributors and maintainers do all of it under identical rules ("ours included"). Why exactly these two: `ready` is where the project's guarantee is *issued*, merge is where it is *honored* — a promise needs an accountable guarantor; everything before and after stays open. · *Source:* the open-source commit-bit model, minimized. · *Gate:* branch protection (merge rights) + the `state: ready` transition restricted to maintainers; every other transition open. **D-R1 — Two phases, one public constitution: PREPARE, then DELIVER.** **PREPARE** — raw input is *isolated and shaped* into a ready-to-go issue whose body is governed by these principles (the issue body IS the constitution applied). **DELIVER** — a prepared issue is built by human + agent in the harnessed loop and proven. Because this constitution is public, both phases are open to both species; only the `ready` stamp and the merge are maintainer acts. · *Source:* dual-track discovery/delivery (Cagan); open governance. · *Gate:* the state machine — `incoming → prepared` is PREPARE (anyone); `prepared → ready` is the maintainer stamp; `ready → claimed → value-signed` is DELIVER (anyone); `value-signed → merged` is the maintainer honoring the promise. Each phase has its operational protocol below: [PREPARE](#prepare-protocol) (queue item → prepared issue) and [TAKE](#take-protocol) (delivered PR → verdict against the floor). **D-R2 — The preparation function: prepare issues and keep the roadmap true.** Preparation is species-neutral; only the `ready` stamp is a maintainer act. The function's job: (a) turn raw input into prepared, isolated, harnessed issues within the intake SLA (D2b); (b) seed them onto the roadmap in priority order (D2); (c) keep the roadmap a true picture — every known problem represented, nothing stale asserted as ready. It is the first step before any dev starts, and the highest-leverage work: a well-prepared issue makes delivery a known motion instead of a research project. · *Source:* replenishment as a first-class activity (Kanban). · *Gate:* issues reach `ready` only in full D5/D6/D10 shape. **D-R3 — The roadmap holds three tiers; contributors pick or propose.** (1) **prepared issues** — ready to pick, merge guaranteed by their acceptance floor; (2) **generic/raw issues** — incoming, inside the 3-day SLA; (3) **declared items without issues** — intents too far out to prepare, explicitly marked so the picture stays complete without pretending readiness. A contributor either **takes a prepared item** (the guaranteed path) or **self-proposes** — welcome, but with **no merge guarantee** until it meets the same bars. · *Source:* now/next/later roadmapping; pull with an explicit replenishment boundary. · *Gate:* board fields distinguish the tiers; self-proposed PRs are judged on the same value + security bars. ## 5. The roadmap **D2 — The roadmap is one ordered pickup queue.** Every problem we know and every capability we intend maps to exactly one ordered item; anyone takes the highest ready item. No hidden backlog, no parallel priorities. · *Source:* single-queue pull (Kanban, Anderson). · *Gate:* the GitHub Project board is the single source; a coverage check maps every failure mode + committed feature to an item. **\[TO BUILD: coverage check]** **D2b — Coverage is a promise with an SLA: world feedback becomes a ready bundle in 3 days.** Every incoming signal is triaged and converted into a prepared issue within **3 days**, then seeded onto the roadmap. Nothing we know stays unrepresented; no report dead-ends as a raw ticket. (`state: needs-info` pauses the clock when only the reporter can unblock.) The incoming-report template asks **"observed on which deployed version / verified against which tag?"** as a required field — a report anchored to a deleted tree earns a fast re-anchor verdict instead of a phantom fix (of seven same-day prod reports, the two that stamped their era got correct fast verdicts, #779/#783). · *Source:* lead-time SLA / class-of-service (Kanban). · *Gate:* intake bot ages `state: incoming` items; >3 days without `prepared` alarms. **\[TO BUILD: intake bot]** **D3 — Every item carries business meaning.** A tracker entry states what a user gains or loses, in plain language, before any mechanism — named by the problem, opened with dry, factual stakes. No jargon titles, no drama. · *Source:* jobs-to-be-done (Christensen). · *Gate:* title + "why this matters" check at preparation review. **D4 — Grounded in the code as it is.** Items are shaped by the *current* module tree and its contracts, never by mechanism-narratives reconstructed from old reports; old reports contribute the symptom only. · *Source:* the code is the truth (Feathers). · *Gate:* every component's `Target:` names a module/seam that exists in the tree. ## 6. The issue **D5 — Atomicity is two-level: the issue is the atom of VALUE; its components are atoms of CODE.** The *issue* is the smallest holistic thing a human recognizes as "this delivers value to me" — the smallest complete unit of perceived value, which is what gives a contributor the incentive to take it and a clear idea of what they're validating. The *components* are isolated, harnessed code atoms — each exactly ONE module or seam (named in a `Target:` line), each with its own fixture/golden lane — composing into the issue's one value. A solution that needs two modules is two components. · *Source:* minimum marketable feature (Denne–Cleland-Huang); information hiding (Parnas). · *Gate:* issue template requires the one-sentence value statement + components each with a real Target and harness. **D5b — Bundle by diff, not by theme: one code change = one issue, however many values it carries.** Issues are deduplicated by the CODE CHANGE, never by topic. When one change delivers several recognizable values (one root cause behind several reports, one seam fix that closes several asks), they ride ONE issue: every value is stated in its own value sentence, and the acceptance table carries a discriminating row — and a preferred validator — *per value*, so no value is silently absorbed into another's. When values require different changes, they stay separate issues no matter how adjacent — relatedness is recorded as a `same-setup` note (a claim-together recommendation on the board), never a merged item. The two-way test at preparation: *would splitting duplicate the same diff across issues?* → bundle; *would bundling staple independent diffs into one PR?* → split. This is D8's precondition: issue=PR one-to-one only works if the issue is shaped like exactly one change. **The perceiver bounds the atom — platform-facing behavior is per-platform:** a Meet user's value is Meet working; they will not debug Teams, and a validator can only sign the platform they sit on. Issues about in-meeting behavior are prepared one per platform even when the engine diff is shared: the first platform issue claimed carries the shared-engine change, its siblings reference it (`same-setup`) and validate their own platform's fixtures and live leg. · *Source:* single responsibility applied to work items — one reason to change; cohesion/coupling (Constantine). · *Gate:* preparation review runs the split/bundle test; the acceptance-table check requires one discriminating row per stated value. **D6 — Preparation is ours; validation is theirs.** Every issue ships good solutions AND the along-the-way forks — mechanism, files, steps, the branches a contributor may hit — so delivery is a known motion, not a research project. The contribution asked for is the validation, not the invention; alternate solutions are welcome, never required. · *Source:* "make the change easy, then make the easy change" (Beck); paved paths. · *Gate:* prepared-solution + along-the-way sections required before `ready`. **D6b — The delivery motion: human + agent, harnessed loop, PR.** An issue is delivered by a human who starts it with a capable coding agent, drives the change inside the issue's harnessed validation loop (fixtures + early checks give fast, honest feedback), and emerges with a PR whose bundle is the record of that loop. The harness does the mechanical proving; the human does the recognizing. · *Source:* fast-feedback inner loop (Humble–Farley); human-in-the-loop only where judgment is required. · *Gate:* the PR bundle shows the loop was run (per-component early-validation observations), not just a final green. **D6c — The docs are part of the change, and the docs STORY is part of the human validation.** A change isn't delivered until the documentation tells it. Every prepared issue names its docs surface up front — which pages, at which altitudes — and the PR carries the docs diff beside the code diff. A real docs change is rarely one line: the same truth usually lands in several places at several levels (the quickstart step that touches it, the how-to that walks it, the reference that specifies it, the concept page that explains why), each written for its reader. The human validator's signature covers the docs too, and it is judgment work no checker replaces: does the update *follow the docs story* — right pages, right altitude, consistent with how the docs already teach, explained from the angles a reader actually arrives from — not merely "words were edited somewhere". A docs-less capability or a story-breaking page is an incomplete delivery, same as a red acceptance row. · *Source:* documentation as part of done (DoD discipline); Diátaxis — the four documentation modes are different readers, not duplication. · *Gate:* prepared-issue template requires the "docs surface" section before `ready`; the PR template's bundle includes the docs diff; the value attestation includes the docs-story check. **D7 — Every defect indicts a principle or founds one.** Each fix names the architecture principle it restores (verbatim) and the gate that should have caught it; a defect covered by no principle is a constitution finding that feeds back into `ARCHITECTURE.md`. · *Source:* five-whys to systemic cause (Toyota); the two books talk to each other. · *Gate:* "Principle check" section on fix-requests. ## 7. The two loops — debug before you package The DELIVER arc (Loop box 5) runs **two** loops, not one, and confusing them is the most expensive mistake we make. v0.12.9 proved the bill: \~6 of \~12 wall-clock hours went to driving fixes through the release pipeline to discover whether the *next* layer worked — the pipeline used as a debugger. This cluster (`D-L`) makes the split law, gives debugging an exit criterion and a parallel-inventory discipline, earns the word "flake", and fixes the human to two moments. Its enforcement arms are issues #689, #690, #691; this section is the law they enforce. **D-L0 — Two loops: debug ≠ deliver.** The release pipeline (PR → gates → tag → build, \~40 min/iter) is a *packaging* loop; it must never be used to answer *"does this code work?"*. A hypothesis drops to the **hottest loop that carries the real external semantics** — live staging exec, a ConfigMap overlay of the runtime env, a spawn probe against a dead URL — where an iteration costs seconds to minutes, not a build; ceremony packages *proven* diffs only. · *Source:* the fast inner loop vs. the slow outer loop (Humble–Farley); a build is a packaging step, not an experiment. · *Evidence:* v0.12.8 was cut after four serially-found helm fixes with no assembled-system probe, and the 5th bug was in the new code — PR #684 emitted `containers: [{name}]` in every `kubectl run --overrides`, which merges the containers list *by replacement* and wiped the generated image/env/command, so the API server rejected every Pod (`spec.containers[0].image: Required value`) and every spawn died in \~0.3 s, forcing a full re-cut to v0.12.9; the eventual hot loop (ConfigMap overlay + dead-URL spawn probe) proved the whole chain in \~10 minutes. · *Gate:* #690 (`make probe` — the standing hot loop) * \#689 (the pre-tag real-spawn leg, so the packaging loop never *has* to be the debugger). **\[TO BUILD: #689, #690]** **D-L1 — Debug exit criterion: a green end-to-end probe of the assembled system on the target surface.** You leave debug when the **assembled chain** runs green on the surface that carries the bug — never on "N green unit fixes". The last component compiling is not the exit; the whole journey running on the real surface is. A unit green claims only its unit (P19/D12): four green helm fixes never touched the spawn path that was actually broken. · *Source:* prove at the altitude of the claim (P19) applied to the debug loop's exit. · *Gate:* #690 / #689 — the assembled-system probe *is* the exit criterion, machine-run. **\[TO BUILD: #689, #690]** **D-L2 — Parallel failure inventory before fixing.** On a suspect surface, run the **full-journey probe matrix** (spawn · schedule · boot · join · transcribe · live-view · stop) and sweep **all** component logs **once, before touching a fix** — inventory the whole failure set at once, never peel one bug at a time. Serial onion-peeling pays the outer-loop tax per layer. · *Source:* see the whole line, not one station (Toyota jidoka); log-sweep-before-fix. · *Evidence:* the v0.12.9 helm chain was peeled one bug at a time (#656 → #675/#681 → #677/#680 → #676/#679 → #684), \~3h a single 20-min full-journey probe + one all-component log sweep would have inventoried at once. · *Gate:* #690 — the probe matrix as standing infrastructure. **\[TO BUILD: #690]** **D-L3 — Flake discipline: "flake" is a claim of nondeterminism, and must be earned.** Calling a failure a *flake* asserts the code is correct and the run was noise — a claim, not a shrug. One unexplained failure buys **exactly one** rerun; an **identical second failure forbids further reruns and demands reading the code**. Tool answers (gh CLI state, cached reads, a green that "usually passes") are cross-checked against a second source before you act on them. · *Source:* Heisenbug discipline; a flaky-test claim is a hypothesis to reproduce or refute, never assume. · *Evidence:* the `gate:helm` `printf | grep -q` under `set -euo pipefail` SIGPIPE-raced to exit 141 *exactly on a match* — **deterministic** on ubuntu runners once `$RENDER_AUTH` outgrew the pipe buffer; it "failed the v0.12.9 preflight twice" and was rerun as a flake, two full builds burned, before the code was read (#686). · *Gate:* a durable regression test on the fixed gate (here-strings not pipes, `deploy/helm/tests/test_template.sh`, #686) + this rule in the [TAKE protocol](#take-protocol). **have** (#686) / **\[TO BUILD: rerun-discipline check]** **D-L4 — Human placement: the human appears exactly twice.** The final **witness pass** and the **sign / approve** gates — and nothing else. Everything before is **agent-self-served** — the agent drives its own browser session, raw-protocol clients, synthetic and dead-URL meetings, in-bot screenshots — and **rehearses the demo path end-to-end minutes before the human walks it**. A human asked to admit a bot and handed a dead tunnel, a stale session, or a cold-start model is a scarce oracle spent on the agent's un-run homework. · *Source:* the human is the scarce oracle (D9); the [two choke points](#the-two-choke-points) already name the two moments — D-L4 adds the rehearsal obligation. · *Evidence:* \~8 owner round-trips in v0.12.9 on dead tunnels, stale browser sessions, and a cold whisper model — a demo path the agent had not rehearsed. · *Gate:* the witness receipt structurally requires the **delivered deployment record** (`witness_deployment: {url, provisioned_by, prevalidated[]}` — `release-witness-gate` refuses a receipt without it: the human receives a running URL, never a setup recipe) + #690 (the agent-runnable probe *is* the rehearsal) * [choke point 2](#the-two-choke-points). **have** (receipt-level delivered-deployment gate; choke point 2 named) / **\[TO BUILD: #690 rehearsal probe]** ## 8. Proof — PRs and validation **D8 — Issue = PR, one to one; a PR carries two artifacts — the value bundle and the diff.** The bundle answers *"is the value real?"* (D9/D10); the diff answers *"is it correct and safe?"* (review + D-S). They are judged on different axes and neither substitutes for the other. A diff with no observation bundle is not reviewable, whatever it says. · *Source:* evidence-based review ("show your work"). · *Gate:* PR template requires the bundle; value gate checks the bundle, review + security gates check the diff. **D-S — Security is a required lane on the diff, on both sides.** Value never buys a security pass. The contributor runs the security checks the issue names (dependency + licence scan — architecture P17 — secrets scan, SAST where it applies) and shows them in the PR; the maintainer runs the closing security bundle before a change enters a release. · *Source:* shift-left DevSecOps; defence in depth. · *Gate:* security-checks required status on the PR + a maintainer security bundle before release. **\[TO BUILD: contributor security status]** **D9 — Human validation is an instrument, spent only on the reading machines can't take — cross-checked, never sovereign.** The human supplies one irreducible signal: *"this makes sense to me — I witness the value."* Everything measurable is captured by machine alongside it — the validation is **multichannel**: what the bot did (logs, FSM, egress), what the user saw (the eyeball, screenshots, transcript), what the instruments recorded (counters, test output). The validator is any competent non-author — a maintainer, another contributor, or the originating reporter, who is the *preferred* signer for a fix that closes their own report. **Humans mistake, so the human's observation carries no distinctive authority — it is harnessed in the same paradigm as every other channel**: a green PR requires the channels to *corroborate*, not the human to *assert*, and a human/instrument divergence is a first-class finding that blocks merge until reconciled. · *Source:* triangulation / converging evidence; segregation of duties; no single oracle. · *Gate:* `gate:value-signed` — green only on a non-author attestation whose multichannel bundle is internally consistent. **\[TO BUILD: value-gate bot]** **D10 — Acceptance is a pre-declared experiment that guarantees merge — a floor, never a ceiling on value.** Each issue publishes observations that, if presented, **guarantee** the PR merges — a promise, not a hurdle. Every required observation is **discriminating** (a red→green pair, not just green), **controlled** (a negative control shown red — no green-on-empty), **anchored** (shas, ids, timestamps), and **complete** (no-regression rows). We may require some experiments and propose others — but we **never forbid** a contributor from defining value the issue missed: value is ultimately human-witnessed, and extending it is welcome and credited, never scope creep. If a bundle satisfies the table and the PR is still wrong, the table was wrong — our bug, not the contributor's (the plan-bug rule). · *Source:* ATDD; design-of-experiments controls; emergent requirements (Beck). · *Gate:* acceptance table required before `ready`; the checks bot verifies the bundle covers the floor. **\[TO BUILD: bundle checker]** **D11 — Both outcomes are knowledge.** A contributor who follows the prepared path and finds it does NOT deliver has produced a first-class result: a signed INVALIDATED bundle kills a wrong theory with evidence, credited identically to a confirmation. There is no failure state for an honest validator. · *Source:* falsification (Popper); blameless culture. · *Gate:* verdict field CONFIRMED/INVALIDATED; both close-or-advance the issue and appear in release notes. **D12 — Validate at the altitude of the claim, no higher.** Unit/golden for a seam, live for a behavior; live bars scale to the observation — speaker behavior needs 2–5 people, join/API needs one operator, a parser needs none. Never ask for more humans than the observation requires. · *Source:* the test pyramid (Cohn); architecture P19's runtime twin. · *Gate:* per-component early validation at module altitude; the live bar stated and scaled in the issue. **D12b — Every validation names its deployment, and how the setup was built is itself evidence.** Vexa ships as more than one thing — Lite single-machine compose, the full compose stack, k8s/helm, hosted — and a behavior proven on one is only *proven on that one*. The prepared issue declares which deployment(s) the acceptance table must be run against (and which are explicitly out of scope); every attestation states the setup it validated on. The build provenance of that setup is part of the bundle — repo sha, compose file / values used, fresh-clone or long-lived, the env deltas from stock — because "works on my install" without how the install was made is an unanchored claim (the D10 anchor rule applied to the *environment*). Lite is called out deliberately: it takes the most divergent path through the stack, so a fix proven on the full stack may still need its own Lite run — the issue says so when it does. A deployment nobody validated stays honestly unclaimed in the bundle, never assumed covered. · *Source:* test- environment parity ("it works where, exactly?" — 12-factor dev/prod parity); provenance as evidence. · *Gate:* prepared-issue template requires the deployments-to-validate declaration; the attestation names its setup + build provenance; the bundle checker flags rows with no named deployment. ## 9. People **D13 — Humans author; tools assist. Disclosure welcome, co-authorship never.** What ships carries a human name and a human's full responsibility — the sole author is the human, and responsibility is honored as full authorship: full credit, full standing. "The agent wrote it" is neither a defense nor a discount. Disclosing your tooling in the PR is welcome as transparency, never required, and never an attribution: an agent is not a co-author, gets no `Co-Authored-By` trailer, holds no standing. Tools are instruments; instruments don't sign. · *Source:* engineering accountability; provenance without diffused responsibility. · *Gate:* commit-trailer check rejects agent co-authors; the attestation signer is the accountable author. **\[TO BUILD: trailer check]** **D14 — The tracker is the CI of the human loop.** Labels are truth, not decoration: a state label means exactly what it says, enforced by the state machine (`incoming → prepared → ready → claimed → awaiting-evaluation → value-signed → closed-with-release`). Nothing is claimed before `ready`; community-authored issues enter the same machine. · *Source:* explicit value stream (Lean); make-illegal-states-unrepresentable (Wlaschin). · *Gate:* a label bot enforces legal transitions; `ready` is the maintainer stamp. **\[TO BUILD: label bot]** **D14b — A claim is a heartbeat lease, not ownership.** Anyone claims a `ready` item — no permission needed. A checkout is a **4-hour lease** on a *worktree of your own*, created before your first edit — never `main`, never another session's checkout; building on someone else's branch means branching from the ref into a fresh worktree, not editing their tree (the actor contract's worktree rule, AGENTS.md). A **heartbeat** (a short "here's what's going on" update) renews the lease for another 4 hours. No heartbeat → the lease expires and the item returns to `ready`, claimable by anyone; the prior holder may reclaim with a fresh heartbeat. Work is never hoarded and flow never stalls on an absent contributor — and the heartbeats become the front of the PR's observation bundle, the loop's narration written as it happens. · *Source:* TTL leases (distributed systems); WIP-pull with abandonment; work-stealing. · *Gate:* a lease bot stamps `claimed` + expiry, watches heartbeats, auto-releases on a miss — all on the issue timeline. **\[TO BUILD: lease bot]** ### 9b. The tags — the state machine's alphabet Five orthogonal dimensions; a tag is a claim, and the bots keep every claim true. **Exactly one `state:`, exactly one `kind:` on prepared work, any number of `area:`, `P0` only when production-critical, `good first issue` only when truly zero-prerequisite.** | Dimension | Tags | Rule | | ----------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `state:` | `incoming` · `needs-info` · `prepared` · `ready` · `claimed` · `awaiting-evaluation` · `value-signed` | exactly one, always; transitions only via the machine; `ready` = maintainer stamp; `claimed` = live lease; closure carries no state | | `declined:` | `out-of-scope` · `superseded` · `wont-fix` · `too-far` · `stale` | exactly one, on closed-without-merge only (D17) | | `kind:` | `fix-request` · `evaluation-request` · `probe` | the prepared ask's contract shape; legacy `type: bug\|feature\|docs` stays as the raw signal's nature — an issue carries both | | `area:` | existing taxonomy + `security` | routing; any number | | flags | `P0` · `good first issue` · `help wanted` | strict meanings; never inflate. **Canonical spellings — `good first issue` and `help wanted`, with spaces, are the only accepted forms** (GitHub's newcomer-discovery UI promotes exactly these names); hyphenated or underscored variants (`good-first`, `good-first-issue`, `help-wanted`) are never created and never applied | Invariants the bots enforce: one `state:` at all times; illegal transitions reverted; `ready` maintainer-only on a passing template check; `claimed` only with a live lease; a PR merges only if its issue is `value-signed` + security green; `incoming` >3 days alarms; `needs-info` quiet 14 days → proposed `declined: stale`; every closed-without-merge issue carries one `declined:` reason. ## 10. Release and closure **D15 — Release closes the loop, fast.** A maintainer batches a few value-signed PRs, runs the release machinery (image set, gates, VM-validated) and the closing security bundle, ships, and the notes credit every signer — including INVALIDATED ones. The release loop and the contribution loop are the same loop. · *Source:* small batches, fast flow (Accelerate/DORA); continuous delivery. · *Gate:* release-set + `release/vm-validated` + maintainer-security-bundle required statuses; notes generated from signed bundles. **D16 — The loop closes back to its origin.** When a change ships, the report that seeded it is told — "your report → this fix → this release" — and the reporter is invited to the validation. A fix that ships without closing back leaves the loop open: the person who gave us the truth never learns it landed, and never becomes the repeat contributor they were about to be. · *Source:* close the feedback loop (Deming); the flywheel. · *Gate:* release notes link origin reports; a ship-closes-report step comments on the originating issue. **\[TO BUILD: close-back step]** **D17 — Decline is a first-class outcome; nothing rots.** Not everything is taken forward, and a graceful, reasoned *no* is part of the system: a self-proposed PR not accepted, a prepared issue unclaimed too long, a report out of scope — each closed with one stated `declined:` reason, recorded and auditable. INVALIDATED is the honest-negative of a *delivery*; decline is the honest-negative of a *proposal or a stale item* — both are results. A silent-rot backlog is the failure state we refuse. · *Source:* explicit disposition; stop starting, start finishing (Lean). · *Gate:* every closed-without-merge issue carries a `declined:` reason; intake/stale bots propose declines rather than letting items age. **\[TO BUILD: stale proposer]** **D18 — Every release session ends in a retrospective; abandonment doubly so.** A release session — whether it promoted or abandoned its candidate — closes with a retro stage covering three axes: **loop efficiency** (where wall-time went; which hypotheses ran the packaging loop instead of the hot loop — D-L0), **delivery hardening** (what shipped-or-nearly-shipped that the machine missed, and which gate would have caught it), and **architecture quality** (what class of gap the batch exposed — not the instances, the class). Findings leave the chat as *prepared issues* before the session closes; a reflection that stays in the transcript is a reflection lost. An **abandoned candidate is not an embarrassment to skip past — it is the highest-yield retro input we have** (v0.12.5–v0.12.8 each carried a lesson no green run could teach). · *Source:* the v0.12.9 retrospective — six shipped bugs, two release-infra bugs, and the D-L cluster itself all came out of one retro stage. · *Gate:* the CTO handover template carries the three retro axes; a release event in the planning log links its retro issues. **\[TO BUILD: handover template section]** *** ## 11. How this document changes Like the architecture book: propose the change, record the decision as an ADR under [`docs/adr/`](https://github.com/Vexa-ai/vexa/tree/main/docs/adr) (process decisions live beside build decisions), and update the [enforcement map](#enforcement-map). A principle without its gate enters as TO BUILD, never as silently assumed. *** # Part II — Operating the loop Part I is the law; this part is the loop run day to day, module by module. Each module has one concern, one source of truth, and a contract on its edge — the same discipline the [architecture](/governance/architecture) applies to the software (P23: one writer per carrier). ## The roadmap The [roadmap board](https://github.com/orgs/Vexa-ai/projects/2) is the one ordered queue (D2), grouped into **lanes named by who feels the work**: Google Meet · MS Teams · Zoom · API integrators · Transcription · Recordings · Webhooks & billing · First run · Production ops · Feature shelf. Four axes on every item: * **Lane** — the business value: match it to what the contributor actually uses. * **Milestone** — the version it gates ([`v0.12.x`](https://github.com/Vexa-ai/vexa/milestones) is the current patch stream; its progress bar is the roadmap-at-a-glance). * **Human bar** — what validation costs in human terms: *Desk check* (read the bundle, eyeball the logs) · *Operator run* · *Solo meeting* · *Small group (2–4)* · *Crowd 5+* · *In-the-loop UX* (walk the flow, judge the experience itself). * **Setup** — the infrastructure floor: *None* · *Lite* (one laptop) · *Compose* · *k8s/helm*. Both bars are **extracted from each issue's own acceptance floor**, never assigned by feel — D12's promise (no issue demands more humans than its table justifies) made filterable. **Draft cards with no issue number are declared direction** — we state where a lane goes before the work is prepared; ask about them, don't claim them. The whole board comes back in one public GraphQL call — the query lives in [AGENTS.md](https://github.com/Vexa-ai/vexa/blob/main/AGENTS.md), the repo's agent front door. ### The state machine Every open issue carries exactly one `state:` label: | State | Meaning | | ---------------------------- | ------------------------------------------------------------------------------------------------ | | `state: incoming` | New report — triaged within 3 days (intake SLA, D2b) | | `state: prepared` | Full delivery spec, code-grounded; awaiting the maintainer's ready stamp | | `state: ready` | Maintainer-stamped — claimable now | | `state: claimed` | Someone is on it (heartbeat lease, D14b: renew by activity; \~4h silence releases) | | `state: awaiting-evaluation` | Delivered — waiting for a non-author to validate the value live | | `state: value-signed` | A non-author witnessed the value; merge proceeds | | `state: accepted` | Feature acknowledged into the queue, not yet prepared | | `state: incident-thread` | User-facing thread of a hosted/ops incident — open for the reporter; fix tracked in the ops lane | | `state: needs-info` | A real, dated question pending; \~14 quiet days → honest stale-close | | `declined: *` (closed) | Every close-without-merge names its reason and what would reopen it (D17) | ## PREPARE protocol ### The prompt You are preparing ONE issue for Vexa-ai/vexa in full compliance with `docs/docs/governance/delivery.mdx`. **Input:** one queue item — a value sentence plus its source signals (GitHub issue numbers, incident postmortems, failure-mode entries, probe findings) — and a checkout of the current tree. **Output:** the artifacts in step 6. You post NOTHING; a human reviews and publishes. #### Step 0 — Read the law Read `docs/docs/governance/delivery.mdx` end to end and `.github/ISSUE_TEMPLATE/3-prepared-issue.md`. Every section you write maps to a principle; when in doubt the principle wins over habit. #### Step 1 — Gather the signal (D4: symptoms only) Read every source issue WITH its comments, every named incident report, every failure-mode entry. Extract: who hurts, what they observed, on which version and deployment, when. Old reports contribute **symptoms only** — never adopt a mechanism-narrative from a stale report. #### Step 2 — Ground in the code as it is (D4, D-A) Locate the real modules/seams in the current tree. Every `Target:` you will write must be a path that exists — verify each one. If the code moved since the report, the issue is shaped by today's tree. Find the module's existing harness and fixtures (D-A): you compose them, you do not invent bespoke scaffolding; a missing harness is architecture debt to surface, named in the issue. #### Step 3 — The D5b split/bundle verdict (explicit, first) State it before writing the body: * Would splitting force the **same diff** into two issues? → bundle: one issue, one value sentence PER value, one acceptance row + preferred validator per value. * Would bundling staple **independent diffs** into one PR? → split: emit a split recommendation (two prepared issues) instead of one bloated issue. * Adjacent-but-different-diff work → a `same-setup:` note naming the batch, never a merge. * **Platform rule (the perceiver bounds the atom):** in-meeting behavior prepares ONE issue per platform, even when the engine diff is shared — a Meet user won't debug Teams, and a validator signs only the platform they sit on. The first platform issue claimed carries the shared-engine change; siblings reference it and validate their own platform's fixtures + live leg. #### Step 4 — Write the body (template sections, in order) 1. **Title** — name the bug and explain it, plainly (D3: named by the problem): for fixes, `component: the defect — its consequence` (house example: "api-gateway: shared HTTP pool starves token validation under load — valid API keys mass-401"); for probes, the question being measured; for features, the capability. Never an aspirational state-sentence ("The API answers while…") — the title states what is wrong, not the world after the fix. 2. **Value this issue delivers** — one witnessable sentence per value (D5/D5b), FIRST: the reader decides whether to care before anything else, so the value leads the body. 3. **Why this matters** — dry, factual stakes: what a user loses, what depends on it, what was observed and where (D3). 4. **Where we are (honest)** — current-code facts, `file:line` for every claim. Unknowns stated as unknowns. **The era note is mandatory when the report predates the current tree:** state the report's version/era vs today's code explicitly — an old-era report means we are *validating new code against a known failure mode of the old code*, and the body must say so in those words, so nobody mistakes an inherited symptom for a confirmed present-tense bug. 5. **Components** — each exactly ONE module or seam, `Target:` a real path (D5, D-A); each a business-named waypoint with early validation in that module's own harness lane and a named fixture **range** — deterministic producer / captured live output / hand-authored edge case (D-A2). A solution needing two modules is two components. 6. **Prepared solution + along-the-way forks** — mechanism, files, steps, and the branches a contributor may actually hit (D6). Alternates welcome, never required. 7. **Acceptance table** — the floor that guarantees merge (D10). Every row: discriminating (red→green pair), controlled (negative control shown red), anchored (sha/id/timestamp), complete (no-regression rows). One discriminating row per stated value (D5b). Floor, never ceiling — extending value is welcome and credited. 8. **Deployments to validate** (D12b) — which of Lite compose / full compose / k8s-helm / hosted the table runs against, which are explicitly out of scope, and whether Lite needs its own run (say so either way — Lite takes the most divergent path). State that every attestation must name its setup + build provenance (sha, compose/values file, fresh-clone vs long-lived, env deltas). 9. **Docs surface** (D6c) — the pages this change must touch and at what altitude: quickstart step / how-to / reference / concept, each for its reader. Usually several files. "No docs impact" must be argued, never assumed. 10. **Live bar** (D12) — scaled to the observation: speaker behavior 2–5 people, join/API one operator, a parser none. Never more humans than the observation requires. 11. **Validation request** (D9) — the preferred signer is the originating reporter(s), named; any competent non-author qualifies. Multichannel: what the bot did, what the human saw, what the instruments recorded — channels must corroborate. 12. **Principle check** (D7, fix-requests only) — the architecture principle this defect indicts (verbatim) and the gate that should have caught it; if none covers it, say so — that is a constitution finding. #### Step 5 — Labels, board, closures * Labels: `state: prepared` (only a maintainer flips to `ready`) · `kind: fix-request | evaluation-request | probe` · legacy `type:*` kept · `area:*` · P0 only if production-critical. * Board: where it sits in the one ordered queue, and its `same-setup:` batch note. * Supersede list: which raw issues close into this one, each with a drafted closing comment that links here and invites the reporter to validate when it ships (D16). #### Step 6 — Self-audit, then emit Run the gate checklist: value sentence(s) witnessable · every Target exists · every component has a harness + fixture range · table rows discriminating/controlled/anchored/complete · row per value · deployments declared · docs surface named · live bar scaled · preferred signer named · D5b verdict stated · D7 check present (fixes). Then emit, in order: 1. the D5b verdict, 2. the full issue body, 3. the label set, 4. the supersede/close list with drafted comments, 5. open questions for the maintainer — anything you could not ground in code or signal. **Never guess to fill a section**; an honest open question beats a confident invention. #### Honesty rules (override everything) No promise the project can't keep. Every code claim carries `file:line`. Every behavior claim names its deployment. Uncertain solutions ship as forks, not as facts. If preparation reveals the queue item itself is wrong (already fixed, wrongly bundled, not reproducible), emitting that finding IS the deliverable (D11 applies to preparation too). ## TAKE protocol ### The prompt You are triaging ONE pull request against Vexa-ai/vexa in full compliance with `docs/docs/governance/delivery.mdx`. **Input:** one PR that claims a prepared issue, and a checkout of the current tree. **Output:** one verdict (step 6) with its full evidence map. You are reviewing against a **pre-declared floor** — the issue promised that presenting the table merges the PR; TAKE exists to keep that promise exactly, in both directions. #### Step 0 — Read the law and the claim Read `docs/docs/governance/delivery.mdx`, the claimed issue END TO END (body + comments — rulings and sharpenings live in the thread), and the PR (body + every commit). D8: issue = PR, one to one. A PR claiming no issue, or several, is returned for scoping before any review. #### Step 1 — The bundle gate (before reading a line of the diff) The PR carries two artifacts judged on different axes: the **observation bundle** (is the value real?) and the **diff** (is it correct and safe?). **A diff with no bundle is not reviewable, whatever it says** — request the bundle, warmly, and stop. Do not review code on vibes and do not let a good-looking diff substitute for evidence. **The machine rows count.** The bundle is assembled from two sources: the harness's runs on the PR's head sha (`gates`, `pr-value` — the machine-presented rows) and the contributor's own witness (everything above the automation line). Before requesting a bundle, check what the harness already presented — asking a contributor to hand-write evidence `pr-value` carries on their sha is our process bug, not their gap. #### Step 2 — Floor mapping, in the issue's own numbering Rebuild the issue's acceptance table AS DECLARED (A1…An) and map every row to the PR's evidence. **Detect renumbering and thinning** — a PR body that re-labels or quietly drops rows hides its gaps; the map is always in the issue's numbering, never the PR's. Per row: * **presented** — red→green pair shown, negative control shown red, anchors present (base+head shas, ids, timestamps); **a green `pr-value`/`gates` run on the head sha is `presented` for every row those legs carry** — link the run, don't re-demand the row; * **partial** — claimed but missing its control, its anchor, or its red side; * **missing** — including the rows only a live leg can carry. The live row exists precisely because unit greens can lie; never waive it for a plausible diff — but route it to the cheapest rung that carries it (the ladder, §Validation) before routing it to a human. #### Step 3 — The diff on its own axis Review correctness, security (the checks the issue names, D-S), and architecture conformance (the P-book: fix at the point of introduction, contracts owned by the core, per-runtime front doors, comments state the designed present). **Spot-check the PR's code claims in the tree** (D10's plan-bug rule cuts both ways — verify before you assert). Two questions always: 1. Does the change hold **at the altitude of the claim** (P19) — is user-facing behavior proven by a live leg, or only by unit green? 2. Do the negative controls actually discriminate — would the test stay green if the fix were reverted? #### Step 4 — The plan-bug rule (own our half) If the bundle satisfies the table but the change is still wrong — or the contributor was misled by an imprecise issue — **that is OUR bug in the table, not theirs.** Say so in the review in those words, sharpen the issue in its thread so the floor is exact for the next attempt, and never let a table defect read as a contributor failure. #### Step 5 — Tone (first contributions are load-bearing) Credit what is verifiably right FIRST, and say what you verified ("checked, correct" beats silence). Findings carry `file:line` and the failure scenario, never adjectives. On a contributor's first PR, post the validation-protocol explanation (the two human layers and the exact path to merge) so nothing surprises them after the fixes — the review comment is also the onboarding. #### Step 6 — The verdict (exactly one) Every verdict names the **head sha it examined** — a later push renders the endorsement visibly historical, the same way GitHub dismisses stale approvals (#621: a TAKE praised a head the author replaced hours later, and the stale endorsement stood while the architecture changed underneath it). * **Floor met + diff sound** → arrange the value validation: a **non-author** runs it live (preferred signer: the originating reporter, D16/D9), multichannel evidence corroborating, attestation naming setup + build provenance (D12b). On the signature: merge, label `state: value-signed`, credit author AND validator. * **Floor not met** → CHANGES\_REQUESTED with the row-by-row map from step 2 and the exact, finite path to merge — then restate the promise: present the table and this merges. Nothing beyond the floor may be demanded (value beyond it is welcomed and credited, never required). * **Diff unsound** → CHANGES\_REQUESTED with the grounded findings; pair every 🔴 with the fix direction the issue's prepared solution already implies. * **Wrong work entirely** (doesn't deliver the claimed issue) → decline honestly: what it would take, what would reopen it, and whether the diff carries value worth its own issue. #### Step 7 — After merge: close the loop `value-signed` → ships in the next release batch (D15) → the issue closes **with the release**, the close-back names the fix and invites the reporter who validated (D16), and release notes credit authors and signers by name. An issue closed without its reporter hearing about it is an unclosed loop. #### Honesty rules (override everything) The floor is a floor: never move it mid-review, never demand past it. Every finding carries `file:line` and a concrete failure scenario. A human-red is never bounced back as "try again" — fully elaborate the failure on instruments first, then request the next human attempt. **A red is not a flake until earned** (D-L3): one unexplained failure buys exactly one rerun, an identical second demands reading the code — never rerun a deterministic red into green. An invalidation ("row A3 is red") is a first-class, credited result. If TAKE reveals the issue itself was wrong, saying so publicly IS the deliverable (D11). ## Debugging — the hot loop [Validation](#validation) is machine-first *proof*; this module is how a provable diff is *produced*. It operates the `D-L` cluster ([§7](#the-two-loops)): the packaging pipeline is never the debugger, and a hypothesis is tested on the hottest loop that carries the real external semantics. **Pick the hottest loop that carries the real semantics (`D-L0`).** Rank the loops by iteration cost and by how much external truth each carries; drop the hypothesis to the *hottest one that still exercises the real failure*: | Loop | Iter cost | Carries | Use it to | | -------------------------------------------------------------------- | --------------- | ---------------------------------- | ----------------------------------------------------- | | unit / golden | seconds | one seam, mocked edges | localize a seam bug you can already name | | **assembled hot loop** — live exec · ConfigMap overlay · spawn probe | seconds–minutes | the real chain on the real surface | **answer "does this work?"** — this is the debug loop | | release pipeline — PR → gates → tag → build | \~40 min | the packaged artifact | package a *proven* diff — never to discover one | If you find yourself cutting a build to learn whether the next layer works, you are debugging in the wrong loop — stop and build the overlay / probe that carries the same semantics in minutes. **Inventory in parallel, before the first fix (`D-L2`).** On a suspect surface, run the full-journey probe matrix — spawn · schedule · boot · join · transcribe · live-view · stop — and sweep **every** component's logs **once**, before touching a fix. Write the whole failure set down, then fix. Serial onion-peeling pays the outer-loop tax per layer (v0.12.9's helm chain: \~3h of one-bug-at-a-time a single 20-min sweep would have inventoried). **Leave debug only on the assembled-system green (`D-L1`).** The exit is a green end-to-end probe of the *assembled* system on the surface that has the bug — not the last component compiling. A unit green claims only its unit (P19/D12). **Earn the word "flake" (`D-L3`).** A failure is not a flake until you have earned the claim. One unexplained failure → **exactly one** rerun; an identical second failure → **stop rerunning, read the code**. Cross-check tool answers (CLI state, cached reads) against a second source before acting. A deterministic failure rerun as a flake burns builds and hides the bug (v0.12.9's SIGPIPE gate, #686). **Rehearse before the human (`D-L4`).** The human appears exactly twice — the witness pass and the sign/approve gates. Everything else the agent self-serves (own browser session, raw-protocol clients, synthetic and dead-URL meetings, in-bot screenshots), and the agent **runs the demo path end-to-end minutes before the human walks it** (`make probe` is the rehearsal, #690). A human handed a dead tunnel or a cold model is a wasted oracle. ## Validation ### The ladder — machine first, human last Validation is a ladder; each rung runs **as early and as often as it can be automated**, and a human is asked only for what no rung below can see (D9: the human is the scarce oracle). The rungs, and where the machinery runs them: | Rung | Proves | Machinery | When | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- | | **L1/L2 — sound** | isolation, contracts, units, licenses, build | `gates` | every PR push, \~5 min | | **L3 — delivers** | the PR's own tree assembles and drives the full product FSM — join lifecycle, failure attribution, transcript-segment emission, concurrent bots, acts — through the **real** backend (mock bot: real orchestrator + real adapters; only join/audio faked) | `pr-value` | every runtime-surface PR, \~10–15 min | | **L3.5 — transcribes** *(to build)* | fixture audio → real pipeline → real CPU STT → **golden words** land via the API — no Meet, no human | wav→words leg | PRs touching the transcription path; every release | | **L4 — artifact truth** | the *published* images deliver on every deployment shape (compose · mock-FSM · 0.10-compat · bot-spawn · lite · helm) + fresh-VM concurrent-bots | `release-validate` + VM leg | every release, ≤10 min re-runnable | | **L5 — witnessed value** | a real Meet, real audio, real admission → words a human saw land, plus the batch's user-visible values walked once | the release **witness script** over the eval lane (`bot/eval/run.sh`: autonomous verdict; human acts: admit + speak + walk the script, \~10 min) | once per release — the combined pass | Two laws fall out of the ladder: * **The machine presents what the machine can prove.** A green `pr-value` run on the PR's head sha **is** the live-leg evidence row for backend-visible behavior — TAKE step 2 consumes it directly. Contributors hand-assemble only the rows above the automation line: platform-specific behavior, UX, real external services. Never ask a contributor or a human validator to re-witness a rung the harness already witnessed on their exact sha. * **Claim only your rung** (P19/D12 restated for the ladder). `pr-value` green does not claim audio→words; L3.5 green does not claim real-Meet admission. Every rung's report names what it proved AND the rungs it explicitly does not reach — an over-claimed rung is a false green, treated as an incident (the v0.12.1 lesson: a machinery-green release was published as if value-witnessed; the witness was still unsigned). ### The human signature Before merge, a **non-author** signs what they witnessed **above the automation line** — after the **author's own witness**: the author runs their change live first and their run is the bundle's first row; you never ask another human to witness value you haven't witnessed yourself. Where every acceptance row sits at or below the automation line (behavior `pr-value`/L3.5 proves on the PR's sha), the machine rows stand as the live witness and the non-author signs the *review of the evidence*, not a re-run. The attestation is **multichannel** — what the bot did (logs), what the human saw (the meeting, the transcript), what the instruments recorded (test output, API responses) — and the channels must **corroborate**: a human/instrument divergence blocks merge until reconciled, as a finding in its own right (D9). Per **D12b** it names its setup and build provenance: deployment, repo sha, fresh-clone or long-lived, env deltas from stock. Per **D6c** it covers the docs story. Validators are credited in release notes alongside authors; the originating reporter is the preferred signer (D16); an invalidation is a first-class, credited result (D11). ## Integration — the merge bar Main is **always releasable, never auto-released**. A PR enters it when: the acceptance floor is presented (TAKE, step 2 — machine rows + contributor rows) · the diff passes review + the named security checks (D-S) · the value is signed (D9) · sealed-contract changes rode `lane:contract` with a human review · the full CI gate suite is green **and `pr-value` is green when the PR touches a runtime surface** — the machine contract between every PR and main ([the live map](/governance/arch-compliance)). **Merge is the promise to the contributor, honored.** **One rule for sealed-contract re-stamps.** A back-compatible re-seal MAY ride the delivering PR when (a) the PR carries `lane:contract`, (b) the seal hunk is named in the PR body, and (c) a maintainer performs the merge — the merge IS the ruling. Breaking changes and cross-contract mirror divergences (the api.v1 `MeetingCompletionReason` case, #607) always get an explicit issue-recorded ruling first. (One day, three contract acts, three ad-hoc treatments — #755 re-sealed in-PR, #607 ruled human-only, #765 held for an explicit maintainer act — all defensible; the inconsistency was the defect.) **`Closes` asserts the full acceptance table is delivered; a partial delivery links `Part of #N` (a plain reference, not a closing keyword). The merge card enforces it** — a `Closes`-target issue with undelivered acceptance legs is a ❌ row on the card (choke point 1), so a dropped leg is a decision on record, never a GitHub-keyword side-effect. Cross-reference sweeps miss what was never referenced, so the closure mechanism (#712) carries a second detection mode: **periodic mechanism-vs-tree verification over open prepared issues** — reading whether the tree already delivers an issue, not whether a PR happens to link it (one night surfaced eleven stale-open issues, five of them delivered by PRs that named no issue at all — invisible to any link sweep). ## Release — the ship bar **A release is the promise to users** — a batch event, distinct from merge: 1. **Batch** — the value-signed merges since the last release, cut on the version milestone. 2. **Validate together** — the release machinery proves the batch as a whole (the compose stack bot-ready end-to-end, L4 evidence); individually-proven changes can still interfere. 3. **Publish** — images built and pushed; **tags move per the declared tag policy** (`:v012` moves every release; `:latest` only by explicit, policy-conformant promotion) — artifact pointers are part of the release's truth surface. 4. **Credit + coverage** — release notes name authors **and** validators, per change (D11/D15), and carry the **coverage statement**: which deployments/platforms each change was validated on and which stay *honestly unclaimed* (D12b). A release is proven on what we name — never a bare "works". 5. **Close back** — every issue in the batch closes *with the release*; its reporter is invited to validate on the shipped artifact (D16). An issue closed without its reporter hearing about it is an unclosed loop. 6. **The milestone ticks** — the public progress bar is the release ledger. **The value-witness law.** "All value witnessed" is a *named act*, never an inference from machinery green (the v0.12.1 incident: promoted and published on L4 alone, witness unsigned): * **PR-level value is machine-witnessed.** A PR's value rows are proven by the harness on its own sha (`pr-value`, path-aware legs); no human eyeballs individual PRs. A PR reaches the human only as *proven value + reviewed diff* — or when it needs a genuine ruling. * **Every release carries ONE combined-value witness pass.** The release generates a **witness script** from the batch: a single live session (one real meeting on the release candidate + one product walkthrough) in which every user-visible value shipped in the batch is experienced once. The autonomous legs surround it; the human contributes the irreducible minimum — admit the bot, speak, walk the script (\~10 min). Backend-invisible changes are listed under the script with their machine evidence, witnessed by proxy and named as such. * **Publish and promote are the same act** for witness purposes: neither happens before the witness pass is signed. A release published unwitnessed is retracted to pre-release until the pass is met. **The verdict-latency law.** The release loop is only honest if it is also *fast to re-ask*: * **Re-verdict in ≤10 minutes, no rebuild.** The release verdict (the validate legs) must be re-obtainable against already-published artifacts without rebuilding anything unchanged. Build+publish and validate+promote are separate machinery; a harness fix re-runs only the second. · *Gate:* `release-validate` is independently dispatchable with a version input. * **Harness fixes iterate off-main.** A validation-harness change is testable from its branch against the published set *before* it merges — a smoke-script fix must never cost a PR→merge→full-pipeline round trip per attempt (the v0.12.1 maiden run paid 3 × \~20–50 min for two one-line harness fixes; this law is that bill, paid once). * **Silence is an incident signal.** A triggered release run that has produced no first job within 10 minutes is stuck, not slow — check the runner queue instead of waiting (the same maiden run lost 50 minutes to an unwatched queue stall). ### The guarantee — what every release promises, and what proves each line Owner-approved canon (2026-07-13). The release notes carry this block **generated from the actual run results with links** — never written by hand. A line that cannot link its proof is stated as *honestly unclaimed*; a hidden gap is an incident. 1. **Every artifact users pull is the artifact we proved.** All images — services, agents, workers, bot — published, and validation ran against those published bytes, never a local build. · *Proof: release-validate pulls-only; the install digest audit (#569).* 2. **The product works end-to-end on every documented install path.** Compose, lite, Kubernetes: up from scratch, product FSM green. · *Proof: the deployment-shape matrix.* 3. **Real audio becomes correct, speaker-attributed words.** Fixture audio → real pipeline → real STT, exact-words oracle, both pipelines. · *Proof: the wav→words leg (#560); until it ships, this line reads "proven at the witness pass only" — and the notes say so.* 4. **A fresh machine with nothing on it succeeds.** Clean cloud VM, documented install, working bots. · *Proof: the fresh-VM leg, born and destroyed per release.* 5. **Nothing that worked before broke.** Prior-release behavior holds; 0.10 clients still function; deliberate breaks recorded by name. · *Proof: v010-compat + no-regression rows.* 6. **Every image contains what its name promises.** · *Proof: image-identity leg.* 7. **A human witnessed the assembled value.** One live pass on the release candidate — real meeting, real words, every user-visible batch change walked once — signed by the human who did it. **No signature, no release.** · *Proof: the witness pass.* 8. **Every change in the batch was individually proven before it entered.** Machine-validated on its own tree, diff-reviewed, merged through the card. · *Proof: pr-value + gates green on each merged head.* 9. **The notes tell the truth about coverage.** Proven-on-what-we-name, per path, per leg; the honestly-unclaimed list explicit; every author and validator credited; the loop closed to every reporter. · *Proof: the guarantee block is machine-generated from run results.* 10. **Pointers moved honestly.** `:v012` only after all of the above; `:latest` only by explicit ruling; the 0.10 line never touched. · *Proof: promote's constitutional guards.* ## The two choke points The whole delivery loop routes through exactly **two** engineered human moments — everything else is autonomous machinery or agent work. Adding a third human gate anywhere is a process regression; making either of these two vague is a process bug. **Choke point 1 — the merge card, one per PR.** Before any merge, the maintainer receives ONE card, plain language, no jargon, no flood: 1. **What you get** — the value in one or two sentences, in user terms. 2. **Is it real?** — the proof, honestly: which machine legs ran green *on this PR's sha* (linked), what the contributor witnessed themselves, and — never omitted — what is NOT yet proven and which later gate carries it. 3. **The change** — the diff in plain words: what was touched, how big, where the risk sits, what the review found (fixed or outstanding). 4. **Your decision** — the exact ask, one sentence, and what changes on each answer. A card that buries the gap, inflates the proof, or asks two questions is returned to its author (us). The maintainer rules on the card in minutes; they never re-derive evidence. **Choke point 2 — the release witness pass, one per release.** The batch's user-visible value, walked once, live, on the release candidate (the witness script; ship bar above). The human acts where machines cannot — admission, and seeing the assembled product deliver. The agent **rehearses the whole path end-to-end minutes before** — every tunnel, session, and model warm — so the human meets a working demo, not the agent's un-run homework (D-L4). ## Asking the human The human is the scarce oracle (D9) — so every ask of them is engineered, not tossed over the wall. Whenever the loop needs a human verdict — a `ready` stamp, a ruling, a validation leg, a release call — the request obeys this contract: * **One concern per ask, one ask at a time.** Never a wall of parallel questions; the queue is ours to hold, not theirs to untangle. The next ask waits for the verdict on this one. * **The full bundle in one shot.** Everything the decision needs arrives together: the value at stake, the reasoning, the architecture implications, the recommendation with its trade-off — no follow-up round-trip to assemble context the asker already had. * **No jargon — readable at both altitudes in the same text.** The CEO reading (what value, what risk, what it costs) and the CTO reading (which module, which principle, which gate) must both land without translation. Codenames and session shorthand never survive into an ask. * **Say exactly what the verdict changes.** An ask is a decision with consequences, never an FYI; if nothing changes on their answer, don't ask. * **One eyeball request at a time** (the validation form of the same rule): a human validation ask names one flow, fully instructed, earned by a green instrument first — never "try again" as a probe (D9's human-red rule). ## The maintenance cycle The standing duty loop that drives every open PR toward merge and forms release batches — run it on every PR event and as a daily sweep. PREPARE and TAKE are per-item prompts; this is the orchestrator that invokes them. Only the `ready` stamp, the merge, and the ship are maintainer acts (D-R0); everything else in the cycle is species-neutral. ### The prompt You are running one maintenance cycle over Vexa-ai/vexa. Input: the open PRs, the tracker, the board. Output: every open PR advanced exactly one honest state (or explicitly left, with why), and a release verdict. You never lower a floor, never merge unsigned value, never let silence rot a claim. **Step 1 — sweep every open PR, oldest first, and classify:** * **No linked issue** (pre-constitution or drive-by) → the era rule: a PR is *signal + a candidate diff*. Map it: delivers a now-prepared issue → invite adoption (author claims the issue; their diff meets its floor); valuable but unprepared → run PREPARE seeded by the PR; era-superseded → decline with credit and what would reopen it. * **Bundle missing** → request it warmly (TAKE step 1) and stop there — never review a diff on vibes. * **Awaiting triage** → run TAKE → one verdict. * **Changes requested** → heartbeat check: active → wait; silent days → one nudge (issue + Discord); silent past the lease → release the claim, mark the PR draft, thank the author, requeue the issue. * **Floor met, awaiting validation** → drive it: ping the preferred signer (the reporter); no signer in reach → recruit any competent non-author (the lane names the species); an owner-only leg → surface it to the owner explicitly per [Asking the human](#asking-the-human), never let it wait silently. * **Value-signed** → merge same-day (the promise, honored), label, credit in the thread. **Step 2 — cross-PR invariants:** two PRs on one issue → the earlier claim holds, the later gets an honest note (and its novel value, if any, becomes a fork on the issue). One PR spanning two issues → split request (D8). Every touched issue/PR leaves the cycle with exactly one `state:` label. **Step 3 — release ripeness.** After merges, ask: is a release due? Triggers, any one sufficient: a P0 fix merged (ship fast) · the value-signed backlog reaches a batch worth validating together · a milestone checkpoint or time-box arrives. Ripe → run the [ship bar](#release--the-ship-bar) end to end: batch → validate together → publish + move tags per policy → notes crediting authors and signers → close-backs to every reporter → milestone tick. Not ripe → say what would make it ripe. **Step 4 — report facts.** End the cycle with the dashboard, not adjectives — its FIRST line is always the pipeline position (intake → prepared → ready → claimed → delivering → validating → merged → release), counts per stage, so no one ever asks where we are: PRs by state · each blocked-on-whom (named) · validation legs scheduled · release ETA and its trigger · incident threads owing updates. A cycle that touched nothing states why that is correct. **Honesty rules:** the floor is the floor (never raised mid-review, never waived for a plausible diff) · a human-red is elaborated on instruments before any human is asked again · silence is released, never punished · every decline names its reopen condition (D17). ## Enforcement map | Principle | Gate | Status | | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | D1 enforced-not-aspirational | this table stays current in every delivery PR | **have** (manual) | | D-A targets resolve to real modules | preparation review: `Target:` paths exist | **have** (manual review) | | D-A2 fixture ranges | preparation review: early validations name fixtures | **have** (manual review) | | D-R0 two species | branch protection (merge) · `state: ready` maintainer-only | **have** (branch protection) / **TO BUILD** (label permission bot) | | D2 one ordered queue | the Vexa Roadmap project board is the single source | **have** (board live) / **TO BUILD** (coverage check) | | D2b 3-day intake SLA | report forms auto-label `state: incoming`; intake bot ages them | **have** (forms) / **TO BUILD** (ager) | | D3/D4 business meaning + code grounding | preparation review checklist | **have** (manual) | | D5/D6 issue shape | issue template with required sections | **have** (`.github/ISSUE_TEMPLATE/3-prepared-issue.md`) | | D5b bundle-by-diff | split/bundle test at preparation review; one acceptance row per stated value | **have** (template note + manual review) / **TO BUILD** (row-per-value in bundle checker) | | D6c docs ride the change; docs declare their release; docs story human-validated | `docs-current` (product-surface PR updates docs/ or declares `docs: none`) · `gate:docs-version` (docs stamp == appVersion) · attestation covers the docs story | **have** (CI: `docs-current-gate` + `gate:docs-version`, [ADR-0032](https://github.com/Vexa-ai/vexa/blob/main/docs/adr/0032-docs-currency-gates.md)) / **TO BUILD** (content-accuracy stays human) | | D12b deployment named + setup provenance | "deployments to validate" required before `ready`; attestation names setup + build provenance | **have** (templates) / **TO BUILD** (bundle checker flags rows with no named deployment) | | D7 principle check on fixes | fix-request template section | **have** (prepared-issue template) | | D-L0/D-L1 debug ≠ deliver + assembled-probe exit criterion | the standing hot loop (`make probe`) + a pre-tag real-spawn leg, so the packaging pipeline is never the debugger | **TO BUILD** (#689 real-spawn leg · #690 `make probe`; [ADR-0033](https://github.com/Vexa-ai/vexa/blob/main/docs/adr/0033-the-two-loops-debug-flake-human.md)) | | D-L2 parallel failure inventory before fixing | full-journey probe matrix + one all-component log sweep, as standing infrastructure | **TO BUILD** (#690 probe matrix) | | D-L3 "flake" is earned | durable regression test on the fixed gate + the one-rerun rule in TAKE | **have** (#686 `deploy/helm/tests/test_template.sh`) / **TO BUILD** (rerun-discipline in the checks bot) | | D-L4 human placement — exactly twice + rehearsal | agent-rehearsable probe is the rehearsal; choke point 2 names the witness moment | **have** (choke point 2 named) / **TO BUILD** (#690 agent-rehearsable probe) | | D8 bundle + diff | PR template requires the observation bundle | **have** (`.github/pull_request_template.md`) | | D-S security lanes | contributor security status + maintainer bundle | **TO BUILD** (status) / **have** (maintainer practice) | | D9 value-signed, corroborated | `gate:value-signed` checks non-author + channel consistency (merge-time) · **release-time:** `value-gate` requires every batch PR pr-value-green/value-signed | **have** (CI: `merge-card-gate` merge-time, `release-value-gate` release-time; [ADR-0029](https://github.com/Vexa-ai/vexa/blob/main/docs/adr/0029-release-witness-and-value-gates-enforced.md)/[ADR-0030](https://github.com/Vexa-ai/vexa/blob/main/docs/adr/0030-merge-card-value-and-diff-gate.md)) | | D10 acceptance floor | bundle-vs-table check (merge-time) · **release-time:** guarantee line 8 enforced by `value-gate` | **TO BUILD** (bundle checker) / **have** (CI: `release-value-gate`) | | Merge bar — value + diff accepted (choke point 1) | `merge-card` required status check: value-fsm green + `state: value-signed` (value) AND a fresh non-author review approval (diff) | **have** (CI: `merge-card-gate`, [ADR-0030](https://github.com/Vexa-ai/vexa/blob/main/docs/adr/0030-merge-card-value-and-diff-gate.md)) | | Contributor onboarding — explain the change | PR template + CONTRIBUTING recommend Discord; `pr-welcome` nudges first-timers (recommended, never a gate) | **have** (templates + CI) | | D11 both verdicts credited | release-notes generation from bundles | **TO BUILD** | | D12 altitude-scaled validation | stated live bar in every prepared issue | **have** (manual review) | | D13 human sole author | commit-trailer check (reject agent co-authors) | **TO BUILD** | | D14/D14b state machine + lease | label bot + lease bot | **TO BUILD** | | D15 release machinery | release-set gate · `release/vm-validated` · **witness + value gates on promote** (publish/promote split; `witness-gate` + `value-gate` + the `release-promote` Environment; `release-published-guard` retracts an unwitnessed release) | **have** (CI: [ADR-0029](https://github.com/Vexa-ai/vexa/blob/main/docs/adr/0029-release-witness-and-value-gates-enforced.md)) | | D16 close-back to reporter | ship-closes-report step | **TO BUILD** | | D17 declined-with-reason | closed-without-merge requires one `declined:` label | **have** (manual) / **TO BUILD** (stale proposer) | Machinery tracking: the TO BUILD rows are one work item (the delivery checks bot: label state machine, lease TTL, value gate, bundle checker, intake/stale agers, trailer check) — tracked on the roadmap as delivery-machinery work. # How Vexa governs itself Source: https://docs.vexa.ai/governance/index The full governance map: two constitutions, two protocols, the surfaces and actors of the delivery loop, who enforces what, and how the law itself changes. Vexa is governed by written, enforced law — not convention. Three documents, each governing a different noun; two operational protocols bookending the contribution loop; a set of surfaces where humans and agents meet the system; and one amendment mechanism through which the law itself evolves. This page is the map. ## The trinity | Document | Governs | Character | | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------- | | **[Architecture](/governance/architecture)** — the P-book (P1–P23) | the **artifact**: what the software must be — structure, contracts, isolation, data-flow, proof altitudes | timeless · machine-enforced | | **[Delivery](/governance/delivery)** — the D-book (D0–D17) | the **flow**: how change earns its way in — intake → prepare → roadmap → claim → deliver → prove → release → close-back | cyclic · process-enforced | | **[AGENTS.md](https://github.com/Vexa-ai/vexa/blob/main/AGENTS.md)** — the actor contract | the **actor**: how a session (human, agent, or both) behaves inside a checkout | per-session · ledger-enforced | AGENTS.md is deliberately the thinnest: it owns no law of its own — it *binds* a session to the other two and adds only session mechanics (worktree isolation, the expect→verdict loop, the claim heartbeat). ## The two protocols Each phase of the delivery loop has an operational protocol — an agent-runnable prompt, not prose about process: * **[PREPARE](/governance/delivery#prepare-protocol)** — signal → spec. Turns one queue item (a raw report, an incident, a failure mode) into a prepared issue: code-grounded, harnessed, carrying an acceptance table that *guarantees* merge. * **[TAKE](/governance/delivery#take-protocol)** — PR → verdict. Triages a delivered PR against its issue's declared floor: bundle before diff, row-by-row in the issue's own numbering, the floor never moved, the plan-bug rule owned on our side. The prepared issue is the hinge between them: the *output* of PREPARE and the *input* of a contributor's work — a PRD whose acceptance table is a merge promise. ## Surfaces and actors — one pass around the loop | Stage | Actor | Surface | Governed by | | -------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | | Discover | remote agent / human | this site's `/llms.txt` (auto-generated) · [AGENTS.md](https://github.com/Vexa-ai/vexa/blob/main/AGENTS.md) (the repo agent front door, 28+ tools read it natively) · README | — | | Feed back | remote agent / user | **GitHub issues** — every report enters `state: incoming`, 3-day triage SLA | D2b | | Prepare | maintainer's agents | the tracker | PREPARE | | Stamp + roadmap | maintainer | the [board](https://github.com/orgs/Vexa-ai/projects/2): Lane × Milestone × Human bar × Setup | D-R1 / D2 | | Pick | contributor's agent | one GraphQL call ([contributing](/governance/delivery#the-roadmap)) | — | | Claim | contributor | issue comment + **Discord hello** (the human-to-human channel) + heartbeat | AGENTS.md / D14b | | Build | contributor + agent | the checkout: issue-as-PRD, worktree, gates | AGENTS.md binding the P-book | | Deliver | contributor | the two-artifact PR: observation bundle + diff | D8 | | Take | maintainer | the PR, against the declared floor | TAKE | | Validate | **a non-author human** | a real meeting / deployment — multichannel, provenance-anchored | D9 / D12b | | Release + close back | maintainer | release notes credit author *and* signer; the reporter is invited to validate | D15 / D16 | ## Who enforces what * **The P-book: machines.** The CI gate suite (28 gates) — red or it didn't happen. The live map of principle → gate is [Architecture compliance](/governance/arch-compliance), generated, never hand-edited. * **The D-book: process.** The state-label machine (exactly one `state:` per issue), acceptance floors, value signing. The enforcement map — including the honestly-marked TO-BUILD rows (the `value-signed` status check, the label bot) — is [the enforcement map](/governance/delivery#enforcement-map). * **The actor contract: visibility.** No gate checks the expect→verdict loop — the human reading the ledger *is* the gate. * **[Contributor rights](/governance/contributor-rights): declaration + DCO + attributable corporate authorization.** Individuals make one conscious choice and encounter no CLA; employer-controlled work waits for a private receipt bound to the current PR head. ## How the law itself changes No principle is edited casually. The amendment loop: > surprise → root-cause **with a human** → learning → **ADR** on `lane:contract` (human-reviewed) > → new principle in the P-book or D-book → new gate that enforces it. The case law lives in [`docs/adr/`](https://github.com/Vexa-ai/vexa/tree/main/docs/adr) — decision records the constitutions cite by number. Machine models keep both books executable: the P-book's is [`architecture.calm.json`](https://github.com/Vexa-ai/vexa/blob/main/architecture.calm.json) (FINOS CALM, drift-gated); the D-book's is the roadmap board itself. ## The one idea underneath Agents made code cheap. The scarce input is **verified truth about delivered value** — so every mechanism above exists to route human attention to the one thing only humans can give: *"I ran it, and I witness the value."* Validation is credited as authorship's equal, invalidation is a first-class result, and nothing merges on anyone's say-so alone — including ours. # Calendar sync Source: https://docs.vexa.ai/how-to/calendar-sync Connect your calendar with its secret ICS address — upcoming meetings import automatically and the bot joins them at start. Connect a calendar once and Vexa keeps your **Upcoming** meetings in sync: every calendar event with a Meet/Zoom/Teams link becomes a [planned meeting](/core/meetings#the-lifecycle--one-meeting-one-row), and the bot [auto-joins](/core/meetings#auto-join--scheduled-means-the-bot-comes) each one at start time. No OAuth, no calendar permissions — it works off the **secret ICS address** your calendar already provides. ## 1. Find your secret ICS address 1. Open [Google Calendar settings](https://calendar.google.com/calendar/r/settings) → pick your calendar under **Settings for my calendars**. 2. Scroll to **Integrate calendar**. 3. Copy **Secret address in iCal format** — it looks like `https://calendar.google.com/calendar/ical/you%40company.com/private-…/basic.ics`. **Not** the field above it. The *Public address in iCal format* (`…/public/basic.ics`) only works for calendars you've made fully public — for a normal private calendar Google answers it with `401`/`404`, and Vexa will show you exactly that error. The *Public URL* and *Embed code* are web pages, not feeds — pasting one is rejected at save with a pointer back here. On a **Google Workspace** domain the secret address is governed by an admin policy, and under the Workspace default (*"Only free/busy information"*) Google **hides the field entirely** — the section ends at the public address. A Workspace admin unlocks it domain-wide: 1. [admin.google.com](https://admin.google.com) → **Apps → Google Workspace → Calendar** 2. **Sharing settings** → **External sharing options for primary calendars** 3. Select **"Share all information, but outsiders cannot change calendars"** and save. Propagation usually takes minutes (Google says up to 24 h). Reload the calendar settings page and the **Secret address in iCal format** field appears below the public one. This only enables the *possibility* of detailed external sharing — nothing is exposed until a user actually hands out their secret address, and each address is revocable per calendar (**Reset** next to the field). 1. Outlook on the web → **Settings → Calendar → Shared calendars**. 2. Under **Publish a calendar**, pick your calendar, permissions **Can view all details**, and click **Publish**. 3. Copy the **ICS** link. The ICS address is a **secret** — anyone holding it can read your calendar. Vexa stores it accordingly: it is never shown back in full (reads return a masked form), and the sync fetch runs through the same server-side request guard as webhooks. Revoke it anytime from your calendar's settings ("Reset" in Google Calendar) — then reconnect with the new address. ## 2. Connect it — and get an answer immediately In the Terminal's **Meetings** list, click **Connect your calendar** (under *+ Plan a meeting*; the calendar icon in the header opens the same panel and stays there for managing the connection). Paste the address and hit **Connect**. **Connecting runs a sync on the spot** — within seconds the panel answers with the result, not silence: * `✓ Synced just now — imported 3` — done; the meetings are under **Upcoming**. * `✓ Synced just now — no meetings with joinable links found` — the feed is fine, but no upcoming event carries a Meet/Zoom/Teams link (see [what imports](#what-imports-and-what-doesnt)). * `⚠ Last sync failed: …` — the actual reason, named (see the [error reference](#reading-the-sync-status) below). The panel keeps showing the **last sync status**, and the **Sync now** button re-pulls the feed on demand — no waiting on the background cycle (default 5 minutes) after you add an event. Over the API: ```bash Connect theme={null} curl -X PUT "$API_BASE/user/calendar" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"ics_url":"https://calendar.google.com/calendar/ical/…/private-…/basic.ics"}' ``` ```bash Sync right now → the fresh result theme={null} curl -X POST "$API_BASE/user/calendar/sync" -H "X-API-Key: $API_KEY" # {"last_sync":"2026-07-08T15:30:00+00:00","last_error":null,"counts":{"created":3,"updated":0,"cancelled":0}} ``` ```bash Read the last sync status theme={null} curl "$API_BASE/user/calendar/sync" -H "X-API-Key: $API_KEY" ``` ## Reading the sync status Every failure names its fix — these are the messages the panel (and the API stamp's `last_error`) can show: | Message | What it means | Fix | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | | `the URL answered HTTP 401` / `HTTP 404` | You pasted the **public** iCal address of a private calendar. | Use the **secret** address (`…/private-…/basic.ics`). | | `the URL returns a web page, not a calendar feed` | An embed/share *page* URL, not a feed. (Google's embed page is also rejected at save.) | Copy the address from **Integrate calendar**, not the browser's address bar. | | `the URL redirects — paste the final feed URL` | The address bounces through a redirect; the guarded fetch doesn't follow them. | Paste the URL the redirect lands on. | | `the URL doesn't return an ICS calendar (no BEGIN:VCALENDAR)` | The server answered, but not with calendar data. | Check you copied the full `.ics` address. | | `the feed is too large (over 2 MB)` | Unusually huge feed. | Sync a specific calendar rather than an aggregate. | | `couldn't reach the URL (unreachable, timed out, or a blocked/internal address)` | Network failure, or the address points somewhere the server-side guard refuses (internal hosts). | Verify the URL opens from a browser; use the calendar provider's public host. | ## What imports (and what doesn't) * **Only events with a recognizable meeting link** — Meet, Zoom, or Teams, found in the event's conferencing field, location, or description. Events without one (lunches, focus blocks) are ignored. * **One meeting per event — the next occurrence only.** A recurring series tracks its next upcoming occurrence; the one after imports once the current completes. * **Moves and cancellations follow the feed.** A rescheduled event moves its meeting; a cancelled or deleted event removes it — unless the bot already joined, in which case sync never touches it. * **Your manual plans are respected.** If you already planned a meeting on the same link, the calendar event links up with it instead of creating a duplicate — your title and workspace binding win. ## Auto-join for imported meetings The calendar panel has one global switch — **Auto-join imported meetings** (default on). It sets the default for every meeting the sync creates; you can still flip auto-join per meeting in its prep view. Turn the global switch off if you want the calendar only to *populate* Upcoming while you send the bot by hand. ## Disconnect Calendar icon → **Disconnect** (or `PUT /user/calendar` with `{"ics_url": null}`). Already-imported meetings stay; they just stop following the feed. ## It's not syncing? The panel's status line is the first stop — it names the failure. For the broader checklist (self-host env, link-less events, timing) see [Troubleshooting → Calendar isn't syncing](/troubleshooting#calendar-isnt-syncing). # Chat with your workspace Source: https://docs.vexa.ai/how-to/chat-workspace Ask an agent that has your whole knowledge base as context — and let it record what you decide. **Chat** is a [dispatch](/core/agents) fired *now*: you send a message, an agent works your [workspace](/concepts#workspace) — every meeting, email, and note already compiled there — and answers, streaming back live. This is not retrieval over a document store; the workspace is a maintained Markdown knowledge base the agent reads and edits like a developer in a repo. ## Ask a question The endpoint returns **Server-Sent Events**: ```bash theme={null} curl -N -X POST "$API_BASE/agent/chat" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"prompt":"What did we agree with Acme on pricing, and what is still open?"}' ``` Each `data:` line is one frame: | frame | fields | meaning | | --------------- | -------------------------- | --------------------------------------------- | | `message-delta` | `text` | a chunk of the streamed reply | | `tool-call` | `tool`, `args`, `callId` | the agent invoked a tool | | `tool-result` | `callId`, `ok`, `summary` | that tool returned | | `commit` | `sha` | the agent wrote to the workspace (git commit) | | `rejected` | `violations` | a write was blocked by governance | | `done` | `reply`, `sessionId`, `ok` | turn finished | ## Record a decision (trusted write) Because input from you is **trusted**, the same agent can write — record a decision, update an entity, draft a doc. Just ask: ```bash theme={null} curl -N -X POST "$API_BASE/agent/chat" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"prompt":"Record that Acme renewed at the Q3 pricing, effective July 1."}' ``` Watch for a `commit` frame — `git` is the durable state and the undo. ## Continue a conversation Pass `session` to keep context across turns; reset or inspect history: ```bash theme={null} # continue a session -d '{"prompt":"And who owns the follow-up?","session":""}' curl -X POST "$API_BASE/agent/chat/reset" -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" -d '{"session":""}' # → {"ok":true} curl -H "X-API-Key: $API_KEY" "$API_BASE/agent/sessions" # list sessions curl -H "X-API-Key: $API_KEY" "$API_BASE/agent/sessions//history" ``` ## Build a cross-source briefing The agent grounds its answer in everything the workspace holds, so you can ask it to pull a topic together across past meetings and emails: ```bash theme={null} -d '{"prompt":"Brief me on the Acme account: every meeting and email, the open decisions, and the next step."}' ``` This is the **knowledge agent** pattern — the output is anchored in accumulated context, and each run leaves the workspace a little more complete. A `rejected` frame means governance blocked the write. Trusted chat may write; untrusted triggers (email, web) are propose-only — see [Governance](/architecture/governance) and [Troubleshooting](/troubleshooting#agent-write-was-rejected). # Use a custom STT endpoint Source: https://docs.vexa.ai/how-to/custom-stt Point Vexa at any OpenAI-compatible speech-to-text service, including self-hosted FunASR or SenseVoice gateways. Vexa sends meeting audio to one OpenAI-compatible speech-to-text endpoint. The endpoint can be the bundled faster-whisper service, Vexa's hosted transcription service, or a self-hosted gateway that implements the same request shape. Use this path when you want a different ASR model without changing the bot, meeting-api, or live transcript pipeline. ## Contract Vexa appends `/v1/audio/transcriptions` to `TRANSCRIPTION_SERVICE_URL` and sends multipart form data: | Field | Required | Notes | | ----------------- | -------: | ------------------------------------------------------------ | | `file` | yes | Audio window captured by the bot. | | `model` | yes | Comes from `TRANSCRIPTION_MODEL`, or `whisper-1` when unset. | | `language` | no | Present when the bot request pins a language. | | `response_format` | no | Backends may return plain JSON or verbose JSON. | The minimum response Vexa needs is: ```json theme={null} { "text": "transcribed speech" } ``` Backends may include segments, words, timestamps, duration, or usage metadata. Keep those fields additive: the meeting pipeline must still work when only `text` is present. ## Configure Vexa Set these variables in `deploy/compose/.env`: ```bash theme={null} TRANSCRIPTION_SERVICE_URL=http://:8000 TRANSCRIPTION_SERVICE_TOKEN= TRANSCRIPTION_MODEL= ``` `TRANSCRIPTION_SERVICE_URL` is the base URL. Do not include `/v1/audio/transcriptions`; Vexa appends that path. If the backend ignores the `model` form part, leave `TRANSCRIPTION_MODEL` unset. If it validates model ids, set the exact served name. ## Example: FunASR or SenseVoice **Client path validated upstream; complete meeting deployment still needs a witness.** The FunASR maintainers ran Vexa's own `TranscriptionClient` against the FunASR 1.3.26 OpenAI-compatible server with SenseVoice on CPU ([#928](https://github.com/Vexa-ai/vexa/pull/928)). Chinese, English, Cantonese, Japanese, Korean, and concatenated Chinese-English samples all produced non-empty transcripts, verbose segments, and duration metadata. **No Vexa maintainer has reproduced this** — a bot-in-meeting run and the `platform_settings` override path remain unwitnessed on either side, and are tracked in [#863](https://github.com/Vexa-ai/vexa/issues/863). Self-hosted FunASR/SenseVoice gateways are reported to be a good fit when you need local Chinese, mixed Chinese-English, Cantonese, Japanese, or Korean transcription. Run or deploy a gateway that accepts: ```bash theme={null} curl -sS http://:8000/v1/audio/transcriptions \ -H "Authorization: Bearer " \ -F file=@sample.wav \ -F model=sensevoice \ -F language=zh ``` Then point Vexa at the same host: ```bash theme={null} TRANSCRIPTION_SERVICE_URL=http://:8000 TRANSCRIPTION_SERVICE_TOKEN= TRANSCRIPTION_MODEL=sensevoice ``` The official `funasr-server --model sensevoice` exposes `sensevoice` as its served model id. A Hub checkpoint path such as `FunAudioLLM/SenseVoiceSmall` is not automatically an API model id; verify the names exposed by your gateway's `GET /v1/models` route. The official Fun-ASR-Nano server uses `fun-asr-nano`. Other gateways may expose different names. FunASR 1.3.26 transcribes all five languages correctly, but when `language` is omitted it reports `language: "zh"` for every verbose response. Pin the meeting language when accurate metadata matters. The upstream fix is tracked in [modelscope/FunASR#3400](https://github.com/modelscope/FunASR/pull/3400). Vexa treats the STT service as a network dependency. Put the ASR runtime on a GPU box if needed, and keep the main stack CPU-only. ## Preflight before a meeting Test the endpoint from the Vexa host before sending a bot: ```bash theme={null} curl -sS "$TRANSCRIPTION_SERVICE_URL/v1/audio/transcriptions" \ -H "Authorization: Bearer $TRANSCRIPTION_SERVICE_TOKEN" \ -F file=@sample.wav \ -F model="${TRANSCRIPTION_MODEL:-whisper-1}" ``` Accept the endpoint only if it returns HTTP 2xx and a non-empty `text` field. A 404 usually means the base URL already included `/v1` or `/v1/audio/transcriptions`. A 401 or 403 means the token is missing or wrong. A `model_not_found` response means `TRANSCRIPTION_MODEL` does not match the served model id. ## Keep provider errors attributable When adding or operating a custom STT backend: * Preserve the raw HTTP status in logs. * Keep provider error messages sanitized, but do not collapse every failure into "transcription failed". * Separate route errors, auth errors, model-id errors, and audio-format errors. * Keep long-audio chunking on the backend side if the model has a fixed window limit. That makes a bad URL, expired token, unsupported model id, or model-specific audio limit diagnosable without changing the meeting bot. # Brief me every morning Source: https://docs.vexa.ai/how-to/daily-brief An unattended agent that runs on a schedule and commits to your workspace. A **routine** is a scheduler entry — a trigger plus a plan. The agent runs with no one watching, works your [workspace](/concepts#workspace), and commits; the output is replayable later. ## Create the routine ```bash theme={null} curl -X POST "$API_BASE/agent/routines" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{ "name":"Morning brief", "cron":"0 8 * * 1-5", "prompt":"Brief me from overnight activity — new meetings, decisions, and follow-ups due today. Write it to brief/today.md.", "run_now":true }' # → 201 {"routine":{...},"job_id":"job_...","ran_now":true} ``` `cron` is a standard five-field expression — `0 8 * * 1-5` is 08:00 every weekday. `run_now:true` also fires it once immediately so you can verify the output without waiting. ## Manage it ```bash theme={null} # list curl -H "X-API-Key: $API_KEY" "$API_BASE/agent/routines" # pause / resume curl -X PATCH "$API_BASE/agent/routines/Morning%20brief/enabled" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"enabled":false}' # delete (by routine id) curl -X DELETE "$API_BASE/agent/routines/" -H "X-API-Key: $API_KEY" ``` ## Read the result ```bash theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/agent/workspace/file?path=brief/today.md" ``` ## Variations Same machinery, different trigger or plan: * **On an event** — run when something happens (e.g. a new email) instead of on a clock: [Triage incoming email](/how-to/email-triage). * **After a meeting** — write a report when a call ends: [Report after every meeting](/how-to/post-meeting-report). Only the trigger changes — same agent, same isolation, same governance. # Triage incoming email Source: https://docs.vexa.ai/how-to/email-triage An event-triggered agent that turns new mail into proposed tasks — safely. Email is an **Integration**, not a backend object. A new email is an **inbound event** that can fire the [scheduler](/concepts#scheduler); the agent reads and drafts through the integration's MCP tool. ## Why this one is different: untrusted input Email is attacker-controllable and **prompt-injectable**, so triage runs [propose-only](/architecture/governance): the agent gets the mailbox `ro` and **cannot write or send directly**. It emits proposal cards — `record` (a task/note), `draft`, `send` (external) — a human approves, and trusted code applies. A `send` stays gated until you approve it. ## Fire a triage on a new email An integration delivers the email as an `event.v1` event carrying a plan: ```bash theme={null} curl -X POST "$API_BASE/agent/events" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{ "name":"email.received", "source":{"uri":"mailbox://u_jane/INBOX/AB12CD"}, "plan":{"prompt":"Triage this email into tasks. Propose a record for each action item and a draft reply if one is warranted."} }' # → 202 {"workload_id":"agent-...","trigger":"event"} ``` The event **must carry a plan** — `POST /events` returns `422` if `plan` is absent. See the [error reference](/api/errors#by-surface). ## Approve the proposals The dispatch emits `proactive-card.v1` frames on its [Stream](/architecture/streaming). Approving a `record` commits it to the workspace (a trusted applier does the write); approving a `send` executes it through the integration. Nothing leaves your control without that approval. ## Make it standing To triage automatically for every inbound message, bind a triage routine to the email integration so each `email.received` event dispatches the agent — the inbound trigger replaces the manual `POST /events` call above. See **Integrations** and [AI routines](/how-to/daily-brief). # Run a live meeting copilot Source: https://docs.vexa.ai/how-to/live-copilot Surface people, action items, and decisions as a call happens — approve them into the workspace. A **meeting copilot** rides the live transcript: a dispatch stays non-idle on the stream and emits **proactive cards** — a new person, an action item, a decision — as they come up. Approving a card commits it to your [workspace](/concepts#workspace). Same agent and governance as everything else; only the trigger (the transcript stream) differs. The flow usually starts before the call: [plan the meeting](/how-to/plan-a-meeting) (or let [calendar sync](/how-to/calendar-sync) plan it), bind its prep workspace, and the bot auto-joins at start — members of the bound workspace get the same live feed the copilot rides. ## 1. Start the copilot The id invariant is `meeting_id == session_uid == native_id`. ```bash theme={null} curl -X POST "$API_BASE/agent/meeting/start" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"native_id":"abc-defg-hij","platform":"google_meet","title":"Acme renewal"}' # → 202, the live-meeting record ``` ## 2. Turn processing on Processing is opt-in. Toggle it on to start consuming the transcript: ```bash theme={null} curl -X POST "$API_BASE/agent/meeting/process" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"native_id":"abc-defg-hij","platform":"google_meet","on":true}' # → { ..., "processing": true, "resumed_from": "" } ``` ## 3. Stream the merged feed Subscribe to the SSE stream that merges transcript and copilot output: ```bash theme={null} curl -N -H "X-API-Key: $API_KEY" \ "$API_BASE/agent/meeting/stream?meeting_id=abc-defg-hij&session_uid=abc-defg-hij" ``` Event types on the stream: `transcript`, `card` (a proposal), `message-delta`, `tool-call`, `ping`, `meeting-end`. Every event carries an SSE `id:` — on reconnect, echo it as `Last-Event-ID` to **resume gaplessly**. Approving a `card` commits it to the workspace (a person, an action item, a decision). ## Pause and resume mid-call Toggle processing off to stop consuming without ending the meeting; the per-meeting **cursor freezes**. Turn it back on and it resumes from exactly where it stopped: ```bash theme={null} # pause — clears the flag, freezes the cursor curl -X POST "$API_BASE/agent/meeting/process" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"native_id":"abc-defg-hij","platform":"google_meet","on":false}' # → { ..., "processing": false } ``` `on:true` later resumes from the saved cursor (`"resumed_from":""`) — no re-processing, no gap. ## Health ```bash theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/agent/meeting/relay-health" ``` A stale bot key surfaces as `native_resolve:{ok:false,kind:"unauthorized"}` rather than silent dead air. ## After the call To write the durable record once the meeting ends — notes, decisions, follow-ups linked to people and companies — see [Report after every meeting](/how-to/post-meeting-report). # How-to guides Source: https://docs.vexa.ai/how-to/overview Task-oriented recipes, split by which plane they touch — meetings, agents, or both. Each guide is a complete path to one outcome. They assume you've finished the [Quickstart](/quickstart) and have an `$API_BASE` and `$API_KEY` ([Authentication](/authentication)). The guides fall into three chapters by the plane they work on. **Meetings** and **agents** are [separate domains](/index), each usable on its own; the third chapter is where they compose. ## Meetings only Capture and access meetings through the [Meetings API](/api/meetings) — no agent involved. Paste one secret ICS address — upcoming meetings import and auto-join. Capture Google Meet, Zoom, or Teams — live, speaker-attributed. Per-segment push instead of polling. Point meeting transcription at your own OpenAI-compatible ASR service. List recordings and stream the audio from your own storage. Calendar to bot to transcript to Slack — no backend to write or host. ## Agents only Put agents to work over your [workspace](/concepts#workspace) through the [Agent API](/api/agent) — with or without any meeting. Ask an agent that has your whole knowledge base as context. Read files over the API, add documents, or attach your own git repo. An unattended agent on a cron schedule. An event-triggered agent that proposes tasks — safely. ## Meetings + agents Where the two planes compose: an agent rides or acts on a meeting. Prepare a shared knowledge space, invite your attendees, and the bot joins at start. Cards for people, decisions, and action items as the call happens. Notes, decisions, and follow-ups written into your workspace when a call ends. ## Operate Running the stack itself — [Deployment](/deployment), [Configuration](/configuration), and [Troubleshooting](/troubleshooting). # Plan and share a meeting Source: https://docs.vexa.ai/how-to/plan-a-meeting Create a meeting before it happens, prepare a shared knowledge space with the agent, invite the people you're meeting, and let the bot join at start. This is the **prepare-for-a-meeting** flow end to end: plan the meeting, build shared context in a workspace, hand that context to your attendees, and have the bot show up on time — so everyone walks in prepared and walks out with the transcript in the same place. It composes both planes: the meeting record lives in the [meetings domain](/core/meetings), the prep space is an [agent workspace](/core/agents). You can drive everything from the Terminal UI or the [API](/api/meetings#plan-a-meeting); both are shown. ## 1. Plan the meeting In the Terminal's **Meetings** list, click **+ Plan a meeting** — one click creates the meeting and opens its **prep view**, which is where everything is edited: type the title, pick the time (quick chips like *Tomorrow 09:00*, or the calendar picker), and paste the Meet/Zoom/Teams link if you already have it. Everything saves as you go; the link is optional and can be attached any time before start. ```bash Via the API theme={null} curl -X POST "$API_BASE/meetings" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"title":"Q3 kickoff with Acme","scheduled_at":"2026-07-10T15:00:00Z", "meeting_url":"https://meet.google.com/abc-defg-hij"}' ``` The meeting sits under **Upcoming** with a `Scheduled` badge (or `Planned` until it has a time); clicking it re-opens the prep view — title, time, link, the auto-join toggle, and the workspace section below. Meetings on your calendar can create these records for you — the **Connect your calendar** row sits right under the plan button. See [Calendar sync](/how-to/calendar-sync). ## 2. Prepare the knowledge space In the prep view, **bind a workspace**: pick an existing one (say, the client's deal workspace) or click **+ Create a prep workspace**. This workspace is where the preparation lives — brief, agenda, open questions, background research. Now put the agent to work in it: open the workspace and ask for what you need — research the company from the open web, pull context from your other workspaces, summarize the last meeting's notes, draft the agenda. Everything it produces is Markdown files in the workspace — reviewable, editable, versioned. ## 3. Share it with the people you're meeting Click **Share with attendees** in the prep view. It mints a workspace **invite link** — send it to your colleagues or the client. Anyone who accepts becomes a member of the prep workspace and gets: * the **prepared context**, live — workspace edits sync in real time, so you can keep refining it together right up to (and during) the call; * **the meeting itself** — a meeting bound to a workspace is visible to every member: the upcoming plan in their Meetings list, the live transcript feed once the bot joins, and the finished transcript after. There is nothing extra to share when the meeting starts — the binding already carries it. ## 4. The bot joins on its own At start time the bot auto-joins (the **Auto-join** toggle is on by default for any scheduled meeting with a link — flip it off for meetings you want to keep bot-free, or click **Send bot now** to bring it in early). Admit the bot like any participant; the transcript starts streaming to every workspace member. If the bot *didn't* appear, the meeting row says why — see [Troubleshooting](/troubleshooting#a-scheduled-meeting-didnt-auto-join). ## 5. After the meeting The transcript lands on the **same meeting record** you planned — with the title and workspace binding intact. From here the usual post-meeting composition applies: run a [post-meeting report](/how-to/post-meeting-report) into the bound workspace, and the notes join the context the attendees already share. # Report after every meeting Source: https://docs.vexa.ai/how-to/post-meeting-report When a call ends, an agent writes notes, decisions, and follow-ups into your workspace. Once a meeting produces a transcript, an [agent](/core/agents) can read it **through your [workspace](/concepts#workspace)** — grounding the write-up in what you already know about the people and the deal — and commit a report. Because the transcript is trusted input, the agent both reads and writes; git is the undo. For a [planned meeting](/how-to/plan-a-meeting) with a bound workspace this closes the loop: the transcript lands on the same meeting record the plan started as, and the report lands in the same workspace the attendees already share. ## Option A — dispatch a report when the meeting ends Fire a one-shot [dispatch](/core/agents) the moment a call wraps (e.g. from your own meeting-end hook): ```bash theme={null} curl -X POST "$API_BASE/agent/invocations" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{ "runner":"claude-code", "workspaces":[{"id":"u_jane","mode":"rw"}], "trigger":"scheduled", "start":{"entrypoint":{"inline":"Write a report for the meeting that just ended: summary, decisions, action items with owners, and links to the people and companies involved. File it in the workspace."}} }' # → 202 {"workload_id":"agent-..."} ``` The agent reads the latest transcript and existing entities, then commits the report. Retrieve it from the workspace: ```bash theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/agent/workspace/tree" curl -H "X-API-Key: $API_KEY" "$API_BASE/agent/workspace/file?path=meetings/2026-06-29-acme.md" ``` ## Option B — a standing routine If you'd rather not wire a hook, run it on a schedule that sweeps recent meetings: ```bash theme={null} curl -X POST "$API_BASE/agent/routines" \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{ "name":"Meeting reports", "cron":"0 * * * *", "prompt":"For any meeting that ended in the last hour and has no report yet, write one: summary, decisions, action items with owners. File each in the workspace.", "run_now":true }' # → 201 {"routine":{...},"job_id":"job_...","ran_now":true} ``` ## What you get The report is **grounded**, not a bare summary — it's anchored in the accumulated context the workspace already holds, and each run leaves the workspace more complete (people, companies, decisions linked). See [Knowledge agent](/how-to/chat-workspace). ## Going live instead To surface decisions and action items **during** the call rather than after, run the live copilot — `POST /agent/meeting/start` then stream cards from `GET /agent/meeting/stream`. See [Meeting copilot](/how-to/live-copilot) and the [Agent API](/api/agent#live-meeting-copilot). Routines and dispatches are workspace writes, so they need a **trusted** trigger (`message`/`scheduled`), which mounts the workspace `rw`. Untrusted triggers are propose-only — see [Governance](/architecture/governance). # Retrieve a meeting recording Source: https://docs.vexa.ai/how-to/recordings List recordings and stream the audio — from your own object storage on a self-host. Recording retrieval is **API-only today** — the terminal has no built-in player yet. The endpoints below are live and covered by service tests. Browser playback and seeking work **through the gateway**: a `Range` request returns a `206 Partial Content` with its `Content-Range` and `Accept-Ranges` headers intact. See the [capability truth table](/roadmap/status#capability-truth-table). Alongside the diarized transcript, each meeting's **audio recording** is uploaded to object storage — on a self-host, your own MinIO bucket, so it never leaves your environment. The recording is the meeting audio, stored separately from the transcript (speaker separation lives in the transcript as text — there is no per-speaker audio). ## List recordings ```bash theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/recordings" ``` Get the detail for one: ```bash theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/recordings/42" ``` ## Get the playable audio Resolve the master metadata (finalize-on-read), which points at the raw byte stream: ```bash theme={null} curl -H "X-API-Key: $API_KEY" "$API_BASE/recordings/42/master?type=audio" ``` The response carries a `raw_url` of the form `GET /recordings/{recording_id}/media/{media_file_id}/raw` — the actual audio bytes the player loads: ```bash theme={null} curl -H "X-API-Key: $API_KEY" \ "$API_BASE/recordings/42/media//raw" -o meeting-42.audio ``` ### Range requests (playback and seek) The `/raw` endpoint honours HTTP `Range` — a partial request returns `206 Partial Content` carrying `Content-Range` and `Accept-Ranges: bytes`, **preserved through the gateway** front door. This is what lets a browser `