# ============================================================================ # Krow backend — example environment # # Copy to .env and fill in. .env is gitignored and must never be committed. # Every value below is a placeholder or a safe local default: no real password, # API key or token belongs in this file. # # cp .env.example .env # ============================================================================ # ── Application ───────────────────────────────────────────────────────────── APP_ENV=development # development | staging | production LOG_LEVEL=info # debug | info | warn | error # ── HTTP server ───────────────────────────────────────────────────────────── HTTP_HOST=127.0.0.1 HTTP_PORT=8080 HTTP_READ_TIMEOUT=15s # 180s, not 30s. internal/config REFUSES TO START when this is below the deep # tier's 2m agent deadline: the server would abort the response mid-run and the # caller would see 502 from the proxy in front, a gateway error for something no # gateway did. 30s shipped here for a long time and was the cause of exactly # that incident. Anything at or under 2m0s is a container that will not boot. HTTP_WRITE_TIMEOUT=180s HTTP_IDLE_TIMEOUT=60s HTTP_SHUTDOWN_TIMEOUT=10s # Browser origins allowed to call this API cross-origin, comma-separated. # Unset in development it defaults to the Vite dev server on both hostnames # (localhost and 127.0.0.1 are different origins to a browser) plus `vite # preview`. Unset anywhere else it defaults to empty, meaning same-origin only. # Origins are matched exactly, echoed back one at a time, and "*" is rejected. # HTTP_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 # Networks whose X-Forwarded-For header may be believed. Comma-separated CIDR # blocks or bare addresses; both IP families accepted. # # Several limits are keyed by the caller's address: failed logins, OAuth client # registration, and OAuth authorization before sign-in. Behind a reverse proxy # every request arrives FROM the proxy, so without this setting those budgets # describe the proxy rather than the caller and every user shares one — one # person retrying a connector exhausts everybody's allowance. # # Unset means no proxy is trusted and the header is ignored entirely, which is # correct for local development: nothing sits in front of the dev server. Leave # it unset here. A misspelt value cannot open a hole — it only restores the # shared bucket — but a malformed entry stops startup rather than being dropped. # # NEVER set this to 0.0.0.0/0. That trusts every caller's own header, which is # not a weaker limit but no limit at all: anyone could mint a fresh budget per # request simply by changing the value they send. # HTTP_TRUSTED_PROXIES= # ── PostgreSQL ────────────────────────────────────────────────────────────── # The local development database. DATABASE_NAME is mixed-case and hyphenated, # so anything that interpolates it into SQL must quote it: "Krow-force". DATABASE_HOST=127.0.0.1 DATABASE_PORT=5432 DATABASE_NAME=Krow-force DATABASE_USER=postgres DATABASE_PASSWORD= DATABASE_SCHEMA=public # sslmode: disable is fine for a loopback dev database. APP_ENV=production # rejects `disable` at startup — use require or verify-full there. DATABASE_SSLMODE=disable # Pool and timeout tuning. DATABASE_MAX_OPEN_CONNS=25 DATABASE_MIN_IDLE_CONNS=2 DATABASE_CONN_MAX_LIFETIME=30m DATABASE_CONNECT_TIMEOUT=5s DATABASE_STATEMENT_TIMEOUT=10s # ── Migrations ────────────────────────────────────────────────────────────── # Consumed by the Makefile, which builds the golang-migrate URL from the # DATABASE_* values above. Keep it pointed at the repository's migrations/. MIGRATIONS_DIR=./migrations # ── Seed ──────────────────────────────────────────────────────────────────── # The demo fixture, generated from the frontend repository's src/api/seed.js. # The Makefile passes an absolute path; this default suits running from the # repository root. SEED_FIXTURE_PATH=./seed/fixtures/seed.json # ── Model gateway ─────────────────────────────────────────────────────────── # The one place this service talks to a language model. An agent spec declares # a `reasoning` tier — fast | balanced | deep — never a model id, so the # mapping below is a deployment decision and changes without editing a single # definition. # # WHICH PROVIDER ANSWERS is a deployment decision, but the wire protocol is no # longer one. There is a single implementation: # # openai the chat-completions shape — which is NOT only OpenAI. Groq, # Gemini (through its OpenAI-compatible endpoint), OpenRouter, # Together, vLLM and a local Ollama all serve it, so moving # between them is MODEL_BASE_URL and MODEL_* ids, nothing more. # # The anthropic path was REMOVED. MODEL_PROVIDER=anthropic is refused at # startup rather than ignored, because a stack still carrying it would # otherwise run on a vendor it never chose. Leave this empty or set "openai". MODEL_PROVIDER=openai # Where the provider is. Defaults to Groq when unset — the model ids below are # Groq ids, and an id is only meaningful against the service that serves it, so # these two settings move together or not at all. # # Groq https://api.groq.com/openai/v1 (the default) # Gemini https://generativelanguage.googleapis.com/v1beta/openai # OpenRouter https://openrouter.ai/api/v1 # Ollama http://localhost:11434/v1 (no key needed) MODEL_BASE_URL=https://api.groq.com/openai/v1 # The credential. ANTHROPIC_API_KEY is NO LONGER READ — if it is set while this # is empty, startup fails rather than silently ignoring it. # May be empty outside production: migrations, seeding and every endpoint that # is not an agent run work without one, and an agent run fails with a # structured `gateway.not_configured` rather than the service refusing to boot. # APP_ENV=production requires one — unless the model is on localhost, which # needs no credential at all. MODEL_API_KEY= # The tiers differ by model AND by *effort*, which the gateway fixes # (fast=low, balanced=high, deep=xhigh) so that "deep" cannot mean two # different things in two deployments. # # These must be ids your MODEL_BASE_URL actually serves. A leftover claude-* # id is refused at startup: it would be accepted by this process, rejected by # the provider, and fail every single run with a 400. MODEL_FAST=openai/gpt-oss-20b MODEL_BALANCED=openai/gpt-oss-120b MODEL_DEEP=openai/gpt-oss-120b MODEL_MAX_OUTPUT_TOKENS=16000 # Send the tier's effort level as `reasoning_effort` on the openai-compatible # wire. OFF by default and it should stay off unless every model named above is # a reasoning model: the others reject the entire request rather than ignoring # an unknown key, so turning this on for a non-reasoning model breaks every run # with a 400. Ignored by the anthropic provider, which always sends effort. MODEL_REASONING_EFFORT=false # ── Knowledge layer (retrieval) ───────────────────────────────────────────── # # The dense half of hybrid retrieval needs an embedding model. Three options, # and the choice is worth making deliberately: all three return vectors and # retrieval works with any of them, so a deployment running the wrong one looks # exactly like one running the right one — until somebody phrases a question # differently. # # ollama A model on this machine. Real semantics, no credential, no # per-token cost, and no tenant text leaving the host. Start here. # # brew install ollama # ollama pull nomic-embed-text # # then EMBED_PROVIDER=ollama. # # voyage Hosted, and better on subtle retrieval over a large messy corpus. # Needs VOYAGE_API_KEY. Anthropic does not serve embeddings, so # this is a separate credential. # # lexical A deterministic stand-in that hashes words into a vector. NOT # semantic — "annual leave" and "time off" are unrelated to it. It # exists so the permission filter and the citation path can be # tested without a network. Startup REFUSES it when # APP_ENV=production. # # Leave EMBED_PROVIDER empty and the choice is inferred from what is set, # preferring the local model. With nothing configured at all, retrieval runs # keyword-only and says so on every result. # # CHANGING PROVIDER MEANS RE-EMBEDDING. Vectors from two models are not # comparable, and every chunk records which model produced it — so after a # switch the old vectors are simply not searched, and retrieval silently drops # to keyword-only until you run: # # make reembed ORG= # EMBED_PROVIDER=ollama EMBED_BASE_URL=http://localhost:11434 EMBED_MODEL=nomic-embed-text EMBED_DIMENSIONS=768 # Only for EMBED_PROVIDER=voyage. VOYAGE_API_KEY= # Legacy switch for the stand-in. EMBED_PROVIDER=lexical is the current spelling. EMBED_USE_LEXICAL=false