# ============================================================================= # Blank Collar — Agentic OS · docker-compose.yml # ----------------------------------------------------------------------------- # Local-first, Mac-friendly. One command to bring up the entire groundwork: # # docker compose up -d # # Phase 0 (Groundwork): # - postgres : real, persistent # - qdrant : real, persistent # - paperclip : nginx placeholder (real service lands in Phase 2) # - hermes : nginx placeholder (real adapter lands in Phase 3) # - openclaw : nginx placeholder (real agent lands in Phase 3) # - gbrain : nginx placeholder (real memory layer lands in Phase 1) # - pgadmin : optional, enable with `--profile tools` # # All variables are loaded from `.env` (see `.env.example` for the schema). # Volumes are prefixed with `bc_` so they don't collide with other projects. # ============================================================================= name: blankcollar # --------------------------------------------------------------------------- # Reusable bits # --------------------------------------------------------------------------- x-restart-policy: &restart-policy restart: unless-stopped x-logging: &default-logging logging: driver: json-file options: max-size: "10m" max-file: "3" # --------------------------------------------------------------------------- # Networks # --------------------------------------------------------------------------- networks: bc_net: name: bc_net driver: bridge # --------------------------------------------------------------------------- # Persistent volumes # --------------------------------------------------------------------------- volumes: bc_postgres_data: name: bc_postgres_data bc_qdrant_data: name: bc_qdrant_data bc_pgadmin_data: name: bc_pgadmin_data bc_neo4j_data: name: bc_neo4j_data bc_neo4j_logs: name: bc_neo4j_logs bc_nango_db_data: name: bc_nango_db_data services: # ========================================================================= # PostgreSQL — relational state (goals, runs, users, audit log) # ========================================================================= postgres: <<: [*restart-policy, *default-logging] image: postgres:18.4-alpine container_name: bc_postgres environment: POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: ${POSTGRES_DB:-blankcollar} ports: - "${POSTGRES_PORT:-5432}:5432" volumes: # NOTE: Postgres 18 changed mount conventions — data lives in a # major-version subdirectory inside this path, not directly here. # See https://github.com/docker-library/postgres/pull/1259. # Mounting at /var/lib/postgresql/data (the old PG ≤17 convention) # makes PG 18 refuse to start with "unused mount/volume" error. - bc_postgres_data:/var/lib/postgresql # Init scripts run on first boot only (when data dir is empty) - ./infra/docker/postgres/init.sql:/docker-entrypoint-initdb.d/01_init.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-blankcollar}"] interval: 5s timeout: 5s retries: 10 networks: [bc_net] # ========================================================================= # Qdrant — vector store for embeddings (Company Brain backbone) # ========================================================================= qdrant: <<: [*restart-policy, *default-logging] image: qdrant/qdrant:v1.18.2 container_name: bc_qdrant environment: QDRANT__SERVICE__API_KEY: ${QDRANT_API_KEY:-} ports: - "${QDRANT_HTTP_PORT:-6333}:6333" # REST - "${QDRANT_GRPC_PORT:-6334}:6334" # gRPC volumes: - bc_qdrant_data:/qdrant/storage # NOTE: the official qdrant/qdrant image is a minimal debian-slim with # neither wget nor curl, so a HTTP-based healthcheck can't be expressed # purely inside the container. We rely on Docker's "running" state + # downstream apps' own retry logic (gbrain/httpx retries on connection # refused, paperclip /api/health probes gbrain). networks: [bc_net] # ========================================================================= # Paperclip (real) — REMOVED. Run natively on your Mac instead: # make paperclip # (or: cd ~ && npx paperclipai@latest run) # # Why not Docker: paperclipai's `local_trusted` mode hard-locks 127.0.0.1 # binding inside the container, and Docker Desktop on Mac doesn't expose # container loopback to the host (Mac-specific limitation, unlike Linux). # Tried: PAPERCLIP_BIND env, --bind lan, sed config patches, Node JSON # patches, host networking, socat reverse proxy. None worked end-to-end # in our environment. Native install does — `npx paperclipai run` from # your home folder takes 30 seconds and Just Works. # ========================================================================= # (intentionally no `paperclip-real` service) # ========================================================================= # Paperclip (legacy / integrations) — our custom Fastify orchestrator. # KEPT alongside paperclip-real because it owns features the upstream # project doesn't ship: Stripe webhook + idempotent event log, Supabase # JWT verification, our custom audit log, gbrain-aware plan generator. # The real command-centre UI lives at :3100; this layer continues to # answer at :3000 for those integrations. # ========================================================================= paperclip: <<: [*restart-policy, *default-logging] build: context: ./apps/paperclip dockerfile: Dockerfile image: blankcollar/paperclip:0.1.0 container_name: bc_paperclip environment: ENV: ${ENV:-local} LOG_LEVEL: ${LOG_LEVEL:-info} DATABASE_URL: ${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/blankcollar} GBRAIN_URL: ${GBRAIN_URL:-http://gbrain:80} HERMES_URL: ${HERMES_URL:-http://hermes:80} OPENCLAW_URL: ${OPENCLAW_URL:-http://openclaw:80} PAPERCLIP_DEFAULT_ORG_SLUG: ${PAPERCLIP_DEFAULT_ORG_SLUG:-blankcollar-demo} PAPERCLIP_WORKER_POLL_MS: ${PAPERCLIP_WORKER_POLL_MS:-1500} PAPERCLIP_WORKER_ENABLED: ${PAPERCLIP_WORKER_ENABLED:-true} # Supabase auth — verify when SUPABASE_JWT_SECRET is set; enforce only when explicit. SUPABASE_URL: ${SUPABASE_URL:-} SUPABASE_JWT_SECRET: ${SUPABASE_JWT_SECRET:-} PAPERCLIP_AUTH_ENFORCE: ${PAPERCLIP_AUTH_ENFORCE:-false} # Stripe webhook receiver — returns 503 when STRIPE_WEBHOOK_SECRET unset. STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} STRIPE_CHECKOUT_URL: ${STRIPE_CHECKOUT_URL:-} STRIPE_BILLING_PORTAL_URL: ${STRIPE_BILLING_PORTAL_URL:-} STRIPE_PRICE_ID_PRO: ${STRIPE_PRICE_ID_PRO:-} STRIPE_PRICE_ID_STUDIO: ${STRIPE_PRICE_ID_STUDIO:-} STRIPE_PRICE_DISPLAY_PRO: ${STRIPE_PRICE_DISPLAY_PRO:-} STRIPE_PRICE_DISPLAY_STUDIO: ${STRIPE_PRICE_DISPLAY_STUDIO:-} BLANKCOLLAR_BILLING_ENFORCE: ${BLANKCOLLAR_BILLING_ENFORCE:-false} # Phase 8.1 — auto-bootstrap on first sign-in (auth.ts + auth pre-handler). PAPERCLIP_AUTO_BOOTSTRAP: ${PAPERCLIP_AUTO_BOOTSTRAP:-true} # Phase 8.4 transactional email. MAIL_PROVIDER: ${MAIL_PROVIDER:-console} MAIL_API_KEY: ${MAIL_API_KEY:-} MAIL_FROM: ${MAIL_FROM:-} # Direct LLM access for prose (briefings). Hermes still owns the agent # loop; this is just a side door for editorial copy. Empty key = templated. ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} PAPERCLIP_LLM_MODEL: ${PAPERCLIP_LLM_MODEL:-claude-sonnet-4-6} PAPERCLIP_LLM_MAX_TOKENS: ${PAPERCLIP_LLM_MAX_TOKENS:-800} BRAND_DIR: ${BRAND_DIR:-/app/brand} BRAND_NAME: ${BRAND_NAME:-blankcollar} # Routine scheduler — fires kind=routine goals on their cron_expr, # auto-generates a daily briefing at PAPERCLIP_BRIEFING_HOUR_UTC. PAPERCLIP_SCHEDULER_ENABLED: ${PAPERCLIP_SCHEDULER_ENABLED:-true} PAPERCLIP_SCHEDULER_TICK_MS: ${PAPERCLIP_SCHEDULER_TICK_MS:-60000} PAPERCLIP_BRIEFING_HOUR_UTC: ${PAPERCLIP_BRIEFING_HOUR_UTC:-8} # Generic capture webhook (POST /api/webhooks/capture) — disabled when secret unset. INBOUND_CAPTURE_WEBHOOK_SECRET: ${INBOUND_CAPTURE_WEBHOOK_SECRET:-} # Nango proxy + provider config keys for Workspace connectors. NANGO_URL: ${NANGO_URL:-http://nango:3003} NANGO_SECRET_KEY: ${NANGO_SECRET_KEY:-} NANGO_GOOGLE_PROVIDER_KEY: ${NANGO_GOOGLE_PROVIDER_KEY:-google} # Portkey AI gateway — required at boot. Set both in .env. PORTKEY_API_KEY: ${PORTKEY_API_KEY:-} PORTKEY_VIRTUAL_KEY_ANTHROPIC: ${PORTKEY_VIRTUAL_KEY_ANTHROPIC:-} PORTKEY_VIRTUAL_KEY_OPENROUTER: ${PORTKEY_VIRTUAL_KEY_OPENROUTER:-} PORTKEY_BASE_URL: ${PORTKEY_BASE_URL:-https://api.portkey.ai/v1} LLM_PROVIDER: ${LLM_PROVIDER:-portkey} LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_API_KEY: ${LLM_API_KEY:-} BLANKCOLLAR_FAKE_LLM: ${BLANKCOLLAR_FAKE_LLM:-false} # Comma-separated list of extra origins added to the CORS allowlist. # localhost:3000 is always allowed; set this in prod for the public domain. WEBSITE_PUBLIC_URL: ${WEBSITE_PUBLIC_URL:-} # Telegram bot — POST /api/webhooks/telegram. Both required to enable. TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_WEBHOOK_SECRET:-} extra_hosts: - "host.docker.internal:host-gateway" ports: - "${PAPERCLIP_PORT:-3001}:80" volumes: - ./brand:/app/brand:ro - ./packages/skills:/app/packages/skills:ro depends_on: postgres: condition: service_healthy qdrant: condition: service_started gbrain: condition: service_healthy hermes: condition: service_healthy openclaw: condition: service_healthy networks: [bc_net] # ========================================================================= # Hermes — general-purpose reasoning agent (Phase 3, real) # Implements the Agent Adapter Contract (docs/AGENTS.md). Wraps Anthropic # Claude Sonnet; deterministic FakeLLM fallback when ANTHROPIC_API_KEY is # unset so the demo runs offline. # ========================================================================= hermes: <<: [*restart-policy, *default-logging] build: context: ./apps/hermes dockerfile: Dockerfile image: blankcollar/hermes:0.1.0 container_name: bc_hermes environment: ENV: ${ENV:-local} LOG_LEVEL: ${LOG_LEVEL:-info} GBRAIN_URL: ${GBRAIN_URL:-http://gbrain:80} # Provider preference: Nexos (Hostinger) > Anthropic > FakeLLM NEXOS_API_KEY: ${NEXOS_API_KEY:-} NEXOS_BASE_URL: ${NEXOS_BASE_URL:-https://api.nexos.ai/v1} NEXOS_MODEL: ${NEXOS_MODEL:-claude-sonnet} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} HERMES_MODEL: ${HERMES_MODEL:-claude-sonnet-4-6} HERMES_MAX_TOKENS: ${HERMES_MAX_TOKENS:-1024} HERMES_MAX_RECALL: ${HERMES_MAX_RECALL:-8} # Portkey AI gateway — required at boot. Set both in .env. PORTKEY_API_KEY: ${PORTKEY_API_KEY:-} PORTKEY_VIRTUAL_KEY_ANTHROPIC: ${PORTKEY_VIRTUAL_KEY_ANTHROPIC:-} PORTKEY_VIRTUAL_KEY_OPENROUTER: ${PORTKEY_VIRTUAL_KEY_OPENROUTER:-} PORTKEY_BASE_URL: ${PORTKEY_BASE_URL:-https://api.portkey.ai/v1} LLM_PROVIDER: ${LLM_PROVIDER:-portkey} LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_API_KEY: ${LLM_API_KEY:-} BLANKCOLLAR_FAKE_LLM: ${BLANKCOLLAR_FAKE_LLM:-false} BRAND_DIR: ${BRAND_DIR:-/app/brand} BRAND_NAME: ${BRAND_NAME:-blankcollar} extra_hosts: - "host.docker.internal:host-gateway" ports: - "${HERMES_PORT:-8001}:80" volumes: - ./brand:/app/brand:ro depends_on: gbrain: condition: service_healthy networks: [bc_net] # ========================================================================= # OpenClaw — tool / web-action agent (Phase 3, real) # web.fetch skill ships in v0; safe-by-default URL handling. # ========================================================================= openclaw: <<: [*restart-policy, *default-logging] build: context: ./apps/openclaw dockerfile: Dockerfile image: blankcollar/openclaw:0.1.0 container_name: bc_openclaw environment: ENV: ${ENV:-local} LOG_LEVEL: ${LOG_LEVEL:-info} GBRAIN_URL: ${GBRAIN_URL:-http://gbrain:80} OPENCLAW_FETCH_TIMEOUT_S: ${OPENCLAW_FETCH_TIMEOUT_S:-10} OPENCLAW_FETCH_MAX_BYTES: ${OPENCLAW_FETCH_MAX_BYTES:-1500000} OPENCLAW_USER_AGENT: "${OPENCLAW_USER_AGENT:-BlankCollar-OpenClaw/0.1 (+https://www.blankcollar.ai)}" OPENCLAW_TEXT_EXCERPT_CHARS: ${OPENCLAW_TEXT_EXCERPT_CHARS:-8000} OXYLABS_API_KEY: ${OXYLABS_API_KEY:-} OXYLABS_BASE_URL: ${OXYLABS_BASE_URL:-https://api.aistudio.oxylabs.io} OXYLABS_SEARCH_PATH: ${OXYLABS_SEARCH_PATH:-/v1/search} OXYLABS_DEFAULT_RESULTS: ${OXYLABS_DEFAULT_RESULTS:-10} OXYLABS_TIMEOUT_S: ${OXYLABS_TIMEOUT_S:-30} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_USER: ${SMTP_USER:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_FROM: ${SMTP_FROM:-agent@blankcollar.ai} SMTP_USE_TLS: ${SMTP_USE_TLS:-false} SMTP_TIMEOUT_S: ${SMTP_TIMEOUT_S:-20} OPENCLAW_BROWSER_TIMEOUT_S: ${OPENCLAW_BROWSER_TIMEOUT_S:-30} OPENCLAW_BROWSER_VIEWPORT_W: ${OPENCLAW_BROWSER_VIEWPORT_W:-1280} OPENCLAW_BROWSER_VIEWPORT_H: ${OPENCLAW_BROWSER_VIEWPORT_H:-800} OPENCLAW_BROWSER_MAX_SCREENSHOT_BYTES: ${OPENCLAW_BROWSER_MAX_SCREENSHOT_BYTES:-2000000} # Portkey AI gateway — required at boot. Set both in .env. PORTKEY_API_KEY: ${PORTKEY_API_KEY:-} PORTKEY_VIRTUAL_KEY_ANTHROPIC: ${PORTKEY_VIRTUAL_KEY_ANTHROPIC:-} PORTKEY_VIRTUAL_KEY_OPENROUTER: ${PORTKEY_VIRTUAL_KEY_OPENROUTER:-} PORTKEY_BASE_URL: ${PORTKEY_BASE_URL:-https://api.portkey.ai/v1} BLANKCOLLAR_FAKE_LLM: ${BLANKCOLLAR_FAKE_LLM:-false} NANGO_URL: ${NANGO_URL:-http://nango:3003} NANGO_SECRET_KEY: ${NANGO_SECRET_KEY:-} NANGO_GOOGLE_PROVIDER_KEY: ${NANGO_GOOGLE_PROVIDER_KEY:-google} BRAND_DIR: ${BRAND_DIR:-/app/brand} BRAND_NAME: ${BRAND_NAME:-blankcollar} ports: - "${OPENCLAW_PORT:-8002}:80" volumes: - ./brand:/app/brand:ro depends_on: gbrain: condition: service_healthy networks: [bc_net] # ========================================================================= # LangGraph Dispatcher — multi-agent orchestrator on top of Hermes + OpenClaw. # Speaks the same agent adapter contract (/run, /run/{id}, cancel, # /healthz). Paperclip can dispatch subtasks to it as kind=langgraph; # internally it classifies + routes to hermes or openclaw. # See apps/langgraph/ and docs/LANGGRAPH.md. # ========================================================================= langgraph: <<: [*restart-policy, *default-logging] build: context: ./apps/langgraph dockerfile: Dockerfile image: blankcollar/langgraph:0.1.0 container_name: bc_langgraph environment: ENV: ${ENV:-local} LOG_LEVEL: ${LOG_LEVEL:-info} HERMES_URL: ${HERMES_URL:-http://hermes:80} OPENCLAW_URL: ${OPENCLAW_URL:-http://openclaw:80} GBRAIN_URL: ${GBRAIN_URL:-http://gbrain:80} NEXOS_API_KEY: ${NEXOS_API_KEY:-} NEXOS_BASE_URL: ${NEXOS_BASE_URL:-https://api.nexos.ai/v1} NEXOS_MODEL: ${NEXOS_MODEL:-claude-sonnet} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} LANGGRAPH_CLASSIFIER_MODEL: ${LANGGRAPH_CLASSIFIER_MODEL:-claude-sonnet-4-6} LANGGRAPH_CLASSIFIER_MAX_TOKENS: ${LANGGRAPH_CLASSIFIER_MAX_TOKENS:-200} LANGGRAPH_MAX_CYCLES: ${LANGGRAPH_MAX_CYCLES:-4} LANGGRAPH_POLL_INTERVAL_S: ${LANGGRAPH_POLL_INTERVAL_S:-0.5} LANGGRAPH_POLL_TIMEOUT_S: ${LANGGRAPH_POLL_TIMEOUT_S:-180} # Portkey AI gateway — required at boot. Set both in .env. PORTKEY_API_KEY: ${PORTKEY_API_KEY:-} PORTKEY_VIRTUAL_KEY_ANTHROPIC: ${PORTKEY_VIRTUAL_KEY_ANTHROPIC:-} PORTKEY_VIRTUAL_KEY_OPENROUTER: ${PORTKEY_VIRTUAL_KEY_OPENROUTER:-} PORTKEY_BASE_URL: ${PORTKEY_BASE_URL:-https://api.portkey.ai/v1} BLANKCOLLAR_FAKE_LLM: ${BLANKCOLLAR_FAKE_LLM:-false} ports: - "${LANGGRAPH_PORT:-8005}:80" depends_on: hermes: condition: service_healthy openclaw: condition: service_healthy networks: [bc_net] # ========================================================================= # Nango — self-hosted OAuth + tool-integration gateway. Lets agents call # 400+ external APIs (Slack, Notion, GitHub, HubSpot, Google Workspace, …) # through a single proxy without bespoke per-service auth code. # # Three services: # - bc_nango_db : dedicated Postgres for Nango state (separate from # our shared bc_postgres so schemas don't fight) # - bc_nango_redis : queue + cache # - bc_nango : Nango server (REST API on 3003 + Connect UI on 3009) # # See docs/NANGO.md for first-run setup. # ========================================================================= nango-db: <<: [*restart-policy, *default-logging] image: postgres:17.10-alpine container_name: bc_nango_db environment: POSTGRES_USER: ${NANGO_DB_USER:-nango} POSTGRES_PASSWORD: ${NANGO_DB_PASSWORD:-nango} POSTGRES_DB: ${NANGO_DB_NAME:-nango} volumes: - bc_nango_db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${NANGO_DB_USER:-nango} -d ${NANGO_DB_NAME:-nango}"] interval: 5s timeout: 5s retries: 10 networks: [bc_net] nango-redis: <<: [*restart-policy, *default-logging] image: redis:7.4.9 container_name: bc_nango_redis networks: [bc_net] nango: <<: [*restart-policy, *default-logging] image: nangohq/nango-server:hosted-0.70.6 container_name: bc_nango # Nango ships amd64 only. On Apple Silicon, Docker Desktop emulates via # Rosetta — slower first-run but works. platform: linux/amd64 environment: NANGO_ENCRYPTION_KEY: ${NANGO_ENCRYPTION_KEY:-Lktpnnl7UNIPVQgJBL3EE9U4VQCwswksinu1HLxbfxg=} NANGO_DB_USER: ${NANGO_DB_USER:-nango} NANGO_DB_PASSWORD: ${NANGO_DB_PASSWORD:-nango} NANGO_DB_HOST: ${NANGO_DB_HOST:-nango-db} NANGO_DB_PORT: ${NANGO_DB_INTERNAL_PORT:-5432} NANGO_DB_NAME: ${NANGO_DB_NAME:-nango} NANGO_DB_SSL: "false" RECORDS_DATABASE_URL: postgresql://${NANGO_DB_USER:-nango}:${NANGO_DB_PASSWORD:-nango}@nango-db:5432/${NANGO_DB_NAME:-nango} SERVER_PORT: ${NANGO_SERVER_PORT:-3003} NANGO_SERVER_URL: ${NANGO_SERVER_URL:-http://localhost:3003} NANGO_PUBLIC_SERVER_URL: ${NANGO_PUBLIC_SERVER_URL:-http://localhost:3003} CSP_REPORT_ONLY: "true" FLAG_AUTH_ENABLED: ${NANGO_FLAG_AUTH_ENABLED:-false} NANGO_DASHBOARD_USERNAME: ${NANGO_DASHBOARD_USERNAME:-admin} NANGO_DASHBOARD_PASSWORD: ${NANGO_DASHBOARD_PASSWORD:-admin} LOG_LEVEL: ${NANGO_LOG_LEVEL:-info} NANGO_LOGS_ENABLED: "false" FLAG_SERVE_CONNECT_UI: "true" ports: - "${NANGO_SERVER_PORT:-3003}:${NANGO_SERVER_PORT:-3003}" - "${NANGO_CONNECT_UI_PORT:-3009}:${NANGO_CONNECT_UI_PORT:-3009}" depends_on: nango-db: condition: service_healthy nango-redis: condition: service_started networks: [bc_net] # ========================================================================= # email-ingest — IMAP-poll service for agent@blankcollar.ai # Idle/heartbeat-only mode unless IMAP_HOST + IMAP_USER are set. # ========================================================================= email-ingest: <<: [*restart-policy, *default-logging] build: context: ./apps/email-ingest dockerfile: Dockerfile image: blankcollar/email-ingest:0.1.0 container_name: bc_email_ingest environment: ENV: ${ENV:-local} LOG_LEVEL: ${LOG_LEVEL:-info} PAPERCLIP_URL: ${PAPERCLIP_URL:-http://paperclip:80} GBRAIN_URL: ${GBRAIN_URL:-http://gbrain:80} PAPERCLIP_DEFAULT_ORG_SLUG: ${PAPERCLIP_DEFAULT_ORG_SLUG:-blankcollar-demo} IMAP_HOST: ${IMAP_HOST:-} IMAP_PORT: ${IMAP_PORT:-993} IMAP_USER: ${IMAP_USER:-} IMAP_PASSWORD: ${IMAP_PASSWORD:-} IMAP_FOLDER: ${IMAP_FOLDER:-INBOX} IMAP_USE_SSL: ${IMAP_USE_SSL:-true} EMAIL_POLL_INTERVAL_S: ${EMAIL_POLL_INTERVAL_S:-60} INBOUND_EMAIL_WEBHOOK_SECRET: ${INBOUND_EMAIL_WEBHOOK_SECRET:-} depends_on: paperclip: condition: service_healthy gbrain: condition: service_healthy networks: [bc_net] # ========================================================================= # gbrain — advanced memory layer (Phase 1, real) # FastAPI + asyncpg + qdrant-client. Wraps Qdrant + Postgres into a # unified, role-scoped memory API (see docs/COMPANY_BRAIN.md, docs/API.md). # ========================================================================= gbrain: <<: [*restart-policy, *default-logging] build: context: ./packages/gbrain dockerfile: Dockerfile image: blankcollar/gbrain:0.1.0 container_name: bc_gbrain environment: ENV: ${ENV:-local} LOG_LEVEL: ${LOG_LEVEL:-info} DATABASE_URL: ${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/blankcollar} QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333} QDRANT_API_KEY: ${QDRANT_API_KEY:-} GBRAIN_EMBED_MODEL: ${GBRAIN_EMBED_MODEL:-text-embedding-3-small} GBRAIN_EMBED_DIM: ${GBRAIN_EMBED_DIM:-1536} OPENAI_API_KEY: ${OPENAI_API_KEY:-} # Optional bridge to graphiti — gbrain best-effort POSTs every memory # to graphiti's /add endpoint. Empty value disables the bridge. GRAPHITI_URL: ${GRAPHITI_URL:-http://graphiti:80} ports: - "${GBRAIN_PORT:-8003}:80" depends_on: postgres: condition: service_healthy qdrant: condition: service_started networks: [bc_net] # ========================================================================= # Neo4j — graph backend for graphiti's temporal knowledge graph. # ========================================================================= neo4j: <<: [*restart-policy, *default-logging] image: neo4j:5.26.27 container_name: bc_neo4j environment: NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-password} # Memory tuning for a Mac dev box. Override in .env if needed. NEO4J_server_memory_heap_initial__size: ${NEO4J_HEAP_INIT:-512m} NEO4J_server_memory_heap_max__size: ${NEO4J_HEAP_MAX:-1g} NEO4J_server_memory_pagecache_size: ${NEO4J_PAGECACHE:-512m} ports: - "${NEO4J_HTTP_PORT:-7474}:7474" - "${NEO4J_BOLT_PORT:-7687}:7687" volumes: - bc_neo4j_data:/data - bc_neo4j_logs:/logs healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"] interval: 10s timeout: 5s retries: 12 start_period: 30s networks: [bc_net] # ========================================================================= # Graphiti — temporal knowledge graph wrapper. Thin FastAPI service around # graphiti-core, backed by Neo4j. See apps/graphiti/ and docs/GRAPHITI.md. # Without OPENAI_API_KEY (or NEXOS_API_KEY / ANTHROPIC_API_KEY), /add # gracefully returns {skipped: true, reason: "no_llm_configured"}. # ========================================================================= graphiti: <<: [*restart-policy, *default-logging] build: context: ./apps/graphiti dockerfile: Dockerfile image: blankcollar/graphiti:0.1.0 container_name: bc_graphiti environment: ENV: ${ENV:-local} LOG_LEVEL: ${LOG_LEVEL:-info} NEO4J_URI: ${NEO4J_URI:-bolt://neo4j:7687} NEO4J_USER: ${NEO4J_USER:-neo4j} NEO4J_PASSWORD: ${NEO4J_PASSWORD:-password} OPENAI_API_KEY: ${OPENAI_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} NEXOS_API_KEY: ${NEXOS_API_KEY:-} NEXOS_BASE_URL: ${NEXOS_BASE_URL:-https://api.nexos.ai/v1} GRAPHITI_LLM_MODEL: ${GRAPHITI_LLM_MODEL:-gpt-4o-mini} GRAPHITI_EMBEDDING_MODEL: ${GRAPHITI_EMBEDDING_MODEL:-text-embedding-3-small} # Portkey AI gateway — passed through; graphiti-core's internal LLM # client doesn't currently route through Portkey (Phase 2.1.b.2 follow-up), # but env vars are forwarded so future builds pick them up automatically. PORTKEY_API_KEY: ${PORTKEY_API_KEY:-} PORTKEY_VIRTUAL_KEY_ANTHROPIC: ${PORTKEY_VIRTUAL_KEY_ANTHROPIC:-} PORTKEY_BASE_URL: ${PORTKEY_BASE_URL:-https://api.portkey.ai/v1} BLANKCOLLAR_FAKE_LLM: ${BLANKCOLLAR_FAKE_LLM:-false} ports: - "${GRAPHITI_PORT:-8004}:80" depends_on: neo4j: condition: service_healthy networks: [bc_net] # ========================================================================= # Website — Swiss-editorial React console (Vite + React + TS → nginx). # Replaces paperclip's htmx UI as the front door at :3000. # Talks to paperclip (now :3001) via its REST API; in-cluster name is # `paperclip` so the website Docker image can reach it as # http://paperclip:80 — same address paperclip's own services already use. # ========================================================================= website: <<: [*restart-policy, *default-logging] build: # Build from the repo root so the Vite alias for @blankcollar/shared # (which points at ../../packages/shared/src/) can resolve. context: . dockerfile: apps/website/Dockerfile # Phase 8.1 — Vite reads VITE_* at build time. Forwarding empty # values is safe; the website skips auth UI when VITE_SUPABASE_URL # is missing and falls back to the demo-mode shell. args: VITE_SUPABASE_URL: ${VITE_SUPABASE_URL:-} VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-} # Default to localhost:3001 — the paperclip API port. If you # serve the API on a different host (e.g. api.blankcollar.ai # in prod), set VITE_PAPERCLIP_URL in .env before building. VITE_PAPERCLIP_URL: ${VITE_PAPERCLIP_URL:-http://localhost:3001} VITE_DEFAULT_ORG_SLUG: ${VITE_DEFAULT_ORG_SLUG:-} image: blankcollar/website:0.1.0 container_name: bc_website ports: - "${WEBSITE_PORT:-3000}:80" depends_on: paperclip: condition: service_started networks: [bc_net] # ========================================================================= # pgAdmin — optional Postgres GUI (enable with: --profile tools) # docker compose --profile tools up -d # ========================================================================= pgadmin: <<: [*restart-policy, *default-logging] image: dpage/pgadmin4:9.15.0 container_name: bc_pgadmin profiles: ["tools"] environment: PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@blankcollar.local} PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin} PGADMIN_CONFIG_SERVER_MODE: "False" ports: - "${PGADMIN_PORT:-5050}:80" volumes: - bc_pgadmin_data:/var/lib/pgadmin depends_on: postgres: condition: service_healthy networks: [bc_net]