# Backend Configuration

The backend is configured through a **required `config.yaml`** file, loaded at
startup with [koanf](https://github.com/knadh/koanf) (see `internal/config`).
This page is the canonical reference for every key. The authoritative,
fully-commented source is `backend/config.example.yaml` in the
[repo](https://github.com/vibexp/vibexp) — copy it to `backend/config.yaml` for
local development (`make backend-run` / `make backend-run-dev` do this
automatically if `config.yaml` is missing).

## How configuration is loaded

1. **Path resolution.** The config file path is taken from the `--config` flag,
   then the `VIBEXP_CONFIG_FILE` environment variable, then `./config.yaml`.
2. **Fail-fast.** The file is **required**: a missing file aborts startup with
   an error naming the expected path and pointing at `config.example.yaml`.
3. **Defaults, then file.** Code-level defaults are merged first; the YAML file
   overrides any of them, and the result is unmarshalled into a nested `Config`
   struct (the koanf tags name each section/key).
4. **Interpolation.** `${VAR}` references in the file's **string values** are
   resolved against the process environment (see the grammar below).
5. **Validation.** Invariants are checked before the server starts — the
   service fails closed on a bad config rather than running misconfigured.
   Sections removed in past releases fail loudly: a leftover top-level
   `github:` block aborts startup with guidance to re-register the App per
   team (removed in v0.9.0), while a leftover `embedding:` block is silently
   ignored.

The published Docker image bakes a production-neutral default config
(`config.docker.yaml`) at `/app/config.yaml` and sets `VIBEXP_CONFIG_FILE`
accordingly, so `docker run` works with environment variables alone; mount your
own file over that path to take full control.

:::danger[Hard requirements]
- A reachable Postgres (`database.*`; the password comes from `${DB_PASSWORD}`).
- `security.encryption_key` — must be **exactly 32 bytes** (AES-256). The
  service refuses to start otherwise. Generate one with `openssl rand -hex 16`
  (32 hex chars = 32 bytes).
- Rate limits must be ≥ 1, retention windows must be in `1..3650` days,
  search-ranking weights must be valid (while the deprecated `search:` section
  still loads), and the OAuth-AS token lifespans must be positive and ordered.
  All are validated at startup.
:::

## Interpolation grammar

Interpolation applies to **string values only** and operates on the parsed YAML
tree, so it can never corrupt YAML syntax:

| Expression | Resolves to |
| --- | --- |
| `${VAR}` | The value of environment variable `VAR`. Unset → empty string plus a startup warning. |
| `${VAR:-default}` | `VAR` when set and non-empty, otherwise `default`. |
| `$${literal}` | A literal `${literal}` — the `$$` escapes interpolation. |

## Secrets live in `.env`, structure lives in `config.yaml`

`config.yaml` holds all **non-secret** settings as literal values. Secrets are
referenced as `${VAR}` and resolved from the environment at load time — keep
them out of the file and out of git. `backend/.env.example` lists every `${VAR}`
secret (database password, encryption key, session key, provider client
secrets, email keys); `make backend-run*` loads `.env` into the environment so
the references resolve.

## The JSON schema

`backend/config.schema.json` is a committed JSON schema generated from the
nested `Config` struct — it gives editors (VS Code / JetBrains via the YAML
language server) validation and autocomplete for `config.yaml`. Both
`config.example.yaml` and `config.docker.yaml` declare it via a
`yaml-language-server` header comment.

```bash
make backend-generate-config-schema   # regenerate via go run ./cmd/gen-config-schema
make backend-config-schema-check      # regenerate, then fail if it differs from the committed file
```

CI runs `backend-config-schema-check`, so regenerate and commit the schema
whenever you change the `Config` struct.

## Server

| Key | Default | Purpose |
| --- | --- | --- |
| `server.port` | `8080` | HTTP listen port. |
| `server.log_level` | `info` | `debug` \| `info` \| `warn` \| `error`. Use `debug` locally. |
| `server.log_format` | `json` | `json` (structured, for aggregators) or `text` (human-readable). |
| `server.service_version` | `dev` | Version tag attached to metrics/traces. Release builds inject it. |
| `server.release_sha` / `server.release_date` | `dev` / `unknown` | Build metadata. |
| `server.max_body_size_bytes` | `10485760` (10 MiB) | Global request-body cap (memory-exhaustion backstop). Webhooks and the OAuth AS JSON endpoints cap at 64 KiB. |
| `server.cors_allowed_origins` | _(empty)_ | Allowed CORS origins. Empty allows only the localhost dev origins (`:5173`, `:5174`); only needed when the SPA is served from a different origin than the API. |
| `server.trusted_proxies` | _(empty)_ | CIDRs of trusted reverse proxies. Empty ignores `X-Forwarded-For`/`X-Real-IP`, so the connecting peer is the client IP. Behind a proxy, list its CIDR(s) (e.g. `TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12`) or every client collapses into one rate-limit bucket. Malformed CIDRs fail startup. |
| `server.error_type_base_uri` | `about:blank` | RFC 9457 error `type` base URI (joined as `<base>/<code>`). |

## Database

| Key | Default | Required | Purpose |
| --- | --- | --- | --- |
| `database.host` | `localhost` | **Yes** | Postgres host. May be a Unix socket path (Cloud SQL) when it starts with `/`. |
| `database.port` | `5432` | **Yes** | Postgres port. |
| `database.user` | `vibexp_app` | **Yes** | Database user. |
| `database.password` | `${DB_PASSWORD}` | **Yes** | Database password (secret — resolved from the environment). |
| `database.name` | `vibexp_io` | **Yes** | Database name. |
| `database.sslmode` | `disable` | No | Connection TLS mode. `disable` (no TLS) or `require` (encrypt, no cert verification). Set `require` for managed Postgres that mandates TLS. |

See [Database & Migrations](/docs/developer-guide/backend/database/) for pooling and
migration behaviour.

## Security & encryption

| Key | Default | Required | Purpose |
| --- | --- | --- | --- |
| `security.encryption_key` | `${ENCRYPTION_KEY}` | **Yes** | AES-256 key for encrypting sensitive data at rest (API keys, OAuth-AS signing keys). Must be exactly 32 bytes. |
| `security.api_key_common` | `${API_KEY_COMMON}` | No | Shared API key for the common API surface. |
| `security.backoffice_admin_api_key` | `${BACKOFFICE_ADMIN_API_KEY}` | No | Admin key for back-office endpoints (`/bo/*`). Separate from JWTs and regular API keys. |
| `security.outbound_allowed_cidrs` | `[]` (env `${OUTBOUND_ALLOWED_CIDRS}`, comma-separated) | No | Networks the SSRF guard may dial even though they are loopback, private, or IPv6 unique-local. Empty refuses every reserved range. |

### Outbound network policy

The SSRF guard covers every destination a team can point VibeXP at: embedding
providers, model providers, A2A agent endpoints, and team-supplied SMTP or
Mailgun hosts. By default it refuses loopback, private (RFC 1918), and IPv6
unique-local addresses, because a team member could otherwise aim one of those
endpoints at your internal network and read back the response. GitHub App calls
are not affected; they always talk to a hardcoded `https://api.github.com`.

That default is what makes a **self-hosted embedding or model sidecar**
unreachable. TEI, Ollama, and llama.cpp typically sit on a private Docker subnet
or on localhost, so declare the range:

```yaml
security:
  outbound_allowed_cidrs: ["172.16.0.0/12"]
```

or, in the combined image, `OUTBOUND_ALLOWED_CIDRS=172.16.0.0/12` (comma-separate
several). Common values: `172.16.0.0/12` for Docker bridge networks,
`127.0.0.1/32` for a same-host Ollama, `10.42.0.0/24` for your own cluster
subnet.

The list only widens the loopback/private tier. Link-local (`169.254.0.0/16`,
`fe80::/10`, which covers the cloud-metadata address) and multicast
(`224.0.0.0/4`, `ff00::/8`) stay blocked no matter what, and an entry that
overlaps them fails startup, so `0.0.0.0/0` cannot be used to switch the guard
off. A malformed CIDR fails startup too. Local development needs none of this: a
localhost `frontend.base_url` already permits reserved destinations.

## Authentication

Session, dev-login, recovery and root-admin settings. The web-login identity
providers and the access allowlist are stored in the database since v0.15.0 and
edited in the admin area; the keys marked deprecated below are only imported at
startup while nothing is stored. See [Authentication](/docs/developer-guide/backend/authentication/) for
the full model.

| Key | Default | Purpose |
| --- | --- | --- |
| `auth.providers` | `[]` | **Deprecated since v0.15.0** (imported at startup while no provider is stored, otherwise ignored). Web-login providers to enable **simultaneously**: any of `google`, `github`, `oidc` (case-insensitive). Empty disables web login (dev login still works). Takes precedence over `auth.provider`. |
| `auth.provider` | _(empty)_ | **Deprecated since v0.15.0**, like `auth.providers`. Backward-compatible single-provider shim; used only when `auth.providers` is empty. |
| `auth.session_encryption_key` | `${SESSION_ENCRYPTION_KEY}` | Hex secret (**64 hex chars = 32 bytes**) backing the AES-256-GCM session cookie and the OAuth state HMAC. Empty disables cookie session auth. Generate with `openssl rand -hex 32`. |
| `auth.dev_login_enabled` | `false` when unset (the shipped `config.example.yaml` and baked Docker config set `true`) | Gates `/api/v1/auth/dev/login`. Effective only when `true` **and** the environment is detected as local development. |
| `auth.access_allowlist.domains` | `[]` | **Deprecated since v0.15.0** (imported at startup while no allowlist is stored, otherwise ignored). Allowed email domains (matched exactly against the part after the last `@`). |
| `auth.access_allowlist.emails` | `[]` | **Deprecated since v0.15.0**, like `domains`. Allowed exact email addresses. A user may sign in if **either** list matches; both empty means open registration. While active, sign-in also requires a provider-verified email (unverified accounts are denied). |
| `auth.recovery_mode` | `false` | Break-glass after an authentication lockout: forces setup mode on and logs a new one-time `SETUP URL` on every start, while the stored providers keep working. Wired to `AUTH_SETTINGS_RECOVERY_MODE` in the Docker config. |
| `auth.instance_admins` | `[]` | Emails designated as **root** instance administrators; further admins are granted in the admin area. Drives `is_instance_admin` and the `/api/v1/admin` portal; matched case-insensitively and validated as emails at startup. |

:::caution
Provider misconfiguration is never fatal: a stored provider that cannot be
built (failed OIDC discovery, say) is skipped and reported as unhealthy in the
admin area, and the backend still boots. The enabled set is exposed at
`GET /api/v1/auth/providers` for the login UI's provider picker.
:::

### Per-provider blocks (deprecated)

**Deprecated since v0.15.0.** These blocks are read only by the startup import:
an enabled, complete provider is copied into the database while no provider is
stored, and the block is ignored from then on. The `redirect_uri` values are
not used at all: every provider redirects to
`<frontend.base_url>/api/v1/auth/callback`.

| Key | Purpose |
| --- | --- |
| `auth.google.client_id` / `auth.google.client_secret` / `auth.google.redirect_uri` | Google OIDC web-login client (reached directly via `accounts.google.com` discovery). |
| `auth.github.client_id` / `auth.github.client_secret` / `auth.github.redirect_uri` | GitHub OAuth2 web-login client (OAuth2, not OIDC; claims come from the GitHub REST API). |
| `auth.oidc.issuer_url` / `auth.oidc.client_id` / `auth.oidc.client_secret` / `auth.oidc.redirect_uri` | Generic OIDC client for any compliant issuer (Keycloak, Authentik, Zitadel, Auth0, …). `issuer_url` drives `/.well-known/openid-configuration` discovery. |

### Embedded OAuth 2.1 Authorization Server (`auth.oauth_as`)

The AS mints the bearer JWTs the MCP endpoint accepts. When `issuer_url` is set
the AS is mounted; empty disables it. In **local development it auto-enables**
at `http://localhost:<server.port>` even when left empty. See
[Authentication → Embedded OAuth 2.1 Authorization Server](/docs/developer-guide/backend/authentication/#the-embedded-oauth-21-authorization-server).

| Key | Default | Purpose |
| --- | --- | --- |
| `auth.oauth_as.issuer_url` | _(empty; auto-derived locally)_ | Public base URL of the AS — becomes the token `iss` and the metadata `issuer`. Must be HTTPS in production. |
| `auth.oauth_as.access_token_ttl` | `15m` | Access-token lifetime. |
| `auth.oauth_as.refresh_token_ttl` | `720h` | Refresh-token lifetime. Must exceed the access-token TTL. |
| `auth.oauth_as.auth_code_ttl` | `10m` | Authorization-code lifetime. |
| `auth.oauth_as.key_rotation_interval` | `720h` | How often the RS256 signing key rotates (coordinated across replicas). |
| `auth.oauth_as.cleanup_interval` | `1h` | How often expired codes, tokens, PKCE and login sessions, and retired signing keys are pruned. |

### API-surface OAuth (`auth.api_oauth`)

`/api/v1/*` accepts issuer-signed bearer JWTs (native PKCE clients, the CLI's
`vibexp auth login`) alongside session cookies and API keys. These keys are
**auto-wired when the embedded AS is enabled** and are override-only, not
required for the embedded-AS path:

- **Embedded AS (auto).** When `auth.oauth_as.issuer_url` is set (including the
  local-dev auto-enable) and `auth.api_oauth.issuer` is left empty, VibeXP
  auto-derives both: `issuer` is set to the AS issuer and `audiences` is pinned
  to `[mcp.resource_uri]` (require that audience). Native-CLI login works on
  `/api/v1` with no manual config.
- **External IdP (manual override).** Set `auth.api_oauth.issuer` explicitly to
  point `/api/v1` at an external issuer; an explicit issuer always wins over the
  auto-wiring.

| Key | Default | Purpose |
| --- | --- | --- |
| `auth.api_oauth.issuer` | _(empty)_ | Issuer URL. When the embedded AS is enabled, empty **auto-fills** with the AS issuer, so bearer JWTs are accepted. Only when the AS is **also disabled** does empty reject non-API-key bearer tokens with 401. Set explicitly to override with an external IdP. |
| `auth.api_oauth.audiences` | `[]` | `aud` allow-list. When the embedded AS is enabled and this is empty, it **auto-pins** to `[mcp.resource_uri]` (require that audience) so the CLI's AS-issued token is accepted. With an external IdP issuer and this left empty, the policy is instead "accept any audience **except** the MCP resource URI". |

:::caution
With an external IdP issuer and an empty `auth.api_oauth.audiences`, any access
token from that issuer for a provisioned user is an API credential. Never log or
forward such tokens.
:::

## MCP OAuth (resource server)

The `/mcp/v1/common` endpoint is an OAuth 2.1 resource server pointed at the
embedded Authorization Server. See
[MCP Server](/docs/developer-guide/backend/mcp-server/).

| Key | Default | Purpose |
| --- | --- | --- |
| `mcp.oauth_issuer` | _(empty; defaults to `auth.oauth_as.issuer_url` when the AS is enabled)_ | Issuer the MCP endpoint trusts; JWKS is fetched from `<issuer>/oauth2/jwks.json`. If set explicitly it must equal the AS issuer. Empty with no AS disables the endpoint (rejects all tokens with 401). |
| `mcp.resource_uri` | _(empty; auto-derived locally as `<issuer>/mcp/v1/common`)_ | Canonical MCP resource identifier and required token audience (RFC 8707). Required when the AS is enabled. |

In local development both fields are auto-derived — leave them empty. In
production set them explicitly (e.g. `https://connect.example.com/mcp/v1/common`).

## Frontend & runtime `/config.js`

The `frontend` block holds the SPA base URL plus deploy-time, non-secret values
the backend serves to the SPA at **`/config.js`** (as
`window.__VIBEXP_ENV__`). Each of those fields mirrors a `VITE_*` value the
frontend would otherwise bake in at build time; empty values fall back to the
SPA's built-in defaults.

Company branding is not here. Since v0.15.0 the name, logo and support and
legal links are the instance's site info, stored in the database and edited in
the admin area: see
[Site Info (Branding)](/docs/user-guide/self-hosting/site-info/). The seven
branding keys this block used to hold are ignored, as listed in
[Upgrading](/docs/user-guide/self-hosting/upgrading/#company-branding-moved-to-site-info-v0150).

| Key | Default | Purpose |
| --- | --- | --- |
| `frontend.base_url` | `http://localhost:5173` | Public URL of the web app (redirects, email links). Also the **local-development detection signal**: a localhost value enables dev-only paths. In the combined image, set it to your public URL. |
| `frontend.mcp_endpoint` | _(empty)_ | MCP endpoint URL shown in the connect UI. |
| `frontend.error_type_base_uri` | _(empty)_ | RFC 9457 base URI the SPA links error codes to. |
| `frontend.gtm_id` / `frontend.ga4_measurement_id` | _(empty)_ | Optional analytics. Setting `gtm_id` **is** the opt-in: the SPA loads Google Tag Manager only when it is non-empty. There is no separate enable flag, and VibeXP ships no cookie-consent gate of its own, so configure consent inside your own tag container. |

:::danger
`/config.js` is **world-readable** — only non-secret values belong in the
`frontend` block.
:::

## Search ranking

:::caution[Deprecated since v0.15.0]
The `search:` section no longer sets the instance defaults. They are stored in
the database and edited in the admin area, under **Settings** → **Search**: see
[Search & AI Summary Settings](/docs/user-guide/self-hosting/search-and-ai-summary-settings/).
A section that differs from the built-in defaults is imported at startup when
nothing is stored yet, and ignored otherwise. The section is scheduled for
removal in the next minor release: see
[Upgrading](/docs/user-guide/self-hosting/upgrading/#search-ranking-and-ai-summary-defaults-moved-to-admin--settings-v0150).
:::

The keys below still load in v0.15.0, and only feed that import. Weights must
be non-negative and not all zero; the half-life and candidate cap must be
positive (still validated at startup).

The values they import are **instance defaults**: each team inherits them and
can override them on its Search Settings page (Teams → Settings → Search) or
via `/api/v1/{team_id}/settings/search`, except the candidate cap, which is
instance-only and never team-overridable.

| Key | Default | Purpose |
| --- | --- | --- |
| `search.recency_ranking_enabled` | `false` | Enable the weighted relevance/freshness blend. |
| `search.rank_weight_relevance` | `0.5` | Weight of semantic/keyword relevance. |
| `search.rank_weight_created` | `0.3` | Weight of creation recency. |
| `search.rank_weight_updated` | `0.2` | Weight of update recency. |
| `search.rank_half_life_days` | `90.0` | Freshness decay half-life (max 36500). |
| `search.rank_candidate_cap` | `200` | Re-rank candidate pool size (max 5000). |

## AI summary

:::caution[Deprecated since v0.15.0]
The `ai_summary:` section, and the `AI_SUMMARY_ENABLED`, `AI_SUMMARY_TOP_N`,
`AI_SUMMARY_REQUEST_TIMEOUT` and `AI_SUMMARY_STYLE` variables that feed it in
the published image, no longer set the instance defaults. They are stored in
the database and edited in the admin area, under **Settings** → **AI Summary**:
see
[Search & AI Summary Settings](/docs/user-guide/self-hosting/search-and-ai-summary-settings/#ai-summary).
The section is imported at startup under the same conditions as `search:`, and
is scheduled for removal in the next minor release. `ai_summary.max_top_n` and
`ai_summary.max_output_tokens_ceiling` are ignored and never imported. See
[Upgrading](/docs/user-guide/self-hosting/upgrading/#search-ranking-and-ai-summary-defaults-moved-to-admin--settings-v0150).
:::

## Embeddings

Embeddings are generated in-process by an async event-bus worker: text is
chunked in Go, embedded via the active provider, and stored in pgvector. There
is **no** external AI service and **no** message broker.

:::caution[Not in config.yaml since v0.4.0]
There is **no `embedding` block** in `config.yaml`. All embedding settings are
**per-team embedding providers**, managed in the app (Settings) or via
`/api/v1/{team_id}/settings/embedding-providers`. A leftover `embedding:` block
in an old config file is silently ignored.
:::

Each per-team embedding provider stores:

| Setting | Default | Purpose |
| --- | --- | --- |
| `base_url`, `api_key`, `provider_type` | _(required)_ | Any OpenAI-compatible `/v1/embeddings` endpoint: OpenAI, Ollama, LocalAI, vLLM, TEI, and similar. API keys are stored encrypted. |
| `model` | _(set per provider)_ | The `model_id` tag written on every vector row and used as the search filter. |
| `chunk_size` | `1000` | Rune-based chunk size for the in-Go chunker. |
| `chunk_overlap` | `200` | Overlap between chunks (must be smaller than the chunk size). |
| `concurrency` | `1` | Max concurrent embedding requests sent to this provider. |
| `query_prefix` / `document_prefix` | _(empty)_ | Optional prefixes for asymmetric models (mxbai, BGE, E5) prepended to queries and documents. |

Provider behavior:

- Providers are validated on save and must return **1024-dimension** vectors.
  The width is locked to the pgvector column and is not configurable.
- Changing a provider's identity (endpoint or model) **wipes and re-embeds**
  that team's vectors. Coverage, one-click reprocess, and clear-all-embeddings
  actions are available on the settings page and API.
- If no provider is configured, entities are still saved and search falls back
  to keyword (full-text) mode.

Per-team **model providers** (bring-your-own OpenAI-compatible LLM endpoints)
are also managed in-app, under `/api/v1/{team_id}/settings/model-providers`,
not in `config.yaml`.

## Email

:::caution[Deprecated in v0.15.0]
The `email:` section no longer configures instance mail. Since v0.15.0 the
instance provider is stored in the database and an instance admin edits it in
the admin area, under Settings → Email: see
[Instance Email](/docs/user-guide/self-hosting/instance-email/).

The keys below are read at startup only to import a populated section into the
database when nothing is stored there yet: for an upgrading instance, on the
first start of v0.15.0. While a provider is stored the section is ignored, and
it is removed in the next minor release.
The exact rules are in the
[upgrade note](/docs/user-guide/self-hosting/upgrading/#instance-email-moved-to-admin--settings--email-v0150).
:::

| Key | Default | Purpose |
| --- | --- | --- |
| `email.provider` | `smtp` | `smtp`, `mailgun`, `postmark`, or `sendgrid`. |
| `email.from_address` | `dev@vibexp.local` (example file) | Sender address for all providers. Empty falls back to `email.smtp.username` when that is itself an email address; with neither, nothing is imported. |
| `email.contact_recipient_address` | _(empty)_ | Inbox for support notification emails. Empty uses the from address. |

The privacy policy URL key this section used to hold is gone: v0.15.0 ignores
it, logs a warning at startup while it is still set, and does not import it.
The privacy policy link is part of the instance's
[site info](/docs/user-guide/self-hosting/site-info/) now, set in the admin
area.

**SMTP** (`email.provider: smtp`). Dev defaults point at Mailpit (UI at
`http://localhost:8025`).

| Key | Dev default | Purpose |
| --- | --- | --- |
| `email.smtp.host` | `localhost` | SMTP host. Imported only when host and port are both set and either a password is set or the pair differs from the built-in default `smtp.gmail.com:587`. |
| `email.smtp.port` | `1025` | SMTP port. |
| `email.smtp.username` | `dev` | SMTP username. |
| `email.smtp.password` | `${SMTP_PASSWORD}` | SMTP password (secret). |

**Mailgun** (`email.provider: mailgun`).

| Key | Purpose |
| --- | --- |
| `email.mailgun.domain` | Mailgun sending domain (required). |
| `email.mailgun.sending_key` | `${MAILGUN_SENDING_KEY}` — private API key (required). |
| `email.mailgun.base_url` | Region base URL; empty uses the US default. |

**Postmark** (`email.provider: postmark`).

| Key | Purpose |
| --- | --- |
| `email.postmark.server_token` | `${POSTMARK_SERVER_TOKEN}` — Server API token (required). |
| `email.postmark.message_stream` | Message stream to send on (default `outbound`). |

**SendGrid** (`email.provider: sendgrid`).

| Key | Purpose |
| --- | --- |
| `email.sendgrid.api_key` | `${SENDGRID_API_KEY}` — API key with the "Mail Send" permission (required). |

The [instance provider](/docs/user-guide/self-hosting/instance-email/) is
also the **fallback** for teams without their own provider: each team can
configure its own provider in the app (Teams → Settings → Email Provider; see
[Email Provider](/docs/user-guide/integrations/email-provider/)). Instance and
team credentials are both stored encrypted with `security.encryption_key`, and
rotating that key invalidates them. Support mail always uses the instance
provider. When neither a team provider nor an instance provider is configured,
the mail is discarded.

## GitHub App

There is **no `github` block** in `config.yaml` since v0.9.0. GitHub App
integration is configured **per team** in the app (open the team → Settings →
GitHub Integration); credentials are stored encrypted in the
`github_app_configs` table. A leftover top-level `github:` section in an old
config file **fails startup**; delete it and re-register the App on each
team. (`auth.github`, the web-login OAuth client, is unaffected.)

Team-admin setup is in [GitHub App](/docs/user-guide/integrations/github-app/); the
one-time instance upgrade is [Migrating to per-team GitHub Apps](/docs/user-guide/self-hosting/github-app-migration/).

## Attachments (GCS)

| Key | Default | Purpose |
| --- | --- | --- |
| `storage.attachments_bucket` | _(empty)_ | GCS bucket for attachments. Empty disables attachments (upload/download/delete return 503). |

## Rate limiting

Per-IP request limits (requests per minute), applied per route group. Each must
be ≥ 1.

| Key | Default | Purpose |
| --- | --- | --- |
| `rate_limit.auth_per_minute` | `100` | Per-IP limit on `/api/v1/auth/*` and the OAuth-AS endpoints. |
| `rate_limit.api_per_minute` | `1000` | Per-IP limit on the authenticated API surface. |

## Retention

| Key | Default | Purpose |
| --- | --- | --- |
| `retention.activity_days` | `90` | Activity-log retention window (1–3650 days). |
| `retention.access_event_days` | `90` | Access-event retention window (1–3650 days). |
| `retention.content_version_limit` | `20` | Content-version snapshots kept per resource. `0` disables pruning (keep every version). |

## A2A (agent-to-agent)

| Key | Default | Purpose |
| --- | --- | --- |
| `a2a.default_timeout` | `5m` | Max time to wait for synchronous agent-to-agent HTTP responses. |
| `a2a.stream_timeout` | `2h` | Max lifetime of a streaming (SSE) agent task. Decoupled from the sync timeout so long-running streams are not cut short. |

## Scheduler

The platform's own engine for recurring work, meant to remove the need for an
external cron as features move onto it. A ticker loop claims each due schedule
under its own Postgres advisory lock, which keeps it correct across replicas:
two instances ticking at once never double-run the same schedule. Due-ness is
computed from the database clock, not the app server's, so replica clock skew
does not matter. A job that panics, fails, or times out is logged and the
schedule still advances; on shutdown the loop waits for the job in flight to
return before exiting.

Today it runs one job type, freshness-rule evaluation (`freshness_evaluate`):
every team with freshness rules gets a schedule that flags resources nobody has
read for long enough. Retention and digests have not moved onto it, so the
externally driven [internal job endpoints](#internal-jobs-pubsub-oidc)
(`/internal/jobs/*`) still need their external scheduler.

Schedules live in the `schedules` table (migration `012_schedules`) and their
interval has a **1-hour floor**, enforced both in code and by a database check
constraint.

| Key | Default | Env var | Purpose |
| --- | --- | --- | --- |
| `scheduler.enabled` | `true` | `SCHEDULER_ENABLED` | Turns the loop on. `false` means nothing is claimed or run. |
| `scheduler.tick_interval` | `1m` | `SCHEDULER_TICK_INTERVAL` | How often the loop looks for due schedules. A polling cadence, not a job cadence. |
| `scheduler.job_timeout` | `10m` | `SCHEDULER_JOB_TIMEOUT` | Bounds a single job handler invocation. |
| `scheduler.due_limit` | `100` | `SCHEDULER_DUE_LIMIT` | Caps how many due schedules one tick claims. |

:::note
The published image's baked config wires all four keys to these environment
variables, so on `docker run` you can tune or disable the scheduler with `-e`
alone. `SCHEDULER_ENABLED=false` also stops freshness-rule evaluation. Mounting
your own `config.yaml` remains the alternative; `config.example.yaml` sets the
keys as literals, so a config file built from it ignores these variables.
:::

## Deployment environment detection

The `deployment` block holds environment-detection indicators (used to label
telemetry and pick the deployment environment). Most are auto-populated by the
hosting platform; `config.example.yaml` passes them through with
`${VAR:-}` references, so manual setting is rarely needed. Detection order:
`otel_environment`, then `environment`/`env`/`deployment_environment`, then
cloud indicators (`kubernetes_service_host`, `k_service` / `k_revision`
(Cloud Run), `google_cloud_project` / `gcp_project`, `aws_region` /
`aws_default_region`), defaulting to `production`.

## Event bus

Async event processing (embeddings and other post-commit side effects). All
optional with production-tuned defaults; values are validated and capped.

| Key | Default | Max | Purpose |
| --- | --- | --- | --- |
| `event_bus.worker_count` | `20` | `1000` | Concurrent event workers. |
| `event_bus.buffer_size` | `500` | `10000` | Event queue buffer size. |
| `event_bus.max_retries` | `3` | `10` | Max retry attempts per event. |
| `event_bus.retry_backoff` | `200ms` | — | Base retry delay; exponential per attempt. |
| `event_bus.retry_jitter` | `true` | — | Randomize backoff (±10%) to avoid thundering herd. |

## OpenTelemetry

| Key | Default | Purpose |
| --- | --- | --- |
| `otel.endpoint` | `localhost:4317` | OTLP collector endpoint (gRPC `host:port`, no scheme). |
| `otel.export_interval` | `60s` | Metric export interval. |
| `otel.trace_sample_ratio` | `0.1` | Trace sampling ratio. |
| `otel.tracing_enabled` | `false` | Enable trace export. |

## Internal jobs (Pub/Sub OIDC)

OIDC auth for internal job endpoints (`/internal/jobs/*`), e.g. Cloud
Scheduler. Unrelated to embeddings; leave empty for local dev.

| Key | Default | Purpose |
| --- | --- | --- |
| `gcp.project_id` | _(empty)_ | GCP project id, used only for trace/log correlation. |
| `gcp.pubsub_push_audience` | _(empty)_ | Public HTTPS URL the OIDC caller targets; the token's audience must match it. |
| `gcp.pubsub_push_service_account_suffix` | _(empty)_ | Optional. Restrict accepted tokens to a service-account domain (token email must end with this suffix). |