# Integration settings

This page owns the deployment settings of the optional integrations: GitLab, Slack, Outline, practice review, email, and sync. [Kubernetes](/admin/kubernetes.md) and the deprecated [Docker Compose install](/admin/install.md) own the base installation. [First steps](/admin/first-steps.md) owns the GitHub OAuth app and the first admin.

[Integrations](/admin/integrations.md) owns the workspace connections that a workspace admin makes in the UI. [Runtime roles](/admin/runtime-roles.md#webapp-container) owns the container contract of the webapp image. The [Install guide](/admin/install.md#clocks) owns the clock requirement. The [Configuration reference](/admin/configuration-reference.md) lists every variable with its default.

[]()

## Base environment variables

These base values are required. On Kubernetes, [Secret custody](/admin/secret-custody.md#inventory) gives their lifecycle constraints. On Docker Compose, the [Install guide](/admin/install.md#2-configure-env) gives them. [First steps](/admin/first-steps.md) covers the login provider and the first admin.

* `HEPHAESTUS_SECURITY_ENCRYPTION_KEY`.
* `HEPHAESTUS_SECURITY_CREDENTIAL_ENCRYPTION_KEY`.
* `HEPHAESTUS_AUTH_STATE_COOKIE_KEY`.
* `WEBHOOK_SECRET` and `WEBHOOK_ROUTING_SECRET`.
* `POSTGRES_PASSWORD`.
* One login provider.
* `HEPHAESTUS_AUTH_BOOTSTRAP_ADMINS`.

The self-host installer generates its internal secrets. Operators of the multi-file Compose stack must supply equivalent values through their own secret management. Rotate integration credential keys with the [credential-key rotation procedure](/admin/credential-key-rotation.md).

These deployment settings complement the install table. `SECURITY_CONTACT` and `HEPHAESTUS_RESEARCH_ORGANIZATION` also ship in the self-host `.env.example`:

| Variable                                                                                          | Purpose                                                                                                                                                                                                                                                                                                                                                                                        |
| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `HEPHAESTUS_PUBLIC_ACTIVITY_ENABLED`                                                              | Supplies the public activity instance default (`false`). A stored administrator choice takes precedence. Workspace publication also requires its own setting. See [Public activity pages](/admin/public-activity.md).                                                                                                                                                                          |
| `HEPHAESTUS_PUBLIC_ACTIVITY_REPOSITORY_VISIBILITY_MAX_AGE`                                        | Limits public visibility to recent successful repository syncs (`48h`). Use twice your metadata sync interval. See [Public activity pages](/admin/public-activity.md).                                                                                                                                                                                                                         |
| `SECURITY_CONTACT`                                                                                | One `mailto:`, `https://` or `tel:` URI, the security contact of this deployment. With it, the webapp serves `/.well-known/security.txt`. Empty serves none. Canonical is `APPLICATION_CLIENT_URL` only for one `https://` origin without workspace addresses. It expires 364 days after the container start. Every release restarts the webapp.                                               |
| `HEPHAESTUS_RESEARCH_ORGANIZATION`                                                                | Names the organization that runs optional research on this deployment. With a value, first-login setup asks each account **Allow research use of your data?** and shows this name. Empty by default. With no value, it never asks. A change to the value asks every account again. Read the operator obligations in [Legal Pages](/admin/legal-pages.md#the-optional-research-question) first. |
| `HEPHAESTUS_INTEGRATION_OAUTH_SUCCESS_REDIRECT` / `HEPHAESTUS_INTEGRATION_OAUTH_FAILURE_REDIRECT` | Post-callback landing URLs for integration OAuth. Success defaults to `/integrations?status=success`, failure to the success base + `?status=error`. Set absolute URLs when the SPA host differs from the API host.                                                                                                                                                                            |
| `springdoc.api-docs.enabled` / `springdoc.swagger-ui.enabled`                                     | Publish the OpenAPI description at `/v3/api-docs` and the interactive Swagger UI. Both default to `false`.                                                                                                                                                                                                                                                                                     |

CAUTION: The enabled API description needs no authentication

`springdoc.api-docs.enabled` controls whether the endpoint exists, not whether it requires login. Whenever springdoc registers, `/v3/api-docs/**` and `/swagger-ui/**` answer any request. Activation publishes the instance's complete route list, including admin routes, to anyone. Leave both off in production. Generate the spec at build time instead.

CAUTION: `docker/.env` does not forward every setting

The shipped Compose files forward `SECURITY_CONTACT`, so its `docker/.env` value works. They do **not** forward these settings:

* **`HEPHAESTUS_AUTH_ISSUER`:** `application-server` fixes it at `https://${APP_HOSTNAME}`.
* **`SPRINGDOC_API_DOCS_ENABLED` and `SPRINGDOC_SWAGGER_UI_ENABLED`:** Compose does not forward them.
* **The two OAuth redirect variables:** no Compose file passes them through, so defaults still apply.
* **`HEPHAESTUS_DEV_SEED_ENABLED`:** a flag for local development only. It adds endpoints that write practice versions and feedback. Never set it in production.

Values for these settings only in `docker/.env` have no effect. To change the issuer, change `APP_HOSTNAME`. To override any of these settings, add it to the `application-server` service's own `environment:` block. Leave the API description endpoints disabled unless you supply an explicit service environment override.

CAUTION: `GH_`, not `GITHUB_`

The GitHub OAuth keys in `docker/.env` are `GH_OAUTH_CLIENT_ID` / `GH_OAUTH_CLIENT_SECRET`. GitHub Actions reserves the `GITHUB_` prefix. Compose maps the keys to `GITHUB_OAUTH_CLIENT_ID` / `_SECRET` on the server. A `GITHUB_OAUTH_CLIENT_ID` value directly in `docker/.env` has no effect. The provider is skipped, with no sign-in button.

### Slack app configuration

After you create the app, upload [`slack-app-icon-512.png`](/assets/files/slack-app-icon-512-e8e5f3755bf42659bc0abe82ab69ffd7.png) as its icon. Slack applies its own corner treatment. Do not round the asset before upload.

| Variable                                              | Purpose                                                                                                                                                                                         |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `HEPHAESTUS_INTEGRATION_SLACK_ENABLED`                | Enables the Slack OAuth admin surface (per-workspace connect). Off by default.                                                                                                                  |
| `SLACK_OAUTH_CLIENT_ID` / `SLACK_OAUTH_CLIENT_SECRET` | Seed the Slack account-linking provider. If unset, they fall back to the workspace-install OAuth credentials below. Compose forwards the integration credentials, not these separate overrides. |
| `SLACK_OAUTH_DISPLAY_NAME`                            | Account-linking button label, default `Slack`. A Compose change requires an explicit application-server environment override.                                                                   |
| `HEPHAESTUS_INTEGRATION_SLACK_CLIENT_ID`              | Slack app client ID used by the OAuth flow                                                                                                                                                      |
| `HEPHAESTUS_INTEGRATION_SLACK_CLIENT_SECRET`          | Slack app client secret used by the OAuth flow                                                                                                                                                  |
| `HEPHAESTUS_INTEGRATION_SLACK_REDIRECT_URI`           | Required whenever Slack is enabled. Use the public application-server callback URL, including any proxy path prefix — e.g. `https://<host>/api/oauth/callback/slack`.                           |
| `HEPHAESTUS_INTEGRATION_SLACK_SIGNING_SECRET`         | Required when Slack is enabled, on both the webhook server (Events API) and the application server (interactivity). A blank value fails the boot of either while Slack is enabled.              |

OAuth issues per-workspace bot tokens. The `connection` table stores them encrypted at rest. There is no global bot token.

When Slack is enabled, configure one Slack app. OAuth URLs use the public application-server base URL, including any proxy path prefix. In the production compose stack, that base is `https://<host>/api`.

1. Add `<api-public-base-url>/oauth/callback/slack` to **OAuth redirect URLs** for workspace app installation.
2. Add `<api-public-base-url>/login/oauth2/code/slack` for member account linking.
3. Set **Events request URL** to `https://<host>/webhooks/slack`.
4. Set **Interactivity request URL** to `https://<host>/webhooks/slack/interactivity`.
5. Enable **App Home**.

Slack opens Home when someone selects the app. Hephaestus uses Home for account linking and privacy controls. Hephaestus uses the Messages tab, which is the DM with the app.

Events and interactivity go to different roles. Events are published to the event stream, and the webhook role receives them. A button press changes consent or erasure state, and that change is committed before Slack gets its acknowledgement. Only the application-server role can commit it, so interactivity goes there. The webhook role does not answer `/webhooks/slack/interactivity`.

Route this path to the webhook role:

* `/webhooks/slack`.

Route this exact path to the application-server role, without stripping its prefix:

* `/webhooks/slack/interactivity`.

Its rule must outrank the webhook role's `/webhooks` prefix rule. The production compose stack routes it this way on the public host. If the webhook receiver has a separate public host, point the **Interactivity request URL** at a host that reaches the application server.

Route OAuth and account-linking paths to the application-server role:

* `/oauth/callback/slack`.
* `/auth/login`.
* `/oauth2/authorization/*`.
* `/login/oauth2/code/slack`.

Include the same public prefix where applicable.

The bot token scope set stays minimal — every scope maps to a feature the code exercises:

| Scope                                 | Feature                                                                                                                                    |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `chat:write`                          | Hephaestus replies in DMs, weekly digest notes, and monitored-channel announcements                                                        |
| `assistant:write`                     | Hephaestus's streamed answers in DM threads                                                                                                |
| `im:history`                          | DM `message` events with their edits and deletions, and DM history reads that look for an unsettled Hephaestus reply or weekly digest note |
| `reactions:write`                     | The 👀 reaction on a DM question while Hephaestus works on it                                                                              |
| `channels:history` / `groups:history` | Reading messages in workspace-admin-consented monitored channels (public / private)                                                        |
| `channels:read` / `groups:read`       | Channel directory listing for the admin channel picker and periodic channel-metadata refresh                                               |
| `channels:join`                       | Letting an admin add a public channel to monitoring directly from the picker                                                               |
| `users:read`                          | Resolving a Slack user to a workspace member for Hephaestus                                                                                |

The user token scope set is only for Sign in with Slack account linking:

```
openid

profile

email
```

Workspace app installation gives Hephaestus a bot token. Hephaestus in DMs also requires the member's Slack account connected to their Hephaestus account.

1. Create an enabled Hephaestus login provider with registration id `slack` and type `SLACK`.
2. Set its base URL to `https://slack.com`.
3. Set its scopes to `openid profile email`.

Do not add unused broad scopes unless new code uses them. These include `chat:write.public`, `commands`, `team:read`, `app_mentions:read`, `im:write`, `mpim:read`, and `users:read.email`.

Use [`slack-app-manifest-template.yml`](/assets/files/slack-app-manifest-template-56a3787c3edec8dc666456df49ba1fb7.yml) as the dashboard baseline. It owns scopes, event subscriptions, and request URLs. Its `settings.event_subscriptions` covers these event groups:

* DM and channel `message` events for conversation turns and channel ingestion. DM edits and deletions arrive as `message` events too.
* Channel-lifecycle events that update the monitored-channel allow-list.
* App-lifecycle events, and `app_home_opened` for the Home tab.
* `agent_session_stopped`, which Slack sends when a developer selects **Stop** while Hephaestus works in a Slack thread. It needs no OAuth scope beyond `chat:write`, which also sets that Stop's status.

Hephaestus does not read Slack reactions.

The weekly digest note needs no other scope. The bot posts the note with `chat:write` to the developer's Slack user ID. Thus, the app does not need `im:write`.

The nightly Slack history-reconciliation sweep replays `conversations.history` for already-consented channels through this bot token. It needs no additional scopes.

#### Update an existing Slack app

A Slack app that you created from an older manifest does not have `reactions:write`. Without it, Hephaestus cannot show its working reaction on a DM.

1. Apply the current [`slack-app-manifest-template.yml`](/assets/files/slack-app-manifest-template-56a3787c3edec8dc666456df49ba1fb7.yml) to the Slack app.
2. Reinstall the app in the Slack dashboard when Slack asks for it.
3. In each Hephaestus workspace with Slack, use **Reconnect Slack** with the same Slack workspace ([Integrations](/admin/integrations.md#slack)).

Only a new authorization gives the stored bot token the new scope. [`MIGRATION.md`](https://github.com/hephaestus-build/Hephaestus/blob/main/MIGRATION.md) gives the full upgrade procedure.

A Slack app from an older manifest also does not subscribe to `agent_session_stopped`. Without it, Slack shows no Stop while Hephaestus works in a Slack thread, and developers stop Hephaestus in the app. Apply the current manifest to add the subscription. It adds no OAuth scope, so a Slack app that already has `reactions:write` keeps its bot token.

### Outline integration

Outline supplies documentation content. A workspace admin connects one Outline instance under **Administration → Integrations → Outline**. They supply the wiki base URL and API key, then register collections to mirror. The `connection` table encrypts the key at rest.

There is no default Outline operator. Before you enable the integration, record these facts for the exact origin in the [processor checklist](/admin/dsms/processor-checklist.md):

* Legal role.
* Hosting region.
* Transfer basis.
* Retention terms.
* AVV status.

A workspace admin cannot approve a new operator by entering its URL.

| Variable                                         | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `HEPHAESTUS_INTEGRATION_OUTLINE_ENABLED`         | Enables Outline beans for connect, sync, webhook, and content projection. Every bean requires it. Defaults to **off**. Without it, there is no admin surface, sync, or webhook verification.                                                                                                                                                                                                                                                                                                                                          |
| `HEPHAESTUS_INTEGRATION_OUTLINE_ALLOWED_ORIGINS` | Comma-separated publicly routable HTTPS origins approved by the instance operator, such as `https://wiki.example.com`. Empty by default. No paths, queries, fragments, credentials, or private/site-local addresses are permitted. An invalid entry fails startup. All server, worker, and webhook roles need the same value. After a change, restart all roles. An origin removal blocks its connections, API sync, webhook collection, evidence projection, and identity linking. Its mirror remains until workspace disconnection. |
| `OUTLINE_OAUTH_BASE_URL`                         | Outline instance for account linking, such as `https://wiki.example.com`. **Required whenever `OUTLINE_OAUTH_CLIENT_ID` is set**. Self-hosted Outline has no default origin. If blank or invalid, the provider seed is skipped. The reason appears at `ERROR` with the variable name. The app still boots, but has no linking button.                                                                                                                                                                                                 |
| `OUTLINE_OAUTH_CLIENT_ID`                        | Outline OAuth app client ID (redirect `https://<host>/api/login/oauth2/code/outline`)                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `OUTLINE_OAUTH_CLIENT_SECRET`                    | Outline OAuth app client secret                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `OUTLINE_OAUTH_DISPLAY_NAME`                     | Label shown on the account-linking button (defaults to `Outline`)                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |

Set `HEPHAESTUS_INTEGRATION_OUTLINE_ENABLED` on **all three** roles. The compose files pass the same value to each:

* **application-server:** connect/admin API, collection registration, reconcile scheduler, and webhook-subscription registrar.
* **application-worker:** assembles agent context. The Outline content source has no runtime-role gate. Without the flag, reviews that require wiki evidence decline, and conversation turns omit wiki context.
* **webhook-server:** without the flag, no Outline delivery endpoint registers. Every delivery to `POST /webhooks/outline/…` returns `404`. Outline counts these as failures and **auto-disables the subscription after 25 failures**.

There is **no Outline signing-secret environment variable**. Slack has one global signing secret. Each Outline subscription has its own secret, which Hephaestus derives from `WEBHOOK_ROUTING_SECRET` and the subscription's address. Nothing stores it.

A workspace connection's activation automatically registers the subscription. Its delivery URL is `https://<host>/webhooks/outline/<key>/<route>`, one route for each subscription. It comes from `WEBHOOK_EXTERNAL_URL`, which defaults to the application host.

`OUTLINE_OAUTH_*` is independent of the workspace connection. It seeds a **link-only** login provider with registration id `outline`. Members use it to link their Outline identity to their Hephaestus account for document attribution. There is no "sign in with Outline". The provider never appears on the login picker.

A blank `OUTLINE_OAUTH_CLIENT_ID` leaves it unseeded. You can add the provider later under **Instance admin → Login providers**.

**Registering the OAuth app in Outline** (Settings → Applications, admin-only):

1. Set **Redirect URI** to `https://<host>/api/login/oauth2/code/outline`. Outline matches exact strings. Include your proxy's API path prefix, without a trailing slash. It allows 1–10 URIs, all HTTPS except loopback.
2. Set **Scope** to `read`. Outline uses plain OAuth2, not OIDC. Never request `openid`. `POST /api/auth.info`, the identity probe, needs `read`.

PKCE S256 is sent automatically, with no configuration necessary. Access tokens last one hour. Refresh tokens last 30 days and rotate on use with reuse detection.

**API keys do not self-rotate.** Outline's `apiKeys.create` and `apiKeys.delete` require an interactive session, never a bearer token. Hephaestus cannot rotate its own key. The connection admin card shows expiry before it happens, using `apiKeys.list` when the key can see itself. To renew it without erasing the mirror, follow [Replace an Outline key without erasing the mirror](/admin/integrations.md#replace-an-outline-key-without-erasing-the-mirror).

An expired key reports `accepted=false`. The mirror stops refreshing until replacement.

### GitHub integration

Hephaestus connects to GitHub with a **Personal Access Token (PAT)** or **GitHub App**. [GitHub Integration](/admin/github-integration.md) owns the mode comparison, required App permissions, webhook events, and manual webhook creation. Unlike GitLab, GitHub has **no** auto-registration.

GitHub App mode uses these environment variables:

* `GH_APP_ID`.
* `GH_APP_PRIVATE_KEY` or `GH_APP_PRIVATE_KEY_LOCATION`.
* `GH_APP_INSTALLATION_URL`.
* `GH_APP_CLIENT_ID`.
* `GH_APP_CLIENT_SECRET`.

Without them (`GH_APP_ID=0`, the default), workspaces use PATs configured through the UI.

### GitLab rollout bundle

GitLab login, workspaces, webhook auto-registration, and practice review are not independent switches. For a GitLab practice-review rollout, enable this bundle together:

| Variable                          | Purpose                                                                                                                                                                                                                                                                                                                                                                                                               |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITLAB_OAUTH_CLIENT_ID`          | GitLab OAuth application client ID (callback `https://<host>/api/login/oauth2/code/gitlab`)                                                                                                                                                                                                                                                                                                                           |
| `GITLAB_OAUTH_CLIENT_SECRET`      | GitLab OAuth application client secret                                                                                                                                                                                                                                                                                                                                                                                |
| `GITLAB_OAUTH_BASE_URL`           | GitLab instance for login. Defaults to `https://gitlab.com`. For self-hosted GitLab, set the instance URL, such as `https://gitlab.lrz.de`.                                                                                                                                                                                                                                                                           |
| `GITLAB_OAUTH_DISPLAY_NAME`       | Login-button label, default `GitLab`. Example: `gitlab.lrz.de`.                                                                                                                                                                                                                                                                                                                                                       |
| `GITLAB_DEFAULT_SERVER_URL`       | The one GitLab instance for workspace creation, sync, and checkout, not login. Falls back to `GITLAB_OAUTH_BASE_URL`. Give the application server and every worker the same value.                                                                                                                                                                                                                                    |
| `GITLAB_ENABLED`                  | Enables GitLab on the application server and every worker. The webhook receiver does not read it. Without it, a worker cannot check out merge requests. Each claimed GitLab review fails preparation with `SCM Git preparation is unavailable`. It stops before any result.                                                                                                                                           |
| `GITLAB_WORKSPACE_CREATION`       | Enables GitLab workspace creation in the UI and API                                                                                                                                                                                                                                                                                                                                                                   |
| `WEBHOOK_SECRET`                  | Instance-wide secret for GitHub webhook verification and operator-created GitLab hooks on shared `POST /webhooks/gitlab` (`X-Gitlab-Token`). Hephaestus group hooks instead use the connection endpoint and signed route credential. See `WEBHOOK_ROUTING_SECRET`. For manually supplied signing tokens on GitLab 19.0 and later, only the signature verifies the hook. This value must then be that `whsec_…` token. |
| `WEBHOOK_ROUTING_SECRET`          | Signs each Hephaestus-registered GitLab group hook token. Each hook can deliver only for its registered GitLab connection. Requires at least 32 characters. Must differ from `WEBHOOK_SECRET` and every encryption key. Give the application server and webhook receiver the same value. Required for every production deployment, including GitHub-only instances. Compose and readiness reject absent values.       |
| `WEBHOOK_ROUTING_PREVIOUS_SECRET` | Previous value, used only during `WEBHOOK_ROUTING_SECRET` rotation. Its signed hooks keep delivering until registration replaces them with new-value hooks. After replacement, remove it.                                                                                                                                                                                                                             |
| `WEBHOOK_EXTERNAL_URL`            | Public GitLab webhook base URL. Reference Compose leaves it unset so auto-registration derives it from the application host URL. For an override, add it to the `application-server` service's `environment:` block.                                                                                                                                                                                                  |

The login provider supports gitlab.com by default. For self-hosted GitLab, set `GITLAB_OAUTH_BASE_URL` to that instance.

1. Create the GitLab OAuth application on that instance.
2. Set its Redirect URI to `https://<hostname>/api/login/oauth2/code/gitlab`.
3. Set Scope to `read_user`.
4. Enable Confidential client.

The environment seeds **one** default login provider, with registration id `gitlab`. An instance admin can add other GitLab instances at runtime under **Instance admin → Login providers**, without redeployment.

GitLab workspaces exist only on `GITLAB_DEFAULT_SERVER_URL`. Hephaestus reads every GitLab project, member, and event from that instance. It refuses any other server before token validation, group listing, or token storage. A login provider for another instance permits sign-in and identity linking, not workspace hosting.

The workspace owner is the creator's GitLab account on the default instance. Workspace creation requires a login provider for that instance and a creator linked to it.

The auto-registered group webhook subscribes to these events:

* Merge request, issue, and note.
* Push, tag push, and pipeline.
* Milestone and member.
* Subgroup and project.

An upgrade does not add subscriptions to an older release's hook. For a workspace connected before pipeline events had a consumer:

1. Enable **Pipeline events** under the group's **Settings → Webhooks**. Alternatively, delete the hook. Hephaestus then registers it again on the next sync.

Until then, only scheduled sync supplies the head pipeline's state. Hephaestus then has no pipeline jobs as evidence, and no review starts when a pipeline finishes. Before GitLab 18.6, merge request events omit each reviewer's review state. On those versions, only scheduled sync (`MONITORING_SYNC_CRON`) supplies approvals, change requests, or renewed review requests.

The `server` runtime role inspects GitLab tokens daily at **03:00 UTC**. This is independent of repository sync and webhook registration. A token within `hephaestus.webhook.token-rotation.threshold-days` of expiry (default 7 days) self-rotates with configured validity (default 90 days). It needs GitLab `api` or `self_rotate` scope and an instance version with self-rotation support. The server serializes rotation with token replacement.

CAUTION: Do not share a token that Hephaestus rotates

Rotation immediately revokes the old token. Do not share that token with other applications. Hephaestus never automatically retries an uncertain rotation response. If the provider rotated the token but its response was lost, replace the token.

If rotation fails or is not permitted, workspace admins subscribed to **Workspace connection alerts** receive one email for the recorded problem. A refused token records a separate credential problem and sends one alert. Rotation or replacement clears the problem and sends a recovery email. Repeated checks do not repeat alerts for the same problem revision.

The connection page shows the observed expiry date, **no expiry**, or unknown expiry until inspection succeeds. It never shows the token. Replace refused or expiring tokens there. GitHub App installation tokens renew automatically without expiry action. **GitHub personal access token expiry is not tracked**. Workspace admins must track and replace them.

Slack revocation alerts and Outline expiry information stay unchanged.

### Practice review

**[Practice Review](/admin/practice-review.md)** owns these subjects:

* Deployment variables and workspace setup in screen order.
* Each step's cost.
* Campaigns versus recurring sweeps.
* Agent queue-health metrics and retention.
* Checks for a quiet workspace.

Two deployment-topology facts belong here. The **worker runtime role** (`hephaestus.runtime.worker.enabled`), not `AGENT_ENABLED`, activates the Docker sandbox. Only `application-worker` runs sandboxes and holds the Docker socket, for both queued reviews and the conversation host. `application-server` disables its worker role and uses connected workers.

`NATS_ENABLED` controls webhook-driven sync consumption. It is not part of the practice-review bundle. Production startup refuses a server or webhook role without NATS, as [Runtime roles](/admin/runtime-roles.md#boot-log) describes.

Each worker runs one conversation host for all conversation turns that it claims. The host starts with the first turn on that worker. [Runtime Roles](/admin/runtime-roles.md#the-conversation-host) gives its fixed limits. Size each worker for this host in addition to its review sandboxes. The application server needs no memory for Conversation runtimes. [Runtime Roles](/admin/runtime-roles.md#conversation-turns) explains dispatch, capacity, and the host lifecycle.

### Instance-wide LLM settings

Every sandbox uses the LLM proxy on its worker, whether for Hephaestus or queued practice reviews. That proxy is its only path to a provider key. Keys are never injected directly. There is no separate intelligence service to configure.

**Environment variables never set a provider, model, price, or credential.** Those are runtime catalog data entered in the admin UI. [Connect an AI Provider](/admin/ai-providers.md) explains each field, the connection test, and a price calculation example.

Five optional instance-wide LLM variables hold deployment facts rather than catalog data. Each defaults to off or its standard value:

| Variable                                         | Default                  | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ------------------------------------------------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `HEPHAESTUS_LLM_DISPLAY_CURRENCY`                | *(empty)*                | Shows converted spend **alongside** USD. Empty disables the second currency. Only `EUR` is supported. Other values, even valid codes such as `GBP`, fail startup with an explanation of accepted values. An unsupported currency cannot silently appear as a missing feature.                                                                                                                                                                                                                         |
| `HEPHAESTUS_LLM_EGRESS_ALLOW_LOOPBACK`           | `false`                  | Permits saved and connected `http://localhost` provider base URLs. **Never set in production.** It exposes host-local services to workspace-admin "provider" requests through SSRF. Private, link-local, and CGNAT checks remain unconditional.                                                                                                                                                                                                                                                       |
| `HEPHAESTUS_LLM_EGRESS_PRIVATE_HOSTS`            | *(empty)*                | Model endpoints on your own network, as comma-separated host names, IP addresses, or CIDR ranges. Set the same value on the server and the worker. Only private addresses qualify. Loopback, link-local, and the cloud metadata ranges listed there stay refused. A range wider than `/16` (IPv4) or `/48` (IPv6), outside the private ranges, or covering the server's own address stops startup. [Model endpoints on your own network](/admin/ai-providers.md#model-endpoints-on-your-own-network). |
| `HEPHAESTUS_LLM_EGRESS_PRIVATE_HOSTS_ALLOW_HTTP` | `false`                  | Permits `http://` base URLs for the hosts in `HEPHAESTUS_LLM_EGRESS_PRIVATE_HOSTS`. Every other host still needs HTTPS.                                                                                                                                                                                                                                                                                                                                                                               |
| `HEPHAESTUS_LLM_FX_DAILY_URL`                    | ECB daily reference feed | Override only on an air-gapped instance with an internal mirror of the ECB file.                                                                                                                                                                                                                                                                                                                                                                                                                      |

Conversation turns run in the conversation host of a worker, for both the webapp and Slack DMs. The application server keeps the conversation store and makes a first admission check when a question arrives. The worker admits the turn again, with the budget check, and builds the turn's context. With its worker role disabled, it creates no Docker client. A turn waits in the queue until a worker has free capacity.

[Runtime roles](/admin/runtime-roles.md#conversation-turns) owns dispatch, leases, drain, and the conversation host. The proxy resolves each model's upstream base URL, auth header, and key from its bound connection. No process-level configuration needs to fix those values. In split-pod deployments, worker and app pods use the same provider catalog. No LLM credentials pass between pods.

Each conversation turn reads the model and route of the workspace's Hephaestus binding when it starts. A change to the binding applies to the next turn without a restart of the conversation host.

#### Workspace budgets and alerting

Two monthly LLM budgets exist. **They are never added together**:

* **The instance budget** caps a workspace's **shared** model spend on your registered connections, billed to you. An instance admin sets it under **Instance admin → AI usage → Set budget**. It applies per workspace and UTC calendar month.
* **The workspace's own budget** caps its spend on **its own connected provider**, billed to its provider account. A workspace admin sets it on their **AI usage** page.

Both are uncapped by default. For either cap, **Remove cap** clears it. An exact `0` cap pauses that purse immediately. This is the supported way to stop spend mid-month.

**Each cap pauses only work it funds.** An exhausted instance budget stops shared-model work, not own-provider work. An exhausted workspace budget does the reverse. The instance must not stop spend it does not pay for or know the price of. To stop *all* AI work, change the workspace status or feature switches, not its budget.

Once confirmed monthly spend reaches the cap, that purse's verdict becomes **EXHAUSTED**. New funded agent jobs are refused at submission. Queued jobs have another check immediately before claim. Conversation turns are refused when a worker claims them, before the conversation host runs them. Work resumes automatically next UTC calendar month, or about a minute after a cap increase or removal. The pause appears on both workspace **AI usage** and **AI models** pages.

**Practice reviews and conversations fail differently.** A queued practice review is refused before execution and posts nothing. The end user sees no error. **Hephaestus does not go silent**. A conversation turn that uses an exhausted purse gets a reply that states the monthly cap and who can increase it. The workspace admin controls the own-provider cap, and the instance admin controls the shared budget.

A silently lost turn would appear broken. Users therefore see and may report an exhausted cap. Plan for support contact, not only a dashboard change.

Some started attempts may return no trustworthy usage counters. Migrated legacy attempts may lack an admission price snapshot. These usage records have no confirmed cost and are **excluded from monthly totals**. Missing rates and unverified measurements are separate reasons for an unavailable cost. The reported total is then a lower bound on spend. The UI shows the confirmed total and a separate count of runs with unavailable cost across both purses.

The purse reads **UNVERIFIABLE** instead of **WITHIN**. **EXHAUSTED** still takes priority. A non-zero unpriced count indicates real spend may exceed the display. New models require a price or **No metered API cost** before activation. The latter counts as confirmed $0 spend only when the usage measurement is accepted.

There is **no setting** for UNVERIFIABLE-month behavior. An **uncapped** purse never pauses for unpriced usage because it has no cap to breach. A **capped** UNVERIFIABLE purse pauses exactly like an exhausted one because its cap cannot be verified. Adding missing rates cannot resolve an unknown or unverified measurement. Existing unpriced usage continues to block a capped purse for the current UTC month. [AI providers](/admin/ai-providers.md#missing-prices-and-unverified-measurements) explains measurement verification, historical records, and the stopped-writer upgrade requirement.

**Alerting hook:** there is no built-in push notification for a crossed workspace cap. Use the private endpoint and example rules in [Scrape metrics](/admin/observability.md#scrape-metrics) for these Micrometer counters:

* `llm.budget.exhausted`: increments once per usage event whose confirmed contribution crosses the workspace's monthly **instance** cap. This is your funded purse and the one that alerts you.
* `llm.budget.blocked`: tagged by admission-point `surface`, including `review_job` and `mentor`. The agent-job tag also has `cap`, which names the purse that refused work.

Each EXHAUSTED transition logs a WARN. Compatible endpoint reasoning tokens remain in telemetry when reported. They have no separate price. OpenAI-compatible usage includes them in billable output tokens. A second reasoning rate would count the same work twice.

### Email

Hephaestus uses one SMTP relay for the whole instance. Account-security and deletion confirmations are essential mail. New product feedback emails, connection alerts, and survey invitations are separate opt-in subscriptions in personal settings. Email stays off until a relay host is set. There is no per-workspace mail configuration or connection authorization. Spring Boot's `spring.mail.*` is the transport, so each variable is the uppercase property path.

The weekly digest is also an opt-in subscription in personal settings. Its email note, of kind `mentor-digest`, goes only to a developer with a verified address whom Hephaestus cannot reach in Slack. This note is optional mail and expires if it is not sent within 24 hours.

**Running without email is supported.** For no-email operation:

1. Leave `SPRING_MAIL_HOST` blank.
2. Leave `HEPHAESTUS_EMAIL_FROM` blank.

Startup, sign-in, account management, product feedback, and in-app surveys require no SMTP service. Account-security and deletion emails are withheld, but their operations complete. Do not promise email confirmation as the only action record.

Personal settings hide email choices if transport is unconfigured and the account has no active subscriptions. After previous activation, only retained subscriptions remain visible so people can disable them. New opt-ins stay hidden. Users see no SMTP setup warning. Instance admins retain **Instance settings → Email** for setup and verification. Survey administration keeps results and request history but replaces the send action with a setup link.

A direct invitation request returns `503` without email request records. Removal of SMTP configuration does not erase preferences. Attempts without configuration complete as `NOT_CONFIGURED` rather than waiting for reactivation. Existing subscriptions apply to future events after reconfiguration. Temporary relay outages and silent mode do **not** hide controls. Configuration, transport health, and deliberate withholding are separate states.

| Variable                                                         | Default               | Purpose                                                                                                                                                                                                                                            |
| ---------------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SPRING_MAIL_PROTOCOL`                                           | `smtp`                | Transport protocol: `smtp` (STARTTLS or implicit SSL) or `smtps` (implicit TLS).                                                                                                                                                                   |
| `SPRING_MAIL_HOST`                                               | *(unset — email off)* | Relay hostname. Setting it turns email on.                                                                                                                                                                                                         |
| `SPRING_MAIL_PORT`                                               | `587`                 | Relay port. `587` uses submission with STARTTLS. For implicit TLS, use `465` with `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true`.                                                                                                              |
| `SPRING_MAIL_USERNAME` / `SPRING_MAIL_PASSWORD`                  | *(unset)*             | Relay credentials. For an unauthenticated internal relay, leave both unset. Set `SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=false`.                                                                                                                     |
| `SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH`                          | `true`                | Authenticate with the credentials above.                                                                                                                                                                                                           |
| `SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE` / `_REQUIRED` | `true` / `true`       | Upgrades with STARTTLS. Refuses relays that cannot upgrade. Disable both only for implicit TLS or an unauthenticated relay inside a trusted private network. The latter requires `SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=false` and no credentials. |
| `HEPHAESTUS_EMAIL_FROM`                                          | *(unset)*             | Sender address, required once the host is set. The relay must be allowed to send for its domain (SPF/DKIM), or receivers reject or junk the mail.                                                                                                  |
| `HEPHAESTUS_EMAIL_FROM_NAME`                                     | `Hephaestus`          | Display name next to the sender address.                                                                                                                                                                                                           |
| `HEPHAESTUS_EMAIL_REPLY_TO`                                      | *(unset)*             | Where a human reply lands. Unset means replies go to the sender address.                                                                                                                                                                           |
| `HEPHAESTUS_EMAIL_RATE_LIMIT_DAILY_CAPACITY`                     | `250`                 | Shared SMTP attempt budget per 24-hour refill interval.                                                                                                                                                                                            |
| `HEPHAESTUS_EMAIL_RATE_LIMIT_OPTIONAL_DAILY_CAPACITY`            | `200`                 | Optional-mail attempt budget. Must be below the total to reserve essential-mail capacity.                                                                                                                                                          |

TLS verifies the relay certificate and hostname. Authenticated SMTP without implicit TLS or required STARTTLS fails startup to prevent credential exposure. For implicit TLS on port `465`:

1. Enable SSL.
2. Disable both STARTTLS settings.
3. Leave certificate verification enabled.

Alternatively, select `SPRING_MAIL_PROTOCOL=smtps` with port `465`. Both protocols verify the relay hostname and use the timeouts below. Protocol-specific overrides use `mail.smtp.*` or `mail.smtps.*` respectively. For authenticated relays, keep authentication and certificate verification enabled.

Connection, read, and write timeouts default to 5 s, 10 s, and 10 s respectively. A relay that stops answering cannot leave a socket operation waiting indefinitely. Only `application-server` reads these variables. Workers and webhook roles never send email.

A university or corporate relay usually requires port `587`, STARTTLS, and a service account, with a daily recipient cap. The defaults fit that model.

1. Ask the relay operator which sender domains it signs.
2. Set `HEPHAESTUS_EMAIL_FROM` within one of those domains.

**Verify it.**

1. Open **Instance settings → Email**.
2. Select **Send test email**.
3. Check the outcome in the response.

The outcomes are:

* `SENT`: includes the message id to find in the relay log.
* `SILENT_MODE`: [silent mode](/admin/production-operations-runbook.md) is on, as on a fresh instance.
* `NOT_CONFIGURED`.
* `INVALID_ADDRESS`.
* `REJECTED`: refused mailbox.
* `UNAVAILABLE`: unreachable relay or refused credentials.
* `RATE_LIMITED`: shared attempt budget exhausted or its store unavailable.

The [configuration readiness](/admin/configuration-readiness.md#email) fact `notification.email` reports the same setting pair. `/actuator/health` gains a `mail` indicator that connects to the relay on each check. SMTP is in neither the readiness nor liveness group. Startup does not require connection validation. A relay outage must not take the application offline.

**Delivery.** The transaction that changes state also writes a notification to `event_publication`. Delivery follows immediately after commit. If the relay is unreachable, the row remains `FAILED`. A job retries every five minutes, in batches of 50. An account-deletion confirmation expires at its purge deadline.

Its next attempt completes without delivery, records `EXPIRED`, and deletes the registry row. The listener handles expiration, not a batch filter. Silent mode, absent or unverified addresses, and refused mailboxes are final outcomes. The row completes, and `notification.email.delivery` counts the outcome by `kind` and `outcome`. Logs contain the message id, never the address. Delivery is at-least-once.

A crash after SMTP acceptance but before registry completion can produce duplicates. `SENT` means relay acceptance, not inbox arrival. Opt-out and eligibility checks apply to later attempts. An SMTP attempt already in progress cannot be recalled.

**Capacity.** The existing PostgreSQL-backed Bucket4j store shares limits across server replicas. Keep `hephaestus.auth.rate-limit.postgres-backed=true` in production. An SMTP attempt uses capacity even if the relay rejects it or disconnects. A refund after an uncertain failure could exceed the relay limit. Withheld mail uses no capacity.

Durable notifications wait for capacity on a later retry. A synchronous test reports the outcome and does not queue itself. The 24-hour interval starts at first bucket use, not midnight or the relay's billing day. Match limits conservatively to relay policy, with allowance for other applications on the account. After limit changes, restart. Existing buckets keep their configuration until expiry.

**Optional-mail delivery.** Optional emails contain a visible unsubscribe confirmation link and an RFC 8058 one-click POST endpoint. A GET never changes subscriptions. The local HTTP Mailpit loop deliberately omits the HTTPS-only one-click header.

1. Set public application host and webapp URLs to externally reachable **HTTPS** addresses.
2. Configure relay DKIM signatures to include **both `List-Unsubscribe` and `List-Unsubscribe-Post`** headers.
3. Keep SPF, DKIM, and DMARC aligned with the sender.
4. Follow [Google's sender requirements](https://support.google.com/a/answer/81126?hl=en) and [RFC 8058](https://www.rfc-editor.org/rfc/rfc8058).

Headers added in Hephaestus alone do not satisfy the standard.

A mail provider's published limits do not confirm your service account's allowance. Ask support:

1. Which account incurs the quota charge?
2. Do other applications share it?
3. How does its window reset?
4. How does it indicate quota rejection?

A shared From address alone does not establish a separate quota. Allow capacity for other senders and retries. For example, this instance might have 200 total and 150 optional attempts from a shared 250/day account. This operating budget does not guarantee compliance with a rolling-window relay quota. Application interval refills can permit bursts across a boundary.

For a larger research campaign, obtain an approved higher limit or suitable relay. Do not increase only the application budget. Five reports to two subscribed admins need ten attempts. A daily digest is unnecessary at that volume. Survey invitations and reminders need separate capacity planning.

**Privacy.** The relay operator receives personal data: an address and the fact that its owner has an account. Before you set the host, complete the review in the [processor checklist](/admin/dsms/processor-checklist.md#follow-up-if-the-processing-surface-changes).

### Data sync and backfill

The sync scheduler fetches recent GitHub issues, PRs, and reviews for monitored repositories. Optional backfill syncs historical data in batches to avoid rate-limit exhaustion.

| Variable                                   | Default       | Purpose                                    |
| ------------------------------------------ | ------------- | ------------------------------------------ |
| `MONITORING_RUN_ON_STARTUP`                | `true`        | Run initial sync when application starts   |
| `MONITORING_TIMEFRAME`                     | `7`           | Days of recent activity to sync each cycle |
| `MONITORING_SYNC_CRON`                     | `0 0 * * * *` | Cron schedule for sync (hourly by default) |
| `MONITORING_SYNC_COOLDOWN_IN_MINUTES`      | `60`          | Minimum gap between syncs per repository   |
| `MONITORING_BACKFILL_ENABLED`              | `true`        | Enable historical data backfill            |
| `MONITORING_BACKFILL_BATCH_SIZE`           | `50`          | Issues/PRs per backfill batch              |
| `MONITORING_BACKFILL_RATE_LIMIT_THRESHOLD` | `500`         | Skip backfill if rate limit below this     |
| `MONITORING_BACKFILL_INTERVAL_SECONDS`     | `60`          | Interval between backfill batches          |

**Backfill behavior:** after recent sync completes, backfill works backward from the highest issue number in small batches. It pauses below the rate-limit threshold and resumes on the next cycle. It records progress per repository.

A workspace connection disconnect or workspace purge permanently erases the mirror. [Disconnect is an erase](/admin/integrations.md#disconnect-is-an-erase) gives the details.
