Everything smooth-operator lets you replace, and what replacing it costs. 20 traits, 46 implementations that ship, and the config value or builder call that selects each one.
Derived from sourceHand-written noteCaveat / known gap
Every teal entry on this page is generated from rust/**/src/*.rs at 158c9b4c, not from prose about it. Regenerate with pnpm --filter @smooai/web gen:operator-reference.
Application and agent code never names a backend. It holds a trait object, and the process picks the implementation once — from an environment variable at boot, or from a builder call if you are embedding the server in your own binary.
wiring a seam
// The reference server's own wiring, in shape. Every method below is a real
// AppState builder — see rust/smooth-operator-server/src/state.rs.
let state = AppState::new(storage, config) // StorageAdapter
.with_auth(verifier) // AuthVerifier
.with_backplane(backplane) // Backplane
.with_settings(settings) // SettingsStore
.with_indexing(indexing) // IndexingStore
.with_tools(tools) // ToolProvider
.with_agent_config(agents) // AgentConfigResolver
.with_widget_auth(widget_auth); // WidgetAuthProvider
12 of the 20 seams below name their AppState builder method. The rest are installed further down — on a tool, on a verifier, or on the ingestion pipeline — and each says where.
These are the Rust seams. smooth-operator has server implementations in five languages, and the environment variables are a shared contract, but the trait surface on this page is the Rust one. A Go or .NET host exposes its own equivalent, not this list.
State
Where conversations, settings, and indexing history live. Picking one here picks all of them — the server selects matching admin stores for the storage backend you chose.
The single storage seam. All slices are backend-agnostic.
20 methodsinstall with AppState::new / AppState::with_storage
Selected by SMOOTH_AGENT_STORAGE (memory · postgres · dynamodb); unset means memory. This is the only seam whose choice also changes which admin stores (settings, connector config, indexing runs) you get, because the server picks those to match.
Table name from SMOOTH_AGENT_DDB_TABLE (default smooth-operator). Its knowledge slice defaults to KnowledgeBackend::BruteForce— embeddings stored on DynamoDB items, cosine computed in-process across the org’s partition on every query. Amazon S3 Vectors is implemented as the other backend but sits behind the adapter crate’s s3-vectors feature, which is notin that crate’s defaults. Assume brute force unless you turned it on.
In-memory storage adapter. Cheap to clone is *not* a goal — wrap in Arc for sharing.
The default, and the only storage compiled into a --no-default-features build. Everything — conversations, checkpoints, knowledge — dies with the process.
Needs the server’s postgres feature (on by default via cloud) and a Postgres with pgvector. That same feature is what compiles in the gateway embedder and reranker, so a build without it has no semantic retrieval path at all. Connection string from SMOOTH_AGENT_DATABASE_URL, falling back to DATABASE_URL.
Durable record of indexing runs + per-connector cursors. Ships with an InMemoryIndexingStore. Postgres/DynamoDB stores follow as a sibling table to the existing conversation/checkpoint adapters — record_run is an upsert-by-id INSERT, latest_cursor is SELECT max(cursor) WHERE connector_name = $1 AND status = 'Succeeded', list_runs is an ordered SELECT. Only the trait + in-memory impl are built here; the persistent adapters are intentionally left to the adapter crates.
Seam for resolving an agent's AgentBehaviorConfig by agent_id. The ws protocol's create_conversation_session carries only an agent UUID, so per-agent config is looked up **server-side by id**. Implemented by the host (backed by the monorepo agents table). Returning None means "no per-agent config" — the runner falls back to the org default persona, exactly as before this seam existed. Matches the sibling lanes' AgentConfigResolver.resolve.
Postgres-backed AgentConfigResolver over the agents table.
Per-agent model and loop cap read from an agents table, so two agents in one deployment can differ. Missing columns degrade to the deployment-wide defaults.
Static map resolver (agentId → config), for tests and DB-free hosts. The empty default is the server's no-op resolver (every agent → None), so the reference/OSS server stays on its org-default behavior.
One agent, configured in code.
Fan-out
How a replica reaches the socket that asked for a stream. The seam you only need once there is more than one replica — and the one whose default silently works right up until there is.
The connection backplane: a per-pod sink registry + cross-pod event delivery. Implementations must be cheap to clone behind an Arc and safe to share across every connection task.
4 methodsinstall with AppState::with_backplane
Only consulted by the Rust reference server, from SMOOTH_AGENT_BACKPLANE. An unrecognised value is a boot error rather than a silent fallback — and so is asking for redis/nats on a build that did not compile that adapter in.
Single-process Backplane: an in-memory registry with direct local delivery. The default — keeps the server runnable standalone. Multi-pod deployments install a Redis / NATS impl instead.
Installed already. Correct for exactly one process — with two replicas a stream can be fanned to a pod that does not hold the socket.
Retrieval
Turning a corpus into vectors and a question into the right chunks. Ingest and query must agree on the embedder; every stage after it is optional.
A source of RawDocuments. pull(since) returns every document the connector currently exposes, or — when since is Some and the source supports incremental sync — only those changed at/after that timestamp. Connectors that cannot do incremental sync ignore since and return the full set (the pipeline's (id, hash) idempotency keeps re-ingests cheap regardless).
2 methods
Not installed on AppState — a connector is handed to the ingestion pipeline in the ingestion crate.
A fixed-payload connector for tests — yields the documents it was built with. The credential-free fixture behind the ingestion contract test (G9: the unit tier that runs on every PR).
A public test double, not a source. It ships in the crate so your own connector tests do not need a real backend.
Turn text into dense vectors. Implementations must return one vector per input string, each of length Embedder::dim.
2 methods
The server picks one with build_embedder(&EmbedderConfig); a gateway key is what makes the semantic embedder eligible. Documents and queries must go through the same embedder to land near each other, so switching one after ingest means re-embedding the corpus.
OpenAI-compatible /v1/embeddings embedder (the SmooAI LiteLLM gateway). Only used when explicitly configured. Reads the endpoint from SMOOAI_GATEWAY_URL and the key from SMOOAI_GATEWAY_KEY (or pass them in). The default model is text-embedding-3-small (1536-d) — set the adapter dimension to OPENAI_SMALL_EMBEDDING_DIM when using it.
The real one: an OpenAI-compatible /v1/embeddings call to SMOOAI_GATEWAY_URL. Lives in the Postgres adapter crate to keep an HTTP client out of the core dense path, so it rides the postgres feature.
Deterministic, network-free pseudo-embedder. Produces a stable vector from the text via a token-hashing bag-of-words projection, then L2-normalizes it so cosine distance is well-behaved. Same text → same vector, always. This makes pgvector retrieval (and ingestion) tests reproducible without any external service: a document and a query that share salient tokens land close together in the projected space.
Network-free token hashing to a reproducible 1024-dimension vector. The fallback when no gateway key is present, and what makes retrieval testable offline — but it carries no semantics, so results are lexical at best.
Reorder retrieval candidates by query relevance, returning the top top_k.
1 method
Off by default. build_reranker(&RerankerConfig) returns None unless SMOOTH_AGENT_RERANK asks for a stage, and the tool takes one through KnowledgeSearchTool::with_reranker.
Cross-encoder reranker over the SmooAI gateway's /v1/rerank endpoint (feature gap G8). Reorders retrieval candidates by a sharp query↔candidate relevance model and truncates to top_k. On any backend failure it falls back to the input order (truncated) — a reranker is a quality stage, so an identity reorder is always a safe fallback. Construct with from_env for the live gateway, or with_backend to inject a stub in tests.
Cross-encoder over the gateway’s /v1/rerank. On any failure it degrades to the input order truncated to top_k and warns — a quality stage never drops the turn.
Identity reranker — the behavior-preserving default. Leaves candidate order untouched and truncates to top_k. Wiring this in is a no-op versus not reranking at all, which is exactly what makes the rerank stage opt-in.
Identity. Wiring it in changes nothing, which is what makes the stage opt-in.
Deterministic, network-free lexical reranker. Scores each candidate by how much of the query's vocabulary its chunk contains — a simple BM25-ish lexical signal (term-frequency saturated and length-normalized) computed entirely offline. No embeddings, no network, no cost, fully reproducible — so it stands in for a paid cross-encoder in tests and as a sane default reorder when no gateway reranker is configured. The score per candidate is, over the set of distinct query terms q that the candidate contains: text score = Σ_q tf_saturated(q) / (1 + ln(1 + chunk_len_in_tokens)) where tf_saturated(q) = count(q) / (count(q) + K1) saturates repeated hits so a chunk can't win on raw frequency alone, and the length penalty discounts long chunks that match by sheer size. Ties (and zero-overlap candidates) keep their original relative order (stable sort), so a no-signal query degrades to the upstream ranking rather than shuffling.
Deterministic term-overlap scoring, no network. The offline-testable stage.
A pluggable rerank backend. The production HttpRerankBackend POSTs to the gateway's /v1/rerank. Tests inject a stub so the GatewayReranker reorder/truncate/error-fallback logic runs offline (mirrors github_search's GithubSearchBackend seam).
1 method
The seam inside the gateway reranker, so its HTTP call can be stubbed offline. Installed with GatewayReranker::with_backend.
The single auth seam: turn a bearer token into a Principal. Implemented by JwtVerifier (BYO), SmooIdentityVerifier (hosted), and NoAuthVerifier (dev). Send + Sync so a single verifier rides on the shared server state across connections.
2 methodsinstall with AppState::with_auth
Chosen from SMOOTH_AGENT_AUTH_MODE, falling back to the older AUTH_MODE. Unset behaves as jwt.
Validates a JWT against the issuer's **published JWKS** — fetched, cached, and rotation-aware (see JwksKeyStore). Selects the signing key per-token by kid, builds a DecodingKey from the matching Jwk, and validates with the key's algorithm — so **any** JWS algorithm the issuer advertises works (ES256/ES384/RS256/PS256/EdDSA/…), not just a static RS256 PEM. This is what makes auth.smoo.ai (the smoo issuer, **ES256**) verifiable. verify stays synchronous: the keyset is read from cache; the network fetch happens at most once per TTL (plus on a never-seen kid).
The keyless path: AUTH_JWT_JWKS_URL, or {AUTH_JWT_ISSUER}/.well-known/jwks.json derived from the issuer. Any OIDC issuer works.
Validates a JWT and extracts a Principal. The **BYO** path: SST OpenAuth (or any OIDC IdP) issues the token; this verifies signature + standard claims and maps sub→user_id, org/org_id→org_id, role→Role, name→display_name. Two backends (see JwtBackend): a **static** key (HS256/RS256) or a **JWKS**-backed multi-algorithm verifier that fetches + caches the issuer's keys and selects one per-token by kid.
AUTH_MODE=jwt, or unset. Key precedence: AUTH_JWT_RS256_PUBLIC_KEY → AUTH_JWT_HS256_SECRET → a JWKS URL.
Validates a **Smoo-issued** token — the Smoo-identity path (the platform wires Smoo's identity). Implemented as JWT validation keyed to Smoo's issuer/audience, reusing JwtVerifier's internals. ## Live introspection (hosted, stubbed) The fully-hosted variant would call Smoo's auth server /introspect endpoint (RFC 7662) to validate an opaque token and pull the principal. That requires a network round-trip + a client credential, so it is intentionally **not** implemented here: SmooIdentityVerifier::introspect documents the contract and returns AuthError::Misconfigured until the introspection client is wired. The JWT form below is the one exercised in tests + the default hosted deployment (Smoo signs a JWT; we verify it locally with Smoo's public key / shared secret — no per-request network call).
AUTH_MODE=smoo, and AUTH_JWT_ISSUER is required. Only the JWT form is built — the opaque-token introspect path documents its contract and returns a misconfiguration error, so it is not a working option today.
**Dev-only** verifier: returns a fixed Admin principal for *any* token (including none). Reachable only via an explicit AUTH_MODE=none (AuthConfig::from_env) so it can never be the silent production default.
AUTH_MODE=none. Every connection is an admin in AUTH_DEV_ORG_ID (default dev-org). Local dev only.
**Local single-user** verifier — the auth for the *local deployment flavor*. Holds one shared secret (the local daemon auto-provisions it). The presented token must equal the secret, compared in **constant time**; on match the connection runs as a fixed local Admin principal, and on mismatch/empty it **fails closed**. This gates stray local processes from connecting to the loopback/tailnet server without dragging in the multi-tenant JWT/IdP machinery — exactly the posture a single-user always-on daemon wants. The token rides in the **same slot** a JWT would: the /ws?token= query param (reference server) or the send_messagetoken field (Lambda), so all existing transport plumbing is reused.
A single shared token, compared constant-time, in the slot a JWT would occupy (/ws?token=). Built for a single-user always-on daemon; fails closed on mismatch or empty. Not selected by AUTH_MODE — you install it in code.
**Tokenless trusted-upstream** verifier — AUTH_MODE=trusted. For the **proxied-integration** deployment shape: an existing application's backend has *already* authenticated the user and proxies smooth-operator over a trusted/internal network. That upstream forwards the user's identity (sub / org / role / groups); smooth-operator **trusts** it **without any signature verification** — the upstream owns identity *and* token lifetime, so there is no signature to check and no exp to enforce. ## Wire format — identity in the same slot a token would ride The forwarded identity rides in the **exact same slot** a JWT would: the /ws?token= query param (reference server) or the send_messagetoken field (Lambda). So *all* the existing transport plumbing is reused — the only difference from JwtVerifier is **trust, don't verify**. The value is **base64url(JSON)** of the Claims shape, e.g. base64url({"sub":"u1","org":"acme","role":"basic","groups":"github:acme/secret"}). base64url is used (not raw JSON) so the blob survives the query-string and JSON-string transports cleanly without escaping. No padding is required (URL_SAFE_NO_PAD is accepted; padded URL_SAFE is also tolerated). ## Security boundary — this is **trust without verification** AUTH_MODE=trusted is **only safe when smooth-operator is not directly reachable by clients** — it must be fronted by your authenticated backend/proxy on a trusted network. A client that *can* reach /ws directly could forge any identity (any org, any groups). AuthConfig::from_env emits a loud startup tracing::warn! to that effect whenever this mode is selected. ## Fail closed — never silently no-auth-admin Absent / empty / malformed trusted identity yields an AuthError, which the connect path (crate::access_control::AccessContext::anonymous) maps to an **anonymous** connection (org-public only) — exactly like the no-token path. Trusted mode **never** degrades to an admin / all-access principal on bad input.
AUTH_MODE=trusted. Identity is taken from the caller with no verification, and the server logs a loud warning at startup. Only safe when clients cannot reach the server directly. Bad or absent identity falls to anonymous, never admin.
Builds the configured AuthVerifier from the environment — secure by default. ## Environment | var | default | meaning | | --- | --- | --- | | AUTH_MODE | jwt | jwt (BYO) \| smoo (hosted) \| trusted (proxied, tokenless — see below) \| none (dev only). | | AUTH_JWT_HS256_SECRET | — | HS256 shared secret. | | AUTH_JWT_RS256_PUBLIC_KEY | — | Static RS256 PEM public key. | | AUTH_JWT_JWKS_URL | — | JWKS endpoint to fetch signing keys from (any algorithm — ES256/RS256/…). | | AUTH_JWT_ISSUER | — | Required iss (optional). Also the JWKS auto-derivation root ({issuer}/.well-known/jwks.json). | | AUTH_JWT_AUDIENCE | — | Required aud (optional). | | AUTH_DEV_ORG_ID | dev-org | Org id for the none-mode admin principal. | ## Key-source precedence (jwt and smoo) 1. **Static AUTH_JWT_RS256_PUBLIC_KEY** (RS256 PEM) — the BYO path, unchanged. 2. **Static AUTH_JWT_HS256_SECRET** (HS256 shared secret). 3. **JWKS** — AUTH_JWT_JWKS_URL if set, else derived from the issuer as {AUTH_JWT_ISSUER}/.well-known/jwks.json. This is the **ES256-capable** path: keys are fetched + cached and selected per-token by kid, so auth.smoo.ai's ES256 tokens verify and key rotation needs no redeploy. So AUTH_MODE=smoo now needs only AUTH_JWT_ISSUER (+ optionally AUTH_JWT_AUDIENCE) — no static public key required. **Explicitly** setting AUTH_MODE=jwt/smoo with **no** usable key source (no static key, no JWKS URL, and — for jwt — no issuer to derive one) is a hard AuthError::Misconfigured error — not a silent fall-through to no-auth. Leaving AUTH_MODE **unset** with no key source boots the server with the admin API **disabled** (AdminDisabledVerifier) so /ws serves without forcing auth config; /admin then returns 401 until configured (or AUTH_MODE=none for dev). A verifier that rejects every request. The default when neither AUTH_MODE nor a key is configured: the server still boots (so /ws serves) but the /admin API is disabled until an operator sets AUTH_MODE + a key, or AUTH_MODE=none for local dev. Secure-by-default without hard-failing the whole service over admin config.
What you get when no auth mode and no key are set: the server boots, /ws serves, and every /admin call returns 401. Setting AUTH_MODE=jwt explicitly with no key is a hard startup error instead — the quiet fallback exists only for the unset case.
Fetches a JwkSet. The seam that lets JwksKeyStore pull keys from an HTTP issuer in production (HttpJwksFetcher) and from an in-memory set in tests (StaticJwksFetcher) — so the verification logic is exercised with **no network**. fetch is synchronous so AuthVerifier::verify can stay synchronous (no per-request await): the real HTTP impl runs its blocking call on a dedicated thread, and the result is cached, so the common path is a local read.
1 method
Installed through JwksVerifier::with_fetcher. The production HTTP fetcher is a module-private type rather than a public option, so the only publicly named implementation is the static one used for injection.
Hook for resolving an agent's AgentWidgetAuth policy. Implemented by the host application (commonly backed by its agent DB/API). Returning None means "no policy for this agent" — the server treats that as allow in permissive mode, or deny in strict mode (WIDGET_AUTH_STRICT).
HTTP-backed provider: resolves agentId → AgentWidgetAuth by GETting {base_url}/{agentId} from a host's policy service, with TTL caching. This is the **generic mechanism** a host installs instead of writing a custom WidgetAuthProvider: stand up an endpoint that returns the AgentWidgetAuth JSON ({ "allowed_origins": ..., "public_key": "..." }) for an agent, point HttpWidgetAuth at it, and embed-auth is enforced against live data. (SmooAI backs this with an api-prime route over its agent DB.) Response handling — chosen so a flaky policy service never *silently* opens a hole: - **2xx** → parse + cache the policy. - **404** → cache None (the agent legitimately has no policy; in WIDGET_AUTH_STRICT the server then denies it). - **5xx / network / malformed body** → return None **without caching**, so the next connect retries. Combined with strict mode this fails closed; in permissive mode enforcement is off anyway. Cached results (incl. 404s) are reused for ttl (default 60s) so a busy embed doesn't hammer the policy service on every WebSocket connect.
Asks your service whether an origin may embed, with a cached answer (WIDGET_AUTH_URL, WIDGET_AUTH_BEARER, WIDGET_AUTH_TTL_SECS).
Host seam for end-user OTP identity verification. Implemented by the host application (it owns code generation, delivery, expiry, and attempt counting); the reference server only orchestrates the wire flow around it. Installing one via AppState::with_otp_service turns the fail-closed end_user auth gate into an OTP-offered flow. Leaving it unset keeps the current behavior — a refused end_user tool with no verification offered.
2 methodsinstall with AppState::with_otp_service
No implementation ships. Without one installed, an end_user-level tool on an unverified session is refused rather than offered a code — the OTP challenge flow only appears once you supply the delivery side.
send_otpverify_otp
No implementation ships in this repo. The trait is the contract for something you supply.
Hook for resolving the LLM gateway key to use for a given org's turn. Implemented by the host application (commonly backed by a per-org key store — e.g. a LiteLLM virtual key per tenant). Returning None means "no org-specific key" and the server falls back to its configured env key, so a resolver that covers only some orgs is safe.
1 methodinstall with AppState::with_gateway_key_resolver
Default resolver: returns the single configured environment gateway key for every org (the unchanged local/default behavior — no per-org scoping). Constructed from the server's resolved gateway key. When the env key is absent (None), this resolver returns None for every org, so the server behaves exactly as it does today (a clean LLM_UNAVAILABLE error on a turn).
One key for the whole deployment, from SMOOAI_GATEWAY_KEY. A multi-tenant host implements this seam to bill each org to its own key.
Tools and interaction
What the agent can do during a turn, and how it asks the person a structured question mid-stream.
Host seam for contributing EXTRA tools to a turn's ToolRegistry. The runner calls tools_for once per turn and merges the returned tools with the built-ins (built-ins registered first; a returned tool whose name collides with a built-in replaces it — the host opted into that by naming it the same). Returning an empty Vec (or not installing a provider at all) leaves the registry as exactly the built-ins. Async so a provider may consult host state (config store, DB) to resolve an org's tool catalog.
A ToolProvider backed by MCP servers. Connects lazily on the first turn that asks for tools, then caches the resulting tool list (and the underlying connections) for the process lifetime. Servers that fail to connect are logged and dropped.
Run several providers as one — the LocalServer builder takes a single ToolProvider, so a host with both its own tools and MCP tools composes them here.
Composes providers, so host tools and MCP tools reach the same turn.
Resolves a skill name to its markdown body. None means "unknown skill" — the handler turns that into a SKILL_NOT_FOUND error and does **not** run the turn, so a typo'd skill never silently degrades into an unskilled answer.
1 methodinstall with AppState::with_skill_resolver
The default resolver: reads <root>/<name>/SKILL.md, first root wins.
Resolves a send_message.skill name against SMOOTH_SKILLS_DIR, a colon-separated path list. Unset means no resolver is installed and the feature is off.
One interaction kind — the extension seam of the Rich Interactions pattern. A kind supplies exactly the pieces that differ per interaction; ALL park / resume / event / registry machinery is shared and kind-agnostic: 1. identity (kind / capability), 2. the LLM-facing raise-tool surface (tool_schema + parse_request) — per-kind so the model sees a precise parameter schema, 3. the **server-side validator** (validate) producing the canonical values (the same payload on rich and fallback channels), 4. the **conversational degradation** (fallback_directive) for channels without the render capability.
6 methods
Registered into an InteractionRegistry and installed with AppState::with_interactions. Each kind owns one round-trip of the interaction_required → submit_interaction pair on the wire.
The choices Rich Interaction kind — a structured multiple-choice ask modeled on AskUserQuestion (see the module docs and spec/interactions/choices.schema.json).
The identity_intake Rich Interaction kind — the reference implementation of InteractionKind (see the module docs and spec/interactions/identity-intake.schema.json).
A pluggable web-search backend. Implement this over a provider's API (Brave/Bing/Tavily/…), then inject it with ToolContext::with_web_search. search returns up to k results for query.
2 methods
Installed with ToolProviderContext::with_web_search. Nothing in the repo implements a real search provider — the tool exists and the seam is open, but you bring the search.
The default no-op provider: returns a single explanatory result instead of real search hits, so the agent gets a clear "search is unavailable" signal rather than an empty list it might mistake for "no results found".
A pluggable GitHub-search backend. The default OctocrabGithubSearch hits the real API. Tests inject a stub so the tool's arg-parsing + formatting can be exercised offline.
1 method
Installed with GithubSearchTool::with_backend, so the GitHub tool is testable without network.