Skip to main content

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:

  1. 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.
  2. 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>_env pair 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:

SituationResult
foo_env missingmissing 'foo_env' in plugin section — startup fails
foo_env names a variable that is not setenvironment variable 'X' is not set — startup fails
Both presentthe 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.

The one exception

[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]​

KeyDefaultMeaning
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]​

KeyDefaultMeaning
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_size16Pool 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.

KeyDefaultMeaning
enabledtrueMaster switch
sensitivity"conservative""conservative" · "balanced" · "high" — see sensitivity
nudge_delay_secs150Grace window before a private nudge; author self-correction cancels it
min_confidence0.7Minimum connector confidence before context is posted publicly
unfurl_linkstrueEnrich links the author already posted with live status

[policy.quiet_hours] — optional​

KeyMeaning
startInclusive start, UTC ("22:00")
endExclusive 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.

KeyDefaultMeaning
max_overdue_secs900 (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]​

KeyDefaultMeaning
rulesthe three built-in rulesArray 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.

KeyDefaultMeaning
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):

KeyTypical defaultMeaning
api_key_env—Env var holding the key. Required for hosted providers, optional for Ollama and compat
modelprovider-specificThe model id. Set it explicitly
api_baseprovider-specificEndpoint root, trailing slash trimmed
max_tokens512Reply cap. OpenAI and OpenRouter clamp it to 64–4096

→ OpenAI · OpenRouter · Anthropic · Gemini · Grok · Ollama · OpenAI-compatible

[digest] — optional​

KeyDefaultMeaning
enabledfalseTurn scheduled delivery on
interval_hours168 (weekly)How often to send
days7Reporting window
channel—Channel plugin id used for DMs ("slack")
recipients[]Platform user ids that receive the digest as a DM
notifyfalseAlso hand the digest to every configured notify provider

See Operations → The digest.

[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.

KeyDefaultMeaning
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.

KeyRequiredMeaning
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.

KeyRequiredDefaultMeaning
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.

KeyRequiredDefaultMeaning
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 sectionRestricts 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 Async never holds a provider's token-signing secret

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.

warning
allowed_domains is a decision, not a default

With 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]​

KeyRequiredDefaultMeaning
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/apiWeb API root
bot_user_id—resolved via auth.testPre-seeds the bot's own user id, skipping one startup round trip

→ Slack

[channels.discord]​

KeyRequiredDefaultMeaning
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/v10REST 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]​

KeyRequiredDefaultMeaning
token_env✅—Env var with a GitHub read token
api_base—https://api.github.comREST API root
host—github.comWeb 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]​

KeyRequiredMeaning
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]​

KeyRequiredDefaultMeaning
token_env✅—Env var with a Gauzy API token
tenant_id✅—Gauzy tenant
organization_id✅—Gauzy organization
base_url—https://api.gauzy.coGauzy API endpoint
app_base_url—https://app.gauzy.coWeb 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.

KeyRequiredDefaultMeaning
api_key_env✅—Env var with the Novu API key
api_base—https://api.novu.coNovu API endpoint
workflow—ever-asyncNovu 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:

ErrorCause
environment variable 'X' is not setThe config names X but the process cannot see it
missing 'foo_env' in plugin sectionA required secret key was not declared
[llm] names provider 'x' but there is no [llm.providers.x] sectionPrimary 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/statusIts section is absent or the table name is misspelled