name: tideline services: misskey-db: image: postgres:18-alpine restart: unless-stopped environment: POSTGRES_DB: ${MISSKEY_DB_NAME} POSTGRES_USER: ${MISSKEY_DB_USER} POSTGRES_PASSWORD_FILE: /run/secrets/misskey_db_password volumes: - misskey-db:/var/lib/postgresql secrets: - misskey_db_password networks: - misskey-internal healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] interval: 5s timeout: 5s retries: 20 misskey-redis: image: redis:7-alpine restart: unless-stopped command: ["redis-server", "--appendonly", "yes"] volumes: - misskey-redis:/data networks: - misskey-internal healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 20 misskey: image: misskey/misskey:2026.7.0 restart: unless-stopped depends_on: misskey-db: condition: service_healthy misskey-redis: condition: service_healthy environment: MISSKEY_URL: ${TIDELINE_MISSKEY_URL} DATABASE_DB: ${MISSKEY_DB_NAME} DATABASE_USER: ${MISSKEY_DB_USER} entrypoint: ["/bin/bash", "/usr/local/bin/tideline-misskey-entrypoint"] ports: # Keep the unauthenticated first-admin setup local. Put a TLS reverse # proxy in front of Misskey deliberately when publishing the instance. - "${MISSKEY_BIND_ADDRESS:-127.0.0.1}:${MISSKEY_PORT:-3300}:3000" volumes: - misskey-files:/misskey/files - ./config/misskey/default.yml:/misskey/.config/default.yml:ro - ./services/misskey/entrypoint.sh:/usr/local/bin/tideline-misskey-entrypoint:ro secrets: - misskey_db_password networks: - misskey-internal - agent-internal - misskey-egress healthcheck: test: ["CMD-SHELL", "curl -fsS -X POST -H 'Content-Type: application/json' --data '{}' http://localhost:3000/api/meta >/dev/null"] interval: 10s timeout: 5s retries: 30 start_period: 45s litellm-db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_DB: ${LITELLM_DB_NAME} POSTGRES_USER: ${LITELLM_DB_USER} POSTGRES_PASSWORD_FILE: /run/secrets/litellm_db_password volumes: - litellm-db:/var/lib/postgresql/data secrets: - litellm_db_password networks: - litellm-internal healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] interval: 5s timeout: 5s retries: 20 litellm: image: ghcr.io/berriai/litellm:v1.96.0 restart: unless-stopped depends_on: litellm-db: condition: service_healthy environment: LITELLM_DB_HOST: litellm-db LITELLM_DB_NAME: ${LITELLM_DB_NAME} LITELLM_DB_USER: ${LITELLM_DB_USER} # Keep Claude Code on Z.AI's documented Anthropic-compatible endpoint. # This preserves its native tool/schema protocol through LiteLLM instead # of translating it to OpenAI Chat Completions. ZAI_ANTHROPIC_BASE_URL: ${ZAI_ANTHROPIC_BASE_URL:-https://api.z.ai/api/anthropic} entrypoint: ["/bin/sh", "/app/tideline-entrypoint.sh"] volumes: - ./config/litellm/config.yaml:/app/config.yaml:ro - ./services/litellm/entrypoint.sh:/app/tideline-entrypoint.sh:ro secrets: - litellm_db_password - litellm_master_key - litellm_salt_key - zai_api_key networks: - litellm-internal - agent-internal - litellm-egress healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness', timeout=5)"] interval: 10s timeout: 5s retries: 30 start_period: 20s litellm-key-init: image: node:22-bookworm-slim restart: "no" depends_on: litellm: condition: service_started environment: LITELLM_BASE_URL: http://litellm:4000 LITELLM_KEY_ALIAS: tideline-agent-orchestrator LITELLM_MODEL_NAME: ${TIDELINE_LITELLM_MODEL} LITELLM_AGENT_MAX_BUDGET: ${LITELLM_AGENT_MAX_BUDGET:-15.00} entrypoint: ["node", "/bootstrap/bootstrap-agent-key.mjs"] volumes: - litellm-runtime:/run/tideline - ./services/litellm/bootstrap-agent-key.mjs:/bootstrap/bootstrap-agent-key.mjs:ro secrets: - litellm_master_key networks: - agent-internal litellm-city-key-init: # A second virtual key keeps the city builder independent from the later # Misskey/SNS residents. It is still restricted to the one GLM model. image: node:22-bookworm-slim restart: "no" depends_on: litellm: condition: service_started environment: LITELLM_BASE_URL: http://litellm:4000 LITELLM_KEY_ALIAS: tideline-city-orchestrator LITELLM_MODEL_NAME: ${TIDELINE_LITELLM_MODEL} LITELLM_AGENT_MAX_BUDGET: ${TIDELINE_CITY_MAX_BUDGET:-3.00} LITELLM_RUNTIME_DIR: /run/tideline-city LITELLM_KEY_FILENAME: litellm-city-api-key entrypoint: ["node", "/bootstrap/bootstrap-agent-key.mjs"] volumes: - litellm-city-runtime:/run/tideline-city - ./services/litellm/bootstrap-agent-key.mjs:/bootstrap/bootstrap-agent-key.mjs:ro secrets: - litellm_master_key networks: - agent-internal agent-orchestrator: build: context: ./services/agent-orchestrator args: CLAUDE_CODE_VERSION: ${CLAUDE_CODE_VERSION:-2.1.227} restart: unless-stopped depends_on: misskey: condition: service_started litellm-key-init: # Do not let an agent read a soon-to-be-revoked virtual key while the # initializer rotates the model or budget scope. condition: service_completed_successfully environment: AGENT_CONFIG: /app/config/agents.yaml AGENT_STATE_DIR: /data AGENT_CONTROL_PORT: "8080" TIDELINE_AUTONOMY_ENABLED: ${TIDELINE_AUTONOMY_ENABLED:-false} TIDELINE_TIME_ZONE: ${TIDELINE_TIME_ZONE:-Asia/Tokyo} TIDELINE_LITELLM_MODEL: ${TIDELINE_LITELLM_MODEL} MISSKEY_URL: http://misskey:3000 # Claude Code speaks Anthropic Messages to LiteLLM; LiteLLM routes that # request to the configured GLM provider. LITELLM_CLAUDE_BASE_URL: http://litellm:4000 LITELLM_AGENT_KEY_FILE: /run/tideline-private/litellm-agent-api-key CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" DISABLE_TELEMETRY: "1" DISABLE_ERROR_REPORTING: "1" DISABLE_BUG_COMMAND: "1" DISABLE_AUTOUPDATER: "1" volumes: - ./config/agents/agents.yaml:/app/config/agents.yaml:ro - litellm-runtime:/run/tideline-private:ro - agent-state:/data secrets: - source: civic_planner_misskey_token target: /run/tideline-secrets/civic_planner_misskey_token - source: block_builder_misskey_token target: /run/tideline-secrets/block_builder_misskey_token - source: city_chronicler_misskey_token target: /run/tideline-secrets/city_chronicler_misskey_token networks: - agent-internal - agent-control healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:8080/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] interval: 15s timeout: 5s retries: 10 agent-status-proxy: # Docker Desktop does not expose ports from a container attached only to # an internal network. This tiny proxy restores the localhost-only status # endpoint without adding an outbound route or credentials to the agent. image: nginx:1.27.5-alpine restart: unless-stopped depends_on: agent-orchestrator: condition: service_started entrypoint: ["nginx"] command: ["-g", "daemon off;", "-c", "/etc/nginx/nginx.conf"] read_only: true tmpfs: - /tmp:mode=1777 - /var/cache/nginx:mode=0755 cap_drop: - ALL cap_add: # Needed solely for Nginx to hand its tmpfs cache directory to the # unprivileged worker. No network-bind capability is needed for 8080. - CHOWN - SETUID - SETGID security_opt: - no-new-privileges:true volumes: - ./config/agent-status-proxy/nginx.conf:/etc/nginx/nginx.conf:ro ports: # Local operational visibility only. The proxy can reach just the # agent-control network and never receives any runtime credential. - "${AGENT_BIND_ADDRESS:-127.0.0.1}:${AGENT_CONTROL_PORT:-8080}:8080" networks: - agent-control - agent-control-egress healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/healthz"] interval: 15s timeout: 5s retries: 10 luanti: # Luanti (the renamed Minetest engine) is the actual voxel-city runtime. # It owns a persistent world, while the source-controlled city mod is # mounted read-only into that world on every start. image: lscr.io/linuxserver/luanti:5.16.1 restart: unless-stopped environment: PUID: "1000" PGID: "1000" TZ: Asia/Tokyo CLI_ARGS: "--world /config/.minetest/worlds/tideline --gameid devtest --port ${LUANTI_PORT:-30000}" volumes: - luanti-data:/config/.minetest - ./services/luanti/bootstrap-world.sh:/custom-cont-init.d/10-tideline-world:ro - ./services/luanti/mods/tideline_city:/config/.minetest/worlds/tideline/worldmods/tideline_city:ro ports: - "${LUANTI_BIND_ADDRESS:-127.0.0.1}:${LUANTI_PORT:-30000}:30000/udp" networks: - city-internal # Docker Desktop requires a non-internal attachment to forward the # local UDP client port. The server itself receives no credentials. - luanti-client healthcheck: test: ["CMD-SHELL", "pgrep -f '[l]uantiserver' >/dev/null"] interval: 15s timeout: 5s retries: 10 city-bridge: # The only path by which an agent can request a Luanti construction. It # validates the small building envelope, queues it, and Luanti consumes it # on its private network. Nothing is published from this service. build: context: ./services/city-bridge restart: unless-stopped environment: CITY_BRIDGE_PORT: "8090" CITY_BRIDGE_STATE_DIR: /data volumes: - city-bridge-state:/data networks: - city-internal healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:8090/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] interval: 15s timeout: 5s retries: 10 city-orchestrator: # Claude Code turns each resident's scoped urban role into a single, # validated construction request. It has no Misskey credentials and no # direct route to Luanti's public client network. build: context: ./services/city-orchestrator args: CLAUDE_CODE_VERSION: ${CLAUDE_CODE_VERSION:-2.1.227} restart: unless-stopped depends_on: city-bridge: condition: service_healthy litellm-city-key-init: condition: service_completed_successfully environment: CITY_AGENT_CONFIG: /app/config/agents.json CITY_AGENT_STATE_DIR: /data CITY_AGENT_CONTROL_PORT: "8091" TIDELINE_CITY_AUTONOMY_ENABLED: ${TIDELINE_CITY_AUTONOMY_ENABLED:-false} TIDELINE_TIME_ZONE: ${TIDELINE_TIME_ZONE:-Asia/Tokyo} LITELLM_CLAUDE_BASE_URL: http://litellm:4000 LITELLM_CITY_KEY_FILE: /run/tideline-private/litellm-city-api-key CITY_BRIDGE_URL: http://city-bridge:8090 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" DISABLE_TELEMETRY: "1" DISABLE_ERROR_REPORTING: "1" DISABLE_BUG_COMMAND: "1" DISABLE_AUTOUPDATER: "1" volumes: - ./config/city/agents.json:/app/config/agents.json:ro - litellm-city-runtime:/run/tideline-private:ro - city-agent-state:/data networks: - agent-internal - city-internal healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:8091/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] interval: 15s timeout: 5s retries: 10 networks: misskey-internal: internal: true ipam: config: - subnet: 10.240.0.0/24 litellm-internal: internal: true ipam: config: - subnet: 10.240.1.0/24 agent-internal: # Claude Code can reach only Misskey and LiteLLM over this private network. # It has no direct route to the public internet or upstream Z.AI endpoint. internal: true ipam: config: - subnet: 10.240.10.0/24 misskey-egress: ipam: config: - subnet: 10.240.11.0/24 litellm-egress: ipam: config: - subnet: 10.240.12.0/24 agent-control: internal: true ipam: config: - subnet: 10.240.13.0/24 agent-control-egress: ipam: config: - subnet: 10.240.14.0/24 city-internal: internal: true ipam: config: - subnet: 10.240.15.0/24 luanti-client: ipam: config: - subnet: 10.240.16.0/24 volumes: misskey-db: misskey-redis: misskey-files: litellm-db: litellm-runtime: agent-state: luanti-data: city-bridge-state: litellm-city-runtime: city-agent-state: secrets: misskey_db_password: file: ./.secrets/misskey_db_password litellm_db_password: file: ./.secrets/litellm_db_password litellm_master_key: file: ./.secrets/litellm_master_key litellm_salt_key: file: ./.secrets/litellm_salt_key zai_api_key: file: ./.secrets/zai_api_key civic_planner_misskey_token: file: ./.secrets/civic_planner_misskey_token block_builder_misskey_token: file: ./.secrets/block_builder_misskey_token city_chronicler_misskey_token: file: ./.secrets/city_chronicler_misskey_token