# ============================================================================= # Blank Collar — Agentic OS · .env.example # ----------------------------------------------------------------------------- # Copy to `.env` and edit as needed: # # cp .env.example .env # # For Phase 0 (Groundwork) you do NOT need any external API keys — the data # layer runs locally with the defaults below. Future phases will require the # placeholder keys at the bottom of this file. # # RULE: never commit `.env`. Only `.env.example` is tracked. # ============================================================================= # ----------------------------------------------------------------------------- # Project metadata # ----------------------------------------------------------------------------- PROJECT_NAME=blankcollar ENV=local LOG_LEVEL=info PUBLIC_DOMAIN=www.blankcollar.ai AGENT_EMAIL=agent@blankcollar.ai # Public domains used by Caddy in production (docker-compose.prod.yml). # Point an A record for each at the VPS IP, then Caddy auto-issues TLS. NANGO_PUBLIC_DOMAIN=nango.blankcollar.ai NANGO_UI_DOMAIN=nango-ui.blankcollar.ai ACME_EMAIL=agent@blankcollar.ai # ----------------------------------------------------------------------------- # PostgreSQL # ----------------------------------------------------------------------------- POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=blankcollar POSTGRES_PORT=5432 # Convenience: full URL form for apps that prefer it DATABASE_URL=postgresql://postgres:postgres@postgres:5432/blankcollar # ----------------------------------------------------------------------------- # Neo4j (graph backend for Graphiti) # ----------------------------------------------------------------------------- NEO4J_HTTP_PORT=7474 NEO4J_BOLT_PORT=7687 NEO4J_USER=neo4j NEO4J_PASSWORD=password NEO4J_URI=bolt://neo4j:7687 # Memory tuning. Bump if you have RAM to spare; lower for a tight laptop. NEO4J_HEAP_INIT=512m NEO4J_HEAP_MAX=1g NEO4J_PAGECACHE=512m # ----------------------------------------------------------------------------- # Graphiti (temporal knowledge graph wrapper) # ----------------------------------------------------------------------------- GRAPHITI_PORT=8004 # In-cluster URL gbrain uses to reach graphiti (best-effort bridge). GRAPHITI_URL=http://graphiti:80 # graphiti-core uses an LLM to extract entities + relationships per episode. # Without an API key, /add returns {skipped: true, reason: "no_llm_configured"}. GRAPHITI_LLM_MODEL=gpt-4o-mini GRAPHITI_EMBEDDING_MODEL=text-embedding-3-small # ----------------------------------------------------------------------------- # Qdrant (vector store) # ----------------------------------------------------------------------------- QDRANT_HTTP_PORT=6333 QDRANT_GRPC_PORT=6334 # Leave empty for local-only. Set to a strong key when exposing Qdrant. QDRANT_API_KEY= QDRANT_URL=http://qdrant:6333 # ----------------------------------------------------------------------------- # Website (Swiss-editorial React console — apps/website) # ----------------------------------------------------------------------------- # The user-facing front door. Vite-built SPA served by nginx. WEBSITE_PORT=3000 # Comma-separated list of public website origins added to the Paperclip CORS # allowlist. localhost:3000 is always allowed. Set this in production: # WEBSITE_PUBLIC_URL=https://blankcollar.ai,https://www.blankcollar.ai WEBSITE_PUBLIC_URL= # Build-time vars consumed by Vite (apps/website). Inlined into the bundle. # VITE_PAPERCLIP_URL — base URL the SPA hits for /api/* (default :3001) # VITE_DEFAULT_ORG_SLUG — sent as X-BC-Org-Slug header (forward-compat, # currently informational; auth/multitenant # wiring lands in Phase 6) VITE_PAPERCLIP_URL=http://localhost:3001 VITE_DEFAULT_ORG_SLUG=blankcollar-demo # ----------------------------------------------------------------------------- # Paperclip (orchestrator + REST API) # ----------------------------------------------------------------------------- # Phase 4 port swap: website claims :3000, paperclip serves /api/* on :3001. PAPERCLIP_PORT=3001 # In-cluster URL other services use to reach Paperclip. PAPERCLIP_URL=http://paperclip:80 # Real Paperclip (upstream paperclipai) — primary command centre UI. PAPERCLIP_REAL_PORT=3100 # Used by the future API to sign session/JWT cookies. Generate with: # openssl rand -hex 32 PAPERCLIP_AUTH_SECRET=replace-me-with-a-32-byte-hex-string # Until real auth ships (Phase 6), every request resolves to the demo org's owner. # Personal headless mode sets this to blankcollar-personal (make personal does it # for you) and activates the overlay stack via COMPOSE_FILE, e.g.: # COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml:docker-compose.personal.yml PAPERCLIP_DEFAULT_ORG_SLUG=blankcollar-demo # In-process queue worker tuning. Disable with PAPERCLIP_WORKER_ENABLED=false. PAPERCLIP_WORKER_POLL_MS=1500 PAPERCLIP_WORKER_ENABLED=true # In-process scheduler — fires kind=routine goals on their cron_expr, # and auto-generates daily briefings at PAPERCLIP_BRIEFING_HOUR_UTC (default 8). PAPERCLIP_SCHEDULER_ENABLED=true PAPERCLIP_SCHEDULER_TICK_MS=60000 PAPERCLIP_BRIEFING_HOUR_UTC=8 # Phase 2.6 strict-RLS flip. When true (the default), unscoped queries on # tenant tables return zero rows and writes fail. Set to "false" only as a # temporary escape hatch if a callsite was missed during the migration. PAPERCLIP_RLS_STRICT=true # HMAC secret for the generic capture webhook receiver (POST /api/webhooks/capture). # When unset, the endpoint returns 503 — same convention as Stripe. INBOUND_CAPTURE_WEBHOOK_SECRET= # Nango — Provider-Config-Key for Google Workspace + secret for the proxy. # Set NANGO_GOOGLE_PROVIDER_KEY if your Nango config uses a non-default name. NANGO_GOOGLE_PROVIDER_KEY=google NANGO_SECRET_KEY= # AI gateway — every LLM call from paperclip (briefings, classifier) is # routed through Portkey for unified observability + key management. # # OPTIONAL out of the box. With these unset, paperclip + hermes run in # FakeLLM mode and return clearly-labelled canned responses, so you can # explore the stack with `make bootstrap` before paying for API tokens. # # To enable real Claude: # 1. Get a key at https://app.portkey.ai/ # 2. Either (a) configure a Model Catalog workspace + Anthropic provider # and set PAPERCLIP_LLM_MODEL=@workspace/model-id, OR (b) create a # legacy "virtual key" and paste it in PORTKEY_VIRTUAL_KEY_ANTHROPIC. # 3. Paste your Portkey API key below. PORTKEY_API_KEY= PORTKEY_VIRTUAL_KEY_ANTHROPIC= # Force FakeLLM mode regardless of Portkey config — handy for offline # development. Default: false (use real Claude when configured). BLANKCOLLAR_FAKE_LLM=false # Custom / Subscription LLM Proxy support: # Set LLM_PROVIDER=custom_proxy to bypass Portkey and forward requests # directly to your custom subscription LLM proxy (Codex, Antigravity, Cursor, Kimi, etc.) # LLM_PROVIDER options: "portkey" (default), "custom_proxy", "fake" # # Custom Proxy Example: # LLM_PROVIDER=custom_proxy # LLM_BASE_URL=http://host.docker.internal:8080/v1 # LLM_API_KEY=your-proxy-key-if-any LLM_PROVIDER=portkey LLM_BASE_URL= LLM_API_KEY= # Optional second virtual key — Portkey can also route to OpenRouter, # which exposes hundreds of models behind one API. Default callers use # the Anthropic VK; per-call override (provider='openrouter') routes # through OpenRouter for models Anthropic doesn't host (Gemini, Llama, …). PORTKEY_VIRTUAL_KEY_OPENROUTER= PORTKEY_BASE_URL=https://api.portkey.ai/v1 PAPERCLIP_LLM_MODEL=claude-sonnet-4-6 PAPERCLIP_LLM_MAX_TOKENS=800 # In-cluster URL Paperclip uses to reach gbrain. GBRAIN_URL=http://gbrain:80 # ----------------------------------------------------------------------------- # Hermes Agent (general-purpose reasoning) # ----------------------------------------------------------------------------- HERMES_PORT=8001 HERMES_API_KEY= # In-cluster URL Paperclip uses to dispatch runs to Hermes. HERMES_URL=http://hermes:80 # LLM model + budget. Without ANTHROPIC_API_KEY, Hermes falls back to FakeLLM. HERMES_MODEL=claude-sonnet-4-6 HERMES_MAX_TOKENS=1024 HERMES_MAX_RECALL=8 # ----------------------------------------------------------------------------- # LangGraph Dispatcher (multi-agent orchestrator on top of Hermes + OpenClaw) # ----------------------------------------------------------------------------- LANGGRAPH_PORT=8005 LANGGRAPH_URL=http://langgraph:80 # Classifier picks the model from the same LLM precedence Hermes uses # (NEXOS > ANTHROPIC > OPENAI > keyword fallback). LANGGRAPH_CLASSIFIER_MODEL=claude-sonnet-4-6 LANGGRAPH_CLASSIFIER_MAX_TOKENS=200 # Hard cap on graph cycles per run, to prevent infinite loops. LANGGRAPH_MAX_CYCLES=4 LANGGRAPH_POLL_INTERVAL_S=0.5 LANGGRAPH_POLL_TIMEOUT_S=180 # ----------------------------------------------------------------------------- # OpenClaw Agent (tool / web actions) # ----------------------------------------------------------------------------- OPENCLAW_PORT=8002 OPENCLAW_API_KEY= OPENCLAW_URL=http://openclaw:80 # Politeness controls for web.fetch OPENCLAW_FETCH_TIMEOUT_S=10 OPENCLAW_FETCH_MAX_BYTES=1500000 OPENCLAW_USER_AGENT="BlankCollar-OpenClaw/0.1 (+https://www.blankcollar.ai)" OPENCLAW_TEXT_EXCERPT_CHARS=8000 # web.browse (Playwright + headless Chromium) OPENCLAW_BROWSER_TIMEOUT_S=30 OPENCLAW_BROWSER_VIEWPORT_W=1280 OPENCLAW_BROWSER_VIEWPORT_H=800 OPENCLAW_BROWSER_MAX_SCREENSHOT_BYTES=2000000 # Oxylabs AI Studio (web.search skill). When OXYLABS_API_KEY is empty, # OpenClaw falls back to DuckDuckGo Instant Answers so the demo runs. OXYLABS_API_KEY= OXYLABS_BASE_URL=https://api.aistudio.oxylabs.io OXYLABS_SEARCH_PATH=/v1/search OXYLABS_DEFAULT_RESULTS=10 OXYLABS_TIMEOUT_S=30 # ----------------------------------------------------------------------------- # Brand Foundation (design.md). The runtime layer Hermes and OpenClaw use to # pull voice/banned-words/positioning into every response. Default file: # brand/blankcollar.md (mounted read-only at /app/brand inside containers). # Swap by adding brand/.md and setting BRAND_NAME=. # ----------------------------------------------------------------------------- BRAND_DIR=/app/brand BRAND_NAME=blankcollar # ----------------------------------------------------------------------------- # gbrain (memory layer) # ----------------------------------------------------------------------------- GBRAIN_PORT=8003 # Default embedding model used to populate the Company Brain. GBRAIN_EMBED_MODEL=text-embedding-3-small GBRAIN_EMBED_DIM=1536 # ----------------------------------------------------------------------------- # LLM providers — Hermes prefers Nexos > Anthropic > FakeLLM (no key) # ----------------------------------------------------------------------------- # Nexos.ai (Hostinger AI gateway, OpenAI-compatible Chat Completions) NEXOS_API_KEY= NEXOS_BASE_URL=https://api.nexos.ai/v1 # Available models per Hostinger's nexos.ai guide: # claude-sonnet · claude-opus · gpt-4.1 · gemini-3-flash · grok-4 NEXOS_MODEL=claude-sonnet # Direct providers (handy for local dev / fallback) ANTHROPIC_API_KEY= OPENAI_API_KEY= # ----------------------------------------------------------------------------- # pgAdmin (only used when you run with `--profile tools`) # ----------------------------------------------------------------------------- PGADMIN_EMAIL=admin@blankcollar.local PGADMIN_PASSWORD=admin PGADMIN_PORT=5050 # ============================================================================= # FUTURE PHASES — placeholders only, no value required today # ============================================================================= # --- Supabase (auth + roles) ------------------------------------------------- # When SUPABASE_JWT_SECRET is set, Paperclip's API routes verify incoming # `Authorization: Bearer ` tokens. When unset, every caller resolves to # the demo org's owner (today's behaviour). Get the JWT secret from your # Supabase project: Settings → API → JWT Secret. SUPABASE_URL= SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= SUPABASE_JWT_SECRET= # Set to `true` to require a valid JWT on every API call. Default: false # (verify when present, fall back to stub when absent). PAPERCLIP_AUTH_ENFORCE=false # --- Nango (OAuth + tool integrations gateway) ------------------------------ # Self-hosted Nango server lets agents call 400+ external APIs (Slack, Notion, # GitHub, etc.) through a single proxy. After bring-up, open the Connect UI at # http://localhost:3009 to register your first integration. See docs/NANGO.md. NANGO_SERVER_PORT=3003 NANGO_CONNECT_UI_PORT=3009 NANGO_DB_USER=nango NANGO_DB_PASSWORD=nango NANGO_DB_NAME=nango NANGO_DB_HOST=nango-db NANGO_DB_INTERNAL_PORT=5432 # 32-byte base64 (256 bit) — Nango refuses to start if this is shorter. # CHANGE THIS in production. Generate with: # openssl rand -base64 32 # Keep stable across restarts or stored OAuth tokens become unreadable. NANGO_ENCRYPTION_KEY=Lktpnnl7UNIPVQgJBL3EE9U4VQCwswksinu1HLxbfxg= # URLs Nango uses to reference itself in OAuth callbacks etc. NANGO_SERVER_URL=http://localhost:3003 NANGO_PUBLIC_SERVER_URL=http://localhost:3003 # Flip to "true" + set the dashboard credentials below before you expose Nango # to anything beyond localhost. NANGO_FLAG_AUTH_ENABLED=false NANGO_DASHBOARD_USERNAME=admin NANGO_DASHBOARD_PASSWORD=admin NANGO_LOG_LEVEL=info # In-cluster URL OpenClaw uses to reach Nango. NANGO_URL=http://nango:3003 # Nango server-side secret key for the OpenClaw → Nango proxy calls. Find this # in the Nango dashboard after first-run (Environment Settings → Secret Key). # Empty value disables the nango.invoke skill at runtime (returns a clear # error message instead of crashing). NANGO_SECRET_KEY= # --- Stripe (billing) -------------------------------------------------------- # Paperclip exposes POST /api/webhooks/stripe — verifies the Stripe-Signature # header against STRIPE_WEBHOOK_SECRET and records events idempotently in # billing.stripe_event. The endpoint returns 503 if STRIPE_WEBHOOK_SECRET is # unset, so the rest of the stack stays healthy without billing configured. STRIPE_SECRET_KEY= STRIPE_PUBLISHABLE_KEY= STRIPE_WEBHOOK_SECRET= # Optional: hosted Stripe checkout link (no-code) — Settings/Billing surfaces # this as the "Upgrade" button when the subscription is on the free tier. STRIPE_CHECKOUT_URL= # Optional: customer-portal link Settings/Billing renders as "Manage plan" # once a subscription exists. For per-customer sessions, swap in # Stripe.billingPortal.sessions.create() later — same wire shape. STRIPE_BILLING_PORTAL_URL= # Phase 8.2 Stripe Checkout — paste the Price IDs from Stripe Dashboard # → Products. STRIPE_PRICE_DISPLAY_* are cosmetic (the headline used in # the in-app pricing card). Leave price ids empty to disable that tier. STRIPE_PRICE_ID_PRO= STRIPE_PRICE_ID_STUDIO= STRIPE_PRICE_DISPLAY_PRO=$49 / mo STRIPE_PRICE_DISPLAY_STUDIO=$199 / mo # Phase 7.b tier gating. When true, paperclip's checkTier() / gateOr402() # helpers enforce the org's subscription tier on protected endpoints # (e.g. agent count caps in /api/agents POST). Default: false — OSS # local installs and self-hosted deployments stay fully open. Set to # true on the hosted SaaS tier to require a paid plan. BLANKCOLLAR_BILLING_ENFORCE=false # --- Auth (Supabase) — Phase 8.1 -------------------------------------------- # Set in production to enable per-user accounts + per-user org bootstrap. # Leave empty in OSS local mode — every request resolves to the demo # org's owner via the stub scope. SUPABASE_URL= # JWT signing secret — Supabase Dashboard → Project Settings → API → JWT Secret. SUPABASE_JWT_SECRET= # When true, requests without a valid Bearer JWT get 401. Default false # so OSS dev keeps working. Set to true in production after the website # auth UI is wired and tested. PAPERCLIP_AUTH_ENFORCE=false # When true, the very first authenticated sign-in for a verified email # without an account auto-creates their org + owner role + seed pack. # Default true — flip to false to gate provisioning behind manual approval. PAPERCLIP_AUTO_BOOTSTRAP=true # Website-side anon key (safe to expose publicly per Supabase docs). # Frontend reads VITE_SUPABASE_URL + VITE_SUPABASE_ANON_KEY at build time. VITE_SUPABASE_URL= VITE_SUPABASE_ANON_KEY= # --- Transactional email (Phase 8.4) ---------------------------------------- # Provider: console (default — log only) | resend | postmark # Set MAIL_API_KEY for resend/postmark. MAIL_FROM is the sender; pick a # verified domain in your provider before going live. MAIL_PROVIDER=console MAIL_API_KEY= MAIL_FROM=Blank Collar # --- Email pipeline (agent@blankcollar.ai) ----------------------------------- # Outbound (used by OpenClaw's email.send skill). Empty values = "drafted" mode # (email NOT sent; the message is still recorded as a memory). SMTP_HOST= SMTP_PORT=587 SMTP_USER= SMTP_PASSWORD= SMTP_FROM=agent@blankcollar.ai SMTP_USE_TLS=false SMTP_TIMEOUT_S=20 # --- Telegram bot (Sprint TG / Phase 5b follow-up) --------------------------- # Optional — when both are set, paperclip exposes POST /api/webhooks/telegram # and the bot can converse with your studio brain via Portkey. # 1. Open Telegram, search BotFather, send /newbot, copy the token here. # 2. Pick any random string for TELEGRAM_WEBHOOK_SECRET (or run # `openssl rand -hex 32`). This secret round-trips between Telegram + # paperclip so we know inbound messages are genuine. # 3. Run `./infra/scripts/setup-telegram.sh https://.ngrok.io` # to register the webhook with Telegram. # The endpoint returns 503 when either is unset — the rest of the stack stays # healthy without Telegram configured. TELEGRAM_BOT_TOKEN= TELEGRAM_WEBHOOK_SECRET= # Inbound (used by the email-ingest service). When IMAP_HOST/IMAP_USER are # empty, email-ingest runs in idle/heartbeat mode (no polling). IMAP_HOST= IMAP_PORT=993 IMAP_USER= IMAP_PASSWORD= IMAP_FOLDER=INBOX IMAP_USE_SSL=true EMAIL_POLL_INTERVAL_S=60 INBOUND_EMAIL_WEBHOOK_SECRET=