odysseus Documentation

Self-hosted AI workspace.

85,261 stars Python Python 1 file ยท ~1,569 tokens
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy odysseus on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name odysseus -p 7011:7011 --restart always docker.io/chromadb/chroma:latest
Configured Services & Ports
โš™๏ธ odysseus
image: ${ODYSSEUS_IMAGE:-ghcr.io/odysseus-dev/odysseus:latest}
๐Ÿ”Œ Ports: ${APP_BIND:-127.0.0.1}:${APP_PORT:-7011}:7000
โš™๏ธ chromadb
image: docker.io/chromadb/chroma:latest
๐Ÿ”Œ Ports: ${CHROMADB_BIND:-127.0.0.1}:8100:8000
โš™๏ธ searxng
image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6
๐Ÿ”Œ Ports: 127.0.0.1:8080:8080
โš™๏ธ ntfy
image: docker.io/binwiederhier/ntfy
๐Ÿ”Œ Ports: ${NTFY_BIND:-127.0.0.1}:8091:80
docker-compose.yml
yaml (docker-compose.yml)
# Standalone AMD ROCm GPU Compose file for stack-management UIs (Portainer,
# Coolify, Dockhand, etc.) that accept only a single Compose file and do not
# reliably honor COMPOSE_FILE or multiple `-f` overlays.
#
# This is equivalent to: docker-compose.yml + docker/gpu.amd.yml.
# The base docker-compose.yml plus the docker/gpu.amd.yml overlay remain the
# source of truth โ€” CLI users should keep using the COMPOSE_FILE overlay
# workflow. Keep this file in sync with both when either changes.
#
# Requires ROCm drivers on the host (kfd + DRI devices) and the host user
# running Docker in the `video` and `render` groups. Set RENDER_GID to your
# host's numeric render group id when needed. See docker/gpu.amd.yml for details.
services:
  odysseus:
    # Official multi-arch GHCR image (linux/amd64 + linux/arm64), published by
    # the "ci / docker publish" workflow on every push to main and dev.
    # Docker pulls this image when it is reachable, and only falls back to the
    # local build below when the pull fails (e.g. no network on the host), so
    # hosts without a build toolchain (Portainer stacks, etc.) get the
    # registry build. For production, pin an immutable tag via ODYSSEUS_IMAGE
    # - e.g. ghcr.io/odysseus-dev/odysseus:1.0.2-7c8070f (X.Y.Z-) - since
    # :latest and bare :X.Y.Z tags move on every main push.
    image: ${ODYSSEUS_IMAGE:-ghcr.io/odysseus-dev/odysseus:latest}
    build: .
    ports:
      - "${APP_BIND:-127.0.0.1}:${APP_PORT:-7011}:7000"
    volumes:
      - ${APP_DATA_DIR:-./data}:/app/data:z
      - ${APP_LOGS_DIR:-./logs}:/app/logs:z
      # Cookbook remote-server SSH identity. Odysseus can generate a key here;
      # add the shown public key to each remote server's authorized_keys.
      - ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
      # Cookbook local model cache. Inside Docker, "Local" means the Odysseus
      # container, so persist its HuggingFace cache under ./data/huggingface.
      - ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
      # Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
      # land under /app/.local for the odysseus user. Persist them so a
      # container recreate does not silently remove installed serve engines.
      - ${APP_DATA_DIR:-./data}/local:/app/.local:z
    extra_hosts:
      # Lets the container reach local services on the Docker host, including
      # Ollama at http://host.docker.internal:11434.
      - "host.docker.internal:host-gateway"
    environment:
      - LLM_HOST=${LLM_HOST:-localhost}
      - LLM_HOSTS=${LLM_HOSTS:-}
      - OPENAI_API_KEY=${OPENAI_API_KEY:-}
      - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-}
      - RESEARCH_LLM_ENDPOINT=${RESEARCH_LLM_ENDPOINT:-}
      - HF_TOKEN=${HF_TOKEN:-}
      - HUGGING_FACE_HUB_TOKEN=${HUGGING_FACE_HUB_TOKEN:-}
      - SEARXNG_INSTANCE=http://searxng:8080
      - CHROMADB_HOST=chromadb
      - CHROMADB_PORT=8000
      - DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
      - AUTH_ENABLED=${AUTH_ENABLED:-true}
      - LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
      - COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
      - ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
      - ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
      - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
      - SECURE_COOKIES=${SECURE_COOKIES:-}
      - EMBEDDING_URL=${EMBEDDING_URL:-}
      - EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
      - EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
      - FASTEMBED_MODEL=${FASTEMBED_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
      - FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}
      - CLEANUP_INTERVAL_HOURS=${CLEANUP_INTERVAL_HOURS:-24}
      - ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
      - ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
      - ODYSSEUS_QWEN_NATIVE_COMPACT_BUILTINS=${ODYSSEUS_QWEN_NATIVE_COMPACT_BUILTINS:-1}
      - ODYSSEUS_QWEN_SUPPRESS_LOCAL_CONTEXT=${ODYSSEUS_QWEN_SUPPRESS_LOCAL_CONTEXT:-0}
      - ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
      - ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
      - ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
      - ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
      - ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
      - ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
      - ODYSSEUS_MEMORY_IMPORT_MAX_BYTES=${ODYSSEUS_MEMORY_IMPORT_MAX_BYTES:-10485760}
      - ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES=${ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES:-26214400}
      - ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
      - ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
      - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
      - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
      - ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
      # Host workspace translation is opt-in. Keep the public compose file
      # user-neutral; configure these in a local .env or use the host-workspace
      # overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
      - ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
      - ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
      - ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
      - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
      - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
      - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
      - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
      - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
      - GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
      # Externally reachable origin for MCP OAuth callbacks. The container
      # always listens on 7000 and cannot see the host port map above, so
      # remote MCP OAuth needs this set whenever the browser reaches
      # Odysseus on anything other than http://localhost:7000.
      - OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
      - TAVILY_API_KEY=${TAVILY_API_KEY:-}
      - SERPER_API_KEY=${SERPER_API_KEY:-}
      # PUID / PGID โ€” the user/group the container drops to before
      # running uvicorn (entrypoint also chowns /app/data + /app/logs
      # to match, so bind-mounted files stay editable from the host).
      # 1000 is the default first user on most Linux installs. If your
      # host user has a different id, override here or via .env, e.g.:
      #   PUID=1001
      #   PGID=1001
      # Find yours with:  id -u  /  id -g
      - PUID=${PUID:-1000}
      - PGID=${PGID:-1000}
    depends_on:
      searxng:
        condition: service_healthy
      chromadb:
        condition: service_started
    restart: unless-stopped
    # AMD ROCm overlay (from docker/gpu.amd.yml).
    devices:
      - /dev/kfd
      - /dev/dri
    group_add:
      - video
      - ${RENDER_GID:-render}

  chromadb:
    image: docker.io/chromadb/chroma:latest
    ports:
      - "${CHROMADB_BIND:-127.0.0.1}:8100:8000"
    volumes:
      # The image runs `chroma run /config.yaml`, whose persist_path is "/data".
      # It declares no VOLUME, so anything written outside a mount lives in the
      # container's writable layer and dies with it. /chroma/chroma is where
      # pre-1.0 images kept the store; mounting there now catches nothing.
      - chromadb-data:/data
    environment:
      - ANONYMIZED_TELEMETRY=FALSE
    restart: unless-stopped

  searxng:
    # Pinned, not :latest โ€” odysseus waits on searxng's healthcheck
    # (depends_on: condition: service_healthy), so a broken upstream `latest`
    # tag blocks the whole app from starting. 2026.6.2 crashes on boot with
    # `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
    # Bump this deliberately after verifying a newer tag boots clean.
    image: docker.io/searxng/searxng:2026.9.25-12f8b6515@sha256:5286edb35782454ab8a102c5eff6b54bff745853191b46aeead95f225aa6dfb6
    entrypoint:
      - /bin/sh
      - -c
      - |
        set -eu
        if [ ! -s /etc/searxng/settings.yml ] || grep -q 'odysseus-local-searxng-json-2026-05-30\|__SEARXNG_SECRET__' /etc/searxng/settings.yml; then
          secret="$${SEARXNG_SECRET:-}"
          if [ -z "$$secret" ]; then
            secret="$$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
          fi
          sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
        fi
        # Advisory: a settings file the migration cannot parse or rewrite must
        # not be what stops searxng from booting. It explains itself on stderr
        # and we carry on, letting searxng report anything genuinely wrong.
        /usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
        exec /usr/local/searxng/entrypoint.sh
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - searxng-data:/etc/searxng
      - ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
      - ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
    environment:
      - SEARXNG_BASE_URL=http://localhost:8080/
      - SEARXNG_SECRET=${SEARXNG_SECRET:-}
    # The official searxng image runs as the non-root `searxng` user, but its
    # entrypoint still needs to chown /etc/searxng on first boot, drop privs via
    # su-exec, and (with our wrapper above) write settings.yml into the named
    # volume. Without these capabilities the wrapper aborts at the redirection
    # with EACCES and the container fails its healthcheck with permission
    # errors during setup. Mirrors the cap set recommended by the upstream
    # searxng-docker compose file. See issue #721.
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETGID
      - SETUID
      - DAC_OVERRIDE
    healthcheck:
      test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\""]
      interval: 5s
      timeout: 6s
      retries: 20
      start_period: 10s
    restart: unless-stopped

  ntfy:
    image: docker.io/binwiederhier/ntfy
    command: serve
    ports:
      - "${NTFY_BIND:-127.0.0.1}:8091:80"
    volumes:
      - ntfy-cache:/var/cache/ntfy
    environment:
      - NTFY_BASE_URL=${NTFY_BASE_URL:-http://localhost:8091}
    restart: unless-stopped

volumes:
  searxng-data:
  chromadb-data:
  ntfy-cache:

File: README.md


Quick Start

dev is the default branch and gets the newest changes first. Use main if you want the more curated branch.

bash
git clone https://github.com/odysseus-dev/odysseus.git
cd odysseus
cp .env.example .env
docker compose up -d --build

Open http://localhost:7011 when the containers are healthy. The first admin password is printed in docker compose logs odysseus.

The compose files pull the official multi-arch image ghcr.io/odysseus-dev/odysseus (published by CI on every push to main and dev) and only build locally if the pull fails โ€” so this also works on hosts without a build toolchain, e.g. as a Portainer stack.

Production deployments: pin the immutable tag instead of :latest. :latest and bare :X.Y.Z tags move on every push to main, but :X.Y.Z-<sha> (e.g. 1.0.2-7c8070f) always refers to one specific build:

bash
ODYSSEUS_IMAGE=ghcr.io/odysseus-dev/odysseus:1.0.2-7c8070f docker compose up -d

Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the setup guide.

Features

  • Chat + Agents โ€” local/API models, tools, MCP, files, shell, skills, and memory.
  • Cookbook โ€” hardware-aware model recommendations, downloads, and serving.
  • Deep Research โ€” multi-step web research with source reading and report generation.
  • Compare โ€” blind side-by-side model testing and synthesis.
  • Documents โ€” writing-first editor with AI edits, suggestions, Markdown, HTML, CSV, and syntax highlighting.
  • Email โ€” IMAP/SMTP inbox with triage, tags, summaries, reminders, and reply drafts.
  • Notes, Tasks + Calendar โ€” reminders, todos, scheduled agent tasks, and CalDAV sync.
  • Extras โ€” gallery/image editor, themes, uploads, web search, presets, sessions, and 2FA.

Demo

A full hover-to-play tour lives on the Odysseus landing page. Its source lives under website/.

Contributing

Help is welcome. The best entry points are fresh-install testing, provider setup bugs, mobile/editor polish, docs, and small focused refactors. See CONTRIBUTING.md and ROADMAP.md.

Security

Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly.

  • Keep AUTH_ENABLED=true for any network-accessible deployment.
  • Keep LOCALHOST_BYPASS=false outside local development.

Deployment details are in the setup guide.

Star History

License

AGPL-3.0-or-later -- see LICENSE and ACKNOWLEDGMENTS.md.


File: companion/README.md

Companion bridge

A thin, additive layer so a LAN client (e.g. a phone) can discover what an
Odysseus server offers and pair to it, without duplicating any LLM logic.

Method Path Auth Purpose
GET /api/companion/ping session or token cheap, auth-validated health check
GET /api/companion/info session or token server identity + capability flags
GET /api/companion/models session or token the caller's own model endpoints
GET /api/companion/pair admin cookie pairing page (a form; never mints)
POST /api/companion/pair admin cookie mint a one-time pairing token (?format=json for an in-app screen)

/models scopes to the caller's real owner plus legacy null-owner shared rows
(same rule as owner_filter) and never returns API-key material.

Pairing CSRF posture

Minting happens only on POST. The session cookie is SameSite=Lax
(routes/auth_routes.py), so a browser will not send it on a cross-site POST โ€”
the same protection POST /api/tokens relies on. A GET would be unsafe (Lax
cookies ride top-level GET navigations), so GET /pair only renders a form.
Minting invalidates the auth middleware's token cache, so a freshly minted token
works on the next request without a restart.

The pairing/scoping rules live in small, tested units (token_owner,
owner_can_see, mint_pairing_token, pairing.*) โ€” see
tests/test_companion_readonly.py and tests/test_companion_pairing.py.


File: integrations/codex/README.md

Odysseus Codex Integration

This directory contains the Codex plugin/skill bundle for Odysseus.

User Flow

  1. Open Odysseus Settings > Integrations.
  2. Add a Codex Agent.
  3. Copy the full setup commands shown after the generated token.
  4. Toggle the tools Codex is allowed to use.
  5. Configure the terminal Codex session:
bash
export ODYSSEUS_URL=http://your-odysseus-host:7000
export ODYSSEUS_API_TOKEN=ody_generated_token
mkdir -p ~/plugins
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/codex/plugin.zip" -o /tmp/odysseus-codex-plugin.zip
python3 -m zipfile -e /tmp/odysseus-codex-plugin.zip ~/plugins
python3 - <<'PY'
import json
from pathlib import Path

p = Path.home() / ".agents" / "plugins" / "marketplace.json"
p.parent.mkdir(parents=True, exist_ok=True)
if p.exists():
    data = json.loads(p.read_text())
else:
    data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}

data.setdefault("name", "personal")
data.setdefault("interface", {}).setdefault("displayName", "Personal")
plugins = data.setdefault("plugins", [])
entry = {
    "name": "odysseus",
    "source": {"source": "local", "path": "./plugins/odysseus"},
    "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
    "category": "Productivity",
}
data["plugins"] = [item for item in plugins if item.get("name") != "odysseus"] + [entry]
p.write_text(json.dumps(data, indent=2) + "\n")
PY
codex plugin add odysseus@personal
  1. Verify:
bash
python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities

Codex must use /api/codex/* endpoints. SSH, Docker, direct Python imports, database queries, and MCP internals bypass Odysseus Settings and must not be used for user data access.