Adaptive Learner uses a three-layer config chain so secrets stay out of the project tree:
┌─────────────────────────────────────────────────────────────┐
│ env-vars (CI / Docker / shell, highest priority) │
│ ADAPTIVE_LEARNER_ANTHROPIC_API_KEY, ..._OPENAI_API_KEY, ... │
│ ADAPTIVE_LEARNER_SECRET_KEY (Fernet) │
└─────────────────────────────────────────────────────────────┘
↑ overrides
┌─────────────────────────────────────────┐
│ user override file (outside the repo) │
│ ~/.config/adaptive_learner/secrets.yaml │
└─────────────────────────────────────────┘
↑ overrides (for AI keys only)
┌─────────────────────────────────────────┐
│ database UserSettings (Settings UI) │
│ Fernet-encrypted api_key_<provider> │
└─────────────────────────────────────────┘
Override-wins semantics: a value in secrets.yaml replaces the
encrypted DB column; an env-var replaces both. Lists are
replaced, not merged.
secrets.yaml is read from the config directory of the process
that runs the backend. For a backend started from source
(make dev) that is ~/.config/adaptive_learner/ on your
machine. The desktop launcher and Docker run the backend in a
container with HOME=/app/data, so there the file lives inside
the data volume (/app/data/.config/adaptive_learner/) and a
host-side file is not seen; keys entered under Settings > AI are
written there. Container deployments can also rely entirely on
env vars + the Settings UI.
| Layer | Examples | Lives in |
|---|---|---|
Project app.yaml |
non-secret defaults: app.default_language, server.cors_origins, plugins.enabled / plugins.disabled. The backend port is ADAPTIVE_LEARNER_PORT, not a key here. Never API keys. |
gitignored; created on first start from the committed app.yaml.example |
User secrets.yaml |
ai.<provider>.api_key, ai.<provider>.default_model, secret_key (Fernet) |
~/.config/adaptive_learner/secrets.yaml (Linux/macOS), %APPDATA%/adaptive_learner/secrets.yaml (Windows) |
| Settings UI (DB) | per-user api_key_<provider>, model_override_<provider>, language, active_provider |
SQLite, Fernet-encrypted for the api_key_* columns |
| Env-var | CI / Docker / shell overrides of any of the above | environment |
Rule of thumb: anything sensitive belongs in secrets.yaml,
env-vars, or the Fernet-encrypted DB column. Nothing sensitive
belongs in app.yaml.
A first-run template is auto-generated at the path above with all
keys commented out. POSIX permissions are set to 0600 on
creation. Uncomment + fill in the keys you want:
# secret_key: "generate with: python3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'"
ai:
anthropic:
api_key: "sk-ant-..."
default_model: "claude-sonnet-4-20250514" # optional override
openai:
api_key: "sk-..."
default_model: "gpt-4o" # optional override
gemini:
api_key: "AIza..."
default_model: "gemini-2.0-flash" # optional overrideThe file is parsed with yaml.safe_load. Missing keys are
treated as "not configured here" and resolution falls through to
the next layer. Malformed YAML logs a single WARNING at startup
and the loader falls back to "no overrides" (the app continues
to start). World-readable permissions trigger a one-line WARNING
at startup recommending chmod 0600.
When the AI orchestrator needs an API key for <provider>:
ADAPTIVE_LEARNER_<PROVIDER>_API_KEYenv var.secrets.yaml: the Fernet-encryptedai.<provider>.api_key_encryptedthe Settings UI writes, or a hand-edited plaintextai.<provider>.api_key.- Legacy Fernet-encrypted
api_key_<provider>column on the user'sUserSettingsrow: a read-only fallback for keys saved before the move tosecrets.yaml, migrated into the file at startup and no longer written. None— the AI call returns an error asking the user to configure a key.
The Settings UI surfaces which layer the active key came from via per-provider badges:
- "Key from: environment"
- "Key from: secrets.yaml"
- "Key from: Settings"
- "No key configured"
Only an environment key disables the Save + Remove buttons.
A secrets.yaml key stays editable: the info line names the file,
and saving in the UI overwrites the stored key.
Same chain for default_model (per-provider model override) —
the only difference is that the third layer is
UserSettings.model_override_<provider> instead of the
encrypted key column. Per the v1.20.0 decision, secrets.yaml
beats the UI override (file-level config wins over UI for power
users).
Default: ~/.config/adaptive_learner/secrets.yaml.
Set XDG_CONFIG_HOME to relocate (XDG-conformant):
export XDG_CONFIG_HOME=/srv/configs
# Adaptive Learner now reads /srv/configs/adaptive_learner/secrets.yamlDefault: %APPDATA%/adaptive_learner/secrets.yaml.
Falls back to ~/AppData/Roaming/adaptive_learner/secrets.yaml
when %APPDATA% is unset.
| Env-var | Maps to | Notes |
|---|---|---|
ADAPTIVE_LEARNER_ANTHROPIC_API_KEY |
ai.anthropic.api_key |
Beats secrets.yaml + DB |
ADAPTIVE_LEARNER_OPENAI_API_KEY |
ai.openai.api_key |
Beats secrets.yaml + DB |
ADAPTIVE_LEARNER_GEMINI_API_KEY |
ai.gemini.api_key |
Beats secrets.yaml + DB |
ADAPTIVE_LEARNER_ANTHROPIC_DEFAULT_MODEL |
ai.anthropic.default_model |
Beats secrets.yaml + UI override |
ADAPTIVE_LEARNER_OPENAI_DEFAULT_MODEL |
ai.openai.default_model |
Beats secrets.yaml + UI override |
ADAPTIVE_LEARNER_GEMINI_DEFAULT_MODEL |
ai.gemini.default_model |
Beats secrets.yaml + UI override |
ADAPTIVE_LEARNER_SECRET_KEY |
Fernet encryption key for api_key_* DB columns |
hydrated from secret_key: in secrets.yaml when env empty; fail-hard if neither is set |
ADAPTIVE_LEARNER_PORT |
backend listen port (default 18001) | uvicorn reads its own --port flag too |
ADAPTIVE_LEARNER_CORS_ORIGINS |
CORS allowed origins | comma-separated |
ADAPTIVE_LEARNER_DEBUG |
DEBUG constant in main.py |
true/1/yes to enable |
ADAPTIVE_LEARNER_TEST |
enables test isolation guards | set automatically by conftest.py |
ADAPTIVE_LEARNER_CONFIG_DIR |
overrides platformdirs.user_config_dir(...) |
rarely needed; XDG-conformant default works |
ADAPTIVE_LEARNER_DATA_DIR |
overrides platformdirs.user_data_dir(...) |
for sandboxed installations |
For Docker / CI / Kubernetes you typically inject every secret
via env vars from your orchestrator's secrets store. The
secrets.yaml layer is meant for human desktop use — it works
in Docker too if you bind-mount the path, but env-vars are the
idiomatic choice in containers:
# docker-compose.prod.yml (example excerpt)
services:
backend:
image: adaptive_learner:1.19.2
environment:
ADAPTIVE_LEARNER_SECRET_KEY: ${ADAPTIVE_LEARNER_SECRET_KEY}
ADAPTIVE_LEARNER_ANTHROPIC_API_KEY: ${ADAPTIVE_LEARNER_ANTHROPIC_API_KEY}
ADAPTIVE_LEARNER_DEBUG: "false"
volumes:
- adaptive-learner-data:/app/dataInject the env-vars from your CI secrets store (GitHub Actions secrets, GitLab CI variables, Vault, AWS Secrets Manager, etc.).
# What does the Settings endpoint say about each provider?
curl http://localhost:18001/api/settings/<user-id> \
| jq '.key_source_anthropic, .key_source_openai, .key_source_gemini'Expected values: "env", "secrets_yaml", "settings", "none".
To confirm WHICH layer holds a value at the source-of-truth level:
# Env var
echo "$ADAPTIVE_LEARNER_ANTHROPIC_API_KEY"
# secrets.yaml
yq '.ai.anthropic.api_key' ~/.config/adaptive_learner/secrets.yaml
# DB column (encrypted ciphertext)
sqlite3 ~/.local/share/adaptive_learner/adaptive_learner.db \
"SELECT api_key_anthropic IS NOT NULL FROM user_settings WHERE user_id='<id>';"Whichever is non-empty AND highest in the chain wins. The
Settings UI mirrors this resolution and disables the input
fields when the source is env or secrets_yaml.
The secret_key is a separate concern from the AI provider API
keys: it's the symmetric encryption key Adaptive Learner uses to
encrypt api_key_<provider> columns at rest in SQLite. If you
rotate it, existing encrypted rows become unreadable —
CryptoDecryptionError raised on read, and the user has to
re-enter their keys via the Settings UI.
Resolution order at startup:
ADAPTIVE_LEARNER_SECRET_KEYenv var.secret_key:insecrets.yaml(hydrated into the env var by_hydrate_env_from_configso downstream readers see it too).- Fail-hard with
CryptoConfigurationError. No silent default — generating one at startup would mean different keys per dev machine and across container restarts, which would silently corrupt the encrypted DB rows.
Generate a fresh key for local dev:
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# -> paste into secrets.yaml as ``secret_key: "..."``,
# OR export ADAPTIVE_LEARNER_SECRET_KEY="..."For dev convenience, make dev-secret writes a key into
~/.adaptive-learner/dev-secret.env (gitignored) the first time
it's invoked, so subsequent runs of make dev Just Work.
The Progressive Web App manifest and service worker live as
build-time configuration in frontend/vite.config.ts under
the VitePWA(...) plugin block. There's NO runtime config
chain for PWA settings — they're baked into the generated
dist/sw.js and dist/manifest.webmanifest during
npm run build. Changing them requires a rebuild + a fresh
SW registration in the user's browser (Workbox's
registerType: "autoUpdate" handles that on next page load).
manifest: {
name: "Adaptive Learner",
short_name: "Adaptive", // ≤12 chars (Android home-screen rec)
theme_color: "#6366f1", // matches --accent CSS variable
background_color: "#ffffff",
display: "standalone",
orientation: "any",
start_url: "/",
scope: "/",
lang: "en",
categories: ["education", "productivity"],
icons: [
// SVG + PNG ("any maskable") at 192 and 512.
],
}workbox: {
globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2}"],
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api\//],
runtimeCaching: [
{
// GET /api/ → NetworkFirst with 24h LRU
urlPattern: ({url, request}) =>
url.pathname.startsWith("/api/") &&
request.method === "GET",
handler: "NetworkFirst",
options: {
cacheName: "adaptive-learner-api",
networkTimeoutSeconds: 4,
expiration: {maxEntries: 60, maxAgeSeconds: 86400},
cacheableResponse: {statuses: [0, 200]},
},
},
{
// Mutating /api/ — NetworkOnly
urlPattern: /^\/api\//,
handler: "NetworkOnly",
},
],
}Why these defaults:
- 4s
networkTimeoutSeconds— gives real network a fair chance before falling back to cache; feels snappy on flaky cellular without being noticeable on broadband. - 24h + 60-entry LRU — keeps the cache useful for a typical learning week without growing unbounded.
cacheableResponse.statuses: [0, 200]—0covers opaque-CORS,200excludes 5xx so transient backend errors don't poison the cache.navigateFallbackDenylist: [/^\/api\//]stops the SPAindex.htmlfrom masking real backend 4xx/5xx responses.
Adaptive Learner writes a small YAML at
~/.config/adaptive_learner/identity.yaml that lets the app
recover your user identity after a browser data wipe. The
file is auto-managed; do not edit it manually.
# Auto-generated by Adaptive Learner. Do not edit manually.
# Last written: 2026-05-23T14:30:00+00:00
active_project_id: 86071d42-653f-4827-a4e5-242207f3c2ab
language: de
last_seen: '2026-05-23T14:30:00+00:00'
user_id: d2a7e8dd-078b-4489-852c-d2ab87c1d589- File permissions:
0600(owner read/write only, same assecrets.yaml). - Path: same XDG-conformant config dir as
secrets.yaml. Override withADAPTIVE_LEARNER_CONFIG_DIRif you need a non-default location (tests, Docker).
The backend updates identity.yaml automatically after every
domain change that affects who-am-I or which-project state:
POST /api/users(user creation) seedsuser_id+language.PATCH /api/users/{id}(language switch) refresheslanguage.POST /api/users/{user_id}/projects(new project) setsactive_project_idto the new project.PATCH /api/projects/{id}whenactive=true(project switch) movesactive_project_idto the chosen project.
last_seen refreshes on every write so the Settings >
About > Identity panel can surface a meaningful timestamp.
On Landing, if localStorage is empty (cleared, private mode,
fresh browser, etc.):
- API mode: the frontend calls
GET /api/identity. A hit re-seedslocalStorageand redirects to the Dashboard silently — recovery is invisible per design. - Dexie mode: the frontend queries the most recent
usersrow + its activelearningProjectsrow from IndexedDB. A hit re-seedslocalStorage, redirects, and shows a friendly "Welcome back! Your learning data is still here." toast.
When neither path produces a hit, the regular onboarding flow runs as if this were a first visit.
The Settings page surfaces the identity file in two places:
- Settings > About > Identity file shows the path, Active / Not found status, and the last-updated timestamp. Read-only.
- Settings > Danger Zone (bottom of Settings) is the Reset Everything action with the typed-confirm gate. Deletes the identity file along with every other piece of learner state.
POST /api/reset {confirmation: "RESET"} (API mode) wipes
everything the backend owns:
- Every SQLite table (truncated in reverse FK order).
identity.yaml(removed entirely).- The
ai.*block insidesecrets.yaml(API keys + per- provider defaults).
secrets.yaml's top-level secret_key Fernet field is
deliberately preserved — removing it would make any
surviving encrypted data unreadable forever, which is a
worse failure mode than retaining a key whose ciphertexts no
longer exist. The same field survives the Dexie-mode reset
(it isn't touched there).
The confirmation token is case-sensitive and must be the
exact string "RESET". Anything else returns 400 before
any side effect runs.
User dismissal of the "Add to home screen" banner persists in
localStorage:
adaptive-learner.install_dismissed = "1"
Clear that key (DevTools → Application → Local Storage) to re-test the prompt without uninstalling the app.
CSS breakpoints live in frontend/src/styles/global.css
under the Mobile responsive polish (Phase 9A) block:
@media (max-width: 768px)— canonical mobile cut-over (hamburger drawer, 44px touch targets, stacked layouts).@media (max-width: 360px)— extreme-narrow safety net.- Desktop styles (≥769px) remain unchanged.