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 | The operator’s exposure ceiling, over a moving window. It counts only calls nobody is billed for — the node’s own background work, the owner’s own use, and calls on a node whose owner has opened its key to everybody. A member spending credits they bought is neither refused by it nor counted in it. Default 40. |
LLM_BUDGET_DAILY_USD | The same exposure, per UTC day. Default 6. |
LLM_BUDGET_PER_PROJECT_USD | Hard ceiling on spend for a single Studio project. Default 1.5. |
LLM_BUDGET_WINDOW_DAYS | How far back LLM_BUDGET_TOTAL_USD looks. Default 30 — one billing month, which is the period the operator’s own invoice arrives on. off, none or unlimited makes it count every call the node has ever made, which is what it used to do unconditionally: a ceiling that can only be approached and never recede is one that will be hit, by arithmetic. |
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.
Membership (hosted nodes)
A membership is a separate, recurring charge from the top-ups above — one of its two plans provisions a personal node. It rides the same Stripe keys the top-ups do, so it is also dormant unless both are set. See Hosted nodes and membership for the plans themselves and what a subscription actually triggers.
| Variable | Purpose | Default |
|---|---|---|
STRIPE_PRICE_MEMBERSHIP_MEMBER, STRIPE_PRICE_MEMBERSHIP_NODE | Dashboard-managed recurring Stripe Price ids for the monthly price of each plan. Until a plan’s is set, that plan is shown, priced, and marked unavailable — never sold on an inline price. | unset |
STRIPE_PRICE_MEMBERSHIP_MEMBER_ANNUAL, STRIPE_PRICE_MEMBERSHIP_NODE_ANNUAL | The same, for the yearly price of each plan (two months free). An interval with no Price id is not on sale and is never inferred from the other one, so leaving these unset is how a node sells monthly only — /pricing then says “Paying by the year is not on sale on this node yet.” | unset |
MEMBERSHIP_SUCCESS_URL, MEMBERSHIP_CANCEL_URL | Where Stripe returns the browser after a membership checkout. | <WEB_ORIGIN>/settings?membership=started#billing, <WEB_ORIGIN>/pricing?membership=cancelled |
MEMBERSHIP_PORTAL_RETURN_URL | Where Stripe’s customer portal sends a person back to. | <WEB_ORIGIN>/settings#billing |
PROVISIONER_ENDPOINT, PROVISIONER_TOKEN | How this API reaches services/provisioner — a separate process, its own database, one bearer token over loopback. Without both, the Node plan cannot actually provision a machine. | unset — checkout still succeeds and the customer is still charged; the walker flags the node for operator attention as ProvisionerUnreachable and retries every pass, so setting these later resumes it rather than requiring a new checkout |
services/provisioner has its own environment on top of this (a Fly API token, org and region, the
hostname zone for custom domains) — a different service’s configuration, not repeated here.
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 directory this build shipped with, as a PAIR — the address and the did:key that must answer at it. Set it to none to have no directory at all. |
DIRECTORY_DID | The address that must answer at your own DIRECTORY_ENDPOINT. Setting both is the strongest form: the key is known before the first packet. | Trust on first use for an endpoint you set by hand; the shipped key for the shipped endpoint. |
NOVA_DIRECTORY_AUTO_REGISTER | on or off. Whether the node lists itself without being asked. | On for an installed or hosted node (NOVA_INSTALL), off in a checkout, never under NODE_ENV=test. |
NOVA_NODE_ENDPOINT | The address other nodes should use to reach this one, when the node cannot work that out for itself — behind a tunnel, a forwarded port, or a reverse proxy. | WEB_ORIGIN, unless that is loopback, in which case this node has no address others can reach and does not list itself. That is an ordinary state, not an error: it can still find other nodes, push to them and collect its own mail from them. What it cannot do is be found. |
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. |
NOVA_RELAY_ENDPOINT | The relay this node leaves mail at when it cannot dial a peer directly, and cannot be dialled either. See Can other people reach you?. | The relay this build shipped with, as a pair with NOVA_RELAY_DID, on an INSTALL. Set to none for no relay at all. |
NOVA_RELAY_DID | The address that must answer at your own NOVA_RELAY_ENDPOINT. | Trust on first use for a hand-set endpoint; the shipped key for the shipped one. |
NOVA_RELAY_AUTO | on or off. Whether the node enrols at its relay without being asked. | On for an installed node (NOVA_INSTALL), off in a checkout — enrolling opens a mailbox that lasts 30 days and renews on every pass, which a repository checkout should not do by surprise. |
NOVA_RELAY_ACCESS_CODE | Per-person, so deliberately not compiled into a build the way the endpoint and its key are. Read from this variable, or from one line in <data>/relay-code (the same directory owner-access-code lives in) if the variable is unset. Nodes built by services/provisioner are handed one; it is being wound down. | None on almost every install, and that is no longer a problem. A node with no code asks the relay for a signed admission instead — see Can other people reach you?. |
NOVA_RELAY_ADMISSION | off (also no, none, false, 0) stops this node asking its relay for an admission at all. For an owner who would rather be unreachable than tell a relay operator that this node exists — the same shape as NOVA_RELAY_ENDPOINT=none, one step finer. | On. The node asks once, keeps the signed document, and asks again when it expires. It only ever asks its own relay, the one compiled into this build or named by NOVA_RELAY_ENDPOINT. |
These three are set on the relay (services/relay), not on a node, and are listed here because
what a node must present is decided by them:
| Variable | What it does | If unset |
|---|---|---|
RELAY_ENROLMENT | What a node must present to open a mailbox. open — nothing, even if a code is set. grant — something, and any form this relay admits satisfies it, so a provisioned node keeps using its code while a self-hosted one asks for a grant and neither has to know which it is. | auto: a code if RELAY_ACCESS_CODE is set, otherwise nothing. Exactly what the service did before admission existed, so a relay somebody runs for their family is unaffected. |
RELAY_ADMIT | Whom this relay will mint an admission for. issuers — nobody; it mints nothing and believes only RELAY_GRANT_ISSUERS. off — no admission at all. | anyone: any address that proves its key and has not been banned. anyone is not open — an open relay cannot throw out one node, only close to everybody, while a per-node grant can be revoked and expires on its own. |
RELAY_GRANT_ISSUERS | Comma-separated did:keys whose admissions this relay believes besides its own. Keys, never endpoints: a node must not learn where to ask from a document a relay controls. Anything that is not a did:key is dropped rather than kept as a string nobody will ever match. | Empty. |
The directory’s own address is known before the first packet when it came from the build or from
DIRECTORY_DID, and pinned on first registration when you set only a URL. Either way, an
endpoint that answers with a different directory key is refused rather than followed, so changing
directories is a deliberate act and not something a hijacked URL can do quietly. The two are not the
same strength and GET /api/directory/status reports which you have, as pinSource: shipped,
configured, first-contact or none. Trust on first use is exactly as strong as the first
packet, which is the one an attacker in the path gets to answer — so it is named rather than
flattened into the others.
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=40LLM_BUDGET_DAILY_USD=6LLM_BUDGET_PER_PROJECT_USD=1.5SESSION_SECRET=8f3a1c9e2b7d4f60a1e5c3b8d9f2a7e40c6b1d8f3a9e2c7b5d0f4a8e1c9b6d3fENCRYPTION_KEY=2d9f4b7a1e6c3d8f0a5b9e2c7d4f1a8b3e6c9d0f2a7b4e1c8d5f3a9b6e0c2d7fOWNER_ACCESS_CODE=NOVA-OWNER-0001(Generate your own secrets — never reuse the examples above.)