Configuration reference
One file. everasync init writes a fully commented starter; this page is the
complete reference for what is in it.
Two rules govern the whole file:
- A section that is absent disables its plugin. No error, no warning — the
plugin may be compiled into the binary but stays unregistered. The one
exception is
[llm]: naming a provider without giving it a section is a hard startup error, because you asked for something the binary cannot deliver. - A section that is present but rejected is a hard startup error. A half-configured connector never runs silently.
The *_env secret indirection rule
No credential is ever written in
everasync.toml. Every secret key is a<key>_envpair naming an environment variable, and the value lives only in that variable.
[channels.slack]
signing_secret_env = "SLACK_SIGNING_SECRET" # ← the NAME of a variable
bot_token_env = "SLACK_BOT_TOKEN"
export SLACK_SIGNING_SECRET=... # ← the VALUE, only here
export SLACK_BOT_TOKEN=xoxb-...
This is enforced by the loader, not by convention. Given a key foo, the
config reader looks for foo_env, reads the named variable, and:
| Situation | Result |
|---|---|
foo_env missing | missing 'foo_env' in plugin section — startup fails |
foo_env names a variable that is not set | environment variable 'X' is not set — startup fails |
| Both present | the value is used, and never logged |
Providers that can legitimately run without a credential (Ollama, a bare vLLM endpoint) use a tolerant variant: absent is fine, but named-and-unset is still an error. A variable you mentioned and forgot to set is a mistake, not a choice.
Why it works this way: config files get committed, pasted into issues, and copied between environments. Environment variables do not. The file can be checked into Git as-is.
[llm.providers.compat.headers] values are read from the file verbatim —
it is a passthrough for endpoints (like Azure) whose auth scheme is not a
bearer token. Prefer api_key_env wherever the endpoint accepts it. See
OpenAI-compatible.
Complete reference
[server]
| Key | Default | Meaning |
|---|---|---|
bind | "0.0.0.0:8100" | Listen address |
public_url | — | Public base URL of the API host, used when a message needs to link back to Ever Async itself. Optional |
[storage]
| Key | Default | Meaning |
|---|---|---|
driver | "sqlite" | "sqlite" (one file, survives restarts, a single writer — so one replica), "postgres" (several replicas share one database), "orm" (SQLite, Postgres or MySQL through one SeaORM implementation) or "memory" (ephemeral — tests and trying it out) |
path | "everasync.db" | SQLite file path. In Docker point it at the mounted volume. sqlite and orm |
url_env | — | Env var holding the connection string. Required when driver = "postgres"; optional for orm, where leaving it out means "use path as SQLite". The URL carries a password, so it never lives in the file |
pool_size | 16 | Pool connections. postgres and orm |
Any other driver value refuses to start and lists the ones this binary was
built with, rather than quietly writing to a local file. Naming "postgres" in
a binary compiled without the postgres feature says so explicitly.
[storage]
driver = "postgres"
url_env = "EVERASYNC_DATABASE_URL" # postgresql://everasync:…@postgres:5432/everasync
The schema is created on first start.
driver = "orm" — one backend, three databases
"orm" is the same storage over SeaORM. It
implements the same Storage trait as the two above, so nothing else in the
product changes — but it is one query set instead of one per database, with
versioned migrations rather than a CREATE TABLE IF NOT EXISTS batch per
backend.
Which database it opens comes from the URL scheme, not from a second driver id:
[storage]
driver = "orm"
url_env = "EVERASYNC_DATABASE_URL"
# sqlite://everasync.db
# postgres://everasync:…@postgres:5432/everasync
# mysql://everasync:…@mysql:3306/everasync # needs the orm-mysql feature
With no url_env at all it opens path as SQLite — so an existing self-hosted
install switches by changing one word:
[storage]
driver = "orm" # was "sqlite"
path = "everasync.db" # unchanged
That existing everasync.db is adopted in place: the tables it already has
are kept with their rows, anything missing is added, and a file written before
multi-tenancy existed gains its tenant column with every row assigned to the
default tenant. Nothing is wiped and nothing is rejected. Both backends can be
pointed at the same file, so the switch can also be undone.
MySQL is behind the orm-mysql build feature — it costs an extra driver in the
image, and the deployments that exist today are SQLite and Postgres. Nothing in
the code branches on it; the ORM is what makes it work.
[policy]
The default policy; overridden per conversation by /async or the API. Full
treatment in policy.
| Key | Default | Meaning |
|---|---|---|
enabled | true | Master switch |
sensitivity | "conservative" | "conservative" · "balanced" · "high" — see sensitivity |
nudge_delay_secs | 150 | Grace window before a private nudge; author self-correction cancels it |
min_confidence | 0.7 | Minimum connector confidence before context is posted publicly |
unfurl_links | true | Enrich links the author already posted with live status |
[policy.quiet_hours] — optional
| Key | Meaning |
|---|---|
start | Inclusive start, UTC ("22:00") |
end | Exclusive end, UTC ("07:00") |
start > end wraps midnight. Quiet hours suppress nudges only — context
cards are unaffected.
[nudges] — optional
How a nudge that fell due while the process was stopped is treated when
everasync serve restores the schedule at boot.
| Key | Default | Meaning |
|---|---|---|
max_overdue_secs | 900 (15 min) | How late a restored nudge may be and still be delivered. Anything more overdue is dropped at startup |
Unlike everything in [policy], this is not per conversation and cannot be
changed with /async: it is a property of this process's restart, applied once
before any conversation is known.
Why there is a cutoff at all: a pending nudge has already spent its grace period waiting, so one that came due during a restart would otherwise fire the instant storage answers. For a 30-second deploy that is correct. For the first boot after a two-hour outage it is a batch of nudges about messages nobody is reading any more — the most annoying thing this bot can do, in a product whose whole premise is not being annoying.
15 minutes is six times the default nudge_delay_secs and longer than any
ordinary restart, so a normal deploy delivers everything it was holding; it is
far shorter than an outage, a weekend, or a pod that sat unschedulable overnight.
0— deliver nothing that came due while the process was down.- A very large value — deliver everything, however old (the pre-cutoff behaviour).
Dropped nudges are consumed, not left in the table, and counted in
ever_async_nudges_stale_dropped_total. See
Operations → Restoring the queue.
[charter]
| Key | Default | Meaning |
|---|---|---|
rules | the three built-in rules | Array of the team's own communication rules, cited by every nudge |
A charter saved at runtime overrides this. See the charter.
[llm] — optional
Omit the whole block to run heuristics-only.
| Key | Default | Meaning |
|---|---|---|
provider | — | Registry id of the primary provider |
fallback | [] | Ids tried in order when the one before errors; deduplicated against provider |
[llm.providers.<id>]
One section per provider, keyed by its registry id. Supported ids:
anthropic, openai, openrouter, gemini, grok, ollama, compat.
Common keys (exact set per provider on its own page):
| Key | Typical default | Meaning |
|---|---|---|
api_key_env | — | Env var holding the key. Required for hosted providers, optional for Ollama and compat |
model | provider-specific | The model id. Set it explicitly |
api_base | provider-specific | Endpoint root, trailing slash trimmed |
max_tokens | 512 | Reply cap. OpenAI and OpenRouter clamp it to 64–4096 |
→ OpenAI · OpenRouter · Anthropic · Gemini · Grok · Ollama · OpenAI-compatible
[digest] — optional
| Key | Default | Meaning |
|---|---|---|
enabled | false | Turn scheduled delivery on |
interval_hours | 168 (weekly) | How often to send |
days | 7 | Reporting window |
channel | — | Channel plugin id used for DMs ("slack") |
recipients | [] | Platform user ids that receive the digest as a DM |
notify | false | Also hand the digest to every configured notify provider |
[auth] — optional
Off by default. Omit the whole block and no credential is ever required:
every caller is the local board principal. That is the right default for a
self-hosted deployment on a private network, and the wrong one for anything
reachable from the internet — /api/v1 is a writable surface.
| Key | Default | Meaning |
|---|---|---|
mode | "local_trusted" | "local_trusted" (no credential required) or "authenticated" (every route outside the public allowlist needs one). "strict" is accepted as a synonym for "authenticated" |
provider | — | Registry id of the auth provider — "token", "local" or "oauth". Required when mode = "authenticated" |
Only one provider is active at a time.
[auth.providers.token]
Static bearer tokens for machine callers — CI, monitoring, curl. No user
store, no sessions, no login form.
| Key | Required | Meaning |
|---|---|---|
tokens_env | ✅ | Env var holding the token list |
The variable holds user_id:role:secret entries separated by commas or
whitespace, with an optional fourth :workspace\|workspace field (absent = all
workspaces). role is viewer, operator or board. Mint an entry with
everasync auth token --user ci --role operator; a secret may not contain :.
[auth]
mode = "authenticated"
provider = "token"
[auth.providers.token]
tokens_env = "EVERASYNC_API_TOKENS"
[auth.providers.local]
Locally-configured users with Argon2id passwords, exchanged for session tokens. Supports login, so the dashboard renders a password form.
| Key | Required | Default | Meaning |
|---|---|---|---|
users_env | ✅ | — | Env var holding the user list |
session_ttl_secs | — | 604800 (7 days) | Session lifetime. Clamped to 300..=2592000, with a warning |
The variable holds email:role:argon2-phc-hash entries separated by commas or
newlines. Mint an entry with
everasync auth hash --user alice@example.com --role board, which prompts for
the password without echoing it — never write a plaintext password anywhere.
[auth]
mode = "authenticated"
provider = "local"
[auth.providers.local]
users_env = "EVERASYNC_USERS"
session_ttl_secs = 604800
[auth.providers.oauth]
Browser single sign-on against Slack, Discord, GitHub, Google and Ever Gauzy.
There is no password form: the dashboard shows one button per enabled provider,
the browser comes back with a verified email, and a session is issued. A Slack
login also reports the workspace it came from — and an Ever Gauzy login its
tenantId — which is how either resolves to a tenant without asking anyone to
pick one.
| Key | Required | Default | Meaning |
|---|---|---|---|
base_url | ✅ | — | Public origin of this deployment. Callback URLs are built from it as <base_url>/api/v1/auth/oauth/<provider>/callback — register exactly that in each developer console |
allowed_domains | — | (empty — see below) | Email domains permitted to sign in. Whole-domain match after the @, case-insensitive; ever.co admits neither mail.ever.co nor notever.co |
session_ttl_secs | — | 604800 (7 days) | Session lifetime. Clamped to 300..=2592000 |
enabled | — | every configured section | Restricts which of the sections below are offered |
Each identity provider gets its own sub-section — [auth.providers.oauth.slack],
.discord, .github, .google, .gauzy — with client_id and
client_secret_env. Having a section switches a provider on; enabled = false
inside one switches it off again without losing the credentials. A sub-section
whose name is not one of the five is a startup error, not one missing button.
Only .gauzy also accepts a base_url (default https://api.gauzy.co),
because Ever Gauzy is the only one of the five that a customer can deploy
themselves; the other four are single hosted services with fixed endpoints and a
base_url on one of them is a startup error rather than a key that does
nothing. Setting up Ever Gauzy SSO — including the exact POST /oauth/clients
call an operator runs and the redirect URI to register — is
Ever Gauzy SSO.
→ Setting up SSO is the page to follow while creating the applications: the exact redirect URI per provider and host, the exact scopes, the credential fields to copy back, and what every first-attempt failure looks like.
Ever Gauzy signs every access token, in every tenant, with one global HS256
secret — so anything holding it can mint a token for any user anywhere. Ever
Async therefore treats a provider's access token as opaque: it is presented
back to the provider, and whatever the provider answers is the identity. There
is no signature check, no JWT decode, and no config key that accepts a signing
key — a section naming one (jwt_secret, jwt_secret_env, signing_key,
token_secret) fails at startup with an explanation rather than being ignored.
allowed_domains is a decision, not a defaultWith no allowlist, any account at these providers can complete a login — everyone on earth with a Google account, not only your colleagues. That is correct for public SaaS, where a new signup lands in its own empty tenant and can reach nothing else. It is wrong for a private deployment, where every successful login joins the one tenant that already holds your charter and your channel policies. Startup logs a warning when the list is empty.
[auth]
mode = "authenticated"
provider = "oauth"
[auth.providers.oauth]
base_url = "https://async.your-team.com"
allowed_domains = ["your-team.com"]
[auth.providers.oauth.slack]
client_id = "1234567890.1234567890"
client_secret_env = "EVERASYNC_SLACK_CLIENT_SECRET"
[auth.providers.oauth.google]
client_id = "1234567890-abcdef.apps.googleusercontent.com"
client_secret_env = "EVERASYNC_GOOGLE_CLIENT_SECRET"
→ Security & authentication for the threat model, the
role-to-endpoint table, what stays public (/ingress/* and /healthz — but
not /metrics), and a lockdown checklist.
[channels.slack]
| Key | Required | Default | Meaning |
|---|---|---|---|
signing_secret_env | ✅ | — | Env var with the Slack signing secret |
bot_token_env | ✅ | — | Env var with the bot token (xoxb-…) |
api_base | — | https://slack.com/api | Web API root |
bot_user_id | — | resolved via auth.test | Pre-seeds the bot's own user id, skipping one startup round trip |
→ Slack
[channels.discord]
| Key | Required | Default | Meaning |
|---|---|---|---|
public_key | ✅¹ | — | The application's Ed25519 public key, 64 hex characters. Not a secret — Discord prints it in the developer portal — so it may live in the file |
public_key_env | ✅¹ | — | Alternative to public_key, for keeping all platform config in the environment |
bot_token_env | ✅ | — | Env var with the bot token |
api_base | — | https://discord.com/api/v10 | REST API root |
¹ One of public_key / public_key_env is required. Without it no signature
can be verified, and the plugin refuses to start rather than run an unverified
ingress endpoint.
⚠️ Message classification does not run on Discord — Discord delivers
ordinary channel messages only over the Gateway websocket. /async, buttons
and outbound posts do work. → Discord
[connectors.github]
| Key | Required | Default | Meaning |
|---|---|---|---|
token_env | ✅ | — | Env var with a GitHub read token |
api_base | — | https://api.github.com | REST API root |
host | — | github.com | Web host claimed for link enrichment (set for Enterprise) |
org | — | — | Org login used to scope searches |
[connectors.github.user_map] — chat user id → GitHub login.
→ GitHub
[connectors.jira]
| Key | Required | Meaning |
|---|---|---|
base_url | ✅ | Jira site root; also the link matcher's authority |
email | ✅ | Atlassian account email the token belongs to |
api_token_env | ✅ | Env var with the API token |
[connectors.jira.user_map] — chat user id → Jira accountId.
→ Jira
[connectors.gauzy]
| Key | Required | Default | Meaning |
|---|---|---|---|
token_env | ✅ | — | Env var with a Gauzy API token |
tenant_id | ✅ | — | Gauzy tenant |
organization_id | ✅ | — | Gauzy organization |
base_url | — | https://api.gauzy.co | Gauzy API endpoint |
app_base_url | — | https://app.gauzy.co | Web app root, for building human task URLs |
[connectors.gauzy.user_map] — chat user id → Gauzy employee id.
→ Ever Gauzy
[notify.novu] — optional
Out-of-band delivery: digests, and fallbacks for users not reachable in-channel. Each notification becomes one Novu workflow trigger; Novu then fans it out to whatever channels the workspace configured there.
| Key | Required | Default | Meaning |
|---|---|---|---|
api_key_env | ✅ | — | Env var with the Novu API key |
api_base | — | https://api.novu.co | Novu API endpoint |
workflow | — | ever-async | Novu workflow identifier to trigger |
Worked examples
Minimum viable
[channels.slack]
signing_secret_env = "SLACK_SIGNING_SECRET"
bot_token_env = "SLACK_BOT_TOKEN"
Everything else defaults. Heuristics only, no AI, no connectors.
A real team
[server]
bind = "0.0.0.0:8100"
[storage]
driver = "sqlite"
path = "/app/data/everasync.db"
[policy]
enabled = true
sensitivity = "balanced"
nudge_delay_secs = 180
min_confidence = 0.75
unfurl_links = true
[policy.quiet_hours]
start = "21:00"
end = "06:00"
[charter]
rules = [
"Lead with the ask. First line says what you need and by when.",
"Link the artifact — PR, ticket, doc, dashboard.",
"Name a person. @here is not a recipient.",
]
[llm]
provider = "openrouter"
fallback = ["ollama"]
[llm.providers.openrouter]
api_key_env = "OPENROUTER_API_KEY"
model = "anthropic/claude-sonnet-5"
[llm.providers.ollama]
model = "llama3.1:8b"
api_base = "http://ollama.internal:11434/v1"
[channels.slack]
signing_secret_env = "SLACK_SIGNING_SECRET"
bot_token_env = "SLACK_BOT_TOKEN"
[connectors.github]
token_env = "GITHUB_TOKEN"
org = "ever-co"
[connectors.github.user_map]
U0123ABC = "octocat"
[connectors.jira]
base_url = "https://ever.atlassian.net"
email = "bot@ever.co"
api_token_env = "JIRA_API_TOKEN"
[connectors.jira.user_map]
U0123ABC = "5b10ac8d82e05b22cc7d4ef5"
[digest]
enabled = true
interval_hours = 168
days = 7
channel = "slack"
recipients = ["U0123ABC"]
The matching environment:
export SLACK_SIGNING_SECRET=...
export SLACK_BOT_TOKEN=xoxb-...
export OPENROUTER_API_KEY=sk-or-...
export GITHUB_TOKEN=ghp_...
export JIRA_API_TOKEN=...
Validating a config
everasync doctor --config ./everasync.toml
Loads the file, assembles every plugin exactly as serve does, then
health-checks each channel and connector. It exits non-zero if any check fails,
which makes it the right thing to run in CI and after every credential
rotation. See Operations.
Common startup errors and what they mean:
| Error | Cause |
|---|---|
environment variable 'X' is not set | The config names X but the process cannot see it |
missing 'foo_env' in plugin section | A required secret key was not declared |
[llm] names provider 'x' but there is no [llm.providers.x] section | Primary or fallback id without a section |
unknown llm provider 'x' | Typo, or that provider's cargo feature was compiled out |
missing 'base_url' in [connectors.jira] | A required non-secret key is absent |
no API tokens configured — … | [auth.providers.token] names a variable that is unset or blank. Refusing to start beats coming up with an empty allowlist that rejects everyone |
API token entry #N is malformed (…) | The Nth token entry is not user_id:role:secret. The entry is not echoed — usually it is a bare secret |
`users` entry at index N is malformed: … | The Nth [auth.providers.local] entry is not email:role:argon2-phc-hash, or the hash is not a usable Argon2 PHC string |
Plugin simply missing from /api/v1/status | Its section is absent or the table name is misspelled |