Environment variables
.env lives at the repo root and is never committed (.env.example is the tracked template). The
API reads it at boot via apps/api/src/config.ts. Nothing here is required to start the app —
only SESSION_SECRET and ENCRYPTION_KEY should genuinely be changed from their placeholders
before you register a real account, because they protect your session cookies and your connections’
secrets.
Required for a real deployment
| Variable | Purpose |
|---|---|
SESSION_SECRET | Signs the nova_session cookie. Changing it signs everyone out. Set it to a long random string — openssl rand -hex 32 or the PowerShell one-liner in the self-hosting guide. |
ENCRYPTION_KEY | AES-256-GCM key used to encrypt connection secrets at rest (Gmail tokens, Telegram bot tokens, file-root paths). Same rule: long and random. |
OWNER_ACCESS_CODE | The first access code that exists in the world. Whoever registers with it becomes the owner (see Access codes and the waitlist). Default in .env.example is NOVA-OWNER-0001 — change it before you show the app to anyone else. |
The LLM key and its guards
| Variable | Purpose |
|---|---|
OPENROUTER_API_KEY | The only LLM provider Novaterra speaks to directly. Leave it blank and the API still boots — /api/health reports it as unconfigured, and every LLM-backed path (Muse, Studio, memory extraction, image generation) returns a clear error instead of crashing or silently doing nothing. |
LLM_BUDGET_TOTAL_USD | Hard ceiling on lifetime spend across the whole install. Default 18. |
LLM_BUDGET_DAILY_USD | Hard ceiling on spend per calendar day. Default 18. |
LLM_BUDGET_PER_PROJECT_USD | Hard ceiling on spend for a single Studio project. Default 1.5. |
Every one of these is enforced server-side before the call is made, not after — see
Models and the budget guards for exactly how. 0 is not
“unlimited” — it means zero dollars, so every call is refused. To actually disable a particular
guard, set it to off, none, or unlimited instead (not recommended once real money is on the
key).
Model overrides
| Variable | Purpose |
|---|---|
NOVA_MODEL_CHEAP, NOVA_MODEL_STANDARD, NOVA_MODEL_STRONG, NOVA_MODEL_IMAGE, NOVA_MODEL_EMBED | Override the model id used for that tier, e.g. NOVA_MODEL_STANDARD=openai/gpt-4.1-mini. |
..._FALLBACK variants (e.g. NOVA_MODEL_CHEAP_FALLBACK) | A second model id to retry with if the primary one errors or is rate-limited. |
All of these live in one place in code too — packages/llm/src/models.ts — so you never have to
hunt through call sites to change what a tier resolves to.
Local-first inference (PLAN §7b)
Everything in this section is optional. Leave it all unset and every tier behaves exactly as before — resolved to OpenRouter via the model overrides above. See Local-first inference for the full guide (setup, suggested models, the “detect local models” button, and the failure modes that surprise people); this is just the variable reference.
| Variable | Purpose | When unset |
|---|---|---|
LLM_PROVIDER_CHEAP, LLM_PROVIDER_STANDARD, LLM_PROVIDER_STRONG, LLM_PROVIDER_IMAGE, LLM_PROVIDER_EMBED | Which provider answers that tier: openrouter | local | webllm. | openrouter — that tier is unaffected. An unrecognised value also falls back to openrouter, so a typo can never silently stop inference. |
LOCAL_LLM_BASE_URL | The OpenAI-compatible root of your own machine (Ollama, llama.cpp server, vLLM, LM Studio all answer this protocol). | http://localhost:11434/v1 (Ollama’s default). |
LOCAL_LLM_MODEL_CHEAP, LOCAL_LLM_MODEL_STANDARD, LOCAL_LLM_MODEL_STRONG, LOCAL_LLM_MODEL_IMAGE, LOCAL_LLM_MODEL_EMBED | The model id to ask that server for, per tier — exactly as your server lists it (e.g. ollama list). | That tier silently stays on OpenRouter even if LLM_PROVIDER_<TIER>=local, and the Inference panel shows a note explaining why. |
LOCAL_LLM_API_KEY | Optional bearer token for the local server. Ollama ignores it; vLLM (--api-key) and LM Studio can require one. | No Authorization header is sent to the local server at all. |
LOCAL_LLM_FALLBACK | Whether a local tier is allowed to fall back to OpenRouter after a local failure. Only openrouter opts in. | none — the call simply fails. A sleeping GPU must never silently ship a prompt you meant to keep home; see the local-first guide for why this default is deliberate. |
Local calls cost $0 and do not draw down LLM_BUDGET_*_USD above, but they are still recorded in
llm_calls with costUsd: 0, so usage history stays complete — see
Models and the budget guards.
Payments (Stripe)
Everything in this section is optional, and the whole Stripe path stays dormant unless both
STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are set — see
Payments and credits for why the pairing matters (a secret key alone could
create a checkout the API could never confirm). Without them, top-ups still work through the
manual provider: the owner grants credits by hand.
| Variable | Purpose | Default |
|---|---|---|
STRIPE_SECRET_KEY | Stripe’s secret API key. | none — Stripe stays unconfigured |
STRIPE_WEBHOOK_SECRET | Signs the webhook Stripe sends back; the only thing that actually settles a top-up. | none |
STRIPE_PUBLISHABLE_KEY | Public key, safe to ship to the browser. | none |
STRIPE_PRICE_<PACKID> | e.g. STRIPE_PRICE_STARTER — a dashboard-managed Stripe Price id for that credit pack, needed for Stripe Tax’s tax_code. | unset — falls back to inline price_data |
STRIPE_CHECKOUT_FLOW | hosted (Stripe’s own page — gets Stripe Tax, VAT collection and SCA for free) or payment_intent (an on-page element; tax-exclusive). | hosted |
STRIPE_AUTOMATIC_TAX | Whether Stripe Tax is enabled. | true |
STRIPE_WEBHOOK_TOLERANCE_SECONDS | Clock skew tolerated on a webhook timestamp. | 300 |
PAYMENTS_CURRENCY | The base currency. | gbp |
MERCHANT_NAME, MERCHANT_COUNTRY, MERCHANT_VAT_NUMBER | The trading entity, on Checkout metadata and the owner’s records. | Vocabotics Ltd, GB, none |
PAYMENTS_SUCCESS_URL, PAYMENTS_CANCEL_URL | Where Stripe returns the customer’s browser. | <WEB_ORIGIN>/market?tab=wallet&topup=success|cancelled |
PAYMENTS_MIN_PAYOUT_CREDITS | Smallest payout a seller can request. | 1000 |
PAYMENTS_MAX_TOPUP_CREDITS | Ceiling on a single top-up. | 500000 |
See Stripe, top-ups and payouts for what each of these actually changes.
Optional search backends
| Variable | Purpose |
|---|---|
TAVILY_API_KEY | If set, web.search prefers Tavily first. |
BRAVE_API_KEY | Second preference, after Tavily. |
Neither is required. Without either, web.search falls through to OpenRouter’s :online model
suffix (works with just your OpenRouter key), and finally to a DuckDuckGo HTML scrape that needs no
key at all. See web.search for the full fallback chain.
Integrations
| Variable | Purpose |
|---|---|
TELEGRAM_BOT_TOKEN | The token for the one world bot, from @BotFather. Powers the Telegram bridge to Muse — see Connecting Telegram. |
GOOGLE_CLIENT_ID | OAuth client id from Google Cloud Console. Powers Gmail ingestion — see Connecting Gmail. |
GOOGLE_CLIENT_SECRET | The matching OAuth client secret. |
GOOGLE_REDIRECT_URI | Must exactly match a redirect URI registered on the OAuth client. Default http://localhost:4000/api/connections/gmail/callback. |
Leaving these blank doesn’t break anything — the corresponding Connection in
Twin status simply shows configured: false with a plain-language hint like “Set
TELEGRAM_BOT_TOKEN in .env”, and the connection UI explains what to do.
Paths and networking
| Variable | Purpose |
|---|---|
FILES_ROOT | The default folder the Twin indexes for the files connection, if the owner doesn’t choose one per-connection in Settings. Only ever used as a fallback for the owner’s own being — every other citizen or registered human must connect a folder explicitly. Leave blank to require choosing a folder explicitly. |
WORKSPACE_DIR | Where Studio projects and uploads live on disk. Default ../../workspace (relative to apps/api), i.e. the repo’s workspace/ folder. |
DATABASE_PATH | Path to the SQLite file. Default ./data/novaterra.db. |
PORT | The API’s HTTP port. Default 4000. |
WEB_ORIGIN | The origin allowed to make credentialed CORS requests and to be treated as same-site for the session cookie. Default http://localhost:5173. In production, this becomes your web app’s real origin — see Vercel + Cloudflare production. |
HOST | Bind address for the Fastify server. HOST=:: binds dual-stack (IPv4 and IPv6). |
The mesh
All of these are optional, and a node with none of them set is a perfectly ordinary node that talks to nobody. Nothing here turns itself on.
| Variable | What it does | If unset |
|---|---|---|
DIRECTORY_ENDPOINT | The directory this node registers with and looks other nodes up through. See Finding other nodes. | The node has no directory. Peers are added by hand from an address somebody gives you, exactly as before there was one, and the directory routes answer “no directory is configured on this node”. |
NOVA_FEDERATION_COURIER | off stops the store-and-forward courier, so queued messages to other nodes are held and not attempted. | The courier runs. It never runs under NODE_ENV=test. |
NOVA_FEDERATION_COURIER_MS | How often the courier makes a pass, in milliseconds. | 30000. Retries back off from 30 seconds to a one-hour ceiling regardless of this. |
NOVA_NODE_HEARTBEAT_MS | How often the node writes its own heartbeat, which is what a later boot reads to tell a clean stop from a crash. | 15000, matching the interval at which a run’s claim goes stale. |
The directory’s own address is pinned the first time your node registers. An endpoint that later
answers with a different directory key is refused rather than followed, so changing
DIRECTORY_ENDPOINT to a different service is a deliberate act and not something a hijacked URL can
do quietly.
Two things that are deliberately not environment variables. Whether your Square accepts posts
from other nodes is a stored policy, not a variable, and it defaults to refusing all of them — see
Moderation and the Square. And a node’s identity keys are generated on
first boot and live in the database, encrypted under ENCRYPTION_KEY; there is no variable that
sets or supplies them, and the only way to move them is
pnpm identity:export.
The code sandbox
| Variable | Purpose | Unset |
|---|---|---|
NOVA_SANDBOX_RUNTIME | What runs code.execute and the scaffold build step: wasm | nova | docker | auto. See Sandboxing. | wasm — runtime/nova-wasm, which ships with the repo and needs nothing installed. In wasm mode Docker is not probed at all. |
NOVA_SANDBOX_ALLOW_LOCAL | 1 accepts running model-authored code unsandboxed, on the host, when no sandbox is available. | Off. The skill fails closed instead — which is the correct behaviour, because this variable converts a fail-closed skill into remote code execution by design. Never set it in production. |
NOVA_SANDBOX_DISABLE_DOCKER | Skips the Docker probe entirely, for a machine where a wedged Docker pipe would otherwise cost eight seconds. | Docker is probed when a mode asks for it. |
A minimal working .env
For local development with search and chat working but no Gmail/Telegram:
OPENROUTER_API_KEY=sk-or-v1-...LLM_BUDGET_TOTAL_USD=18LLM_BUDGET_DAILY_USD=18LLM_BUDGET_PER_PROJECT_USD=1.5SESSION_SECRET=8f3a1c9e2b7d4f60a1e5c3b8d9f2a7e40c6b1d8f3a9e2c7b5d0f4a8e1c9b6d3fENCRYPTION_KEY=2d9f4b7a1e6c3d8f0a5b9e2c7d4f1a8b3e6c9d0f2a7b4e1c8d5f3a9b6e0c2d7fOWNER_ACCESS_CODE=NOVA-OWNER-0001(Generate your own secrets — never reuse the examples above.)