Skip to content

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

VariablePurpose
SESSION_SECRETSigns 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_KEYAES-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_CODEThe 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

VariablePurpose
OPENROUTER_API_KEYThe 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_USDHard ceiling on lifetime spend across the whole install. Default 18.
LLM_BUDGET_DAILY_USDHard ceiling on spend per calendar day. Default 18.
LLM_BUDGET_PER_PROJECT_USDHard 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

VariablePurpose
NOVA_MODEL_CHEAP, NOVA_MODEL_STANDARD, NOVA_MODEL_STRONG, NOVA_MODEL_IMAGE, NOVA_MODEL_EMBEDOverride 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.

VariablePurposeWhen unset
LLM_PROVIDER_CHEAP, LLM_PROVIDER_STANDARD, LLM_PROVIDER_STRONG, LLM_PROVIDER_IMAGE, LLM_PROVIDER_EMBEDWhich 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_URLThe 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_EMBEDThe 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_KEYOptional 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_FALLBACKWhether 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.

VariablePurposeDefault
STRIPE_SECRET_KEYStripe’s secret API key.none — Stripe stays unconfigured
STRIPE_WEBHOOK_SECRETSigns the webhook Stripe sends back; the only thing that actually settles a top-up.none
STRIPE_PUBLISHABLE_KEYPublic 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_FLOWhosted (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_TAXWhether Stripe Tax is enabled.true
STRIPE_WEBHOOK_TOLERANCE_SECONDSClock skew tolerated on a webhook timestamp.300
PAYMENTS_CURRENCYThe base currency.gbp
MERCHANT_NAME, MERCHANT_COUNTRY, MERCHANT_VAT_NUMBERThe trading entity, on Checkout metadata and the owner’s records.Vocabotics Ltd, GB, none
PAYMENTS_SUCCESS_URL, PAYMENTS_CANCEL_URLWhere Stripe returns the customer’s browser.<WEB_ORIGIN>/market?tab=wallet&topup=success|cancelled
PAYMENTS_MIN_PAYOUT_CREDITSSmallest payout a seller can request.1000
PAYMENTS_MAX_TOPUP_CREDITSCeiling on a single top-up.500000

See Stripe, top-ups and payouts for what each of these actually changes.

Optional search backends

VariablePurpose
TAVILY_API_KEYIf set, web.search prefers Tavily first.
BRAVE_API_KEYSecond 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

VariablePurpose
TELEGRAM_BOT_TOKENThe token for the one world bot, from @BotFather. Powers the Telegram bridge to Muse — see Connecting Telegram.
GOOGLE_CLIENT_IDOAuth client id from Google Cloud Console. Powers Gmail ingestion — see Connecting Gmail.
GOOGLE_CLIENT_SECRETThe matching OAuth client secret.
GOOGLE_REDIRECT_URIMust 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

VariablePurpose
FILES_ROOTThe 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_DIRWhere Studio projects and uploads live on disk. Default ../../workspace (relative to apps/api), i.e. the repo’s workspace/ folder.
DATABASE_PATHPath to the SQLite file. Default ./data/novaterra.db.
PORTThe API’s HTTP port. Default 4000.
WEB_ORIGINThe 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.
HOSTBind 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.

VariableWhat it doesIf unset
DIRECTORY_ENDPOINTThe 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_COURIERoff 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_MSHow 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_MSHow 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

VariablePurposeUnset
NOVA_SANDBOX_RUNTIMEWhat runs code.execute and the scaffold build step: wasm | nova | docker | auto. See Sandboxing.wasmruntime/nova-wasm, which ships with the repo and needs nothing installed. In wasm mode Docker is not probed at all.
NOVA_SANDBOX_ALLOW_LOCAL1 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_DOCKERSkips 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:

Terminal window
OPENROUTER_API_KEY=sk-or-v1-...
LLM_BUDGET_TOTAL_USD=18
LLM_BUDGET_DAILY_USD=18
LLM_BUDGET_PER_PROJECT_USD=1.5
SESSION_SECRET=8f3a1c9e2b7d4f60a1e5c3b8d9f2a7e40c6b1d8f3a9e2c7b5d0f4a8e1c9b6d3f
ENCRYPTION_KEY=2d9f4b7a1e6c3d8f0a5b9e2c7d4f1a8b3e6c9d0f2a7b4e1c8d5f3a9b6e0c2d7f
OWNER_ACCESS_CODE=NOVA-OWNER-0001

(Generate your own secrets — never reuse the examples above.)