Skip to content

Architecture overview

Zupflow’s backend is one deployable process: an HTTP API and its background workers, sharing one set of adapters. Everything it touches outside its own memory (the database, the job queue, keys, the cache, the network, models, chat providers) sits behind a trait in zupflow-core. The repository ships a working implementation of every one, so a single node runs on SQLite and a disk. A deployment that needs more replaces the adapters without forking the server.

flowchart TB
subgraph process["zupflow (one process)"]
API["HTTP API<br/>axum router"]
W["Workers<br/>discovery · runs · webhooks · channels"]
S["AppState<br/>Arc&lt;dyn Trait&gt; for every resource"]
API --> S
W --> S
end
S --> DB[("Stores + job queue<br/>SQLite")]
S --> K["KeyService<br/>local key"]
S --> C["Cache · EventBus · Metrics<br/>in process"]
S --> O["OutboundHttp<br/>safe HTTPS client"]
O --> M["Model providers"]
O --> MCP["Apps' MCP servers"]
O --> WH["Webhook endpoints"]
O --> CH["Chat providers"]

The server is a Cargo workspace under server/crates, in three layers.

Binary
zupflow-serverHTTP API, workers, the zupflow binary
Adapters
zupflow-sqlitestores + job queue
zupflow-basickeys, cache, events, policy
zupflow-outboundsafe HTTPS client
zupflow-mcpMCP client
zupflow-providersOpenAI, Anthropic, OpenRouter
zupflow-oidcOpenID Connect sign-in
zupflow-slackSlack channels
zupflow-channelsfake channel provider
Core
zupflow-coretypes, rules and every trait
zupflow-conformanceone suite per trait
Everything depends on zupflow-core; nothing in the core depends on an adapter.
Crate What it holds
zupflow-core The shared types (typed IDs, scopes, errors, environments) and every trait: the stores, JobQueue, KeyService, Cache, ObjectStore, EventBus, Metrics, Identity, OutboundHttp, ModelProvider, ModelProviderCatalog, ApprovalPolicy, ChannelProvider, AppProvisioner, MessageHandler, Clock. Also the pure parts of the domain, such as the run reducer and the approval floor.
zupflow-sqlite The basic database: every store and the job queue, in one SQLite file, with migrations.
zupflow-basic The other basic adapters: local keys, in-memory cache, local files, in-process events and metrics, local sign-in, ConfiguredPolicy, and the scripted model, providers and channel provisioner for local work.
zupflow-outbound SafeHttpClient, the only way the server calls anything: HTTPS only, public addresses only, no redirects, credentials injected last.
zupflow-mcp A minimal MCP client over Streamable HTTP: lists tools, calls one, and signs in to an MCP server’s own OAuth.
zupflow-providers Model provider adapters over plain HTTP: OpenAI, OpenRouter and Anthropic, with no vendor SDK.
zupflow-oidc Console sign-in through any OpenID Connect provider.
zupflow-channels The fake channel provider, for development and tests.
zupflow-slack Slack: generates one app per agent, verifies and reads Slack’s events, sends replies.
zupflow-server The HTTP API, the background workers, configuration, and the zupflow binary.
zupflow-conformance One suite per trait. Every implementation, here or elsewhere, runs its suite from its own tests.
zupflow-contract, zupflow-taskboard Development only: the server the contract suite runs against, and a fake MCP app with its own OAuth. Neither builds for release.

AppState holds one Arc<dyn Trait> per resource, and every handler and worker reaches the outside world only through it. The zupflow binary’s main.rs is the whole wiring for the basic build:

let state = AppState {
clock: Arc::new(SystemClock),
identity, // LocalIdentity or OidcIdentity
organizations: Arc::new(SqliteOrganizationStore::new(database.clone())),
workspaces: Arc::new(SqliteWorkspaceStore::new(database.clone())),
// ... every other store on the same SQLite database
jobs: Arc::new(SqliteJobQueue::new(database.clone())),
keys: Arc::new(LocalKeyService::from_server_secret(&secret_key)),
cache: Arc::new(MemoryCache::default()),
events: Arc::new(InProcessEventBus::default()),
metrics: Arc::new(InProcessMetrics::default()),
approvals: Arc::new(ConfiguredPolicy::new(policy_store)),
provider, // HttpModelProvider, ScriptedModel or none
outbound, // SafeHttpClient
// ...
};
bootstrap::serve(&config, state, guard, bootstrap::shutdown_signal()).await

bootstrap::serve binds the listener, starts the workers in the same process, and serves the router until shutdown. It refuses a run store that doesn’t commit synchronously. A deployment with its own adapters writes its own small binary that builds a different AppState and calls the same serve. See Extending Zupflow.

  • Two scopes on every call. Every store method, query, cache key and job names the organization and, where it applies, the workspace. A globally unique ID never stands in for either, and a value in another tenant’s scope answers exactly like one that doesn’t exist.
  • Environments never fall back. Sandbox and Production are separate; one’s configuration is never read for the other.
  • A transaction is the unit of change. A change commits with its audit row, its webhook announcements and its jobs, or not at all.
  • Background work is at least once. Jobs are leased, retried and idempotent; nothing assumes it runs exactly once.
  • Secrets are sealed and scoped. Each is encrypted with its own data key, bound to its owner, and opened only at the moment of use.