Architecture
Source → Ever Async → Target. A minimal Rust kernel in the middle of team communication; everything platform-specific is a swappable plugin.
This page is the contributor on-ramp. If you want to add a chat platform, a tool that resolves references, an AI provider or a delivery channel, everything you need is here.
Design priorities, in order
- Extreme speed / easy to run. One static binary, one TOML file, embedded
SQLite by default, zero external services.
everasync serveis a complete deployment. The heuristic path costs microseconds and no tokens. - Extreme modularity. The core crate never names Slack, GitHub or any
platform. Five object-safe traits are the entire extension surface.
Plugins depend on
ever-async-coreand nothing else; the CLI assembles them behind cargo features, so any plugin compiles out cleanly. - Polite by default. Nudges are always private, sensitivity defaults to conservative, and every nudge cites the team's own charter. False positives are the product's #1 kill risk.
Toolchain
| Version | Why | |
|---|---|---|
Pinned toolchain (rust-toolchain.toml) | 1.94 | SeaORM 2 and the sqlx 0.9 stack under it declare rust-version = 1.94; cargo refuses to build them on anything older |
Workspace MSRV (Cargo.toml rust-version) | 1.85 | What the code itself needs. Every crate except ever-async-storage-orm still compiles on it, and clippy.toml pins msrv = "1.85" |
| Edition | 2021 | rustfmt.toml: max_width = 100, Unix newlines |
The split is deliberate and mirrors ever-co/ever-os: the toolchain tracks
what dependencies need, the MSRV tracks what the code needs. rustup reads
rust-toolchain.toml on the first cargo command, so no manual selection is
needed.
Crate tiers
Tier 0 crates/core ever-async-core types · traits · pipeline · policy · storage ·
metrics · auth · tenancy
Tier 1 crates/plugins/* channels channel-slack, channel-discord
connectors connector-github, connector-jira, connector-gauzy
llm llm-shared, llm-anthropic, llm-openai, llm-openrouter,
llm-gemini, llm-grok, llm-ollama, llm-compat
notify notify-novu
auth auth-token, auth-local, auth-oauth
storage storage-postgres, storage-orm
Tier 2 crates/server axum HTTP surface (library)
crates/cli the `everasync` binary — the only place plugins meet
Twenty plugin crates in five families. memory and sqlite storage are not in
that list because they live in the core itself (crates/core/src/storage.rs) and
are always available; storage-postgres and storage-orm are the two that ship
as plugins behind cargo features.
The dependency rule: plugins depend only on Tier 0. The core never depends on a plugin. The CLI is the composition root.
auth-oauth is the one plugin the server also holds concretelycrates/server re-exports ever_async_auth_oauth::OAuthProvider and takes it as
a typed argument to router_with_sso. The registry only ever sees the
type-erased dyn AuthProvider, and the browser flow needs authorize_url,
exchange and mint_session — none of which a "verify this credential" trait
can express. Registering the provider without also passing the concrete
handle compiles, starts, reports provider = "oauth", and then 404s every login
route.
llm-shared is the one horizontal: it holds the two prompts every provider
sends and the strict-JSON parsing of the classifier reply. It is itself
core-only, so the rule still holds. It exists because a provider that
invented its own prompt would make the fallback chain change its mind about a
message purely by moving down a rung.
The core re-exports its foundational dependencies (async_trait, bytes,
chrono, serde_json, toml), so a plugin's Cargo.toml genuinely only
needs ever-async-core plus whatever HTTP client it uses.
The five traits
ChannelPlugin — moves messages in and out of a platform
#[async_trait]
pub trait ChannelPlugin: Send + Sync {
fn info(&self) -> PluginInfo;
async fn ingest(&self, req: IngressRequest) -> Result<IngressOutcome>;
async fn act(&self, action: &ChannelAction) -> Result<()>;
async fn health(&self) -> Result<()> { Ok(()) }
}
ingest verifies the signature and normalizes; act executes one outbound
action. Both sides of Source → Target live in one plugin.
ConnectorPlugin — turns references into context
#[async_trait]
pub trait ConnectorPlugin: Send + Sync {
fn info(&self) -> PluginInfo;
async fn resolve(&self, query: &ResolveQuery) -> Result<Vec<ContextItem>>;
async fn health(&self) -> Result<()> { Ok(()) }
}
An empty vec is a normal answer ("nothing relevant"); errors are for real failures.
LlmProvider — the ambiguous middle
#[async_trait]
pub trait LlmProvider: Send + Sync {
fn info(&self) -> PluginInfo;
async fn classify(&self, event: &InboundEvent, charter: &Charter) -> Result<Classification>;
async fn suggest_rewrite(&self, event: &InboundEvent, context: &[ContextItem],
charter: &Charter) -> Result<String>;
async fn list_models(&self) -> Result<Vec<ModelInfo>> { Ok(Vec::new()) }
}
Optional. The pipeline degrades gracefully without one.
NotifyProvider — out-of-band delivery
#[async_trait]
pub trait NotifyProvider: Send + Sync {
fn info(&self) -> PluginInfo;
async fn deliver(&self, notification: &Notification) -> Result<()>;
}
Optional. Digests and fallbacks when a user is not reachable in-channel.
AuthProvider — turns a credential into a principal
#[async_trait]
pub trait AuthProvider: Send + Sync {
fn info(&self) -> PluginInfo;
async fn authenticate(&self, credential: &Credential) -> Result<Principal>;
async fn login(&self, user: &str, password: &str) -> Result<IssuedSession> { /* refuses */ }
fn supports_login(&self) -> bool { false }
async fn health(&self) -> Result<()> { Ok(()) }
}
Optional, and off by default — with no [auth] provider the server stays in
AuthMode::LocalTrusted and every caller is the local board principal.
Two rules bind every implementation. authenticate MUST return
AsyncError::Unauthorized for every failure with the same generic message,
so a caller cannot tell "unknown subject" from "bad secret" from "expired
session" and enumerate users — the discriminator belongs in a tracing event.
And any secret comparison MUST be constant-time. supports_login is how the
dashboard knows whether to render a password form (local) or ask for a token
(token); the oauth provider reports false because signing in is a redirect,
not a form.
Three implementations ship, and [auth] provider names exactly one:
| id | Crate | Credential | Login form |
|---|---|---|---|
token | auth-token | Authorization: Bearer — static user:role:secret entries | No |
local | auth-local | Argon2id users → session token (bearer or everasync_session cookie), or HTTP Basic | Yes |
oauth | auth-oauth | Browser SSO against five identity providers — Slack, Discord, GitHub, Google and Ever Gauzy | No — one button each |
→ Security & authentication · Setting up SSO
Tenancy
Principal carries the tenant it is acting in right now, plus every tenant it
may switch to. Every scoped storage call derives its tenant from that verified
principal — never from a path segment, query parameter, header or body field,
because a caller who can name their tenant can name someone else's.
POST /api/v1/auth/switch-tenant is the single place a tenant id travels inbound,
and it is checked against the caller's own memberships before anything happens;
success re-issues the session bound to the new tenant rather than moving a
server-side pointer. Self-hosting is a tenant count of one, not a special case:
Principal::local_trusted() sits in DEFAULT_TENANT and the machinery is
invisible.
The domain types
Everything crossing a plugin boundary is plain data — no lifetimes in signatures, no borrowed state:
| Type | Direction | What it carries |
|---|---|---|
IngressRequest | in | path suffix, lower-cased headers, raw body |
IngressOutcome | out | Events · Challenge · Command · Interaction · Joined · Ignored |
InboundEvent | in | channel, workspace, conversation, message id, thread root, author, text, links, mentions, is_edit, timestamp |
ResolveQuery | in | author, message text, extracted refs, conversation |
ContextItem | out | source, kind, title, url, status, snippet, confidence |
ChannelAction | out | Ephemeral · EphemeralWithButtons · ThreadReply · Post · DirectMessage · Replace |
That shape is deliberate: the trait surface is WASM-friendly, so an
ever-os-style wasm32-wasip1 guest ABI can follow without a redesign. In-process
plugins were chosen for the MVP because one static binary and zero loader
complexity beat extensibility we do not need yet.
Writing a plugin
Four steps, the same for every kind.
1. Create the crate
crates/plugins/channel-mattermost/
├── Cargo.toml
└── src/lib.rs
[package]
name = "ever-async-channel-mattermost"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
ever-async-core = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
ever-async-core and nothing internal. Add the member to the root
Cargo.toml, and a workspace.dependencies entry pointing at its path.
2. Implement from_config
Every plugin is built from its raw TOML table. Use the core's section helpers —
section_str, section_bool, section_int, section_map, section_secret,
section_secret_opt — so the whole fleet reads config identically.
pub fn from_config(section: &PluginSection) -> Result<Self> {
// `*_env` indirection: the file names a variable, the env holds the value.
let token = section_secret(section, "token")?;
let base_url = section_str(section, "base_url")
.unwrap_or_else(|| DEFAULT_BASE.to_string())
.trim_end_matches('/')
.to_string();
// ...
}
Three rules that are not negotiable:
- Secrets only through
section_secret. Never read a credential straight out of the table. Usesection_secret_optwhen the plugin can legitimately run without one — absent is fine, named-and-unset is still an error. - Never derive
Debugon a struct holding a secret. Write it by hand and print<redacted>. A derivedDebugis how a key reaches a log line by accident. - Trim trailing slashes off every base URL, and normalize hosts, so two spellings of the same endpoint behave the same.
3. Implement the trait
Writing a ChannelPlugin
The contract that matters:
Verify the signature before parsing anything, and return
AsyncError::Verificationon failure. The pipeline maps that onto HTTP 401 and counts it. A channel that skips verification is an open relay for anyone who guesses the URL.
Route on req.path — the suffix after /ingress/{channel_id} — and return
Ignored for anything you do not handle. Ignored is a 200: valid traffic the
pipeline must not act on.
async fn ingest(&self, req: IngressRequest) -> Result<IngressOutcome> {
match req.path.as_str() {
"/events" => { self.verify(&req)?; parse_events(&req.body) }
"/commands" => { self.verify(&req)?; parse_command(&req.body) }
other => {
tracing::debug!(path = other, "ignoring unknown ingress path");
Ok(IngressOutcome::Ignored)
}
}
}
For act, honour the private-nudge rule:
Ephemeral and EphemeralWithButtons must map to a private mechanism on
your platform. If the platform has no ephemeral concept, use a DM — never a
public post.
Writing a ConnectorPlugin
Read query.refs. Each ExtractedRef is either:
key: Some(..)— a concrete id. Look it up. High confidence.key: None— a vague phrase ("the PR"). Infer from author + recency. Lower confidence.url: Some(..)— a link the author already posted. Claim it only if the host is yours, and ignore the rest — the core deliberately does not know which host belongs to which tool.
async fn resolve(&self, query: &ResolveQuery) -> Result<Vec<ContextItem>> {
let Some(account) = self.user_map.get(&query.author.id) else {
return Ok(Vec::new()); // no mapping is a NORMAL answer
};
// ...
}
Be honest with confidence. It is what [policy] min_confidence gates on, and
a confidently wrong public card is worse than no card. Calibrate against the
existing connectors: exact key ≈ 0.95, a link ≈ 0.99, sole candidate ≈ 0.85,
best-of-several ≤ 0.75.
Budget: 6 seconds. Set your HTTP client's timeout below that so you fail before the pipeline gives up on you.
Writing an LlmProvider
Do not write prompts. Import them:
use ever_async_llm_shared::{
chat_body, classify_system_prompt, classify_user_prompt, clean_rewrite,
error_excerpt, extract_chat_text, parse_classification,
rewrite_system_prompt, rewrite_user_prompt,
};
If the endpoint speaks OpenAI chat-completions, chat_body + extract_chat_text
are the whole HTTP shape and your crate is ~100 lines. If it does not (Gemini,
Anthropic), write the body mapping and still use the shared prompts and parsers.
Malformed replies must be errors, never guesses. The pipeline reads an
AsyncError::Llm as "no opinion" and falls back to heuristics; a lenient parser
would turn a model outage into a wave of false positives.
Before writing a new crate, check whether
llm-compat already covers your endpoint — it
handles any OpenAI-compatible API with configurable id, headers and JSON mode.
4. Wire it into the CLI
The CLI is the only place plugins meet the core.
crates/cli/Cargo.toml:
[features]
default = [..., "mattermost"]
mattermost = ["dep:ever-async-channel-mattermost"]
[dependencies]
ever-async-channel-mattermost = { workspace = true, optional = true }
crates/cli/src/assemble.rs:
#[cfg(feature = "mattermost")]
if let Some(section) = config.channels.get("mattermost") {
let channel = ever_async_channel_mattermost::MattermostChannel::from_config(section)
.context("[channels.mattermost]")?;
registry.register_channel(Arc::new(channel));
}
The assembly rules:
- An absent section silently disables the plugin. Compiled in, unregistered.
- A present-but-rejected section is a hard startup error. No half-configured plugin ever runs.
[llm]and[auth]are the exception in one direction: naming a provider without a matchingproviders.<id>section is an error, because the operator asked for something the binary cannot deliver. For[auth]that is not merely tidy — booting anyway would serve a writable API with no credential to an operator who believes it is closed.[storage]is not optional at all. The pipeline cannot run without storage, so every branch either returns a live backend or refuses to start.
Storage
One Storage trait, four implementations, selected at runtime by
[storage] driver. Nothing above the storage layer knows which one is running.
driver | Implementation | Where it lives | Notes |
|---|---|---|---|
"memory" | MemoryStorage | crates/core | Ephemeral. Tests and trying it out |
"sqlite" (default) | SqliteStorage (rusqlite) | crates/core, sqlite feature | One file, one writer — so one replica |
"postgres" | PostgresStorage (tokio-postgres + deadpool) | crates/plugins/storage-postgres | Several replicas share one database. Both cross-process gates — claim_event and claim_pending_nudge — are single atomic statements here |
"orm" | OrmStorage (SeaORM) | crates/plugins/storage-orm | SQLite, Postgres and MySQL from one query set, with versioned migrations. MySQL is behind the crate's own mysql feature (orm-mysql in the CLI) |
An unrecognised driver id is a startup error listing the ones this binary was built with, never a silent fallback to SQLite — quietly writing a company's data to the wrong backend is worse than not booting. A driver whose cargo feature was compiled out gets a different sentence, because that is fixed by a rebuild rather than an edit.
The schema
Ten tables. Seven are tenant-scoped and carry a tenant column that leads every
primary key and every index:
events · seen_events · policies · settings · pending_nudges · nudges · contexts
Three exist only in the tenant-aware schema:
tenants · memberships · workspace_bindings
tenant is part of the primary key wherever there was one. Without it a single
global settings.key = 'charter' row would mean one charter for the whole
deployment, and one tenant's policy would overwrite another's.
A database written before multi-tenancy is migrated in place: each
pre-tenancy table is rebuilt with the column added and every existing row
assigned to DEFAULT_TENANT, one table per transaction. A file already at the
current schema is left completely untouched, so the upgrade is a no-op for anyone
who has already taken it.
driver = "orm" describes those tables once, in
crates/plugins/storage-orm/src/migration/, rather than as a hand-written
CREATE TABLE IF NOT EXISTS batch per backend — which is how the two
hand-written backends drifted (the Postgres one grew indexes the SQLite one never
got). It adopts an existing everasync.db in place, so switching to it is one
word and is reversible.
Two rows that are gates, not records
seen_events and pending_nudges are load-bearing for correctness above one
replica, and both are read the same way: a single statement whose affected-row
count is the answer, decided by the database rather than by any process.
| Gate | Statement | Answers |
|---|---|---|
claim_event | INSERT INTO seen_events … ON CONFLICT (tenant, message_key) DO NOTHING | "Am I the first to see this message?" — a Slack redelivery must not produce a second nudge |
claim_pending_nudge | DELETE FROM pending_nudges WHERE tenant = ? AND message_key = ? (RETURNING in the hand-written backend, rows_affected in the ORM one, which also serves MySQL) | "Am I the one who delivers this nudge?" — N replicas arm N timers for it and all N fire |
🛑 Neither may become a read-then-write. Look the row up, decide, then write,
and two replicas both read "unseen"/"pending", both act, and the author is nudged
twice — by a product whose entire premise is not being annoying. It is not a
corner case: tests/live_postgres.rs in both storage crates races 16–32 real
connections at each gate through a barrier, and the naive version hands every
racer the win.
The nudge gate is what makes the scheduler safe: crates/core/src/scheduler.rs
is a per-process tokio delay map and always will be, because timers cannot
elect anything across processes. They do not have to — they all fire, and the
claim picks the one that sends.
→ Multi-tenancy for what the tenant column buys.
Why not X
Why not Novu as the core bus? Novu Connect is excellent for outbound
agent messaging across channels, but Ever Async's differentiator is the
inbound side: signature-verified webhook ingest, classification, and
in-context actions (ephemeral nudges, thread replies) that need native platform
APIs. Novu rides as an optional NotifyProvider for digests and fallbacks, not
as the spine.
Why in-process plugins, not WASM or dylibs? The MVP favours one static binary and zero loader complexity. The trait surface is deliberately WASM-friendly — plain data in and out, no lifetimes in signatures — so a guest ABI can follow without a redesign.
Why SQLite by default? "Easy to run" beats scale most self-hosters do not
have. The trait boundary keeps the exit open — and it has been used: Storage
now has four implementations, and moving between them is one config line. See
Self-hosting → Scaling later.
Why no token verification in auth-oauth? A provider's access token is
treated as an opaque bearer: it is presented back to the provider and whatever
the provider answers is the identity. Ever Gauzy signs its tokens HS256 with a
single global secret, so verifying one locally would mean holding a key that can
mint tokens for any user in any Gauzy tenant. There is therefore no signature
check, no JWT decode, and no config key that accepts a signing key — one is
refused at startup with an explanation rather than ignored. openidconnect is
deliberately not used for the same reason: its client is built around verifying
an ID token.
Future direction
Async work, not just async messages: someone posts a file → Ever Async
reviews or prepares it → the next person wakes up to ready context. The
ConnectorPlugin / ChannelPlugin split already models this — sources that
produce artifacts, targets that receive prepared context — and a WorkerPlugin
tier will slot in beside them.
Contributing
- Repository: ever-co/ever-async
- Branches:
develop→stage→main. Changes promote along that cascade. CONTRIBUTING.mdin the repo root has the local workflow and the CI gates.- Contact Ever Co. if you need repository access.