Notifications: Slack, Microsoft Teams, e-mail, webhooks and browser push ​
CodeLotse can tell a team about events that need attention. Tenant admins manage channels under Notifications in the tenant navigation (permission tenant:manage). Everyone can also turn on browser push for their own devices. The decisions behind the design are in ADR-0016 and ADR-0020.
Events ​
| Event | When it is sent |
|---|---|
| LLM budget alert | A budget threshold is reached (analytics.md); once per alert |
| Review failed | A review ends as failed after its last attempt; once per review run |
| Outputs waiting for approval | A digest when new outputs are waiting in the approvals inbox; checked every 15 minutes, at most one digest per channel and hour |
| Fix pull request ready | A fix pull request was opened (or found already open) by the publisher |
| CI failure analysis posted | A CI failure analysis comment was posted on a pull request |
Each channel subscribes to any subset. Channels are per tenant; there is no per-repository filter.
Messages contain metadata only: the event, the repository, the pull request number and title, a count or budget amounts, and a link to the pull request on the code host. They never contain code, finding text, model output or logs. Titles are stripped of control and invisible characters and escaped for the target format. A message in which the secret scanner finds something that looks like a credential is not sent; the delivery is recorded as skipped.
Channel kinds ​
Slack ​
Create an incoming webhook for the channel and paste its URL. Only https://hooks.slack.com/… is accepted.
Microsoft Teams ​
Teams delivers webhook messages through Workflows (Power Automate). In the Teams channel, choose Workflows, use the template "Post to a channel when a webhook request is received" and copy the URL of the trigger. CodeLotse accepts only hosts ending in .environment.api.powerplatform.com. The older *.logic.azure.com workflow URLs and Office 365 connector URLs (*.webhook.office.com) were retired by Microsoft and are refused. The message is an Adaptive Card.
E-mail ​
E-mail is available only when the instance has an SMTP server configured (see SMTP). A channel has 1 to 20 recipients; every recipient sees the others. The message is plain text with the subject CodeLotse: <event>.
Signed webhook ​
Any https URL. CodeLotse sends POST requests with a JSON body:
{
"id": "<delivery id>",
"event": "review_failed",
"createdAt": "2026-10-15T10:00:00.000Z",
"summary": {
"repository": "acme/web",
"number": 12,
"title": "Add cache",
"count": null,
"budget": null,
"url": "https://github.com/acme/web/pull/12"
}
}Headers:
| Header | Content |
|---|---|
X-CodeLotse-Event | The event, or test for Send test |
X-CodeLotse-Delivery | The delivery id; retries of one delivery use the same id |
X-CodeLotse-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
The signing secret is shown once when the channel is created and when it is rotated (Rotate signing secret); it is stored encrypted. To verify a request, compute the HMAC-SHA256 of <t>.<raw body> with the secret and compare it in constant time with v1. Reject requests whose t is more than five minutes away from your clock, and use X-CodeLotse-Delivery to ignore repeats.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, header, rawBody, now = Date.now() / 1000) {
const { t, v1 } = Object.fromEntries(header.split(',').map((part) => part.split('=')));
if (Math.abs(now - Number(t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const received = Buffer.from(v1 ?? '', 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}Delivery ​
Events are recorded in the same database transaction as the change that causes them and are delivered by the worker queue notify (outbox, ADR-0005). Each request has a 10-second timeout, follows no redirects and reads at most 64 KiB of the answer, which is otherwise ignored.
- HTTP 2xx:
sent. - HTTP 429, 5xx, timeouts and connection errors: retried up to five times with exponential back-off, then
failed. - Other HTTP 4xx, refused redirects and blocked addresses:
failedat once. - Disabled channel, e-mail without SMTP, possible secret in the message:
skipped.
The channel shows its last 50 deliveries with status, attempts and the last error. Errors never contain the target URL. Delivery records follow the tenant data retention.
Send test sends a fixed test message through the same renderer and transport without recording a delivery. It answers with the outcome and the HTTP status, and can be used once every 10 seconds per channel.
Network egress ​
| Channel | Enterprise edition | Self-hosted edition |
|---|---|---|
| Slack, Teams | Host allowlist, guarded fetch (public addresses only, checked at connect time), no redirects | Same |
| Webhook | https only, public addresses only when saving and at connect time, no redirects | https only, private and internal addresses allowed, no redirects |
| The configured SMTP server only | Same | |
| Browser push | Push services of the major browsers only (see Browser push), guarded fetch, no redirects | Same |
Air-gapped instances can use e-mail through an internal relay or a webhook to an internal endpoint.
SMTP ​
| Variable | Meaning |
|---|---|
QUAVON_SMTP_HOST | SMTP server; e-mail channels are unavailable when unset |
QUAVON_SMTP_PORT | Port, default 587 |
QUAVON_SMTP_SECURITY | starttls (default; the server must offer STARTTLS) or tls (implicit TLS, usually port 465). Plain-text SMTP is not supported. |
QUAVON_SMTP_FROM | Sender address, required with the host |
QUAVON_SMTP_USERNAME, QUAVON_SMTP_PASSWORD | Optional login; set both or neither. The password can be read from a file with QUAVON_SMTP_PASSWORD_FILE. |
api (for Send test) and worker read these variables. The server certificate is verified against the system trust store; for an internal CA, add it with NODE_EXTRA_CA_CERTS. Compose and Helm set-up is described in install.md and kubernetes.md; all variables are listed in the configuration reference.
Browser push ​
The web app can be installed on phones and desktops, and each person can turn on push notifications for their own devices (CodeLotse on your phone). Push is off until the instance has a VAPID key:
| Variable | Meaning |
|---|---|
QUAVON_WEBPUSH_PRIVATE_KEY | Base64url-encoded 32-byte P-256 private key. Can be read from a file with QUAVON_WEBPUSH_PRIVATE_KEY_FILE. |
QUAVON_WEBPUSH_SUBJECT | Contact for the push services, mailto: or https: URL; required with the key |
api (for the public key and Send test) and worker read both. The Compose installer generates a key; for existing installations and Helm, create one with
openssl ecparam -name prime256v1 -genkey -noout -outform DER | tail -c +8 | head -c 32 | base64 | tr '+/' '-_' | tr -d '=\n'and store it as a secret. Changing the key invalidates all subscriptions: browsers reject messages signed with another key, the subscriptions are deleted on the next send and people have to enable push again.
Recipients. A subscription belongs to one person, tenant and browser. LLM budget alerts and the approvals digest go to tenant admins; review failures, fix pull requests and CI failure analyses go to tenant admins and to people with a role on the repository. Only memberships refreshed within the last seven days count, checked when the event is recorded and again before sending. Assignment events are never pushed.
Content and privacy. Messages are rendered like the other channels (metadata only) and encrypted end to end for the browser (RFC 8291); the browser vendor's push service cannot read them. The subscription address and keys are stored encrypted. Subscriptions are personal data of the person and are deleted with their account.
Delivery. Queue push, with the same retries as channels. HTTP 404 and 410 delete the subscription. Messages expire after one day if the device stays offline, and a newer message of the same kind replaces an older one.
Egress. fcm.googleapis.com, updates.push.services.mozilla.com, web.push.apple.com and *.notify.windows.com over https through the guarded fetch. Air-gapped instances cannot use browser push.