How a deployment's configuration is loaded and how it reaches code: the
jentic_one.shared package owns both the config model and the Context
object every surface consumes.
Configuration is loaded via load_config() which merges two sources in priority order:
- YAML file — resolved as: explicit
pathargument >JENTIC_CONFIG_FILEenv var >./jentic-one.yaml - Environment variables — convention:
JENTIC__SECTION__KEY=value(double-underscore separated, uppercased)
Environment variables override file values. Types are coerced automatically by pydantic (booleans, ints, floats).
The full key-by-key reference — every section, type, default, and env var — is
generated at docs/reference/config.md
(make config-reference, drift-guarded in CI).
databases:
registry:
name: registry_db
admin:
name: admin_db
control:
name: control_dbAll other fields default to a local single-host shape (localhost:5432, pool
sizes, etc.) — the generated config reference lists
every default.
Database passwords use pydantic.SecretStr — they are automatically redacted in logs, repr, and serialization. Access the raw value only via .get_secret_value().
Context is the central object that holds the resolved config and manages database engines/sessions.
from sqlalchemy import text
from jentic_one.shared import Context, load_config
config = load_config(Path("jentic-one.yaml"))
async with Context(config) as ctx:
async with ctx.registry_db.session() as session:
result = await session.execute(text("SELECT 1"))ctx.registry_db— SQLAlchemy session manager for the registry schemactx.admin_db— SQLAlchemy session manager for the admin schemactx.control_db— SQLAlchemy session manager for the control schema
Each property returns a DatabaseSession instance with:
.engine— the underlyingAsyncEngine.session_factory— theasync_sessionmakerbound to the engine.session()— async context manager yielding anAsyncSession
await ctx.startup()— creates engines and session factoriesawait ctx.shutdown()— disposes all engines gracefully- Or use
async with Context(config) as ctx:which handles both
The database layer uses SQLAlchemy async, with a pluggable backend per
database: PostgreSQL via asyncpg or SQLite via aiosqlite (selected by each
database's backend config key):
RegistryBase/ControlBase/AdminBase— per-database declarative base classes for ORM models (import fromjentic_one.shared.db)DatabaseSession— manages an async engine and session factory per databaseget_database_url(config)— builds the asyncsqlalchemy.engine.URLfor the configured backend from aDatabaseConfig
Define models by subclassing the base for the target database (RegistryBase, ControlBase, or AdminBase):
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from jentic_one.shared.db.base import RegistryBase # or ControlBase, AdminBase
class MyModel(RegistryBase):
__tablename__ = "my_table"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255))See the ORM model definitions under src/jentic_one/*/repos/ for full conventions and the per-database entity breakdown.
Alembic is configured for async multi-database migrations. Each database has its own named section.
uv run alembic -n registry upgrade head
uv run alembic -n control upgrade head
uv run alembic -n admin upgrade headuv run alembic -n <db_name> revision --autogenerate -m "description of change"Autogenerate compares the target database's base metadata (e.g. RegistryBase.metadata) against the live schema. All ORM models must be imported before Alembic runs — place models in packages imported by the migration env.
alembic.ini— multi-database config with[registry],[control],[admin]sectionssrc/jentic_one/migrations/env.py— shared async env that resolves the active section to the correct database URL and metadatasrc/jentic_one/migrations/{registry,control,admin}/versions/— per-database migration scripts
AppConfig.runtime holds hot-reloadable flags (debug, log_level, maintenance_mode). Use config.runtime.reload(overrides) to produce an updated RuntimeConfig from a dict of new values.
A jentic-one install declares its own locality via server.backend: local
for a self-hosted install on your own machine/network, or remote for a hosted
install run elsewhere (e.g. Jentic Cloud). It defaults to local; the hosted
platform sets remote in its own config. A client — the jentic CLI, an agent,
or an MCP server — is pointed at one backend via its own configuration. When a
local install and a remote one are both reachable it is easy for two clients to
disagree: e.g. an MCP server still bound to a remote backend while the CLI talks
to a fresh local install. The two backends have independent registries and
credentials, so a tool call answered by the other backend looks like data loss
("APIs disappeared", "credentials vanished", ID-format mismatches) when nothing
was lost — the two clients are talking to different backends.
Every jentic-one install exposes an unauthenticated backend-identity endpoint
so any client can confirm which backend it reached before diagnosing missing
data:
curl -s http://127.0.0.1:8000/instance{
"backend": "local",
"canonical_base_url": "http://127.0.0.1:8000",
"host": "127.0.0.1:8000",
"instance_id": "…"
}backendis the operator-declared locality fromserver.backend:local(the default) for a self-hosted install on your own machine/network,remotefor a hosted install run elsewhere. It is a hint for humans/agents, not an authorization signal.canonical_base_url/hostcome fromauth.canonical_base_url(set inconfig/local.yamltohttp://127.0.0.1:8000for local runs; a hosted platform sets its own). This is the instance describing itself, so it is the value to trust over any client-side assumption. Any userinfo embedded in the configured URL is stripped before echoing.instance_idis an opaque digest derived from the telemetry instance id (never the id itself). It only disambiguates two installs sharing a host when both have telemetry enabled — it isnullwhenever telemetry has not resolved an id (e.g. telemetry disabled).
To check which backend a given base URL is bound to, hit /instance on that
URL. If the backend/host is not the one you expected, the client is pointed
at the wrong backend.
The per-response backend field is stamped by the MCP server — the local
jentic mcp server reads it from the /instance endpoint above, which is
the jentic-one side of the contract. To move an MCP server (or the CLI) from a remote backend to
a local install, update that client's backend base URL to your local
canonical_base_url (e.g. http://127.0.0.1:8000) and re-check with
GET /instance. For jentic mcp the backend comes from the context's
environment (base_url/broker_url), so repoint the environment (or switch
the context the MCP entry pins with --context). jentic-one never silently
resolves to a remote backend on its own — a client only reaches a remote
backend because it is configured to.