Skip to main content

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​

  1. Extreme speed / easy to run. One static binary, one TOML file, embedded SQLite by default, zero external services. everasync serve is a complete deployment. The heuristic path costs microseconds and no tokens.
  2. 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-core and nothing else; the CLI assembles them behind cargo features, so any plugin compiles out cleanly.
  3. 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​

VersionWhy
Pinned toolchain (rust-toolchain.toml)1.94SeaORM 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.85What the code itself needs. Every crate except ever-async-storage-orm still compiles on it, and clippy.toml pins msrv = "1.85"
Edition2021rustfmt.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.

note
auth-oauth is the one plugin the server also holds concretely

crates/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:

idCrateCredentialLogin form
tokenauth-tokenAuthorization: Bearer — static user:role:secret entriesNo
localauth-localArgon2id users → session token (bearer or everasync_session cookie), or HTTP BasicYes
oauthauth-oauthBrowser SSO against five identity providers — Slack, Discord, GitHub, Google and Ever GauzyNo — 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.

→ Multi-tenancy

The domain types​

Everything crossing a plugin boundary is plain data — no lifetimes in signatures, no borrowed state:

TypeDirectionWhat it carries
IngressRequestinpath suffix, lower-cased headers, raw body
IngressOutcomeoutEvents · Challenge · Command · Interaction · Joined · Ignored
InboundEventinchannel, workspace, conversation, message id, thread root, author, text, links, mentions, is_edit, timestamp
ResolveQueryinauthor, message text, extracted refs, conversation
ContextItemoutsource, kind, title, url, status, snippet, confidence
ChannelActionoutEphemeral · 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. Use section_secret_opt when the plugin can legitimately run without one — absent is fine, named-and-unset is still an error.
  • Never derive Debug on a struct holding a secret. Write it by hand and print <redacted>. A derived Debug is 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::Verification on 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 matching providers.<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.

driverImplementationWhere it livesNotes
"memory"MemoryStoragecrates/coreEphemeral. Tests and trying it out
"sqlite" (default)SqliteStorage (rusqlite)crates/core, sqlite featureOne file, one writer — so one replica
"postgres"PostgresStorage (tokio-postgres + deadpool)crates/plugins/storage-postgresSeveral 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-ormSQLite, 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.

GateStatementAnswers
claim_eventINSERT 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_nudgeDELETE 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.md in the repo root has the local workflow and the CI gates.
  • Contact Ever Co. if you need repository access.