Configuration
tendant reads its settings from four places. When the same setting appears in more than one, the higher one wins:
- Admin settings changed at runtime in the app (stored in the database).
- Environment variables.
- The config file,
tendant.toml. - Built-in defaults.
The config file is found at ./tendant.toml,
$XDG_CONFIG_HOME/tendant/tendant.toml or /etc/tendant/tendant.toml, or
wherever tendant serve --config <path> points. On NixOS the module writes it
for you from services.homefirst.tendant.settings.
Every setting can also be given as an environment variable: TENDANT_ plus
the section and key joined by a double underscore, in capitals —
log.level becomes TENDANT_LOG__LEVEL. The older names in the table below
(DATABASE_URL, TENDANT_PASSWORD, …) keep working.
The lists — model connections, decision connections, sources, agents, MCP servers — can only be set in the config file (or the app), not in the environment.
Secrets
Section titled “Secrets”Don’t write secrets into tendant.toml. Reference them instead; tendant
resolves the reference when it starts:
[[llm_connections]]name = "claude"provider = "anthropic"model = "claude-sonnet-4-6"api_key = "${env:ANTHROPIC_API_KEY}" # from an environment variable# api_key = "${file:/run/secrets/anthropic}" # or from a file${env:NAME} also finds NAME_FILE (a file holding the value) and systemd
credentials (LoadCredential=NAME:…). Settings marked 🔒 below accept the same
three forms directly as environment variables, e.g.
TENDANT_CREDENTIALS_KEY_FILE=/run/secrets/key. Secrets are never logged or
shown in the app.
Required
Section titled “Required”| Setting | Environment | What it does |
|---|---|---|
database.url 🔒 | DATABASE_URL | The Postgres database. pgvector must be installed in it. |
auth.password 🔒 | TENDANT_PASSWORD | The device-pairing password. Unset ⇒ no device can pair (unless SSO is set up). |
credentials.key 🔒 | TENDANT_CREDENTIALS_KEY | openssl rand -base64 32. Encrypts source tokens and homefirst.cloud keys in the database. Back it up separately: a database restored without it loses those secrets. |
Common settings
Section titled “Common settings”| Setting | Environment | Default | What it does |
|---|---|---|---|
server.http_addr | HTTP_ADDR | :8080 | Where the web UI and API listen. |
server.public_url | TENDANT_SERVER__PUBLIC_URL | — | The address your browser uses to reach tendant (used after connecting accounts). |
server.trust_proxy_headers | — | false | Trust X-Forwarded-For from a reverse proxy (nginx, tailscale serve) so rate limiting sees real client addresses. Only enable behind a proxy. |
log.level | TENDANT_LOG__LEVEL | info | debug, info, warn, error. |
agent.connection | TENDANT_AGENT_CONNECTION | — | The model connection agents use. Unset ⇒ everything is routed to you. |
overseer.connection | TENDANT_OVERSEER_CONNECTION | — | The model connection that checks agents’ tool calls. |
intake.decision_connection | — | — | A decision connection (e.g. Laya) that triages incoming items. |
embedding.provider / .model / .base_url | — | off | Embeddings for category matching, e.g. openai + nomic-embed-text on Ollama. |
gatescript.asc_backend | TENDANT_ASC_BACKEND | — | subprocess compiles gate scripts on the server (needs asc). |
gatescript.tsgo | TENDANT_TSGO | — | lsp turns on gate-script editor completion (needs tsgo). |
hitl.approval_timeout | — | 72h | How long an approval request waits for you. |
Model connections
Section titled “Model connections”[[llm_connections]]name = "local-ollama"provider = "openai" # openai | anthropic | gemini | bedrockbase_url = "http://127.0.0.1:11434" # Ollama, or any OpenAI-compatible endpointmodel = "gemma4:12b"reasoning_effort = "none" # OpenAI-compatible only: none | low | medium | hightimeout = "120s" # per request (default 30s)
[[decision_connections]] # a "System One" decision modelname = "laya"provider = "systemone"base_url = "http://127.0.0.1:8000"model = "laya"profile = "laya" # local ⇒ no extra opt-in neededUse a model with reliable tool calls. gemma4:12b (about 8 GB, fits an 8 GB
GPU) is the recommended local model; qwen2.5:3b is a smaller, weaker
fallback, and very small models produce broken tool calls. gemma4 thinks
before answering by default; reasoning_effort = "none" turns that off,
which is several times faster with no loss we could measure. Changing
connections needs a restart.
Sources
Section titled “Sources”Sources are declared in the same file; see Connecting sources.
Notifications (ntfy / Gotify)
Section titled “Notifications (ntfy / Gotify)”To hear about things that need you — approvals, questions, new tasks, reminders — while the app is closed, point tendant at a ntfy topic and/or a Gotify application. A relay is switched on as soon as its required settings are present; both can be on at once.
Notifications never contain task content: they carry a generic title, the text
“Something in tendant needs your attention.” and a link that opens the item in
the web UI (behind tendant’s own sign-in). Set server.public_url so the link
works. A relay that’s down is retried briefly and then logged — the item is
still waiting in your inbox.
| Setting | What it does |
|---|---|
TENDANT_NTFY_URL (notify.ntfy.url) | Your ntfy server, e.g. https://ntfy.sh. Needs a topic. |
TENDANT_NTFY_TOPIC (notify.ntfy.topic) | The topic to publish to; subscribe to it in the ntfy app. On a public server pick an unguessable name or use a token. |
TENDANT_NTFY_TOKEN or …_FILE (notify.ntfy.token) | Optional access token (tk_…) or user:password. |
TENDANT_NTFY_PRIORITY (notify.ntfy.priority) | 1 (min) … 5 (max); unset ⇒ server default. |
TENDANT_NTFY_TAGS (notify.ntfy.tags) | Comma-separated tags / emoji, e.g. bell. |
TENDANT_GOTIFY_URL (notify.gotify.url) | Your Gotify server. Needs a token. |
TENDANT_GOTIFY_TOKEN or …_FILE (notify.gotify.token) | A Gotify application token. |
TENDANT_GOTIFY_PRIORITY (notify.gotify.priority) | Same 1–5 scale, mapped to Gotify’s 1/3/5/8/10. |
The agent’s send-push-notification tool uses the same relays (its title and
body are sent as written, after the gate approves the call).
homefirst.cloud
Section titled “homefirst.cloud”| Setting | What it does |
|---|---|
HOMEFIRST_CLOUD_ADDR (cloud.addr) | The cloud’s address. Unset ⇒ the official cloud, connect.homefirst.cloud:443; set to "" ⇒ tendant stays offline and sources poll. |
HOMEFIRST_SERVER_ID (cloud.server_id) | This server’s id at the cloud; enrollment codes are issued for it. |
HOMEFIRST_CLOUD_ENROLLMENT_CODE or …_FILE (cloud.enrollment_code) | The one-time enrollment code from the operator. Used only while the server has no key; remove it once enrolled (or enroll from Settings → Connected accounts instead). |
cloud.key_rotation_interval | How often the server key is replaced (default 720h; 0 = only when you press Rotate key). Changeable at runtime. |
TENDANT_CREDENTIALS_KEY (credentials.key) | Also encrypts the server’s cloud keys; required to enroll. |
See Connecting to homefirst.cloud for enrolling, key rotation and what to do when the cloud no longer recognizes the server.
Signing in with SSO (OIDC)
Section titled “Signing in with SSO (OIDC)”Instead of typing the pairing password on each device, you can sign in through your OpenID Connect provider (Authelia, Authentik, Keycloak, Pocket ID, Google, …). When it’s set up, the sign-in screen shows Sign in with SSO next to the pairing form. Password pairing keeps working.
tendant has one person, the owner. SSO doesn’t create users. An identity the provider has verified, and that is on your allowlist, signs in as the owner and gets a device session exactly like a paired device: it’s named after the device, appears under Settings → Devices, and can be revoked there. The allowlist decides everything, so it is required. With no allowlist, SSO stays off and the log says why. An identity is let in when any of these match:
- its subject (
sub) is inallowed_subjects. This is the most precise, since a subject never changes or gets reused. - its email is in
allowed_emailsand the provider marks it verified (email_verified). Matching ignores case. - its groups claim contains
allowed_group.
| Setting | Environment | What it does |
|---|---|---|
auth.oidc.issuer_url | TENDANT_OIDC_ISSUER_URL | The provider’s issuer URL. With client_id and an allowlist, this turns SSO on. |
auth.oidc.client_id | TENDANT_OIDC_CLIENT_ID | The client registered for tendant at the provider. |
auth.oidc.client_secret 🔒 | TENDANT_OIDC_CLIENT_SECRET (or …_FILE) | Its secret. Leave it empty for a public client. PKCE is always used. |
auth.oidc.redirect_url | TENDANT_OIDC_REDIRECT_URL | The callback registered at the provider. Defaults to server.public_url + /auth/oidc/callback. One of the two is required. |
auth.oidc.allowed_subjects | TENDANT_OIDC_ALLOWED_SUBJECTS | Comma-separated subjects allowed to sign in as the owner. |
auth.oidc.allowed_emails | TENDANT_OIDC_ALLOWED_EMAILS | Comma-separated verified emails allowed to sign in as the owner. |
auth.oidc.allowed_group | TENDANT_OIDC_ALLOWED_GROUP | A group whose members may sign in as the owner. |
auth.oidc.groups_claim | TENDANT_OIDC_GROUPS_CLAIM | The ID-token claim holding groups (default groups). |
[auth.oidc]issuer_url = "https://auth.example.com"client_id = "tendant"client_secret = "${file:/run/secrets/tendant-oidc}"allowed_emails = "me@example.com"Registering tendant at the provider: create a client using the
authorization-code flow with the scopes openid email profile (plus whatever
adds groups to the ID token, if you use allowed_group). Register one
redirect URI: https://<your tendant>/auth/oidc/callback. The apps need
nothing extra. Only tendant talks to the provider.
How the apps sign in:
- Desktop: the app opens your system browser and listens on a random port
on
127.0.0.1. After the provider, tendant sends the browser to that port with a one-time code, never the session token. The app trades the code, together with a PKCE secret only it holds, atPOST /auth/oidc/exchangefor a device token. Return addresses other than127.0.0.1or[::1]loopbacks are refused. - Web: the page goes to the provider and comes back as
/?sso_code=…. The app trades that code the same way, and an http-only cookie set on the same browser proves it.
Codes work once and expire after 60 seconds. A sign-in must finish within 10 minutes. Failed exchanges and allowlist refusals share the pairing password’s rate limit (below).
tendant finds the provider in the background, so a provider that’s down never
blocks startup or password pairing. Until tendant reaches it, the button stays
hidden (GET /auth/status reports "oidc": false) and tendant keeps retrying.
On NixOS, set the non-secret keys under
services.homefirst.tendant.settings.auth.oidc and pass the secret as a
credential: services.homefirst.tendant.credentials.TENDANT_OIDC_CLIENT_SECRET = "/run/secrets/tendant-oidc";.
Pairing and intake security
Section titled “Pairing and intake security”| Setting | What it does |
|---|---|
TENDANT_PASSWORD (auth.password) | The device-pairing password. Wrong guesses are rate limited: 5 free per client IP, then lockouts doubling from 2s to 15 minutes; more than 30 failures in 15 minutes lock pairing for everyone until the window clears. Failed SSO exchanges and allowlist refusals count toward the same budget. |
server.trust_proxy_headers | Set true when tendant sits behind exactly one reverse proxy that appends X-Forwarded-For, so the limiter sees real client IPs (otherwise every client looks like the proxy and shares one budget). |
[[sources]] webhook_secret | Pins a webhook-in / pebble-in source’s webhook secret (use ${env:…} or ${file:…}). Unset ⇒ tendant generates one. See Webhooks and the Pebble voice memo. |
intake.pebble_secret | Optional instance-wide secret the Pebble endpoint accepts in addition to each source’s own; the endpoint is never open without one or the other. |
Every setting
Section titled “Every setting”The complete list — every key with its type, default, whether it can be
changed at runtime and when a change applies — is generated from the code:
apps/tendant/docs/configuration-reference.md.
A fully commented example config ships as
tendant.example.toml.