Architecture
Eneo is a modular monolith: one FastAPI backend, one SvelteKit frontend, one background worker that runs the same backend image, PostgreSQL with pgvector, and Redis. The bundled tool runtime runs Eneo-maintained compute and file tools in a separate container. Docker Compose supplies the reference deployment; Traefik fronts the application, while the runtime stays on the private network.
High-Level Overview
| Component | Technology | Reference deployment |
|---|---|---|
| Reverse proxy | Traefik v3 with Let’s Encrypt | traefik container, ports 80/443 |
| Frontend | SvelteKit 2, Svelte 5, TypeScript, Tailwind CSS 4 | ghcr.io/eneo-ai/eneo-frontend, port 3000 |
| Backend API | FastAPI on Python 3.11, SQLAlchemy 2 (async), Pydantic 2 | ghcr.io/eneo-ai/eneo-backend, port 8000 |
| Worker | ARQ (Redis-backed async task queue) | Same backend image, RUN_AS_WORKER=true |
| Database | PostgreSQL 16 with pgvector | pgvector/pgvector:pg16 |
| Cache/queue | Redis 7 | redis:7-alpine |
| Tool runtime | Bun, isolated job processes, QuickJS and DuckDB | ghcr.io/eneo-ai/eneo-tool-runtime, private network |
| AI providers | LiteLLM transport to per-tenant configured providers | Outbound HTTPS from backend and worker |
Skills, Functions, tools and views
Everything a model can call in Eneo is an MCP tool, served from one of three places: a loopback server inside the backend, the bundled tool runtime in its own container, or an independently operated MCP server. Skills supply instructions, Functions supply saved intent that resolves to a provider at ask time, MCP Apps and Eneo’s native previews supply presentation. These parts are deliberately separate, and the reasons are laid out in one place:
- Tools and MCP Architecture: the three execution tiers, what they share, why the split exists, the responsibility map and the state and failure boundaries.
- Tool Runtime Security: what the isolated runtime defends against and how.
- Built-in Tool Servers: the loopback servers and the rule for what belongs inside the backend.
For user workflows and limitations, see Functions. For Skill authoring, see Skills. For view approval, isolation and deployment, see MCP Servers.
Core Components
Frontend (SvelteKit)
Technology stack:
- SvelteKit 2 with Svelte 5 runes, TypeScript, Vite
- Tailwind CSS 4
- Node adapter (
@sveltejs/adapter-node) listening on port 3000 @eneo/eneo-js: the typed API client (fetchwrapper, endpoint modules, WebSocket client, andschema.d.tsgenerated from the backend OpenAPI document)- shadcn-svelte components on bits-ui (
apps/web/src/lib/components/ui) @eneo/ui: shared design tokens, themes and icons
Responsibilities:
- User interface, routing, and forms
- Server-side rendering and the login flow (cookies are set server-side)
- Streaming chat responses over Server-Sent Events
- Live job and crawl status: the SvelteKit app listens on the
/api/v1/wsWebSocket; the React app (web-next) subscribes to the per-user/api/v1/jobs/events/Server-Sent Events stream, which the backend feeds from Redis pub/sub after each job status change commits
Directory structure:
frontend/
├── apps/
│ ├── web/ # SvelteKit application
│ │ └── src/
│ │ ├── routes/
│ │ │ ├── (app)/ # spaces, assistants, admin, dashboard, account
│ │ │ └── (public)/ # login, logout, activate, invite, module-login
│ │ └── lib/
│ │ ├── api/
│ │ ├── components/
│ │ ├── core/
│ │ ├── features/ # admin, assistants, auth, chat, knowledge, spaces, ...
│ │ └── hooks/
│ └── docs-site/ # This documentation site (Nextra)
└── packages/
├── eneo-js/ # Typed API client
├── ui/ # Design tokens, themes and icons
└── eslint-plugin/ # Repository lint rulesBackend (FastAPI)
Technology stack:
- FastAPI on Python 3.11
- SQLAlchemy 2 with the async
asyncpgdriver; Alembic for migrations - Pydantic 2 for request/response models and
pydantic-settingsfor configuration dependency-injectorcontainer wiring services and repositories per request- LiteLLM as the transport for completion, embedding, and transcription providers
- Asynchronous HTTP crawling, with
pdfplumber,docx2python,python-pptx, and pandas for text extraction
Process model: the backend container runs Alembic migrations and then
Gunicorn with Uvicorn workers on port 8000 (three workers unless NUM_WORKERS
is set). With RUN_AS_WORKER=true the same image starts the ARQ worker
instead.
Layout: the code is organised by domain under backend/src/eneo/. Newer
domains follow a layered layout (application/, domain/,
infrastructure/, presentation/); older domains use flat
<name>.py, <name>_repo.py, <name>_service.py, and <name>_router.py
modules, sometimes with an api/ folder.
backend/
├── alembic/ # Migrations (alembic upgrade head at startup)
├── init_db.py # First-run migrations and default tenant/user
├── run.sh # Gunicorn or ARQ entrypoint
└── src/eneo/
├── server/ # FastAPI app, routers, middleware, websockets
├── main/ # Settings, DI container, logging, observability
├── database/ # Engine, tables/, repositories/
├── worker/ # ARQ adapter, upload and crawl tasks, feeder
├── tenants/ users/ roles/ authentication/ scim/ sysadmin/ admin/
├── spaces/ assistants/ group_chat/ sessions/ questions/ conversations/
├── collections/ groups_legacy/ info_blobs/ files/ websites/ crawler/
├── object_content/ # Durable content control plane and S3 adapter
├── embedding_models/ completion_models/ transcription_models/
├── model_providers/ ai_models/ tenant_models/ token_usage/
├── mcp_servers/ internal_mcp/ mcp_apps/ skills/ apps/ services/ workflows/
├── integration/ # SharePoint and Confluence knowledge sources
├── audit/ data_retention/ jobs/ limits/ modules/ observability/
└── ...HTTP surface:
- All application routes are mounted under
API_PREFIX(/api/v1in the reference deployment). Swagger UI is served at/docsand the schema at/openapi.json, both at the root. - SCIM provisioning is a separate ASGI app mounted at
/scim/v2. - Loopback MCP servers for knowledge search, attachment reading and image generation are mounted on the same process.
- Middleware adds a request context, an
X-Trace-Idresponse header, CORS whose origins are matched against theallowed_originstable, and OpenTelemetry instrumentation.
Space persistence ownership
A loaded Space is a read projection. It can omit invalid applications, deleted members, deprecated models and disabled MCP servers, and can include knowledge inherited from its organization. It is never a complete database snapshot and must not be written back as one.
| Write owner | Responsibility |
|---|---|
SpaceRepository | Space settings, individual user/group memberships, explicit model/server/capability selections |
AssistantRepository | One assistant, its prompt and explicit knowledge, attachment and tool selections |
GroupChatRepository | One group chat and explicit replacements of its assistant seats |
GroupRepository | Collection configuration and distribution to Spaces |
WebsiteRepository | Website configuration and distribution; crawl lifecycle remains with CrawlRunRepository |
| App, service and integration repositories | Their existing resource lifecycles |
Services authorize the operation and validate its requested values before
passing explicit write intent to the owning repository. Omitted relationships
are untouched. An explicitly submitted list replaces that relationship;
[] clears it, including currently hidden targets. The shared association writer
compares that explicit selection with stored rows and writes only differences.
Unchanged rows keep their timestamps. Renaming a Space cannot delete an assistant
or materialize inherited knowledge as a direct distribution.
Replacement writes lock their parent in the request transaction. Assistant moves lock both Spaces in ID order, then the assistant; group chat seat replacements take the same Space lock before validating membership. Moves update ownership without deleting the assistant or its history. Assistants and services move alone: their collections and websites stay in the Space that owns them. Collections and websites cannot be moved or shared between Spaces; the only distribution is from an organization Space, whose knowledge its child Spaces see. Concurrent changes to distinct resources do not overwrite each other. Two explicit replacements of the same relationship remain serialized, last-writer-wins operations; there is no client-visible optimistic version contract.
The security classification preview reports affected IDs without editing the Space projection. A committed classification change explicitly removes incompatible stored model/server selections, including hidden ones. The default assistant is a supported group chat member through the API and is included when resolving seats.
Malformed stored model options remain visible to operators through the inspection and repair API. No schema migration or automatic data normalization is required. Roll back by redeploying the previous application only if its aggregate-save defect is acceptable; previously deleted resources require restoration from a backup.
Database (PostgreSQL + pgvector)
Technology:
- PostgreSQL 16 (
pgvector/pgvector:pg16in the reference deployment) - pgvector for cosine-distance search over chunk embeddings
- Alembic migrations in
backend/alembic/, applied byinit_db.pyon first start and byrun.shon every backend start
Schema conventions: every table is declared in
backend/src/eneo/database/tables/. Table names are derived from the class
name in snake case. Entity tables use UUID primary keys and carry created_at
and updated_at. Junction tables use composite primary keys;
BaseCrossReference supplies both timestamps. The distribution links
groups_spaces and websites_spaces use CreatedAtMixin and only carry
created_at: links are inserted or deleted and have no mutable settings.
Core tables:
| Table | Purpose |
|---|---|
tenants | Organisations; hold federation, crawler, and API-key policy as JSONB |
users | Accounts within a tenant, with roles and user groups |
spaces | Workspaces; a personal space has a unique user_id, shared spaces have members and roles |
assistants | Assistant configuration, completion model, and knowledge bindings |
sessions | Conversations with an assistant, service, or group chat |
questions | One question/answer pair per row, including token counts and tool calls |
info_blob_references | Which info_blobs a question cited and with which similarity score |
groups | Collections of uploaded documents (the code calls them collections) |
websites, crawl_runs | Crawled sources and each crawl execution |
info_blobs | Extracted text per document or page, with source_id and version_state |
info_blob_chunks | Chunked text with its embedding vector |
files | Uploaded files and attachments |
object_contents and related tables | Durable byte control plane (see below) |
jobs | Background job status for the frontend |
api_keys_v2 | API keys stored as hashes with prefix and suffix for display |
audit_logs and audit config tables | Audit trail and retention policy |
Key relationships:
tenants (1) ─── (N) users
tenants (1) ─── (N) spaces
spaces (1) ─── (N) assistants
spaces (1) ─── (N) groups (collections) / websites / integration knowledge
groups | websites | integration knowledge (1) ─── (N) info_blobs
info_blobs (1) ─── (N) info_blob_chunks
assistants (1) ─── (N) sessions
sessions (1) ─── (N) questions ─── (N) info_blob_referencesVector search: info_blob_chunks.embedding is a dimensionless pgvector
Vector() column, so one table holds embeddings from every configured
embedding model. Retrieval orders by cosine distance, restricts the scope to
the requested collections, websites, or integrations, and reads only the
active document version:
# backend/src/eneo/info_blobs/info_blob_chunk_repo.py
await self.session.execute(sa.text("SET LOCAL enable_seqscan = off;"))
stmt = (
sa.select(
InfoBlobChunks,
InfoBlobChunks.embedding.cosine_distance(embedding),
InfoBlobs.title,
)
.join(InfoBlobs)
.where(active_info_blob_version())
.options(defer(InfoBlobChunks.embedding))
.order_by(InfoBlobChunks.embedding.cosine_distance(embedding))
.limit(limit)
)There is no approximate-nearest-neighbour index (no IVFFlat or HNSW) on the
embedding column. The B-tree indexes on info_blob_id, tenant_id, and the
scope foreign keys narrow the candidate set, and SET LOCAL enable_seqscan = off steers the planner away from a full scan of the TOAST-heavy chunk table.
Retrieval cost therefore grows with the number of chunks in scope, not with
the whole table.
Database schema check
backend/tests/integration/test_database_schema.py checks that every table and
column declared in SQLAlchemy’s Base.metadata exists in the migrated database.
It uses the same table registration as Alembic, including tables without an ORM
class. New table modules must be registered through
backend/src/eneo/database/tables/__init__.py, as required by Alembic. There is
no separate test list to maintain and no dependency on a model’s base class.
The shared integration fixtures start disposable PostgreSQL and Redis containers
and build the database with alembic upgrade head. They do not create the schema
from the models with metadata.create_all(), which would hide missing migrations.
Each table is a separate test case, named after the table. The test selects all
declared columns with LIMIT 0: PostgreSQL validates their existence without
reading or changing rows, so empty tables are checked too.
Run it: with Docker running, from backend/, use the resource supervisor
required by your environment to run:
uv run pytest -n 0 -m integration tests/integration/test_database_schema.py -vWhen it fails: first distinguish a test failure from a fixture setup error.
If container startup or a migration fails during setup, resolve that error first.
For a missing table or column, the test ID identifies the table and PostgreSQL’s
error names the missing object. Compare its declaration in src/eneo/database/tables/
with its migrations in alembic/versions/. Decide whether the model describes the
intended stored data: add a migration for a needed schema change, correct an
incorrect model declaration, or remove confirmed unused model code. Do not skip
the table to make CI pass. For example, groups_spaces and websites_spaces
incorrectly inherited updated_at; their insert/delete lifecycle only needs
created_at, so the fix belongs in the models.
Limits: this is a table-and-column existence check, not a complete schema comparison. Unregistered tables are outside its scope. It does not validate types, constraints, defaults, indexes, extra database columns, or upgrades with existing customer data. Keep focused migration and behavior tests for those contracts. The CI job named Schema drift is a separate check of the generated TypeScript API schema.
Durable Object Content
Durable content has one PostgreSQL control plane and one explicit byte backend per record:
| PostgreSQL control plane | Byte backend |
|---|---|
| Content identity and lifecycle state | Bounded postgres_inline payload or private object_store object |
| Canonical SHA-256, exact size, and media type | Payload bytes only |
| Concrete authorization references, holds, and retention | Backend-specific transport/deletion state |
| Audit and bounded reconciliation/query facts | No authorization, routing, or product policy |
Inline bytes, control state, and the first owner reference commit atomically. Object-store intent and the first reference commit before remote work. Eneo verifies the PostgreSQL-owned SHA-256 before returning full or range responses, and a bounded reconciler resolves process and network failures.
Object storage is optional. The default reference deployment is inline-capable without another service. Operators can run Eneo’s built and attested SeaweedFS profile or an external endpoint such as MinIO. Administrators with the Storage permission connect and select either option through the same vendor-neutral contract.
Read Object Content Architecture for the complete lifecycle, integrity, reconciliation, binding, and image publication design. The Object Content Storage guide covers deployment and operations.
Worker Service (ARQ today)
Technology:
- Queue: Redis-based ARQ (Async Redis Queue)
- Language: Python with async/await
- Concurrency:
WORKER_MAX_JOBSconcurrent jobs per worker process (default 15) andCRAWL_JOB_TENANT_CONCURRENCY_LIMITconcurrent crawl jobs per tenant (default 4), enforced through durable PostgreSQL admission
Responsibilities:
- Document upload processing (extraction, chunking, embedding, versioned publish)
- Crawl admission and recovery, with execution on the dedicated crawler worker
- SharePoint and Confluence synchronisation, including subscription renewal
- Audit log export and cleanup, usage statistics, conversation insights
- Scheduled jobs: daily data retention, weekly cleanup of orphaned models, redispatch and reaping of stale knowledge jobs, object-content reconciliation (also run at worker startup)
Object-content reconciliation is implemented as a queue-neutral async task. Only the current registration and schedule adapter depends on ARQ. A future worker change therefore does not alter the object store, lifecycle, or database contract, and Eneo avoids a speculative generic queue framework in the meantime.
Task registration: each domain declares tasks on its own Worker
instance, and worker/arq.py composes them into one WorkerSettings for ARQ.
# backend/src/eneo/worker/routes.py
@worker.function(with_user=False)
async def log_audit_event(# backend/src/eneo/data_retention/infrastructure/data_retention_worker.py
@worker.cron_job(hour=3, minute=0) # Run daily at 3 AMWeb Crawling:
The API, general worker and crawler worker share a durable crawl lifecycle in
PostgreSQL. Redis carries work to the dedicated arq:crawler queue.
- Admission: A manual request or schedule creates a crawl run in PostgreSQL before dispatch. Concurrency limits determine when queued work can start.
- Execution: The crawler worker claims an execution attempt with a renewable lease, then discovers pages through links or a sitemap. It records progress against that attempt.
- Publication: Extracted text is hashed, split and embedded. Unchanged text with the same embedding model reuses stored content. Publication checks that the attempt still owns its lease before committing each batch.
- Recovery: The general worker reconciles missed deliveries, expired leases and transport cleanup. A dead execution becomes interrupted; a stale worker cannot publish after losing ownership. Both worker services are required.
Recursive crawls fetch HTML again so unchanged pages can still lead to new or previously missed descendants. Conditional page requests are limited to sitemap crawls with file downloads disabled. Robots server errors, timeouts and network failures stop discovery after bounded retries. Only complete discovery can authorize removal of sources no longer present; partial runs preserve them.
A normalized source URL identifies each website document. Files at different URLs remain separate even when their names match; a title change at the same URL keeps the source’s version history. Migration preserves existing metadata and citation IDs. Unknown or ambiguous legacy URLs remain available and require resolution before publication can replace them. Changed content retains the text and metadata of previous versions so citations stay readable; their chunks and embeddings are removed once the replacement is published, because retrieval reads only the active version. Removal of an absent source removes its history and citation references. There is no automatic age-based retention policy. Crawl history is paginated, and the UI loads older runs on request.
Uploads and crawl downloads share extraction subprocesses with time, output, archive and PDF bounds; Linux also enforces address-space limits and parent-death cleanup. HTML parsing and text splitting use a bounded thread pool so they do not run directly on the worker’s event loop. Quota checks include what retained versions keep, credit the chunks a replacement releases, and serialize the final commit after provider work, rolling back an over-limit publication. Stored knowledge also prevents changes to embedding model semantics or provider routing; those changes require a new model and reindexing.
The worker writes a heartbeat key to Redis; /api/healthz reports the worker
as unhealthy when that heartbeat is stale.
Audit Logging
Audit logging captures security- and compliance-relevant actions across tenants. Logs are persisted in PostgreSQL, exports are handled by the worker, and retention is enforced by a daily purge job.
See Audit Logging for implementation details, retention hierarchy, and export options.
Redis (Queue, Pub/Sub, Coordination)
Redis is not a session store. Authentication is a stateless JWT (see below), so no login session lives in Redis.
Responsibilities:
- ARQ job queue and worker heartbeat
- Pub/sub channel between worker and backend: task status updates are published by the worker and forwarded to browsers over the WebSocket
- API-key rate limiting counters per key over
API_KEY_RATE_LIMIT_WINDOW_SECONDS - Small coordination state: pending MCP tool approvals, one-time module-login tickets, the runtime debug toggle, audit-session rate limits
Reverse Proxy (Traefik)
Responsibilities:
- TLS termination with automatic Let’s Encrypt certificates (HTTP challenge)
- HTTP to HTTPS redirect
- Routing by path: the backend receives
/api,/scim,/docs,/openapi.json, and/version; everything else goes to the frontend
Configuration (from docs/deployment/docker-compose.yml):
backend:
labels:
- "traefik.http.routers.eneo-backend-secure.rule=Host(`your-domain.com`) && (PathPrefix(`/api`) || PathPrefix(`/scim`) || PathPrefix(`/docs`) || PathPrefix(`/openapi.json`) || PathPrefix(`/version`))"
- "traefik.http.routers.eneo-backend-secure.tls.certresolver=letsencrypt"
- "traefik.http.services.eneo-backend-svc.loadbalancer.server.port=8000"
- "traefik.http.routers.eneo-backend-secure.priority=10"
frontend:
labels:
- "traefik.http.routers.eneo-frontend-secure.rule=Host(`your-domain.com`)"
- "traefik.http.services.eneo-frontend-svc.loadbalancer.server.port=3000"
- "traefik.http.routers.eneo-frontend-secure.priority=1"Data Flow
User Authentication Flow
Browser ──POST /login (SvelteKit form action)──▶ Frontend server
Frontend ──POST /api/v1/users/login/token/ ──▶ Backend (password login)
Frontend ──GET /api/v1/auth/initiate → IdP → POST /api/v1/auth/callback ──▶ Backend (OIDC)
Backend ──signed JWT (sub, aud, iss, exp)──▶ Frontend
Frontend ──Set-Cookie: auth=<jwt>; HttpOnly; Secure; SameSite=Lax──▶ Browser
Browser ──subsequent requests: frontend server forwards the JWT as a bearer token──▶ BackendThe frontend stores the backend-issued JWT in an httpOnly auth cookie whose
maxAge is the token expiry minus ten minutes. There is no refresh token: when
the token expires the user signs in again. A second httpOnly cookie, acc,
carries a provider access token when the identity provider issues one.
AI Chat Flow
User types message
↓
Frontend POSTs to /api/v1/conversations/ with stream: true
↓
Backend authenticates the JWT and checks space access
↓
Embed the question and run the pgvector search over the assistant's
collections, websites, and integrations (or expose search as an MCP tool)
↓
Build the prompt within the model's context budget
↓
LiteLLM streams from the tenant's configured provider
↓
Server-Sent Events stream chunks back to the browser
↓
Question, answer, token counts, and info-blob references are savedSee Knowledge Retrieval and MCP for the retrieval modes.
Document Upload Flow
- User uploads a File; its bytes follow the deployment’s selected storage policy.
- Backend enqueues document processing.
- Worker extracts text, creates chunks, and generates embeddings.
- One short transaction supersedes the previous version, publishes the new
InfoBlobsversion and everyInfoBlobChunksvector, and removes the previous version’s chunks. - Retrieval reads only the active version. Exact historical references and the previous text remain readable until the document is deleted.
- A failed replacement rolls back; the previous complete version, chunks included, remains active.
- The job records
COMPLETEorFAILEDfor the frontend.
PostgreSQL owns searchable text, version state, and pgvector data regardless of where Eneo stores the original File bytes. Object storage remains optional.
Security Architecture
Authentication
- Username and password (
POST /api/v1/users/login/token/): the backend verifies the credentials and mints a JWT signed withJWT_SECRETusingJWT_ALGORITHM, withJWT_AUDIENCE,JWT_ISSUER, andJWT_EXPIRY_TIME(the session lifetime in minutes;1440is 24 hours). - OIDC federation (
/api/v1/auth/federation-status,/auth/tenants,/auth/initiate,/auth/callback): per-tenant identity providers configured in the tenant’sfederation_config, with a signed state parameter. See Authentication Architecture. - API keys (
X-API-Keyheader): scoped keys stored as HMAC hashes inapi_keys_v2, with optional rate limits, allowed origins, allowed IPs, and expiry. A separate super API key protects the/api/v1/sysadminroutes. - SCIM (
/scim/v2): tenant-scoped bearer token for user provisioning.
Cookies are HttpOnly, Secure outside development, and SameSite=Lax.
There is no separate CSRF token or refresh-token mechanism.
Authorization Model
- Tenant permissions: users hold roles that grant permissions such as
assistants,collections,websites,api_keys,storage,modules, andadmin. - Space roles:
admin,editor, orviewerper space member or user group. A personal space belongs to exactly one user. - Resource checks: every space action goes through a space actor that answers questions such as “can this user create websites here” based on the member’s role and the resource type.
Data Security
- TLS is terminated by Traefik with Let’s Encrypt certificates.
- Secrets are read from environment files (
env_backend.env,env_frontend.env,env_db.env), never from the image. - API keys are stored as keyed HMAC hashes; only the prefix and suffix are kept for display.
- Durable object content is verified against its PostgreSQL-owned SHA-256 before being served.
- PostgreSQL and Redis sit on an internal Docker network (
data_net) with no internet egress and no route from Traefik or the frontend.
Scalability Considerations
Horizontal Scaling
- The API holds no per-user session state, and worker-to-browser updates travel
through Redis pub/sub, so several backend instances can sit behind Traefik.
The reference Compose file pins
container_namefor every service, so remove those names before running more than one replica. - Each backend container already runs three Gunicorn/Uvicorn workers
(
NUM_WORKERS). - Worker capacity scales with additional worker containers; per-process
concurrency is
WORKER_MAX_JOBS, and crawl fan-out per tenant is capped byCRAWL_JOB_TENANT_CONCURRENCY_LIMIT.
Database
- Keep
WORKER_MAX_JOBSbelow roughly 60 % ofDB_POOL_SIZE + DB_POOL_MAX_OVERFLOWso the API always has connections; the backend environment template documents the sizing formula. - Retrieval cost is proportional to the chunks in scope of a search, so large tenants benefit from splitting knowledge into focused collections.
Deployment Topology
The reference deployment in docs/deployment/docker-compose.yml starts the
services in this order, gated by health checks:
db(PostgreSQL 16 + pgvector) andredisbecome healthy.db-initrunspython init_db.py: Alembic migrations and the default tenant and user, then exits.backendandworkerstart;backendexposes/api/healthz.frontendstarts.
Networks: proxy_tier (Traefik, frontend, backend, worker; internet-facing),
data_net (PostgreSQL and Redis; internal only), module_net (Traefik and
backend; reserved for optional module containers), and tool_runtime_net
(backend, general worker and bundled runtime; internal only).
The runtime starts as a separate service, without gating backend readiness. It exposes port 3010 only on the private network and has no database, Redis or internet route in the reference deployment. The separate MCP App content hostname routes view content to the backend, not to the runtime. See Tool Runtime Security for what the container and network settings protect, and the tool runtime operator guide and MCP Apps deployment guide for configuration.
Optional overlays:
docker-compose.object-content.ymladds the bundled SeaweedFS S3 endpoint under theobject-contentprofile on its own internal network.docker-compose.modules.ymladds optional module containers such as speech-to-text.
See Deploy Eneo for the full procedure.
Monitoring & Observability
Logging
Backend and worker log JSON lines to stdout, with the trace id of the current request attached when tracing is active.
Tracing
FastAPI, SQLAlchemy, Redis, aiohttp, and httpx are instrumented with
OpenTelemetry, and every response carries an X-Trace-Id header for
correlating a user report with server logs.
Health Checks
| Endpoint | Purpose |
|---|---|
GET /api/livez | Liveness: the API process can serve requests |
GET /api/healthz | Backend, worker heartbeat, and object-content readiness; 503 when the worker is stale or the store is not ready |
GET /api/readyz | Alias of /api/healthz for readiness probes |
GET /api/healthz/crawler | Detailed crawler diagnostics; requires the deployment’s super API key; not intended for orchestrator probes |
Technology Decisions
Why SvelteKit?
- Server-side rendering with a single Node process
- Small bundles and a compiler-driven component model
- Form actions keep login and cookie handling on the server
Why FastAPI?
- Async-native Python with first-class OpenAPI generation, which also feeds the frontend’s typed client
- Pydantic models shared between validation and documentation
Why PostgreSQL + pgvector?
- One database for relational data, versioned text, and vectors: a document version and its chunks publish in a single transaction
- Dimensionless vector column allows several embedding models per tenant
Why ARQ?
- Python-native async task queue on the Redis that is already deployed
- Cron jobs, job abort, and lifecycle hooks with a small surface, kept behind a queue-neutral task layer
Additional Resources
- Migrations:
backend/alembic - Reference deployment:
docs/deployment - API documentation: Swagger UI at
/docson your instance, or the API Reference - Contributing guide: Development guidelines