# Oduflow

> AI-first Odoo development and CI tool powered by reusable database templates. Provisions isolated, ephemeral Odoo environments on Docker — one per git branch — and exposes them to AI coding agents via MCP.

## Installation

### System Requirements

- Docker (Docker Engine or Docker Desktop)
- Python 3.10+
- Git
- fuse-overlayfs (for filestore overlay mounting; not needed on macOS)

### Install

Recommended — install via [uv](https://docs.astral.sh/uv/):

```bash
uv tool install oduflow
```

Alternative — install via pip:

```bash
pip install oduflow
```

### Configure

All settings are configured via `oduflow.toml`. Oduflow searches `ODUFLOW_TOML`, then `/etc/oduflow/oduflow.toml`, then `~/.oduflow/conf/oduflow.toml`.

Minimal configuration:

```toml
[team.1]
hostname = "localhost"
```

Common configuration (complete reference:
<https://docs.oduflow.dev/installation/#configuration-reference>):

```toml
[server]
host = "0.0.0.0"                     # HTTP bind address
port = 8000                           # HTTP port

[routing]
mode = "port"                         # "port" | "traefik" (auto-HTTPS)
# acme_email = "admin@example.com"    # required for traefik mode

[oauth]
# oauth_base_url = "https://oduflow.example.com"  # OAuth issuer; NOT needed in traefik (auto, per-team host). Set to pin an issuer or in port mode. OAuth client_id = team_<id> (non-secret); auth_token = client_secret and also works as a Bearer token

[database]
user = "odoo"
# password = "..."                    # auto-generated on first launch; set to override
image = "postgres:15"

[storage]
# data_dir = "/srv/oduflow"           # default: /srv/oduflow or ~/.oduflow/data
overlay_threshold_mb = 50             # filestore size threshold for overlay vs copy

[lifecycle]
auto_stop_hours = 48                  # auto-stop after N hours without MCP/dashboard work; 0 disables
auto_delete_hours = 0                 # auto-delete N hours after stop; 0 disables (opt-in; DESTRUCTIVE)

# Per-team coding agent (dashboard Agent Chat / Agent CLI); opt-in, off by default.
# [agent]
# image = "oduist/oduflow-coder:0.3.0"
# opencode_model = ""               # optional provider/model override

[team.1]
hostname = "localhost"
auth_token = ""                       # auto-filled in fresh configs; HTTP MCP Bearer token / OAuth client_secret
ui_password = ""                      # auto-filled in fresh configs; Web UI password for admin
port_range = [50000, 50100]           # port range for Odoo containers
# agent_enabled = false               # enable the per-team coding agent (Agent Chat / Agent CLI)
# agent_default = "claude"            # "claude" | "codex" | "opencode"
# [team.1.agent_env]                  # provider credentials injected into the agent container
# CLAUDE_CODE_OAUTH_TOKEN = ""
# ANTHROPIC_API_KEY = ""
# OPENAI_API_KEY = ""
# OPENCODE_API_KEY = ""               # OpenCode Zen; arbitrary provider vars work
```

### First launch

On first launch Oduflow automatically creates a default `oduflow.toml` at `/etc/oduflow/oduflow.toml` when writable, otherwise at `~/.oduflow/conf/oduflow.toml`, and initializes shared infrastructure (Docker network, PostgreSQL, team directories). Fresh configs include generated `[database].password`, `[team.1].auth_token`, and `[team.1].ui_password`; the MCP token and Web Dashboard password are also printed in the startup log.

### Upgrade

```bash
uv tool upgrade oduflow
oduflow upgrade
# For unattended automation:
oduflow upgrade --force
```

Package upgrade and deployed-file reconciliation are separate. `oduflow
upgrade` three-way merges each team's bundled `odoo.conf`, agent guides, and
sanitize script against a stored pristine baseline. Conflicts preserve the live
file and create `*.oduflow-merge`; pre-baseline installations create
`*.oduflow-new` for one-time manual reconciliation. Both cases exit non-zero
until the sidecar is resolved and removed. `--force` skips only the stdin
confirmation. A first-line `# KEEP` opts a file out entirely. PostgreSQL config
changes use `oduflow retune-postgres`, not `oduflow upgrade`.

### Set up a template

```bash
# From scratch
oduflow init-template --odoo-image odoo:19.0 --template-name default

# From production dump
# Place dump.sql and filestore/ into {data_dir}/team_1/templates/default/ then:
oduflow reload-template default

# Sync template from S3 or local path and reload DB
oduflow reload-template default --source s3://mybucket/prod/ [--quiet]
oduflow reload-template default --source /backups/prod-latest/
```

### Start the MCP server

```bash
oduflow --transport http
# or: oduflow -t http
```

For HTTP mode, the server starts on `http://0.0.0.0:8000`. MCP endpoint: `http://<host>:8000/mcp`; send `Authorization: Bearer <auth_token>` using the value from `oduflow.toml`. The Web Dashboard is at `http://<host>:8000/`; sign in as `admin` with `ui_password`.

## MCP Client Configuration

### Cursor / Windsurf

`.cursor/mcp.json` or `.windsurf/mcp.json`:

```json
{
  "mcpServers": {
    "oduflow": {
      "type": "http",
      "url": "https://<your-oduflow-host>/mcp",
      "headers": {
        "Authorization": "Bearer <your-token>"
      }
    }
  }
}
```

### Claude Desktop / Amp

Same JSON format in `claude_desktop_config.json` or `.amp/settings.json`.

### Claude.ai (self-hosted OAuth)

For OAuth-based MCP clients like Claude.ai Remote MCP, Oduflow runs its own OAuth 2.1 Authorization Server (no external IdP). In **traefik mode** it is enabled automatically and runs on each team's own hostname (issuer derived per-request) — no `oauth_base_url` needed. In **port mode**, set `[oauth].oauth_base_url` to this instance's public URL. In Claude.ai add a custom MCP at `https://<team-hostname>/mcp` and enter `Client ID = team_<id>` (e.g. `team_1`, non-secret) and `Client Secret = the team's auth_token`. The OAuth flow issues an independent expiring access token; the configured `auth_token` also works directly as a plain Bearer token.

## Core MCP Tools

- `create_environment` — provision a new Odoo environment for a branch (optional `env_vars` injects container environment variables)
- `delete_environment` — tear down an environment
- `start_environment` / `stop_environment` / `restart_environment` — lifecycle control
- Idle environments auto-stop after 48h without work (`[lifecycle].auto_stop_hours`). Auto-delete of long-stopped environments is opt-in and off by default (`auto_delete_hours = 0`; set a positive value to enable — destructive; protected environments are exempt). Container-level tools (pull_and_apply, shell/tests/installs/file ops) wake a stopped environment automatically and note it in the response
- `update_environment` — re-create the container preserving DB and filestore (optional `odoo_image` switches image, `env_vars` replaces container environment variables)
- `install_odoo_modules` — install Odoo modules
- `upgrade_odoo_modules` — upgrade Odoo modules
- `export_module_translations` — export a module's .pot/.po translation catalogue
- `translation_status` — check what translations actually loaded, and lint the .po files
- `run_odoo_tests` — run Odoo tests for specific modules (`test_tags` narrows to one class/method; `upgrade=False` skips the `-u`, collects `post_install` tests only, and requires module-scoped positive tags)
- `pull_and_apply` — pull latest code and auto-install/upgrade/restart as needed
- `get_environment_logs` — retrieve container logs
- `run_odoo_command` — execute shell commands inside the Odoo container (through `sh -c`, so pipes and redirections work; `shell=False` for exact argv)
- `run_odoo_shell` — execute Python code in the Odoo shell with full ORM access
- `odoo_search_read` / `odoo_create` / `odoo_write` / `odoo_unlink` / `odoo_call` / `odoo_schema` — XML-RPC `execute_kw`-equivalent ORM tools. Structured JSON in and out, no Python to write; every one takes `as_user` (login or id, empty = the environment's admin) and runs in a real session for that user, so `ir.model.access` and `ir.rule` apply as they do in the web client. `odoo_call` handles other public methods (`read_group`, `name_search`, `action_*`, custom methods), while policy-visible `create`/`write`/`unlink` must use their dedicated tools; `odoo_schema` pages through models or returns `fields_get`. They hit the **running** server: edited Python is invisible until the environment restarts, and each call is its own committed transaction — use `run_odoo_shell` for a fresh registry, `sudo()`, private methods, a dry run, or multi-step atomicity
- `read_file_in_odoo` — read a text file or list a directory inside the container (supports line ranges)
- `write_file_in_odoo` — write a text file inside the container (CSV imports, scripts, configs)
- `search_in_odoo` — search for a pattern (fixed-string grep) in files inside the container
- `http_request_to_odoo` — make an HTTP request to the running Odoo instance (controllers, JSON-RPC, REST)
- `list_installed_modules` — list Odoo modules and their states with name/state filtering
- `run_db_query` — execute SQL queries against the environment's PostgreSQL database
- `reset_admin_password` — reset the admin user password (default: "test")
- `connect_as_user` — mint a passwordless Odoo login session for a user and return the `session_id` cookie + URL (Playwright-ready; skips the login form, supports any role incl. portal)
- `read_output` — read from a cached tool output by ID (paginate, grep, errors, tail)
- `list_environments` / `get_environment_info` — inspect environments
- `create_service` / `delete_service` / `restart_service` / `update_service` / `list_services` / `get_service_info` / `get_service_logs` / `run_service_command` — manage auxiliary services
  - In Traefik TLS mode every service implicitly receives the exact `oduflow-traefik-acme:/etc/traefik:ro` mount; do not pass or override that system volume
- `create_volume` / `list_volumes` / `inspect_volume` / `delete_volume` — manage Docker volumes
- `read_file_in_volume` / `write_file_in_volume` / `search_in_volume` / `delete_file_in_volume` — manage files inside Docker volumes
- `list_service_presets` / `restore_service` / `delete_service_preset` — manage service presets
- `save_as_template` / `delete_template` / `rename_template` / `list_templates` — template management
- `import_template_from_odoo` — import a template from a running Odoo instance; optional `without_filestore` imports database-only
- `refresh_template` — re-apply a template's filestore to live overlay environments (preserves env changes by default; `reset_env_changes=True` is destructive)
- `attach_filestore` — attach/replace a template filestore from a local dir, archive, `rsync://`, or SSH rsync source; preserves env changes by default
- `setup_repo_auth` — cache git credentials for private repositories
- `add_extra_repo` / `list_extra_repos` / `update_extra_repo` / `delete_extra_repo` — manage extra addons repositories
- `get_agent_instructions` — load the compact Oduflow agent workflow once at session start
- `get_odoo_development_guide` — get Odoo development standards guide for a specific version (15–19)
- `report_issue` — build a prefilled GitHub issue link so the user can report an Oduflow bug, request a feature, or send feedback from their own account

## Production Hosting

Opt-in `[production].enabled = true` adds long-lived Odoo productions with a
dedicated PostgreSQL cluster, custom Traefik domains, verified deploys with
automatic code rollback, GitHub webhook auto-deploy, and optional S3 snapshots,
WAL-G archiving, retention, and cluster PITR. The MCP surface includes
`create_production`, list/info/lifecycle tools, `update_production`,
`rollback_production`, deploy history/logs, snapshot/restore/schedule/status,
`prune_production_backups`, `restore_cluster_pitr`, and `delete_production`.
Production REST routes and `/api/webhooks/github` are registered only while the
feature is enabled; public `/healthz` reports dev/prod infrastructure health.

## Coding Agent (hosting)

An opt-in, per-team hosting feature: Oduflow runs one coding-agent container per team (`oduist/oduflow-coder`, Claude Code + OpenAI Codex + OpenCode) and exposes two dashboard surfaces — **Agent CLI** (the agent's TUI in the browser) and **Agent Chat** (a browser ACP chat with per-environment conversation history). The agent edits its own git checkout, `git push`es, and drives the environment through the Oduflow MCP server with a scoped per-environment token. A built-in Agent Browser MCP and Chromium provide browser automation to all three agents, with one persistent profile per environment. Hosted agents run installed MCP methods without interactive approval prompts. OpenCode supports arbitrary provider environment variables or persistent `opencode auth login`. It is off by default; enable it per team with `agent_enabled` and set provider credentials under `[team.X.agent_env]`. The agent UI is hidden for live-mount (`local_path`) environments.

## Database Sanitization

Template-based environments are neutralized by default, then run team-level
sanitization scripts followed by project scripts from
`.oduflow/odoo_sanitize/`. Both SQL and Python scripts are supported.

## Typical Agent Workflow

1. Call `list_environments` to check if an environment for the branch exists
2. If not, call `create_environment` with `branch`, `template_name`, `repo_url`, and `odoo_image`
3. Write code, `git push`, then call `pull_and_apply` (auto-detects what to do); read its response for install/upgrade errors
4. Use `install_odoo_modules` / `run_odoo_tests` and read their direct responses; use `get_environment_logs` only for errors from the running Odoo server
5. Inspect and manipulate data with the `odoo_*` ORM tools — `odoo_schema` first to get the real field names, then `odoo_search_read`; add `as_user` to check what a given role can actually see or change
6. Call `delete_environment` when the task is done

## Links

- Repository: <https://github.com/oduist/oduflow>
- Documentation: <https://docs.oduflow.dev>
- License: BUSL-1.1 (Business Source License 1.1) — free for non-commercial use; commercial use requires a paid license; converts to MPL 2.0 four years after publication
- Website: <https://oduflow.dev>

---

<section class="odu-hero">
  <span class="odu-hero__eyebrow">⎇ AI-First Odoo Development</span>
  <h1 class="odu-hero__title">Oduflow Docs</h1>
  <p class="odu-hero__subtitle">
    Provision isolated, ephemeral <strong>Odoo</strong> environments on Docker —
    one per git branch — and hand them to your AI agents over <strong>MCP</strong>.
    A closed feedback loop for fully autonomous, spec-driven Odoo development.
  </p>
  <div class="odu-hero__actions">
    <a class="odu-btn odu-btn--primary" href="quick-start/">Read the Docs →</a>
    <a class="odu-btn odu-btn--changelog" href="changelog/">Changelog</a>
    <a class="odu-btn odu-btn--ghost" href="https://github.com/oduist/oduflow">View on GitHub</a>
  </div>
</section>

<div class="grid cards" markdown>

-   :material-rocket-launch:{ .lg .middle } **Quick Start**

    ---

    Spin up a fully working Odoo instance for any git branch with a single command.

    [:octicons-arrow-right-24: Quick Start](quick-start.md)

-   :material-content-duplicate:{ .lg .middle } **Reusable Templates**

    ---

    Clone large production databases instantly via PostgreSQL templates and overlayfs.

    [:octicons-arrow-right-24: Template Management](templates.md)

-   :material-source-branch:{ .lg .middle } **Branch Environments**

    ---

    One isolated, ephemeral environment per branch — sharing the template DB and filestore.

    [:octicons-arrow-right-24: Environment Management](environments.md)

-   :material-robot-happy-outline:{ .lg .middle } **MCP for AI Agents**

    ---

    Expose install, test, log and upgrade tools to Cursor, Cline, Amp, Claude and more.

    [:octicons-arrow-right-24: MCP Tools Reference](mcp-tools.md)

-   :material-api:{ .lg .middle } **Dashboard & REST API**

    ---

    Manage everything from a built-in web dashboard or a full JSON HTTP API.

    [:octicons-arrow-right-24: Web Dashboard & REST API](web-api.md)

-   :material-console-line:{ .lg .middle } **CLI Tooling**

    ---

    Every MCP tool is one `oduflow call` away from your terminal and CI pipelines.

    [:octicons-arrow-right-24: CLI Reference](cli.md)

</div>

<div class="odu-shot">
  <img src="img/envs.png" alt="Oduflow web dashboard">
</div>

## Beyond Vibe Coding: Spec-Driven Development

**Vibe coding** — chatting with an AI and eyeballing the output — was the first wave. It works for prototypes, but breaks down on real ERP systems where a module must install cleanly, pass tests, and work against production data.

**Spec-Driven Development (SDD)** is the next step: you write a precise specification of *what* the module should do, and the AI agent autonomously implements *how* — because it has a **closed feedback loop** with the running system:

```
┌──────────────────────────────────────────────────────┐
│                    AI Agent                          │
│          (Cursor, Cline, Amp, Claude, …)             │
└──────┬──────────────────────────────▲────────────────┘
       │ 1. Read spec                 │ 5. Read errors,
       │ 2. Write code                │    fix code,
       │ 3. Install module via MCP    │    retry
       │ 4. Click-test UI via         │
       │    Playwright MCP            │
┌──────▼──────────────────────────────┴────────────────┐
│               Oduflow (MCP Server)                   │
│  • install_odoo_modules → traceback or success       │
│  • run_odoo_tests → test pass/fail with details      │
│  • get_environment_logs → runtime errors             │
│  • upgrade_odoo_modules → upgrade output             │
├──────────────────────────────────────────────────────┤
│            + Playwright MCP / other tools            │
│  • Navigate Odoo UI, click buttons, fill forms       │
│  • Verify business logic end-to-end                  │
│  • Validate acceptance criteria from the spec        │
└──────────────────────────────────────────────────────┘
```

The agent writes code, installs the module, reads the traceback, fixes the error, retries — and when it installs cleanly, it can open the browser via [Playwright MCP](https://github.com/anthropics/mcp-playwright) to click through the UI, verify business flows, and validate acceptance criteria — **all without human intervention**. `connect_as_user` closes the last gap: it mints a passwordless Odoo session and hands back the cookie, so Playwright lands past `/web/login` as any role (admin, sales manager, portal) — no credentials to type, no login form.

| | Vibe Coding | Spec-Driven Development |
|---|---|---|
| **Input** | Conversational prompts | Formal specification with acceptance criteria |
| **Feedback** | Human eyeballs the code | System returns errors, test results, and UI state automatically |
| **Iteration** | Human copy-pastes errors back | Agent retries autonomously via MCP |
| **Scope** | Single files, prototypes | Full modules against real databases |
| **Verification** | "Looks right" | Module installs, tests pass, UI works on production data |

## Key Features

### Core
- **One command to provision** a fully working Odoo instance for any git branch
- **Instant environment creation** from large production databases via PostgreSQL templates and overlayfs
- **Minimal disk footprint** — environments share the template DB and filestore; only per-branch changes consume additional space
- **Template-free mode** — create environments from scratch (`template_name="none"`) when no production dump is available
- **Auto branch creation** — if a branch doesn't exist on the remote, Oduflow clones the default branch and creates the new branch automatically
- **Extra addons repositories** — mount shared addon repos (e.g. Odoo Enterprise) into environments via git worktrees; `addons_path` is auto-merged into `odoo.conf`
- **Environment protection** — protect environments from accidental deletion via a toggle in the dashboard or REST API

### Smart Automation
- **Smart pull** — `pull_and_apply` analyzes changed files (manifest, Python fields, security XML, JS) and automatically decides whether to install, upgrade, restart, or do nothing
- **Auto-install dependencies** — `.oduflow/requirements.txt` (pip, falls back to the repo root) and `.oduflow/apt_packages.txt` (apt) are automatically installed when creating an environment
- **Custom odoo.conf** — if the repository contains an `odoo.conf` in its `.oduflow/` directory, it is used instead of the default template
- **Field change detection** — Python files are analyzed for `fields.*` definition changes, triggering module upgrades only when necessary

### Infrastructure
- **Auxiliary services** — managed sidecar containers for Redis, Meilisearch, Elasticsearch, or any other service your Odoo setup needs
- **Traefik auto-HTTPS** — optional reverse proxy with Let's Encrypt certificates for production-like access
- **Stable port registry** — port assignments are persisted in `ports.json` and survive container restarts
- **Resource monitoring** — per-container CPU and RAM stats, plus system-level metrics (memory, load average)

### Integration
- **AI-agent friendly** — the server exposes tools via [Model Context Protocol (MCP)](https://modelcontextprotocol.io/), so LLM-based coding agents (Cursor, Cline, Amp, etc.) can provision and manage Odoo environments programmatically
- **Hosted coding agent** — an opt-in, per-team AI agent (Claude Code / OpenAI Codex / OpenCode) with a browser **Agent Chat** and **Agent CLI**, driving environments through MCP (see [Coding Agent](agent.md))
- **Web dashboard** — a built-in HTML dashboard for managing environments from a browser
- **REST API** — full JSON API for programmatic control from any HTTP client
- **CLI tools** — every MCP tool can be called directly from the command line via `oduflow call`
- **Dual transport** — stdio (default, for local MCP clients) and HTTP (Streamable HTTP, for remote/multi-user)

---

# Quick Start

## Install

The fastest way — run directly without installing (requires [uv](https://docs.astral.sh/uv/)):

```bash
uvx oduflow
```

Or install permanently:

```bash
uv tool install oduflow
```

On first launch, Oduflow automatically:

- Creates a default `oduflow.toml` config with generated secrets
- Initializes shared infrastructure (Docker network, PostgreSQL, team directories)

The config is created at `/etc/oduflow/oduflow.toml` when that directory is
writable, otherwise at `~/.oduflow/conf/oduflow.toml`. Oduflow searches for the
config in this order:

1. `ODUFLOW_TOML` environment variable (explicit file path)
2. `/etc/oduflow/oduflow.toml`
3. `~/.oduflow/conf/oduflow.toml`

Fresh configs include generated values for:

- `[database].password` — PostgreSQL superuser password
- `[team.1].auth_token` — HTTP MCP Bearer token and OAuth client secret
- `[team.1].ui_password` — Web Dashboard password

The generated `auth_token` and `ui_password` are also printed in the startup log.

## Single-user mode (stdio)

Stdio is the default transport — Oduflow communicates with the MCP client over stdin/stdout. The client starts and manages the Oduflow process directly. No network port is needed.

```bash
# These are all equivalent:
uvx oduflow
oduflow
oduflow --transport stdio
```

Add to your MCP client config (Claude Desktop, Windsurf, etc.):

```json
{
  "mcpServers": {
    "oduflow": {
      "command": "uvx",
      "args": ["oduflow"]
    }
  }
}
```

If Oduflow is installed globally (`uv tool install oduflow`), you can use the shorter form:

```json
{
  "mcpServers": {
    "oduflow": {
      "command": "oduflow"
    }
  }
}
```

## Server mode (HTTP)

HTTP transport starts a persistent server with Streamable HTTP, a Web Dashboard, and a REST API. Suitable for remote and multi-user deployments.

```bash
# Start the HTTP server:
uvx oduflow --transport http
uvx oduflow -t http
# or, if installed:
oduflow --transport http
oduflow -t http
```

The server starts on `http://0.0.0.0:8000` by default (configurable via `[server]` section in `oduflow.toml`). The MCP endpoint is at `/mcp`.

### Authentication

Fresh HTTP installs already have a generated Bearer token for MCP and a separate
generated password for the Web Dashboard. Read them from `oduflow.toml`:

```toml
[team.1]
hostname = "localhost"
auth_token = "..."     # Bearer token for MCP clients
ui_password = "..."    # Web Dashboard password for user admin
```

To sign in to the Web Dashboard, open `http://<host>:8000/`, use username
`admin`, and enter the `ui_password` value. To connect an HTTP MCP client, use
`http://<host>:8000/mcp` with:

```
Authorization: Bearer <auth_token>
```

MCP auth and Web Dashboard auth are independent — they use different credentials
and different mechanisms (Bearer vs form/Basic auth).

### Self-hosted OAuth (Claude.ai)

Some MCP clients (e.g. Claude.ai Remote MCP) require an OAuth flow instead of a static Bearer token. Oduflow can act as its own OAuth 2.1 Authorization Server — no external identity provider needed. In [traefik mode](traefik.md) it's enabled automatically and runs on each team's own hostname, so no extra config is required. In port mode, set the public URL of this instance in `oduflow.toml`:

```toml
[oauth]
oauth_base_url = "https://oduflow.example.com"
```

The OAuth `client_id` is the non-secret `team_<id>` (e.g. `team_1`); each team's `auth_token` is the `client_secret`, and OAuth mints an independent expiring access token. See [Authentication & Security](security.md#self-hosted-oauth-for-claudeai-and-other-mcp-clients) for the full setup and how to connect from Claude.ai.

### MCP client configuration

Point your MCP client (Cursor, Cline, Amp, etc.) to the server with the Authorization header:

```json
{
  "mcpServers": {
    "oduflow": {
      "type": "http",
      "url": "http://your-server:8000/mcp",
      "headers": {
        "Authorization": "Bearer my-secret-mcp-token"
      }
    }
  }
}
```

If the server is behind a reverse proxy with HTTPS (see [Traefik Routing](traefik.md)):

```json
{
  "mcpServers": {
    "oduflow": {
      "type": "http",
      "url": "https://oduflow.example.com/mcp",
      "headers": {
        "Authorization": "Bearer my-secret-mcp-token"
      }
    }
  }
}
```

### Web Dashboard

When running in HTTP mode, a web dashboard is available at the root URL (`http://your-server:8000/`). Sign in as `admin` with the `ui_password` from `oduflow.toml`. It provides environment management, service controls, a WebSocket terminal, and more. See [Web Dashboard & REST API](web-api.md) for details.

## Next steps

- **Set up a template** — `oduflow init-template` (see [Template Management](templates.md))
- **Customize configuration** — edit `oduflow.toml` (see [Configuration Reference](installation.md#configuration-reference))
- **Auto-start on boot** — `oduflow systemd-install` (see [systemd setup](installation.md#auto-start-with-systemd))
- **Multi-team isolation** — add multiple `[team.*]` sections (see [Multi-Team Support](multi-instance.md))

---

# Installation

## System Requirements

- **Docker** (Docker Engine or Docker Desktop)
- **Python 3.10+**
- **Git**
- **fuse-overlayfs** (Linux only, for filestore overlay mounting) — auto-installed on first launch; see below
- **rsync** (all platforms, for incremental filestore copies) — auto-installed on first launch; see below

!!! note "macOS support"
    On macOS, Docker Desktop runs containers inside a Linux VM and projects
    files via VirtioFS. **fuse-overlayfs is not needed** — filestore overlays
    are skipped and a plain directory is used instead.
    File ownership (`chown`) is handled automatically: Oduflow detects the
    `PermissionError` that VirtioFS raises and falls back to running `chown`
    inside a throwaway container. No extra configuration is required.

### Install fuse-overlayfs

On Linux, Oduflow **auto-installs `fuse-overlayfs` on first launch** if it is
missing — it runs `apt-get install -y fuse-overlayfs` when it starts as **root**
on a Debian/Ubuntu host (the Docker image already bundles it). This is
best-effort: if Oduflow is not running as root, `apt-get` is unavailable, or the
install fails, it logs a warning and you can install the package yourself:

```bash
sudo apt install fuse-overlayfs
```

The `/dev/fuse` device must be available (present by default on Ubuntu).

Oduflow mounts each environment's filestore with `fuse-overlayfs`'s `allow_other` option so the Odoo container's (non-root) user can read it. When Oduflow runs as **root** — the default and recommended setup — no further configuration is needed. Only if you run Oduflow as a **non-root user** must you uncomment `user_allow_other` in `/etc/fuse.conf`:

```bash
# Only needed when running Oduflow as a non-root user:
sudo sed -i 's/^#user_allow_other/user_allow_other/' /etc/fuse.conf
```

### Install rsync

`rsync` is auto-installed the same way on Linux (`apt-get install -y rsync` when
running as root on a Debian/Ubuntu host; the Docker image bundles it). Unlike
fuse-overlayfs it matters on **every** platform, including macOS, where it ships
with the system:

```bash
sudo apt install rsync
```

Oduflow uses it to copy only what changed. Saving an environment as a template
snapshots its filestore by hardlinking every file that already matches the
template baseline, so a multi-gigabyte filestore costs only the environment's
own deltas. Without `rsync`, publishing still works but re-copies the whole
filestore each time (logged as a warning), and syncing a template from a local
source fails outright.

## Install Oduflow

### Run without installing

With [uv](https://docs.astral.sh/uv/) you can run Oduflow directly — no installation step needed. `uvx` downloads the package into a temporary environment and runs it:

```bash
uvx oduflow                      # stdio mode (default)
uvx oduflow --transport http     # HTTP server mode
uvx oduflow -t http              # HTTP server mode (short form)
```

This is the quickest way to try Oduflow or use it in CI pipelines.

### Permanent installation

Install via [uv](https://docs.astral.sh/uv/) (recommended — manages an isolated environment automatically):

```bash
uv tool install oduflow
```

Alternative — install via pip:

```bash
pip install oduflow
```

After installation, the `oduflow` command is available globally.

### From source

```bash
git clone https://github.com/oduist/oduflow.git
cd oduflow
uv sync          # or: python -m venv .venv && pip install -e .
```

### Upgrade

```bash
uv tool upgrade oduflow
oduflow upgrade
# For unattended automation:
oduflow upgrade --force
```

The first command upgrades the Python package. The second is a separate,
interactive reconciliation of each team's deployed `odoo.conf`, agent guides,
and bundled sanitize script. Package upgrade alone does not update those
deployed copies. `postgresql.conf` is intentionally separate: preview and apply
resource-tuning changes with `oduflow retune-postgres [--apply]`.

Oduflow keeps the previous pristine bundle under
`<team-data>/.bundled_upgrade/baselines/` and performs a three-way merge. An
unmodified deployed file receives the new bundle directly; local-only changes
stay untouched; disjoint local and upstream changes are merged. The pre-update
live file is retained under `.bundled_upgrade/backups/`.

For an installation created before baselines existed, the first upgrade keeps
the live file and writes the new bundle beside it as `*.oduflow-new`. Merge that
file manually into the live file, then delete the sidecar. A true merge conflict
similarly leaves the live file untouched and writes `*.oduflow-merge`; resolve
that file, install the resolved content as the live file, and remove the
sidecar. Until the sidecar is resolved, `oduflow upgrade` exits non-zero.

For unattended upgrades, pass `--force`. It skips only the stdin confirmation:
legacy files and conflicts are still preserved and still produce a non-zero
exit code.

Automatic merging is the default. To opt a file out of all bundled changes,
add `# KEEP` as the **very first line**:

```conf
# KEEP
[options]
# Keep this odoo.conf entirely operator-managed.
...
```

Files marked with `# KEEP` are skipped and listed as `(kept)` in the upgrade
output.

## Configuration Reference

All settings are configured via a TOML file. Oduflow searches for `oduflow.toml` in the following order:

1. `ODUFLOW_TOML` environment variable (explicit path)
2. `/etc/oduflow/oduflow.toml`
3. `~/.oduflow/conf/oduflow.toml`

If no config file exists when Oduflow starts, the bundled default is copied to
`/etc/oduflow/oduflow.toml` when that directory is writable, otherwise to
`~/.oduflow/conf/oduflow.toml`. The copied file is populated with generated
values for `[database].password`, `[team.1].auth_token`, and
`[team.1].ui_password`; the generated MCP token and Web Dashboard password are
also printed in the startup log.

### Minimal configuration

```toml
[team.1]
hostname = "localhost"
```

### Full configuration reference

```toml
# ── Server ────────────────────────────────────────────
[server]
host = "0.0.0.0"           # HTTP server bind address
port = 8000                 # HTTP server port
allow_local_path = true     # trusted single-user local development; disable on hosted/multi-user servers
# allow_insecure_http = false  # serve /mcp over HTTP with NO auth (only behind your own proxy)
# trace = false             # verbose tracing for git analysis & env ops
# disable_telemetry = false # disable anonymous first_run/env_created events

# ── Routing ───────────────────────────────────────────
[routing]
mode = "port"               # "port" (direct host port) | "traefik" (reverse proxy with auto-HTTPS)
# acme_email = "admin@example.com"  # required when mode = "traefik" and tls = true
# tls = true                # traefik only. false = plain HTTP on :80, no ACME (behind a Cloudflare tunnel / TLS proxy)
# hostname = "localhost"    # port mode only: default host for teams without their own
                            # (traefik requires each team to set its own hostname)

# ── Extra routes (Traefik only) ───────────────────────
# [route.legacy-api]
# host = "api.example.com"
# url = "http://127.0.0.1:3000"

# ── OAuth (optional) ──────────────────────────────────
# In traefik mode the self-hosted OAuth 2.1 Authorization Server is enabled
# automatically and runs on each team's own hostname (issuer derived per-request),
# so oauth_base_url is NOT needed. Set it only to pin a fixed issuer, or in port
# mode: the public URL of this instance (for Claude.ai and other OAuth MCP
# clients). OAuth client_id = team_<id> (non-secret); auth_token = client_secret.
# OAuth mints independent expiring access tokens; auth_token also works as Bearer.
[oauth]
# oauth_base_url = "https://oduflow.example.com"

# ── Database ──────────────────────────────────────────
[database]
user = "odoo"               # PostgreSQL user for the shared database container
# password = "..."          # auto-generated on first launch; set explicitly to override
image = "postgres:15"       # PostgreSQL Docker image

# ── Storage ───────────────────────────────────────────
[storage]
# data_dir = "/srv/oduflow"         # base directory for all data (default: /srv/oduflow or ~/.oduflow/data)
overlay_threshold_mb = 50            # template filestore size threshold (MB) — larger uses fuse-overlayfs, smaller uses copy

# ── Lifecycle ─────────────────────────────────────────
[lifecycle]
auto_stop_hours = 48        # auto-stop environments idle for N hours (no MCP/dashboard work); 0 disables
auto_delete_hours = 0       # auto-delete environments stopped for N hours; 0 disables (opt-in; DESTRUCTIVE, protected envs exempt)

# ── Coding agent (optional) ───────────────────────────
# One agent container per team (Claude Code + OpenAI Codex + OpenCode), driven
# from the dashboard (Agent Chat / Agent CLI). Opt-in per team via
# agent_enabled below.
# [agent]
# image = "oduist/oduflow-coder:0.3.0"
# claude_model = ""         # optional Claude model override; empty = CLI default
# codex_model = ""          # optional Codex model override; empty = CLI default
# opencode_model = ""       # optional provider/model override; empty = OpenCode default

# ── Production hosting (optional) ─────────────────────
# [production]
# enabled = true            # opt in; requires routing.mode = "traefik"
# postgres_image = ""       # empty = [database].image
# walg_version = ""         # empty = Oduflow's pinned WAL-G version
# workers_cap = 8           # upper bound for auto-tuned Odoo workers

# [backup]                  # optional; requires all three credentials below
# bucket = ""
# access_key = ""
# secret_key = ""
# endpoint = ""             # empty = AWS; set for MinIO/R2
# region = ""
# prefix = "oduflow"
# snapshot_time = "02:00"
# basebackup_time = "03:30"
# keep = ["30:180", "7:30", "1:7"]
# walg_keep_full = 7

# ── Teams ─────────────────────────────────────────────
# Each team gets isolated workspaces, templates, credentials, and services.
# At least one [team.*] section is required.

[team.1]
hostname = "localhost"               # port mode: http://{hostname}:{port}, traefik mode: https://{slug}.{hostname}
environment_slots = 20               # traefik: dev.example.com + N => dev1.example.com..devN.example.com; 0 = legacy hostnames
service_slots = 10                   # maximum managed auxiliary services; 0 = unlimited
auth_token = ""                      # auto-filled in fresh configs; HTTP MCP Bearer token
ui_password = ""                     # auto-filled in fresh configs; Web UI password for admin
port_range = [50000, 50100]          # port range for Odoo containers [start, end)
# agent_enabled = false              # enable the per-team coding agent (Agent Chat / Agent CLI)
# agent_default = "claude"           # "claude" | "codex" | "opencode" — default agent
# db_quota_gb = 50                   # combined PostgreSQL database cap; 0 disables
# disk_quota_gb = 0                  # XFS project quota for team files + databases; 0 disables
# [team.1.agent_env]                 # provider credentials injected into the agent container
# CLAUDE_CODE_OAUTH_TOKEN = ""
# ANTHROPIC_API_KEY = ""
# OPENAI_API_KEY = ""
# OPENCODE_API_KEY = ""              # OpenCode Zen; arbitrary provider vars also work
```

### Server settings

| Key | Default | Description |
|---|---|---|
| `[server].host` | `0.0.0.0` | HTTP server bind address |
| `[server].port` | `8000` | HTTP server port |
| `[server].allow_local_path` | `true` | Allow trusted local-development live-mounts that bind a host checkout read/write. Set `false` on hosted, remote, or multi-user servers, or whenever only git-clone delivery is required |
| `[server].allow_insecure_http` | `false` | Serve the `/mcp` endpoint over plain HTTP with **no** authentication. Only enable behind your own authenticating proxy |
| `[server].trace` | `false` | Enable detailed trace logging for git analysis and environment operations |
| `[server].disable_telemetry` | `false` | Disable anonymous usage telemetry (see [Telemetry](#telemetry)) |

### Routing settings

| Key | Default | Description |
|---|---|---|
| `[routing].mode` | `port` | `port` — direct host port mapping; `traefik` — reverse proxy with auto-HTTPS |
| `[routing].acme_email` | *(empty)* | Let's Encrypt email for TLS certificates. Required when `mode = "traefik"` and `tls = true` |
| `[routing].tls` | `true` | Traefik only. `true`: Traefik terminates TLS (:443, HTTP→HTTPS redirect, Let's Encrypt). `false`: plain HTTP on :80 only, no redirect/ACME — for a TLS-terminating upstream (e.g. a Cloudflare tunnel). Public URLs stay `https://` either way |
| `[routing].hostname` | `localhost` | Default hostname for teams that don't set their own `hostname` |

### OAuth settings

| Key | Default | Description |
|---|---|---|
| `[oauth].oauth_base_url` | *(empty)* | Public URL of this Oduflow instance used as the OAuth issuer. Oduflow runs a self-hosted OAuth 2.1 Authorization Server (exposes `/.well-known/oauth-authorization-server`, `/authorize`, `/token`) so OAuth-based MCP clients like Claude.ai can connect; the OAuth `client_id` is the non-secret `team_<id>` (e.g. `team_1`) and each team's `auth_token` is the `client_secret`. OAuth mints independent expiring access tokens; `auth_token` also works directly as a Bearer token. **In traefik mode this is enabled automatically and the issuer is derived per-request from each team's own hostname — leave empty.** Set it to pin a fixed issuer, or in port mode. Empty + port mode = plain Bearer-token auth only. See [Authentication & Security](security.md) |

### Database settings

| Key | Default | Description |
|---|---|---|
| `[database].user` | `odoo` | PostgreSQL user for the shared database container |
| `[database].password` | *(generated)* | PostgreSQL password. The bundled config omits it and one is auto-generated on first launch; set explicitly to override |
| `[database].image` | `postgres:15` | PostgreSQL Docker image |

### Storage settings

| Key | Default | Description |
|---|---|---|
| `[storage].data_dir` | `/srv/oduflow` or `~/.oduflow/data` | Base directory for all data. Team data directories are `team_{ID}` subdirectories inside |
| `[storage].overlay_threshold_mb` | `50` | Template filestore size threshold (MB). Templates smaller than this use a simple copy per environment; larger templates use fuse-overlayfs. The decision is stored in `metadata.json` at template creation time |
| `[lifecycle].auto_stop_hours` | `48` | Auto-stop environments after N hours without work (env-scoped MCP calls or dashboard actions). `0` disables. Protected environments are exempt |
| `[lifecycle].auto_delete_hours` | `0` | Auto-delete stopped environments N hours after they stopped (manual stops count). Default `0` = **disabled** — auto-delete is opt-in and destructive; set a positive value to enable. Protected environments are exempt; `pull_and_apply` wakes a stopped environment automatically |

### Agent settings

The global `[agent]` section holds deployment-wide settings for the per-team coding agent (see [Coding Agent](agent.md)). Per-team enablement lives in the `[team.*]` sections below.

| Key | Default | Description |
|---|---|---|
| `[agent].image` | `oduist/oduflow-coder:0.3.0` | Immutable image for the per-team coding-agent container (Claude Code + OpenAI Codex + OpenCode); the default is coupled to the Oduflow release |
| `[agent].claude_model` | *(empty)* | Optional Claude model override for the agent; empty = CLI default |
| `[agent].codex_model` | *(empty)* | Optional Codex model override for the agent; empty = CLI default |
| `[agent].opencode_model` | *(empty)* | Optional OpenCode model override in `provider/model` format; empty = OpenCode default |

### Production settings

Production hosting is opt-in and is documented in detail in
[Production Hosting](production.md). Production routes and the dashboard tab
are registered only when `[production].enabled = true`.

| Key | Default | Description |
|---|---|---|
| `[production].enabled` | `false` | Enable long-lived production environments and their dedicated PostgreSQL cluster. Requires Traefik routing |
| `[production].postgres_image` | *(empty)* | PostgreSQL image for the production cluster. Empty inherits `[database].image` |
| `[production].walg_version` | *(empty)* | WAL-G release override. Empty uses the version pinned by Oduflow |
| `[production].workers_cap` | `8` | Upper bound for automatically calculated Odoo workers; must be at least `1` |

### Backup settings

The `[backup]` section is optional. If it is present, `bucket`, `access_key`,
and `secret_key` are all required; remove the whole section to disable backups.

| Key | Default | Description |
|---|---|---|
| `[backup].bucket` | *(required)* | S3-compatible bucket name |
| `[backup].access_key` | *(required)* | S3 access key |
| `[backup].secret_key` | *(required)* | S3 secret key |
| `[backup].endpoint` | *(empty)* | Custom S3 endpoint for MinIO, R2, or another compatible service; enables path-style addressing |
| `[backup].region` | *(empty)* | S3 region |
| `[backup].prefix` | `oduflow` | Object-key prefix, normalized without leading or trailing `/` |
| `[backup].snapshot_time` | `02:00` | Default daily per-production snapshot time in server-local `HH:MM` |
| `[backup].basebackup_time` | `03:30` | Daily WAL-G base-backup time in server-local `HH:MM` |
| `[backup].keep` | `["30:180", "7:30", "1:7"]` | Snapshot retention tiers as `interval_days:age_days` pairs |
| `[backup].walg_keep_full` | `7` | Number of WAL-G full base backups to retain; must be at least `1` |

### Per-team settings

Each `[team.*]` section defines an isolated team with its own workspaces, templates, credentials, and services. At least one team is required.

| Key | Default | Description |
|---|---|---|
| `hostname` | `localhost` | Team hostname. In port mode: `http://{hostname}:{port}`. In traefik mode: `https://{slug}.{hostname}` |
| `environment_slots` | `20` | Traefik reusable hostname pool and concurrent environment cap. `0` keeps branch-derived hostnames; with `dev.example.com`, `N` allocates `dev1.example.com` through `devN.example.com` |
| `service_slots` | `10` | Maximum number of managed auxiliary services for the team. Stopped services count; deleting a service frees its slot. `0` disables the cap |
| `auth_token` | *(generated in fresh config)* | Bearer token for MCP HTTP auth and OAuth client secret. Empty disables MCP auth only when explicitly allowed with `[server].allow_insecure_http = true`; otherwise HTTP startup refuses it |
| `ui_password` | *(generated in fresh config)* | Password for Web UI login (user: `admin`). Separate from MCP auth token. Empty disables UI auth only when explicitly allowed with `[server].allow_insecure_http = true`; otherwise HTTP startup refuses it |
| `port_range` | `[50000, 50100]` | Port range for Odoo containers `[start, end)` — supports up to 100 concurrent environments |
| `agent_enabled` | `false` | Enable the per-team coding agent (dashboard Agent Chat / Agent CLI). Off by default |
| `agent_default` | `claude` | Which agent consoles/chats open by default: `claude`, `codex`, or `opencode` |
| `db_quota_gb` | `50` | Combined size cap for the team's environment and template PostgreSQL databases. `0` disables the check |
| `disk_quota_gb` | `0` | Kernel-enforced cap for team files and databases when the data filesystem supports XFS project quotas. `0` disables it |
| `[team.X.agent_env]` | *(empty)* | Sub-table of environment variables injected into the team's agent container — provider credentials (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENCODE_API_KEY`, or any provider-specific OpenCode variable) and custom vars |

`environment_slots > 0` requires a hostname with a distinct host prefix and
parent domain, such as `dev.example.com`. A bare registrable domain such as
`example.com` has no prefix to number and is rejected.

Team data is stored at `{data_dir}/team_{ID}/`:

```
team_{ID}/
├── workspaces/           # Per-branch environments
├── templates/            # Reusable database snapshots
├── shared_repos/         # Extra addon repositories (bare clones)
├── ports.json            # Port registry
├── hostnames.json        # Reusable Traefik hostname reservations
├── .git-credentials      # Git credentials for this team
└── agent_guides/         # AI agent guides (markdown)
```

### Configuration file overrides

On first startup, Oduflow generates `postgresql.conf` from one host-wide
resource plan and copies the bundled `odoo.conf` if it does not exist. These
files take **priority** over the bundled defaults — edit them to customize
PostgreSQL tuning or Odoo settings globally:

```
/etc/oduflow/             (or ~/.oduflow/conf/)
  oduflow.toml            ← main configuration file
  postgresql.conf         ← dev PostgreSQL tuning (used by oduflow-db)
  postgresql-prod.conf    ← production PostgreSQL tuning (created lazily)
  odoo.conf               ← custom Odoo defaults (used by new environments)
  license.key             ← license file (optional)
  traefik/                ← Traefik dynamic configuration (auto-generated)
```

The resource plan considers `[production].enabled`. With production disabled,
the lean dev PostgreSQL profile targets about 10% of host RAM for
`shared_buffers` (128 MB–1 GB). With production enabled, the planner budgets
the host as a whole: dev PostgreSQL targets 5% (128–512 MB), production
PostgreSQL targets 20% (512 MB–8 GB), production Odoo worker sizing gets a 45%
RAM budget, and 20% stays reserved for the OS and other services. CPU values
are concurrency ceilings, not Docker reservations.

Generated configs contain an `ODUFLOW-TUNE` fingerprint. Oduflow warns when
CPU, RAM, or the production mode no longer matches that fingerprint, but never
rewrites or restarts PostgreSQL during a normal startup or package upgrade.
Preview and explicitly apply a new plan with:

```bash
oduflow retune-postgres          # plan + unified diff; writes nothing
oduflow retune-postgres --apply  # backup/write and stage managed configs
```

`--apply` refuses a custom config unless `--force` is also given. Existing
files are backed up with a UTC timestamp. For each existing production it also
regenerates the derived `odoo.conf` and stages it inside the Odoo container.
Restart the PostgreSQL and Odoo containers listed by the command to activate
the new database and worker settings.

If a repository contains an `odoo.conf` in its `.oduflow/` directory (`<repo>/.oduflow/odoo.conf`), it takes priority over both the bundled and system-level versions for that specific environment.

## Telemetry

Oduflow collects **anonymous** usage telemetry to help us understand adoption and prioritize development. Two events are sent:

- **`first_run`** — sent once on the very first startup (when the instance ID is created).
- **`env_created`** — sent each time a new environment is provisioned.

Each event contains only:

- The event name
- The oduflow version
- A random instance ID (UUID)

**No** personal data, hostnames, IP addresses, branch names, repository URLs, or environment details are collected.

### Opt out

Add to your `oduflow.toml`:

```toml
[server]
disable_telemetry = true
```

## Auto-start with systemd

On Linux servers, Oduflow can be registered as a systemd service so it starts automatically on boot.

### Prerequisites

```bash
# Install uv (if not already installed)
curl -LsSf https://astral.sh/uv/install.sh | sh

# Install oduflow as a tool (as root)
uv tool install oduflow

# Create the configuration file (optional — Oduflow auto-creates a default oduflow.toml on first start)
```

### Install the service

```bash
oduflow systemd-install
```

This will:

1. Generate a systemd unit file at `/etc/systemd/system/oduflow.service`
2. Write `/etc/needrestart/conf.d/oduflow.conf` (only if needrestart is installed)
3. Run `systemctl daemon-reload`
4. Enable the service for auto-start on boot

The unit is ordered after `docker.service` and `containerd.service`, restarts
always, and has no start-rate limit, so a host that upgrades Docker underneath
Oduflow cannot leave the service parked in `failed`.

The needrestart snippet excludes `oduflow.service` from needrestart's automatic
restarts. Oduflow drives the Docker daemon; when `unattended-upgrades` restarts
a library, needrestart would otherwise restart Oduflow in the same batch as
containerd and Docker, and Oduflow's startup would race a daemon that is itself
going down. With the exclusion in place, needrestart lists Oduflow as needing a
manual restart instead:

```bash
systemctl restart oduflow
```

Already installed the service with an older Oduflow? Re-run
`oduflow systemd-install` to refresh the unit and add the needrestart override,
then `systemctl daemon-reload && systemctl restart oduflow`.

### Manage the service

```bash
# Start
systemctl start oduflow

# Status
systemctl status oduflow

# Logs (follow)
journalctl -u oduflow -f

# Restart after config changes
systemctl restart oduflow
```

### Remove the service

```bash
oduflow systemd-uninstall
```

This stops, disables, and removes the unit file, along with the needrestart
override.

---

# Use Cases & Workflows

## 🚀 Feature Branch Development

The most common workflow — test your changes against real production data:

```bash
# Create an environment for your feature branch
oduflow call create_environment feature-login "" default https://github.com/company/odoo-addons.git odoo:19.0

# Make changes, push to remote, then pull into the environment
oduflow call pull_and_apply feature-login
# Oduflow automatically installs/upgrades/restarts as needed

# When done, tear it down
oduflow call delete_environment feature-login
```

## 🐛 Bug Reproduction

Reproduce a production bug with real data:

```bash
# Spin up an environment with production data
oduflow call create_environment bug-12345 "" default https://github.com/company/odoo-addons.git odoo:19.0

# Debug inside the container
oduflow call run_odoo_command bug-12345 "python3 -c 'import odoo; ...'"

# Check the database directly with the environment-scoped DB role
oduflow call run_db_query bug-12345 "SELECT * FROM sale_order WHERE id=42"
```

## 🧪 Module Testing

Run Odoo tests in an isolated environment:

```bash
oduflow call create_environment test-suite "" default https://github.com/company/odoo-addons.git odoo:19.0
oduflow call run_odoo_tests test-suite sale_custom,invoice_custom
oduflow call delete_environment test-suite
```

## 🌱 Greenfield Project (No Production Database)

Start a new Odoo project from scratch:

```bash
# Generate a clean template with common modules
oduflow init-template --odoo-image odoo:19.0 --template-name default --modules base,web,contacts,sale,purchase,stock

# Now create environments that start with your customized setup
oduflow call create_environment dev "" default https://github.com/company/new-project.git odoo:19.0
```

## 🔄 Multiple Odoo Versions

Manage environments across different Odoo versions using named templates:

```bash
# Set up templates for different versions
oduflow init-template --odoo-image odoo:15.0 --template-name v15
oduflow init-template --odoo-image odoo:19.0 --template-name v19

# Create environments targeting specific versions
oduflow call create_environment legacy-fix "" v15 https://github.com/company/v15-addons.git odoo:15.0
oduflow call create_environment new-feature "" v19 https://github.com/company/v19-addons.git odoo:19.0
```

## 🤖 AI-Assisted Development

Let your AI coding agent manage Odoo environments. Configure your MCP client (Cursor, Cline, Amp) to connect to `http://<host>:8000/mcp`, then:

> *"Create an Odoo 19 environment for the `feature-payment-gateway` branch from our repo. Install the `sale` and `payment` modules, then run the tests."*

The agent will call the appropriate MCP tools in sequence:

1. `create_environment` → provision the environment
2. `install_odoo_modules` → install the requested modules
3. `run_odoo_tests` → run the test suite
4. Report results back

### Connecting Your Agent to Oduflow MCP

Add the Oduflow MCP server to your agent's configuration. The exact format depends on the client:

```json
{
  "mcpServers": {
    "oduflow": {
      "type": "http",
      "url": "https://<your-oduflow-host>/mcp",
      "headers": {
        "Authorization": "Bearer test"
      }
    }
  }
}
```

Replace `<your-oduflow-host>` with your Oduflow server address (e.g. `localhost:8000` or `oduflow.example.com`). The Bearer token must match the `auth_token` configured for your team in `oduflow.toml`.

### Recommended Agent Rule (Cursor / Windsurf / Amp)

You can add the following rule to your AI coding agent to automate environment lifecycle management:

```
---
description: "Manage Odoo dev environments via the Oduflow MCP server"
alwaysApply: true
---
```

**Initialization**

1. **Check**: Call `list_environments`. If an environment matching the current branch already exists, use it.
2. **Create**: If not, use `create_environment`:
   - `branch`: `<current branch>`
   - `repo_url`: `<repository URL>` (HTTPS)
   - `odoo_image`: `odoo19_prod` (IMPORTANT: always use this image)
3. **Auth**: On a 401/403 error, suggest `setup_repo_auth`.
4. When creating or finding an existing environment, add the environment URL to `{@artifacts_path}/report.md`.

**Sync & Work Cycle**

1. **Push**: Run `git push` when the task is complete.
2. **Pull**: After every `push` (yours or user-requested), ALWAYS call `pull_and_apply`.
3. **Automation**: The Flow server decides whether a restart or module upgrade is needed. You do NOT need to call `restart_environment` or `upgrade_odoo_modules`.

**Teardown**

- Only delete the environment via `delete_environment` if the task status is **Done** or **Canceled**.
- Do not recreate the environment to fix errors without the user's consent.

**Important**

- One task = one branch = one environment.
- Always display the environment URL to the user when creating an environment.

## 📊 Environment with Auxiliary Services

Set up a full-stack development environment:

```bash
# Create the Odoo environment
oduflow call create_environment dev "" default https://github.com/company/odoo-addons.git odoo:19.0

# Add Redis for caching
oduflow call create_service redis redis:7 6379

# Add Meilisearch for full-text search
oduflow call create_service meilisearch getmeili/meilisearch:v1.6 7700 "" "MEILI_MASTER_KEY=devkey123"
```

A team's services share its isolated `oduflow-{team_id}-net` Docker network and communicate using container names as hostnames — the DNS name is the full container name `oduflow-{team_id}-svc-{name}` (e.g. `oduflow-1-svc-redis:6379`), which is exactly the `Container:` value reported by `create_service`.

## 🔧 CI/CD Pipeline Integration

Use `oduflow call` in your CI pipeline:

```yaml
# .github/workflows/test.yml
steps:
  - name: Create test environment
    run: oduflow call create_environment ci-${{ github.sha }} "" default ${{ github.repository }} odoo:19.0

  - name: Install and test
    run: |
      oduflow call install_odoo_modules ci-${{ github.sha }} my_module
      oduflow call run_odoo_tests ci-${{ github.sha }} my_module

  - name: Cleanup
    if: always()
    run: oduflow call delete_environment ci-${{ github.sha }}
```

## 📦 Importing a Template from Odoo or Another Workspace

You can create a template from a running Odoo instance, from a manual database backup, or by copying a template directory from another Oduflow instance.

**Directly from a running Odoo instance (recommended):**

The easiest way — Oduflow downloads the backup, extracts it, auto-detects the Odoo version, and loads the template in one command:

```bash
oduflow import-template https://my-odoo.example.com master_password --template-name default
```

Options:

- `--db-name <db>` — specify the database name (auto-detected if only one DB exists)
- `--template-name <name>` — template profile name (default: `default`)
- `--without-filestore` — request a database-only PostgreSQL custom dump without filestore files

This is also available as an MCP tool (`import_template_from_odoo`) for AI agents; pass `without_filestore=true` for a database-only import.

If the database dump and filestore are delivered separately, import with `--without-filestore` first, then run `oduflow attach-filestore <template> <source>` when the filestore archive, local directory, or rsync/SSH source is ready. See [Database Dump and Separate Filestore](templates.md#database-dump-and-separate-filestore) for the full sequence.

**From Odoo Database Manager (manual):**

1. Go to `/web/database/manager` in your Odoo instance
2. Download a backup — **make sure to include the filestore** (the checkbox must be enabled, otherwise the template will be missing all attachments, images, and assets)
3. Extract the archive — it contains a `dump.sql` file and a `filestore/` directory
4. Place them into the template directory:

```bash
mkdir -p {data_dir}/team_{ID}/templates/myproject
# Copy or move the extracted files
cp dump.sql {data_dir}/team_{ID}/templates/myproject/
cp -r filestore {data_dir}/team_{ID}/templates/myproject/
```

5. Load the template into PostgreSQL:

```bash
oduflow reload-template myproject
```

**From another Oduflow workspace:**

Simply copy the entire template directory and reload:

```bash
cp -r /other/oduflow/templates/myproject {data_dir}/team_{ID}/templates/myproject
oduflow reload-template myproject
```

!!! warning
    The SQL dump is loaded into the shared PostgreSQL instance by `reload-template`. Without this step, the template will appear in the list but show **DB NOT LOADED** and cannot be used to create environments.

## 🏗️ Template Evolution

Evolve your template as the project grows:

```bash
# 1. Create an environment for template changes
oduflow call create_environment template-update "" default https://github.com/company/odoo-addons.git odoo:19.0

# 2. Install new modules
oduflow call install_odoo_modules template-update accounting,hr,project

# 3. Verify everything works
oduflow call run_odoo_tests template-update accounting,hr,project

# 4. Save as the new template
oduflow call save_as_template template-update default

# 5. All future environments will include these modules pre-installed
```

---

# Template Management

![Templates Dashboard](img/templates.png)

Templates are the foundation of Oduflow's instant environment creation. A template consists of a PostgreSQL dump file and an optional filestore directory.

Create templates from production dumps, staging snapshots, or from scratch. Maintain **multiple named templates** side-by-side (e.g. per Odoo version, per client, per project phase) and spin up any combination of branch + database in seconds.

## Starting from Scratch (No Production Dump)

If you don't have a production database dump — for example, you're starting a new Odoo project or just want to try Oduflow — you can generate a clean template automatically.

### Generate a clean template

```bash
oduflow init-template --odoo-image odoo:19.0 --template-name default
```

If a `dump.sql` or filestore already exists, the command will refuse to run. Use `--force` to overwrite:

```bash
oduflow init-template --odoo-image odoo:19.0 --template-name default --force
```

This will:

1. Start a PostgreSQL container (if not already running)
2. Run a temporary Odoo container that initializes a fresh database with the `base` module
3. Dump the database to `{data_dir}/team_{ID}/templates/{name}/dump.pgdump`
4. Extract the filestore to `{data_dir}/team_{ID}/templates/{name}/filestore/`
5. Load the dump into the template database automatically

### Install additional modules during generation

```bash
oduflow init-template --odoo-image odoo:19.0 --template-name default --modules base,web,contacts,sale
```

### Named templates for different projects

```bash
oduflow init-template --odoo-image odoo:19.0 --template-name myproject-v19
oduflow init-template --odoo-image odoo:15.0 --template-name legacy-v15
```

## From a Production Dump

Place your dump file at `{data_dir}/team_{ID}/templates/default/dump.sql` (plain SQL) or `dump.pgdump` (PostgreSQL custom format) and optionally copy the filestore:

```bash
mkdir -p /srv/oduflow/team_1/templates/default/
cp /path/to/production.sql /srv/oduflow/team_1/templates/default/dump.sql
cp -r /path/to/filestore/ /srv/oduflow/team_1/templates/default/filestore/
oduflow reload-template default
```

## Saving a Branch as Template

When you've made significant changes in a branch environment (installed modules, created configurations), you can save it as the new template:

```bash
oduflow template-from-env my-branch --template-name default
oduflow template-from-env my-branch --template-name myproject  # save to a named template
```

This operation:

1. Dumps the branch database to a new template dump file
2. Reloads the template database from the new dump
3. Snapshots the branch's merged filestore
4. Unmounts the overlay filesystems of other active environments on this template (keeping their `upper` deltas)
5. Replaces the template filestore with the snapshot
6. Remounts those overlays against the new baseline, **preserving each environment's filestore changes** by default
7. Restarts the affected containers

The **source environment** is always reset to the new baseline (its data just became the template). **Other environments keep their filestore changes** (the overlay `upper` layer) — this is non-destructive by default. Their existing files shadow the new template (env-local edits win); files they deleted stay deleted; new template files show through.

To instead discard other environments' changes and reset them to the clean new baseline:

```bash
oduflow template-from-env my-branch --template-name default --reset-env-changes
```

!!! note
    `--reset-env-changes` is **destructive**: other environments lose their filestore deltas. Without it, their changes are preserved.

!!! info "Copy-mode templates"
    Environments created from a small (copy-mode, `use_overlay=false`) template have an independent filestore copy, not an overlay. They are not affected by template filestore updates and are left untouched.

## Refreshing Template Overlays

Re-apply a template's current on-disk filestore to all live overlay environments without re-importing or re-saving — non-destructive by default (each environment keeps its `upper` deltas):

```bash
oduflow refresh-template default
oduflow refresh-template default --reset-env-changes   # discard env deltas (destructive)
```

Use this after changing the template filestore on disk, or to re-sync an environment that was busy/skipped during an import or save.

## Database Dump and Separate Filestore

Production backups are often delivered as two artifacts: a database dump first and a filestore archive or directory later. Use this flow when you imported a template with `--without-filestore`, or when a manual backup gives you the database and filestore separately.

```bash
# 1. Import the database only from a running Odoo instance
oduflow import-template https://my-odoo.example.com master_password \
  --db-name odoo19-mirage \
  --template-name prod \
  --without-filestore

# 2. Attach the filestore when it is available
oduflow attach-filestore prod /backups/odoo19-mirage-filestore.zip

# 3. Create environments from the complete template
oduflow call create_environment '{"branch":"dev","template_name":"prod"}'
```

`attach-filestore` replaces the template's filestore and updates `metadata.json` (`includes_filestore`, `filestore_size_mb`, and `use_overlay`). It does not reload the database.

Supported sources:

```bash
# Local archive; entries like odoo19-mirage/60/<sha1> are normalized to 60/<sha1>
oduflow attach-filestore prod /backups/odoo19-mirage-filestore.zip

# Local directory
oduflow attach-filestore prod /backups/odoo19-mirage/filestore

# Remote rsync over SSH
oduflow attach-filestore prod odoo@example.com:/srv/odoo/.local/share/Odoo/filestore/odoo19-mirage

# rsync daemon URL
oduflow attach-filestore prod rsync://backup.example.com/odoo/filestore/odoo19-mirage
```

Archives may be `.zip`, `.tar`, `.tar.gz`, `.tgz`, `.tar.bz2`, `.tbz2`, `.tar.xz`, or `.txz`. Local directories and remote sources are copied with `rsync -a --delete`, so `rsync` must be installed on the Oduflow host and reachable over SSH for `user@host:/path` sources.

Oduflow detects the wrapper prefix automatically by looking for Odoo filestore paths shaped like `XX/<40-character sha1>`. For example, an archive containing `odoo19-mirage/60/609e7ca59cc05bf0de7233c6781a381b742a2931` is installed as `filestore/60/609e7ca59cc05bf0de7233c6781a381b742a2931`. If a source has multiple possible wrappers, pass the prefix explicitly:

```bash
oduflow attach-filestore default /backups/filestore.zip --strip-prefix odoo19-mirage
oduflow attach-filestore default /backups/filestore.zip --strip-prefix none
```

Like `template-from-env`, this is non-destructive for live overlay environments by default: Oduflow remounts them against the new template filestore while preserving their `upper` changes. Pass `--reset-env-changes` only when you intentionally want those environments reset to the new baseline. Copy-mode environments are independent copies and are not changed by attaching a new template filestore.

## Reloading a Template

Update the template from a newer production dump without touching the filestore:

```bash
oduflow reload-template default --dump-path /path/to/new.dump
oduflow reload-template myproject --dump-path /path/to/new.dump
```

### Syncing from S3 or Local Path

Use `--source` to sync both the dump file and filestore from an external source before reloading:

```bash
# Sync from S3
oduflow reload-template default --source s3://mybucket/prod/

# Sync from local path
oduflow reload-template default --source /backups/prod-latest/

# Cron-friendly (suppress info logging)
oduflow reload-template default --source s3://mybucket/prod/ --quiet
```

The source directory should contain `dump.pgdump` (or `dump.sql`) and optionally `filestore/`. Files are synced using `aws s3 sync` (S3) or `rsync` (local), then the template DB is reloaded.

!!! info "Non-destructive for live environments"
    When `--source` replaces the template filestore, live overlay environments on that template are automatically unmounted and remounted against the new lower layer, **keeping their filestore changes**. `import-template` creates a new template and refuses an existing template name.

## Listing and Dropping Templates

```bash
# List all template profiles with their status
oduflow list-templates

# Delete a template profile (removes DB + files from disk)
oduflow delete-template myproject
```

## Template Metadata

Each template profile can contain a `metadata.json` file that stores defaults and configuration:

```json
{
  "odoo_image": "odoo:19.0",
  "repo_url": "https://github.com/company/addons.git",
  "extra_addons": {"enterprise": "19.0"},
  "use_overlay": true,
  "source_url": "https://my-odoo.example.com",
  "source_db": "production",
  "odoo_version": "19.0+e",
  "pg_version": "15.0"
}
```

When `create_environment` is called with a template name, `repo_url`, `odoo_image`, and `extra_addons` are automatically loaded from metadata if not explicitly provided. This means after importing or configuring a template, you can create environments with just `branch` and `template_name` — all other parameters are inherited.

The `use_overlay` flag determines whether new environments use fuse-overlayfs (for large filestores) or a simple copy (for small ones). It is set automatically based on `overlay_threshold_mb` (in `[storage]`) when the template is created.

## Template Decision Matrix

| Scenario | Command |
|---|---|
| New project, no existing database | `oduflow init-template --odoo-image odoo:19.0 --template-name default` |
| Regenerate template from scratch | `oduflow init-template --odoo-image odoo:19.0 --template-name default --force` |
| Named template for a specific project | `oduflow init-template --odoo-image odoo:19.0 --template-name myproject` |
| Have a production dump file | Place dump at `{data_dir}/team_{ID}/templates/default/dump.sql` and run `oduflow reload-template default` |
| Need to install modules or configure the template | Create an env, configure it, then `oduflow template-from-env my-branch --template-name default` |
| Update the template from a newer production dump | `oduflow reload-template default --dump-path /path/to/new.dump` |
| Sync template from S3 and reload | `oduflow reload-template default --source s3://bucket/prod/` |
| Save a branch environment as template (keep other envs' changes) | `oduflow template-from-env my-branch --template-name default` |
| Save a branch as template and reset all other envs | `oduflow template-from-env my-branch --template-name default --reset-env-changes` |
| Re-apply a template's filestore to live envs | `oduflow refresh-template default` |
| Attach a separately delivered filestore | `oduflow attach-filestore default /backups/filestore.zip` |
| List all templates | `oduflow list-templates` |
| Delete a template | `oduflow delete-template myproject` |

---

# Environment Management

![Environments Dashboard](img/envs.png)

## Creating Environments

```bash
# Create with a named template (env_name, template_name, repo_url, odoo_image)
oduflow call create_environment feature-login "" myproject https://github.com/owner/repo.git odoo:19.0

# Create without a template (fresh Odoo with -i base)
oduflow call create_environment feature-login "" none https://github.com/owner/repo.git odoo:19.0

# Create with JSON arguments (more explicit)
oduflow call create_environment '{"branch":"feature-login","template_name":"myproject","repo_url":"https://github.com/owner/repo.git","odoo_image":"odoo:19.0"}'

# Override the numbered Traefik prefix (dev.example.com -> qa.example.com)
oduflow call create_environment '{"branch":"feature-login","hostname":"qa","template_name":"myproject"}'

# Inject container environment variables (comma-separated KEY=VALUE)
oduflow call create_environment '{"branch":"feature-login","template_name":"myproject","env_vars":"WORKERS=2,LIMIT_TIME_CPU=600"}'
```

`env_vars` are added on top of the database connection variables (`HOST`/`USER`/`PASSWORD`). They are stored on the container and can later be replaced with [`update_environment`](#lifecycle-management).

In Traefik mode, a team with `environment_slots = N` numbers the first label of
its hostname. For `dev.example.com`, the reusable pool is `dev1.example.com`
through `devN.example.com`. The assignment survives stops and container updates
and is released only when the environment is deleted. Pass `hostname` to replace
the numbered prefix: `hostname="qa"` produces `qa.example.com`. The default is
20 concurrent environment slots; set `environment_slots = 0` to retain legacy
branch-derived hostnames.

When creating an environment, Oduflow:

1. **Clones the repository** — shallow clone (`--depth 1`) for speed
2. **Creates the database** — `CREATE DATABASE ... TEMPLATE oduflow_template_{team_id}_{name}` for instant copy, or empty DB when `template=none`
3. **Mounts the filestore overlay** — fuse-overlayfs with the template as lower layer
4. **Detects UID/GID** — runs `id` in the Odoo image to set correct file ownership
5. **Installs dependencies** — auto-installs from `.oduflow/apt_packages.txt` and `.oduflow/requirements.txt` (the latter falls back to the repo root) if present
6. **Configures Odoo** — uses repo's `.oduflow/odoo.conf` if available, otherwise the default template; if the repo keeps its modules in a top-level `addons/` directory, `addons_path` points there automatically
7. **Starts the container** — with `--dev=xml` for hot-reloading XML/QWeb changes
8. **Initializes base** — when `template=none`, runs `odoo -i base --stop-after-init`

### Private repository authentication

For private repos, configure credentials first:

```bash
oduflow call setup_repo_auth https://user:PAT@github.com/owner/private-repo.git
```

Credentials are stored in the git credential store. Subsequent `create_environment` calls can use the clean URL without credentials.

### Auto-dependency installation

Place these files in your repository for automatic installation during environment creation:

**`.oduflow/requirements.txt`** — Python packages installed via pip. Oduflow looks in
`.oduflow/` first and falls back to a `requirements.txt` in the repository root (for
compatibility with conventions used elsewhere, e.g. odoo.sh):

```
phonenumbers==8.13.0
python-barcode==0.15.1
xlsxwriter>=3.0
```

**`.oduflow/apt_packages.txt`** — System packages installed via apt. This is an
Oduflow-specific convention and is read **only** from `.oduflow/` (no repo-root fallback):

```
# Dependencies for wkhtmltopdf
libfontconfig1
libxrender1
xfonts-75dpi
```

## Database Sanitization

When an environment is created from a template, Oduflow **automatically sanitizes** the database to prevent the test instance from sending real emails or polling mailboxes. This is enabled by default (`sanitize=True`).

For Odoo versions that provide it, Oduflow first runs Odoo's native `odoo neutralize` command inside the serving container, after any auto-installed modules are present. Odoo 15 and earlier do not include this command, so Oduflow detects the version from both official and custom Docker image references, skips the native step there, and continues with the custom scripts below.

Custom sanitization then uses a **two-tier** approach:

1. **Team-level scripts** from `{team_data_dir}/odoo_sanitize/` — managed by the administrator, shared across all environments in the team
2. **Per-project scripts** from `.oduflow/odoo_sanitize/` in the repository root — managed by the developer, specific to the project

Both folders support `.sql` and `.py` files, executed in alphabetical order (first all `.sql`, then all `.py`). Team-level scripts run first, then per-project scripts.

### Team-level sanitization

On startup, the folder `{team_data_dir}/odoo_sanitize/` is created and seeded with a default script:

**`01_disable_mail.sql`** — disables incoming and outgoing mail servers:

```sql
-- Disable incoming mail servers (fetchmail)
UPDATE fetchmail_server SET active = false WHERE active = true;

-- Disable outgoing mail servers
UPDATE ir_mail_server SET active = false WHERE active = true;
```

The team administrator can add, modify, or remove scripts in this folder to control sanitization for all environments in the team.

!!! tip
    To disable additional cron jobs team-wide, create `{team_data_dir}/odoo_sanitize/02_disable_crons.sql`:
    ```sql
    UPDATE ir_cron SET active = false;
    ```

### Per-project sanitization

You can add project-specific sanitization under `.oduflow/odoo_sanitize/` in your repository root:

| File type | Execution method |
|-----------|-----------------|
| `*.sql`   | Executed directly against the environment database via `psql` |
| `*.py`    | Executed inside the Odoo container via `python3 -c` |

**Example SQL script** (`.oduflow/odoo_sanitize/01_clean_partners.sql`):

```sql
UPDATE res_partner SET email = 'test@example.com' WHERE email IS NOT NULL;
```

**Example Python script** (`.oduflow/odoo_sanitize/02_reset_passwords.py`):

```python
import os, psycopg2
conn = psycopg2.connect(
    host=os.environ["DB_HOST"],
    dbname=os.environ["ODOO_DB"],
    user=os.environ["DB_USER"],
    password=os.environ["DB_PASSWORD"],
)
with conn.cursor() as cr:
    cr.execute("UPDATE res_partner SET email = 'test@example.com' WHERE email IS NOT NULL")
    conn.commit()
conn.close()
```

Python scripts receive the following environment variables: `ODOO_DB`, `DB_HOST`, `DB_USER`, `DB_PASSWORD`.

### Disabling sanitization

Pass `sanitize=false` when creating an environment to skip all sanitization (both team-level and per-project):

```bash
oduflow call create_environment '{"branch":"my-branch","template_name":"mytemplate","repo_url":"https://...","odoo_image":"odoo:19.0","sanitize":false}'
```

!!! note
    Sanitization only runs when creating from a template. Environments created without a template (`template=none`) are not sanitized since they start with a clean database.

## Lifecycle Management

```bash
# List all environments with status, URL, image, and repo info
oduflow call list_environments

# Check detailed environment info (DB, URL, repo, image, CPU/RAM stats)
oduflow call get_environment_info feature-login

# Stop an environment (preserves data)
oduflow call stop_environment feature-login

# Start a stopped environment
oduflow call start_environment feature-login

# Restart the Odoo container
oduflow call restart_environment feature-login

# Re-create the container (keeps database and filestore)
oduflow call update_environment feature-login

# Switch image and/or replace env vars (keeps database and filestore)
oduflow call update_environment feature-login "WORKERS=4,LIMIT_TIME_CPU=900" odoo:19.0

# Tear down everything (container, database, filestore, workspace)
oduflow call delete_environment feature-login
```

### Recreating Environments

The **Recreate** action (available via the Web Dashboard and REST API) deletes an environment and immediately creates a fresh one using the same parameters (repo URL, Odoo image, template, extra addons). This is useful when you want a clean slate without manually re-entering all environment settings.

```bash
# Via REST API
curl -X POST http://localhost:8000/api/environments/feature-login/recreate
```

Recreate reads the original configuration from the container's Docker labels, so all parameters (repo URL, image, template, extra addons, git user) are preserved automatically.

### Automatic Stop and Cleanup

Environments accumulate: agents create them faster than anyone cleans up. A
background sweep inside the Oduflow server keeps the fleet tidy:

- **Auto-stop** — a running environment with no *work* for `auto_stop_hours`
  (default **48**) is stopped. Work means any env-scoped MCP tool call
  (`pull_and_apply`, module installs, tests, logs, shell, queries, ...) or a
  lifecycle action in the Web Dashboard. Listing environments and dashboard
  polling do **not** count.
- **Auto-delete** — a stopped environment that nobody started for
  `auto_delete_hours` (default **72**) after it stopped is deleted entirely
  (container, database, filestore, workspace). Manual stops count too: a
  stopped environment is on the deletion clock.

**Keeping an environment alive.** Protected environments (`protect_environment`
or the Protect action in the dashboard) are exempt from both auto-stop and
auto-delete — protect anything you hand to customers for testing. Keeping an
environment running (any activity resets the idle clock) also keeps it safe
from deletion, since only stopped environments are ever deleted.

**Waking up.** Container-level tools start a stopped environment
automatically and prepend a short note to the response
(`Note: environment was stopped; started it ...`): `pull_and_apply`, module
installs/upgrades, `run_odoo_tests`, `run_odoo_shell`, `run_odoo_command`,
the ORM tools (`odoo_search_read`, `odoo_create`, `odoo_write`, `odoo_unlink`,
`odoo_call`, `odoo_schema`), file tools (`read/write/search_in_odoo`),
`http_request_to_odoo` and `reset_admin_password`. Read-only and diagnostic
tools never wake an
environment: `run_db_query` and `list_installed_modules` go to the shared
PostgreSQL, and `get_environment_logs` reads logs of stopped containers —
useful when diagnosing why something died.

The dashboard shows each environment's last activity (`Active: 2h ago`) and,
for stopped ones, when and how it stopped (`Stopped: 1d ago (auto)`). Every
auto-stop/auto-delete is logged by the server
(`Auto-stopped environment 'x' (idle longer than 48h)`).

Configure (or disable with `0`) in `oduflow.toml`:

```toml
[lifecycle]
auto_stop_hours = 48    # stop after N hours without work; 0 disables
auto_delete_hours = 0   # delete N hours after stop; 0 disables (opt-in; DESTRUCTIVE)
```

## Viewing Logs

```bash
# Last 100 lines (default)
oduflow call get_environment_logs feature-login

# Last 500 lines
oduflow call get_environment_logs feature-login 500
```

## Installing and Upgrading Modules

```bash
# Install modules (odoo -i)
oduflow call install_odoo_modules feature-login sale,crm,website

# Upgrade modules (odoo -u)
oduflow call upgrade_odoo_modules feature-login sale,crm
```

## Running Tests

```bash
oduflow call run_odoo_tests feature-login sale,crm
```

This runs `odoo --test-enable --stop-after-init --workers 0 --http-port 8089 --gevent-port 8090 -u
<modules>` inside the container. The module must already be installed — tests run via an upgrade
(`-u`); `-i` on an already-installed module is a no-op that never enters the test phase ("0 of 0
tests"). Because `--no-http` has no effect under `--test-enable` (tests require a live HTTP
server), the test server's HTTP and gevent ports are moved off the defaults (8069/8072) — already
held by the running Odoo container — to avoid a port conflict. On Odoo 15.0 and earlier the port
flag is `--longpolling-port` instead (Odoo 16.0 renamed it to `--gevent-port`); Oduflow detects the
environment's Odoo version and uses the right one automatically. `--workers 0` makes the run
deterministic (Odoo recommends single-worker mode for unit tests).

## Smart Pull — Intelligent Change Detection

The `pull_and_apply` tool is one of Oduflow's most powerful features. It pulls the latest changes from the remote repository and **automatically determines the minimal action required**:

```bash
oduflow call pull_and_apply feature-login
```

### How it works

After `git pull --rebase`, Oduflow compares `HEAD` before and after, then classifies every changed file:

| Changed File | Analysis | Action |
|---|---|---|
| `__manifest__.py` (new module) | No previous manifest exists | **Install** the module |
| `__manifest__.py` (version changed) | `version` key differs | **Upgrade** the module |
| `__manifest__.py` (data/assets/demo/qweb changed) | File lists in manifest changed | **Upgrade** the module |
| `*.py` with `fields.*` changes | Field definitions added/removed/modified | **Upgrade** the module |
| `*.py` (no field changes) | Business logic change | **Restart** the container |
| `security/*.xml` | Access control or record rules | **Upgrade** the module |
| `i18n/*.po` | Translation terms, loaded into the database on upgrade | **Upgrade** the module |
| `*.xml` (not in security/) | Views, actions, data | **Refresh** (hot-reloaded via `--dev=xml`) |
| `*.js` | Frontend assets | **Refresh** (hot-reloaded via `--dev=xml`) |

### Action priority

`install` > `upgrade` > `restart` > `refresh`

If any module needs installation, all pending upgrades are also executed. If only Python files changed (without field modifications), a container restart is sufficient. If only XML/JS changed, no server-side action is needed — just refresh the browser.

!!! note
    `pull_and_apply` updates only the **main project repository**. Extra addons repositories are pinned to the commit they were deployed with and are not affected. See [Extra Addons — Updating](extra-addons.md#updating-extra-repos) for details.

### Module detection

Oduflow walks up from each changed file to find the nearest `__manifest__.py`, correctly identifying which Odoo module a file belongs to, even in nested directory structures.

## Reading Files Inside Environments

Use `read_file_in_odoo` to inspect files and directories inside the Odoo container without constructing shell commands:

```bash
# Read Odoo source code
oduflow call read_file_in_odoo feature-login /usr/lib/python3/dist-packages/odoo/addons/sale/models/sale_order.py

# Read a specific line range (lines 100–200)
oduflow call read_file_in_odoo feature-login /usr/lib/python3/dist-packages/odoo/addons/sale/models/sale_order.py "100:200"

# List a directory
oduflow call read_file_in_odoo feature-login /mnt/extra-addons/

# Check the generated Odoo config
oduflow call read_file_in_odoo feature-login /etc/odoo/odoo.conf

# Verify file presence after pull_and_apply
oduflow call read_file_in_odoo feature-login /mnt/extra-addons/my_module/__manifest__.py
```

- If the path is a **directory**, returns a listing (like `ls -la`).
- If the path is a **text file**, returns its contents (up to 100KB by default).
- **Binary files** are not supported — use `run_odoo_command` for binary operations.
- The optional `read_range` parameter accepts a `"START:END"` format (e.g. `"1:50"`, `"100:200"`) to read only specific lines.

!!! tip
    Prefer `read_file_in_odoo` over `run_odoo_command` with `cat` or `ls` commands — it handles file type detection, size limits, and binary file rejection automatically.

## Executing Commands Inside Environments

Run arbitrary shell commands inside the Odoo container:

```bash
# List addon files
oduflow call run_odoo_command feature-login "ls /mnt/extra-addons"

# Check Python version
oduflow call run_odoo_command feature-login "python3 --version"

# Run a Python script
oduflow call run_odoo_command feature-login "python3 -c 'import odoo; print(odoo.release.version)'"

# Install a package as root
oduflow call run_odoo_command feature-login "pip3 install phonenumbers" root

# Query the environment database directly
oduflow call run_db_query feature-login "SELECT count(*) FROM res_partner"
```

The `user` parameter defaults to `odoo`. Use `root` for privileged operations (installing packages, modifying system files).

## ORM and Database Operations

For structured record access, prefer the six `odoo_*` tools over hand-written
shell snippets. They call the running Odoo server through its dataset API and
therefore enforce the same access rights and record rules as the web client:

```bash
# Discover fields first
oduflow call odoo_schema '{"env_name":"feature-login","model":"res.partner"}'

# Search as the environment admin (the default)
oduflow call odoo_search_read '{"env_name":"feature-login","model":"res.partner","domain":[["customer_rank",">",0]],"fields":["name","email"],"limit":20}'

# Verify what another user can see
oduflow call odoo_search_read '{"env_name":"feature-login","model":"sale.order","as_user":"sales@example.com","fields":["name","amount_total"]}'
```

`odoo_create`, `odoo_write`, and `odoo_unlink` commit immediately;
`odoo_unlink` is destructive. `odoo_call` covers other public model methods,
while `odoo_schema` lists models or returns `fields_get`. Each call is a separate
transaction. Edited Python code is not visible to these tools until the serving
Odoo process has restarted.

Use `run_odoo_shell` when you need a fresh registry, `sudo()`, private methods,
or a multi-step transaction. Successful shell writes are committed by default;
pass `auto_commit=false` for a dry run whose transaction is left uncommitted.
Use `run_db_query` for direct SQL; it supports CSV (default) or JSON output and
returns at most 100 rows by default (`max_rows` changes the cap).

## Interactive Terminal

The Web Dashboard provides an **interactive Odoo Python shell** directly in the browser via WebSocket. It launches `odoo shell` connected to the environment's database, allowing you to inspect and manipulate Odoo models in real time.

The terminal is accessible from the environment card in the Web Dashboard. It supports:

- Full interactive Python REPL with Odoo ORM access (`self.env['res.partner'].search([])`)
- Terminal resizing (adapts to browser window)
- Standard TTY features (colors, line editing)

The WebSocket endpoint is `ws://<host>:<port>/api/environments/{branch}/terminal`.

!!! note
    The terminal requires the environment container to be running. If the container is stopped, the terminal will display an error message.

## Environment Protection

Environments can be **protected** from accidental deletion. A protected environment cannot be deleted until protection is removed.

Protection state is stored as a `.protected` marker file in the environment's workspace directory, so it survives container rebuilds and restarts.

When an environment is protected:

- **Delete** is blocked with a `ProtectedError`
- **Stop** is also blocked with a `ProtectedError`
- Other operations (restart, sync, install/upgrade modules) are unaffected

### Via REST API

```bash
# Protect an environment
curl -X POST http://localhost:8000/api/environments/feature-login/protect

# Unprotect an environment
curl -X POST http://localhost:8000/api/environments/feature-login/unprotect
```

### Via Web Dashboard

Click the **🔓 Protect** button on any environment card to toggle protection. When protected:

- The button shows **🔒 Protected** (highlighted)
- The **Delete** button is disabled
- Attempting to delete via MCP or API returns a `ProtectedError`

---

# Production Hosting

Oduflow can host **production** Odoo environments alongside the dev
environments it was built for. Productions get special treatment:

- a **dedicated PostgreSQL cluster** (`oduflow-prod-db`) — physically
  separate from the dev one, auto-tuned for production workloads;
- a **custom domain** per production (`erp.customer.com`), routed by Traefik
  with a Let's Encrypt certificate;
- an **auto-tuned production `odoo.conf`** (workers from host CPU/RAM, cron
  enabled, proxy mode) — never the dev profile;
- **no sanitization/neutralization**, no idle reaper, no `--dev=xml`;
- deploys with **automatic code rollback** on failure;
- **S3 backups**: continuous WAL archiving (WAL-G), scheduled snapshots
  (database dump + deduplicated filestore), disaster-recovery PITR.

Productions are managed by their own MCP tool stack (`create_production`,
`update_production`, …) and a dedicated **Production** tab in the dashboard —
they never mix with dev environment tooling.

## Requirements

- `routing_mode = "traefik"` (custom domains are Traefik `Host()` rules).
- The production's DNS record must point at the server.
- For backups: an S3-compatible bucket (AWS, MinIO, Cloudflare R2, …).
- Debian-based `postgres:*` images (the default; `-alpine` images do not run
  the WAL-G binary).

## Configuration

Production hosting is disabled by default. Enable it globally in TOML and
restart Oduflow; productions themselves are then created at runtime:

```toml
[production]
enabled = true          # required; restart Oduflow after changing
postgres_image = ""     # default: [database].image
workers_cap = 8         # upper bound for auto-tuned Odoo workers

[backup]                # configures backups; production must also be enabled
bucket = "acme-backups"
access_key = "AKIA..."
secret_key = "..."
endpoint = ""           # empty = AWS; set for MinIO/R2 (path-style implied)
region = "eu-central-1"
# defaults you normally leave alone:
# prefix = "oduflow"
# snapshot_time = "02:00"      (daily per-production snapshots)
# basebackup_time = "03:30"    (daily WAL-G base backup)
# keep = ["30:180", "7:30", "1:7"]  (snapshot retention: interval:age days)
# walg_keep_full = 7           (base backups retained)
```

While disabled, the dashboard tab and production HTTP/webhook routes are not
registered, production MCP tools return an enablement error, and scheduled
backup work does not run.

The production PostgreSQL cluster is provisioned lazily and idempotently. If
production hosting is disabled, Oduflow stops every managed production Odoo
container and then its dedicated PostgreSQL container without deleting any
container, volume, database, filestore, or registry data. Re-enabling starts
PostgreSQL first and then starts all managed production Odoo containers.

Enabling production also changes the unified host resource plan. New configs
coordinate dev PostgreSQL, production PostgreSQL, and production Odoo workers
instead of letting each profile size itself against the whole host. Existing
configs are not silently replaced: after changing `enabled`, run
`oduflow retune-postgres` to inspect the new plan, then
`oduflow retune-postgres --apply`. The apply step also stages regenerated
worker settings in every existing production Odoo container; restart the
PostgreSQL and Odoo containers it lists.

## Creating a production

```text
create_production(
    name="erp",
    repo_url="https://github.com/acme/odoo-erp.git",
    branch="production",
    domain="erp.acme.com",
    odoo_image="odoo:18.0",
    template_name="acme-prod",   # optional: seed DB+filestore from a template
    auto_update=False,
)
```

`template_name` is the migration path for an existing production: import it
first (e.g. [from Odoo.sh](templates.md)), then create the production from
that template — the database is copied into the production cluster and the
filestore into the production's plain (non-overlay) directory.

The clone is **full** (not shallow): the branch's commit history is the
production's deploy history and the source of rollback targets.

## Deploys and rollback

`update_production(name)` pulls the branch (and extra-addon worktrees),
classifies the changes (or takes explicit `install=` / `upgrade=` /
`restart=true`), applies them, and **verifies** the deploy — module exit
codes plus an in-container health check. In production a "refresh"-class
change (XML/JS only) still restarts the container: there is no `--dev=xml`.

If verification fails, the checkout is reset to the pre-deploy commit, the
config re-applied and the container restarted — **the code rolls back
automatically**. The database is *never* rolled back automatically: if a
module upgrade left it inconsistent, restore a snapshot explicitly
(`restore_production`). A snapshot is taken automatically before every deploy
when backups are configured.

Every deploy lands in the production's history (`production_deploys`):
commits, action, modules, status (`success` / `rolled_back` /
`rollback_failed`), trigger (mcp / ui / webhook / schedule).

Manual code rollback to any commit: `rollback_production(name, to_commit)`.

## GitHub webhooks (auto-deploy)

Point a GitHub webhook at `POST https://<server>/api/webhooks/github`
(content type `application/json`) with the team's webhook secret — shown in
the dashboard's Production tab, auto-generated with the first production.
Requests are authenticated by their `X-Hub-Signature-256` HMAC.

A push deploys only productions that match the repo + branch **and** have
`auto_update` enabled (`set_production_auto_update`). Dev environments are
never touched by webhooks. Failed webhook deploys roll back like any other.

## Backups

Two complementary layers (both to S3, enabled by `[backup]`):

**Snapshots — per-production restore.** A snapshot is a consistent triple:
`pg_dump` of the production database (streamed to S3, no temp disk), a
deduplicated filestore revision, and a manifest recording the deployed commit
sha. Taken daily (`snapshot_time`, per-production override via
`set_production_backup_schedule`), before every deploy, and on demand
(`snapshot_production`). Restore with:

```text
restore_production(name="erp", snapshot_id="20260711T020000Z", confirm="erp")
```

Restore is swap-based: the dump is restored into a scratch database and
swapped in by rename; the filestore is rebuilt beside the live one and
swapped in. A failed restore leaves the previous state untouched. If the
snapshot's commit differs from the checkout, the result warns you to
`rollback_production` to the matching commit.

The filestore engine (a clean-room, duplicacy-inspired content-defined
chunking store) deduplicates across daily revisions *and* across a team's
productions; retention (`keep`) is applied weekly with safe two-step fossil
collection.

**WAL-G — cluster disaster recovery.** Continuous WAL archiving plus daily
base backups of the whole production cluster. This is the "server burned
down" path:

```text
restore_cluster_pitr(target_time="", confirm="RESTORE-CLUSTER")
```

restores the **entire cluster** (every production database at once) from the
latest base backup + WAL replay — optionally to a point in time
(`target_time="2026-07-10 12:00:00+00"`). The displaced data directory is
kept inside the Docker volume for manual cleanup. Because the state lives in
S3, a **fresh Oduflow server** with the same `[backup]` section can resurrect
the cluster the same way.

`production_backup_status()` shows per-production snapshot state, WAL
archiver health (`pg_stat_archiver`), base backup inventory, and S3
reachability.

## Health

`GET /healthz` (public, no auth, no secrets) returns 200 when healthy and
503 when degraded — point your uptime monitor at it. Checks: dev PostgreSQL,
production PostgreSQL, Traefik, S3 (HeadBucket), disk usage (warn at 85%),
and productions flagged unhealthy by a failed rollback. The dashboard's
status bar shows the same checks as chips.

## MCP tool reference

| Tool | Purpose |
|---|---|
| `create_production` | Provision a production (optionally from a template) |
| `list_productions` / `get_production_info` | Status, deployed commit, history, backups |
| `update_production` | Deploy latest commits with auto code rollback |
| `rollback_production` | Manual code rollback to a commit |
| `production_deploys` | Deploy history |
| `production_logs` | Container logs |
| `start_production` / `stop_production` / `restart_production` | Lifecycle |
| `set_production_auto_update` | Toggle webhook auto-deploy |
| `snapshot_production` / `list_production_snapshots` | Snapshots to S3 |
| `restore_production` | Restore DB + filestore from a snapshot |
| `set_production_backup_schedule` | Per-production snapshot time / off |
| `production_backup_status` | Backup posture (snapshots + WAL-G + S3) |
| `prune_production_backups` | Apply retention now |
| `restore_cluster_pitr` | Cluster-wide disaster recovery / PITR |
| `delete_production` | Remove (database/files kept unless `drop_database`) |

---

# Coding Agent

Oduflow can host a **coding agent** for a team: an opt-in feature where the
client grows their Odoo by chatting with an AI agent directly from the browser
dashboard. Oduflow runs one agent container per team
(`oduist/oduflow-coder`, running Claude Code + OpenAI Codex + OpenCode) and
exposes two front-ends for every environment.

!!! note "Hosting feature — off by default"
    The coding agent is for **hosted** deployments. A local developer already
    has the code and their own agents, so it is disabled unless you set
    `agent_enabled` for the team. It is also **hidden for live-mount
    (`local_path`) environments** — there is nothing for the containerized
    agent to clone.

## Agent CLI vs Agent Chat

Both drive the same agent container over the dashboard's existing
WebSocket ↔ `docker exec` bridge:

- **Agent CLI** — the agent's own terminal UI (TUI) rendered in the browser,
  exec'd with a PTY at the environment's git checkout. Full access to the
  agent's native command-line experience.
- **Agent Chat** — a structured, framework-free browser chat that speaks the
  **Agent Client Protocol (ACP)** to the agent's adapter. Each environment has
  a durable, bounded **conversation history**: use **History** to resume one of
  the 20 most recent conversations, titled from its first prompt. Chats also
  minimize to a dock, so several can run in parallel. Assistant messages render
  as markdown, with collapsible reasoning, tool-call cards, plans, and inline
  approve/deny prompts for permission requests.

## How it works

The agent never touches host files. It holds one full git checkout per
environment (at `/workspace/<slug>` in the container), edits its own clone,
`git push`es, and then drives the environment **only through the Oduflow MCP
server** (`pull_and_apply`, `run_odoo_tests`, etc.) — the same closed loop a
remote MCP client uses.

The image also includes **Agent Browser MCP** backed by Debian Chromium. It is
wired automatically into Claude, Codex, and OpenCode with the complete Agent
Browser tool set. Each environment gets a separate browser profile, while
browser data persists with the team's agent HOME volume across container
recreation.

Lifecycle is automatic: the container is created on startup for each enabled
team and removed for disabled ones; `create_environment` adds the environment's
checkout, `delete_environment` removes it. The container carries a hash of its
injected config as a label and is **recreated automatically** when the config
changes. The only runtime state is a durable ACP conversation-history file in
the team's data directory; transcripts remain owned by the agent adapters.

## Enabling it

Configuration lives entirely in `oduflow.toml` — there is no runtime editing.
The global `[agent]` section holds deployment-wide settings; per-team
enablement and credentials live in the `[team.*]` sections:

```toml
# Deployment-wide (optional)
[agent]
image = "oduist/oduflow-coder:0.3.0"
# claude_model = ""     # optional Claude model override; empty = CLI default
# codex_model = ""      # optional Codex model override; empty = CLI default
# opencode_model = ""   # optional provider/model override; empty = OpenCode default

[team.1]
hostname = "localhost"
auth_token = "…"
agent_enabled = true    # turn the coding agent on for this team
agent_default = "claude"  # "claude" | "codex" | "opencode"

# Provider credentials injected into the team's agent container
[team.1.agent_env]
CLAUDE_CODE_OAUTH_TOKEN = ""   # Claude subscription token (`claude setup-token`); outranks the API key
ANTHROPIC_API_KEY = ""         # Claude API key (used when no OAuth token)
OPENAI_API_KEY = ""            # Codex API key
OPENCODE_API_KEY = ""          # OpenCode Zen; other providers use their own variables
```

The default coder image is an immutable versioned tag coupled to this Oduflow
release. Oduflow pulls a changed tag before replacing the running container; a
failed pull leaves the previous container intact. The former official
`oduist/oduflow-coder:latest` value resolves to the current pinned default
when the configuration is loaded.

When `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` is configured, the
container automatically marks Claude Code's first-run onboarding as complete,
so Agent CLI opens directly in the REPL without asking to select a login method.

Claude supports three alternative authentication modes. Oduflow selects exactly
one for each team: a non-empty `CLAUDE_CODE_OAUTH_TOKEN` wins, otherwise a
non-empty `ANTHROPIC_API_KEY` uses Console API billing, otherwise Claude uses the
interactive `/login` stored on the team's persistent agent home volume. Known
credential values are trimmed when loaded, so whitespace accidentally copied
around a token is not sent to Anthropic. A configured environment credential
always overrides the persisted interactive login; if Anthropic rejects it,
Agent Chat fails closed with mode-specific recovery guidance instead of silently
trying another account or billing method.

OpenCode is provider-neutral. Any provider environment variable can be placed
under `[team.X.agent_env]`; `OPENCODE_API_KEY` is the standard OpenCode Zen
credential and is also inherited from the server environment in single-team
deployments. Alternatively, open **Agent CLI** and run `opencode auth login`;
the resulting provider credentials live on the team's persistent HOME volume
and survive container recreation. OpenCode's runtime self-update is disabled,
so its executable changes only when Oduflow moves to a new immutable coder
image.

See the [`[agent]`](installation.md#agent-settings) and
[per-team](installation.md#per-team-settings) settings tables for the full
reference.

## Security model

!!! warning "A console/chat is arbitrary code execution"
    Opening an Agent CLI or Agent Chat is arbitrary code execution **inside the
    team's agent container**. It is confined to that container, its clones, and
    the session's scoped MCP token.

- **Per-team isolation.** Each team gets its own agent container, volumes, and
  network. Cross-team reach is blocked; the dashboard auth middleware resolves
  the team, so a team can only ever reach its own agent.
- **Scoped MCP access.** The team `auth_token` never enters the agent
  container. Each session injects that **environment's** scoped per-environment
  token, which grants only the dev-loop allowlist on the one environment the
  session already controls. The agent **cannot** create, delete, or stop
  environments, or touch templates, services, or volumes — those remain
  operator actions.
- **Credentials.** Server-level provider keys are inherited by the container
  only in single-team deployments; with several teams, each team sets its own
  keys in `[team.X.agent_env]` so an operator credential never leaks to
  tenants.
- **Sandbox and approvals.** All three agents run approval-free — the security
  boundary is the unprivileged `agent` user inside the per-team Docker
  container, not per-tool prompts. Codex CLI uses
  `--dangerously-bypass-approvals-and-sandbox` and Codex ACP starts in
  `agent-full-access` (no nested Bubblewrap sandbox). Claude matches this: the
  Agent CLI console runs `claude --dangerously-skip-permissions`, and Agent
  Chat's ACP adapter (`claude-agent-acp`) starts in `bypassPermissions`, seeded
  via the container's user-tier `~/.claude/settings.json`
  (`permissions.defaultMode`). OpenCode CLI uses `--auto`; both CLI and its
  native `opencode acp` runtime receive a high-precedence session config with
  `permission = "allow"`. So installed MCP tools run without interactive
  permission prompts for any hosted agent.

## Limitations

- The agent UI is hidden for live-mount (`local_path`) environments.
- Environments created before per-environment tokens existed have no scoped
  token; their consoles warn and the agent works without MCP until the
  environment is updated/recreated.
- Opening a previous Codex conversation is best-effort because its ACP
  `session/load` support is still maturing. A failed switch restores the current
  conversation when possible, otherwise it starts a new one without deleting
  the history entry.

The published image contains redistributable open-source software: Codex CLI,
Codex ACP and Agent Browser are Apache-2.0, OpenCode is MIT, and Debian Chromium
includes its upstream component license notices. Claude Code and its adapter
are installed at first container start onto the persistent home volume —
downloaded directly from npm by the end user's container.

---

# Auxiliary Services

![Services Dashboard](img/services.png)

Oduflow can manage sidecar containers for auxiliary services your Odoo instance depends on — Redis, Meilisearch, Elasticsearch, RabbitMQ, or any other Docker-based service.

## Creating a Service

```bash
# Redis
oduflow call create_service redis redis:7 6379

# Meilisearch with environment variables
oduflow call create_service meilisearch getmeili/meilisearch:v1.6 7700 "" "MEILI_MASTER_KEY=abc123,MEILI_ENV=production"

# Elasticsearch
oduflow call create_service elasticsearch docker.elastic.co/elasticsearch/elasticsearch:8.11.0 9200 "" "discovery.type=single-node,ES_JAVA_OPTS=-Xms512m -Xmx512m"

# WireGuard VPN — needs NET_ADMIN to manage tun/iptables
oduflow call create_service '{"name":"vpn","image":"linuxserver/wireguard","port":51820,"net_admin":true}'

# Publish only selected HTTP prefixes, each on its own backend port
oduflow call create_service '{
  "name":"fs",
  "image":"oduist/freeswitch:latest",
  "hostname":"fs",
  "host_mode":true,
  "routes":[
    {"path":"/RPC2","port":8080,"strip_prefix":false},
    {"path":"/portal","port":8080,"strip_prefix":false}
  ]
}'
```

Services are:

- Attached to the team's isolated Docker network `oduflow-{team_id}-net` (reachable by that team's Odoo containers and other services)
- Given an `unless-stopped` restart policy
- Automatically routed through Traefik with HTTPS when in traefik mode
- In Traefik TLS mode, automatically given the exact system mount `oduflow-traefik-acme:/etc/traefik:ro`
- Labeled for management (`oduflow.managed=true`, `oduflow.service=<name>`)
- Always created from a freshly pulled image — `create_service` and `restore_service` explicitly pull before running, so mutable tags like `:latest` get the current published version instead of a stale local cache

Each team has `service_slots = 10` by default. The cap includes running and
stopped managed services; updating or restarting an existing service does not
consume another slot. Delete an unused service to free capacity, or set
`service_slots = 0` to disable the cap.

### Connecting from Odoo

Inside the team's Docker network the service is reachable by its **container
name** — the DNS name is exactly `oduflow-{team_id}-svc-{name}` (e.g.
`oduflow-1-svc-redis`). There is no shorter alias such as `redis` or
`oduflow-svc-redis`. This is precisely the `Container:` / `Internal hostname:`
value that `create_service` and `get_service_info` report, so configure Odoo
against that:

```
oduflow-1-svc-redis:6379
```

The `URL:` line printed by the service tools is the **external** Traefik/host
address, not the internal one — do not use it for in-cluster connections.

`host_mode` services are not on the team network, so they are not resolvable by
container name; reach them via `host.docker.internal` instead.

### Restricted HTTP Path Routes

In Traefik mode, `routes` can replace the single catch-all `port`. Each route
publishes a URL prefix on the service's hostname and forwards it to another
HTTP port of the **same service**. This works in both networking modes:

- Bridge services are reached on their private IP in the team's Docker network.
- `host_mode` services are reached through `host.docker.internal`.

Routes are prefix matches on path-segment boundaries: `/api` accepts `/api` and
`/api/...`, but not `/apix`. When routes are present Oduflow does not create the
hostname-only catch-all router, so every unlisted path receives Traefik's 404
without reaching the service. Set `strip_prefix=true` when the backend serves
from `/`; Traefik then sends `/portal/assets/app.js` as `/assets/app.js` and
adds `X-Forwarded-Prefix: /portal`.

`routes` is intentionally not an arbitrary reverse-proxy configuration: a route
contains only `path`, `port`, and optional `strip_prefix`, and always targets the
same managed service. It is available only with `[routing].mode = "traefik"`.
Raw TCP/UDP protocols cannot be routed by URL path.

### Traefik Certificate Store

When Oduflow terminates TLS through Traefik, every auxiliary service can read
Traefik's certificate store at `/etc/traefik/acme.json`. The mount is implicit:
do not add `oduflow-traefik-acme` to the `volumes` argument, and do not mount a
different volume at `/etc/traefik`. Oduflow always mounts the exact system
volume read-only; there is no wildcard allowance for other `oduflow-*` volumes.
The implicit mount is runtime configuration and is not saved in the service
preset.

This deliberately makes the shared certificate and private-key material
readable to every service container. Use only trusted service images and grant
service-management access only to trusted operators. Read-only protects the
store from modification, not from disclosure.

### Linux Capabilities

Two optional flags grant additional container privileges:

- `net_admin` — adds the `NET_ADMIN` Linux capability. Required for VPN / WireGuard, `tun`/`tap` devices, and `iptables` manipulation inside the container.
- `privileged` — runs the container in privileged mode (full host access, all capabilities). Use with care — only when a service genuinely needs it (e.g. Docker-in-Docker, hardware passthrough).

Both can be enabled at the same time; on the Docker side, `privileged` implies all capabilities so `net_admin` is then redundant — but it is still recorded in the preset so disabling `privileged` later keeps `NET_ADMIN` active.

## Managing Services

```bash
# List all services with status, ports, URLs, and env vars
oduflow call list_services

# Full state of a single service — image + digest, port/routes, hostname,
# host_mode, volumes, env vars, capabilities, restart count, started_at, preset
oduflow call get_service_info redis

# View service logs
oduflow call get_service_logs redis 200

# Restart a service
oduflow call restart_service redis

# Update a service (pull latest image, recreate container with same settings)
oduflow call update_service meilisearch

# Change environment variables on a running service (fully replaces existing env_vars)
oduflow call update_service '{"name":"meilisearch","env_vars":"MEILI_MASTER_KEY=newkey,MEILI_ENV=production"}'

# Change the image (tag) of a running service
oduflow call update_service '{"name":"meilisearch","image":"getmeili/meilisearch:v1.8"}'

# Toggle Linux capabilities / privileged mode on a running service (recreates it)
oduflow call update_service '{"name":"wireguard","net_admin":true}'
oduflow call update_service '{"name":"wireguard","privileged":true}'

# Delete a service
oduflow call delete_service redis

# Execute a command inside a service container
oduflow call run_service_command redis "redis-cli ping"
```

### Changing a Service

`update_service` is the preferred way to change **any** setting of a running service — image, env vars, port/routes, hostname, `host_mode`, `volumes`, `privileged`, or `net_admin`. It recreates the container automatically and preserves every setting you do not override, so you rarely need to delete and recreate a service by hand. Passing `routes` fully replaces the route list. To return to a single catch-all port, pass `routes=[]` and the replacement `port` in the same call.

If you do recreate a service manually (e.g. to rename it), call `get_service_info` first and reuse its fields in the new `create_service` call. The returned dict carries the full configuration (`image`, `port` or `routes`, `hostname`, `env_vars`, `host_mode`, `volumes`, `cap_add`, `privileged`) so you do not lose anything that `list_services` truncates or that lived only inside the preset.

## Service Update Flow

The `update_service` operation:

1. Reads the saved preset (authoritative source) or inspects the running container as a legacy fallback
2. Applies any overrides passed in (`env_vars`, `image`, `port`/`routes`, `hostname`, `host_mode`, `volumes`, `privileged`, `net_admin`) — each override **fully replaces** the current value
3. Resolves the complete candidate volume configuration before touching the running container; invalid or missing volumes fail without stopping it
4. Pulls the target image (the override, or the current one)
5. Decides whether to recreate:
    - If neither the image digest nor any setting changed → reports "already up-to-date"
    - If the image digest changed, any setting was overridden, or a legacy Traefik TLS service lacks the implicit ACME mount → stops the old container, removes it, and creates a new one with the merged settings
6. Updates the saved preset so subsequent `restore_service` calls use the new configuration

Overrides are optional: calling `update_service` with only `name` pulls the current image and recreates only when its digest changed or the implicit ACME mount is missing.

## Service Presets

Every time a service is created, its configuration (image, port or routes, hostname, environment variables, volumes, `host_mode`, `cap_add`, `privileged`) is automatically saved as a **preset** in `{team_data_dir}/service_presets.json`. This allows you to restore a service after deletion without re-entering its configuration.

```bash
# List saved presets
oduflow call list_service_presets

# Restore a previously deleted service
oduflow call restore_service redis

# Remove a saved preset
oduflow call delete_service_preset redis
```

---

# Extra Addons Repositories

![Extra Addons Dashboard](img/extra_addons.png)

Oduflow supports mounting **extra addon repositories** (e.g. Odoo Enterprise,
third-party themes) into environments. Git objects and immutable checkouts are
shared by all development environments in a team.

## Architecture

```
{data_dir}/team_{ID}/
  shared_repos/
    enterprise/          ← bare git clone (shared)
    custom-themes/       ← bare git clone (shared)
  shared_extra_checkouts/
    enterprise/
      a1b2c3.../          ← immutable checkout of one commit (shared)
    custom-themes/
      d4e5f6.../          ← immutable checkout of one commit (shared)
  workspaces/
    feature-x/
      repo/              ← main project repo (existing)
```

The requested branch selects a commit when an environment is created. Several
environments on the same commit mount the same checkout read-only, without
duplicating its files. Checkouts are keyed by commit rather than branch because
branches move; an environment stays isolated on its current revision until it
is explicitly synced.

Production deployments retain private worktrees because their deploy engine
records and resets each worktree HEAD during rollback.

## Setting Up Extra Repos

Clone an extra repository once (it will be available for all environments):

```bash
# Via CLI
oduflow call add_extra_repo enterprise https://github.com/odoo/enterprise.git

# Private repos — configure auth first
oduflow call setup_repo_auth https://user:PAT@github.com/odoo/enterprise.git
oduflow call add_extra_repo enterprise https://github.com/odoo/enterprise.git
```

## Using Extra Addons in Environments

When creating an environment, specify which extra repos to mount:

```bash
# Mount enterprise addons on branch 19.0
oduflow call create_environment feature-x "" default https://github.com/company/addons.git odoo:19.0 "enterprise:19.0"

# Mount multiple extra repos
oduflow call create_environment feature-x "" default https://github.com/company/addons.git odoo:19.0 "enterprise:19.0,custom-themes:main"
```

For each development environment Oduflow automatically:

1. Fetches the specified branch and resolves its current commit SHA
2. Creates or reuses the team's immutable checkout for that SHA
3. Mounts the checkout **read-only** as `/mnt/extra-addons-{name}`
4. Generates a merged `odoo.conf` with all extra paths added to `addons_path`

## Managing Extra Repos

```bash
# List all cloned extra repos with available branches
oduflow call list_extra_repos

# Delete an extra repo (fails if any environment references it)
oduflow call delete_extra_repo enterprise
```

Extra repos can also be managed from the **Web Dashboard** under the "Extra Addons" tab.

## Protecting Extra Repos

Extra addon repositories can be **protected** from accidental deletion, similar to [environment protection](environments.md#environment-protection). A protected repo cannot be deleted until protection is removed.

Protection state is stored as a `.protected` marker file in the bare repository directory.

### Via REST API

```bash
# Protect an extra repo
curl -X POST http://localhost:8000/api/extra-repos/enterprise/protect

# Unprotect an extra repo
curl -X POST http://localhost:8000/api/extra-repos/enterprise/unprotect
```

### Via Web Dashboard

Extra repo protection can be toggled from the **Extra Addons** tab in the Web Dashboard. When protected:

- The **Delete** button is disabled
- Attempting to delete via API returns a `ProtectedError`

## Updating Extra Repos

Use `update_extra_repo` to fetch the latest changes from the remote:

```bash
oduflow call update_extra_repo enterprise
```

This runs `git fetch --all --prune` on the **shared bare repository** only. It
does **not** change the checkout mounted by any running environment.

### Updating an environment

Run the normal sync operation:

```bash
oduflow call pull_and_apply feature-x
```

`pull_and_apply` fetches every configured extra-addons branch, creates or reuses
the new SHA checkout, classifies its changed files, switches only that
environment's read-only mount, and performs the required install, upgrade, or
restart. Other environments continue using their previous checkout.

Cached checkouts are deliberately not reference-counted or removed with an
environment. Deleting the extra repository removes its bare clone and every
cached revision after Oduflow verifies that no environment or production still
depends on it.

---

# Declarative Stacks

An Oduflow Stack is a versioned YAML manifest describing the complete desired
state of one development environment and its supporting resources. It keeps the
host-level `oduflow.toml` separate from project configuration: teams, routing,
authentication, quotas, and backups remain operator settings, while the Stack
file can live beside the project's code and move between Oduflow installations.

## Commands

```bash
oduflow stack validate oduflow.yaml
oduflow stack plan oduflow.yaml --team 1
oduflow stack apply oduflow.yaml --team 1
oduflow stack status oduflow.yaml --team 1
```

`validate` is local and does not require Docker or `oduflow.toml`. `plan` reads
live state without changing it. `apply` validates and plans again under the
team lock, refuses all conflicts before creating anything, and then converges
resources in dependency order. `status` emits JSON containing the current plan
and last successful apply record.

To reconcile before the MCP server accepts clients:

```bash
oduflow --stack /etc/oduflow/acme/oduflow.yaml \
  --stack-team 1 \
  --transport http
```

A failed startup reconciliation exits without starting the MCP transport. Any
resources already created before an external failure remain owned by the Stack;
rerunning the same command safely continues from live state.

## Example

```yaml
apiVersion: oduflow.dev/v1alpha1
kind: Stack

metadata:
  name: acme-erp

spec:
  environment:
    name: acme-dev
    hostname: qa                # optional; dev.example.com -> qa.example.com
    branch: "18.0"
    repoUrl: https://github.com/acme/odoo-addons.git
    odooImage: odoo:18.0
    template: acme-18
    sanitize: true

    env:
      LOG_LEVEL: info
      PRIVATE_API_KEY:
        fromEnv: ACME_PRIVATE_API_KEY

    modules:
      install:
        - acme_base
        - acme_sale

  extraRepositories:
    enterprise:
      repoUrl: https://github.com/odoo/enterprise.git
      branch: "18.0"

    oca-web:
      repoUrl: https://github.com/OCA/web.git
      branch: "18.0"

  volumes:
    fs-sounds:
      description: FreeSWITCH sounds and configuration

  files:
    - source: files/freeswitch.xml
      volume: fs-sounds
      path: config/freeswitch.xml

  services:
    fs:
      image: oduist/freeswitch:1.4.0
      port: 8080
      hostMode: true

      volumes:
        - source: fs-sounds
          target: /usr/share/freeswitch/sounds
          mode: rw

      env:
        ODOO_URL:
          environmentField: url
        FS_WEBHOOK_TOKEN:
          environmentField: token
        FS_ESL_PASSWORD:
          fromEnv: FS_ESL_PASSWORD
```

The generated JSON Schema is shipped at
`oduflow/schemas/oduflow-stack-v1alpha1.json`. Unknown fields, duplicate YAML
keys, undeclared volume references, invalid names, and unsafe file paths are
rejected.

## Value sources

An environment variable can be a literal string:

```yaml
LOG_LEVEL: info
```

It can be read from the process starting Oduflow:

```yaml
ESL_PASSWORD:
  fromEnv: FS_ESL_PASSWORD
```

Or a service can consume a value generated for the Stack's Odoo environment:

```yaml
ODOO_URL:
  environmentField: url
MCP_TOKEN:
  environmentField: token
```

`environmentField` is deliberately unavailable under `spec.environment.env`,
because an environment cannot depend on an output that exists only after that
same environment has been created. Resolved values are passed directly to the
container. They are never written to the Stack state file or printed by
`plan`.

Docker can expose container environment values to host administrators through
`docker inspect`; Stack value sources do not change that existing Docker trust
boundary. Configure private Git credentials separately with `setup_repo_auth`.

## Reconciliation and ownership

Resources created by a Stack carry these Docker labels:

```text
oduflow.stack=acme-erp
oduflow.stack-resource=services.fs
oduflow.stack-spec-hash=<sha256>
```

Oduflow will not silently adopt an existing environment, service, or volume
with the same name. It reports an ownership conflict instead. Extra-addon bare
repositories remain team-shared by design: an existing repository with the same
name and URL is reused, while a different URL is a conflict.

The V1 apply order is:

1. extra-addon repositories;
2. named volumes;
3. the Odoo environment;
4. text files in volumes;
5. auxiliary services;
6. missing Odoo modules.

Module installation happens after services so an install hook can connect to a
declared dependency. Only missing modules are installed; Stack apply never
uninstalls a module.

## Safe and replacement changes

V1 can reconcile these changes in place:

- Odoo image and Odoo container environment variables;
- service image, environment, port/routes, hostname, volumes, host mode, and
  capabilities;
- new extra repositories, volumes, files, services, and modules.

Changing an existing environment's `repoUrl`, `branch`, `template`, or
`extraRepositories` requires replacement and is reported as a conflict. Volume
descriptions are also immutable in V1. There is no automatic deletion or
`prune`: removing something from YAML does not destroy persisted data.

V1 supports one development environment per manifest. Production stacks,
portable database artifacts, binary volume files, lockfiles, lifecycle shell
hooks, dashboard controls, and OCI distribution are intentionally deferred.

---

# Web Dashboard & REST API

## Web Dashboard

![Web Dashboard — Agent Guides](img/agent_guides.png)

HTTP mode serves the dashboard at `/`. It manages environments, templates,
services, volumes, extra addons, credentials, licenses, usage/quotas, and—when
enabled—coding agents and productions. Environment cards also expose logs,
Odoo/SQL terminals, Connect As, notes, protection, scoped MCP access, and
save-as-template actions.

The header's **Feedback** action opens a prefilled issue form on
`github.com/oduist/oduflow`. Oduflow holds no GitHub credentials and files
nothing itself: it builds the link with the description and a short
version/platform/transport block, then the user reviews and submits it from
their own GitHub account.

## Authentication and responses

Dashboard API routes use the authenticated UI session (user `admin`, password
from `[team.*].ui_password`). The login form creates an HTTP-only session
cookie; HTTP Basic credentials are also accepted. State-changing cookie-auth
requests and all WebSocket handshakes are protected by Origin/Referer checks.
This authentication is separate from MCP Bearer authentication.

Most REST handlers return JSON containing `ok`. Three public surfaces use their
own security model:

- `/healthz` is unauthenticated and contains no secrets.
- `/api/webhooks/github` verifies `X-Hub-Signature-256` against the production
  webhook secret.
- Odoo.sh import ingest routes require a short-lived Bearer import token. The
  token-minting endpoint remains UI-authenticated.

`/oduflow-connect` is a one-time, token-authenticated browser redirect rather
than a JSON API. Production routes are registered only when
`[production].enabled = true`.

## Environment endpoints

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/environments` | List environments |
| `POST` | `/api/environments/create` | Create an environment. Body: `env_name`, optional `hostname`, `repo_url`, `odoo_image`, `template_name`, `extra_addons`, `auto_install_modules`, `env_vars`, `git_user` |
| `POST` | `/api/environments/{branch}/start` | Start an environment |
| `POST` | `/api/environments/{branch}/stop` | Stop an environment |
| `POST` | `/api/environments/{branch}/restart` | Restart its Odoo container |
| `POST` | `/api/environments/{branch}/sync` | Pull and automatically apply code changes |
| `POST` | `/api/environments/{branch}/update` | Recreate the container while preserving DB and filestore. Optional body: `env_vars`, `odoo_image` |
| `POST` | `/api/environments/{branch}/recreate` | Delete and recreate with the recorded parameters |
| `POST` | `/api/environments/{branch}/delete` | Delete the environment |
| `GET` | `/api/environments/{branch}/logs?n=200&container=` | Read environment logs; optionally select a container |
| `POST` | `/api/environments/{branch}/protect` | Protect from stop/delete |
| `POST` | `/api/environments/{branch}/unprotect` | Remove protection |
| `POST` | `/api/environments/{branch}/note` | Store the body `note` on the environment |
| `POST` | `/api/environments/{branch}/storage/refresh` | Refresh cached DB/workspace sizes |
| `GET` | `/api/environments/{branch}/mcp-access` | Return the scoped MCP URL and per-environment token |
| `GET` | `/api/environments/{branch}/users` | List internal and portal users for Connect As |
| `POST` | `/api/environments/{branch}/connect-as` | Mint an Odoo session for body `user` and return URL/cookie details |
| `GET` | `/api/environments/{branch}/connect-open?user=` | Mint a session and redirect toward the environment login handoff |
| `POST` | `/api/environments/{branch}/save-as-template` | Save the environment under body `template_name`; the UI never overwrites an existing template |

Branch parameters use Starlette's `path` converter internally, so names that
contain `/` are accepted and URL-decoded as one environment name.

## Environment WebSockets

| Protocol | Endpoint | Description |
|---|---|---|
| `WebSocket` | `/api/environments/{branch}/terminal` | Interactive `odoo shell` terminal |
| `WebSocket` | `/api/environments/{branch}/sql` | Interactive `psql` terminal using the environment-scoped DB role |
| `WebSocket` | `/api/environments/{branch}/agent` | Hosted Agent CLI PTY |
| `WebSocket` | `/api/environments/{branch}/agent-acp` | Hosted Agent Chat ACP relay |

## Templates and Odoo.sh import

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/templates` | List template profiles |
| `GET` | `/api/templates/{name:path}/metadata` | Read the complete `metadata.json` content and its optimistic revision |
| `PUT` | `/api/templates/{name:path}/metadata` | Validate and atomically replace `metadata.json`; body: `content`, `revision` |
| `POST` | `/api/templates/{name}/delete` | Delete a template |
| `POST` | `/api/templates/{name}/rename` | Rename it; body: `new_name` |
| `POST` | `/api/templates/import-token` | UI-authenticated: mint a 15-minute Odoo.sh import token |
| `GET` | `/api/templates/import/status` | Import-token authenticated: report resumable upload progress |
| `POST` | `/api/templates/import/manifest` | Upload template metadata |
| `POST` | `/api/templates/import/dump` | Stream/chunk the compressed SQL dump |
| `POST` | `/api/templates/import/filestore` | Upload one atomic filestore hash-directory archive |
| `POST` | `/api/templates/import/addon` | Stream/chunk one private addon archive |
| `POST` | `/api/templates/import/addon-remote` | Register an addon repo that the server can clone |
| `POST` | `/api/templates/import/finalize` | Validate staged data and atomically publish/restore the template |
| `GET` | `/import-odoo.sh` | Download the token-authenticated Odoo.sh import client |

Ingest endpoints accept the import token only in `Authorization: Bearer ...`,
not in the URL. See [Template Management](templates.md) for the supported import
workflow.

## Services, presets, and volumes

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/services` | List services |
| `POST` | `/api/services/create` | Create a service with either catch-all `port` or restricted Traefik `routes`, plus optional image/runtime settings |
| `POST` | `/api/services/{name}/update` | Pull/change settings and recreate safely; `env_vars`, `volumes`, and `routes` are full replacements when supplied |
| `POST` | `/api/services/{name}/restart` | Restart a service |
| `POST` | `/api/services/{name}/delete` | Delete a service |
| `GET` | `/api/services/{name}/logs?n=200` | Read service logs |
| `GET` | `/api/service-presets` | List saved presets |
| `POST` | `/api/service-presets/restore` | Restore a preset; body: `name` plus optional runtime overrides |
| `POST` | `/api/service-presets/{name}/delete` | Delete a preset |
| `GET` | `/api/volumes` | List managed volumes and service usage |
| `POST` | `/api/volumes/create` | Create a volume; body: `name`, optional `description` |
| `POST` | `/api/volumes/{name}/delete` | Delete an unused volume |

## Extra addons and credentials

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/extra-repos` | List extra-addon repositories |
| `POST` | `/api/extra-repos/add` | Add one; body: `name`, `repo_url`, optional `git_user` |
| `POST` | `/api/extra-repos/{name}/pull` | Fetch remote changes |
| `POST` | `/api/extra-repos/{name}/protect` | Protect from deletion |
| `POST` | `/api/extra-repos/{name}/unprotect` | Remove protection |
| `POST` | `/api/extra-repos/{name}/delete` | Delete the repository and unused cached revisions |
| `GET` | `/api/credentials` | List stored credential identities (not secrets) |
| `POST` | `/api/credentials/add` | Store credentials embedded in body `repo_url` |
| `POST` | `/api/credentials/delete` | Delete by body `host` and `username` |
| `POST` | `/api/credentials/validate` | Validate by body `host` and `username` |

## System, licensing, and guides

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/stats` | Container/system metrics plus cached environment storage |
| `GET` | `/api/usage` | Cached per-environment and team storage/quotas |
| `POST` | `/api/usage/refresh` | Recompute all team storage usage; potentially expensive |
| `GET` | `/healthz` | Public health report; returns `200` when healthy, `503` when degraded |
| `GET` | `/api/license` | License information |
| `POST` | `/api/license/activate` | Activate body `key` |
| `POST` | `/api/feedback/link` | Build a prefilled `github.com/oduist/oduflow` issue URL. Body: required `details`; optional `kind` (`bug`, `feature`, or `feedback`) and `title` |
| `GET` | `/api/agent-guides` | List available agent guides |
| `GET` | `/api/agent-guides/{filename}` | Read a guide |

## Coding agent endpoints

These endpoints and the WebSocket surfaces are useful only for teams with
`agent_enabled = true`; the dashboard hides agent actions for live-mounted
environments.

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/agent` | Agent enablement and effective default (`claude`, `codex`, or `opencode`) |
| `GET` | `/api/environments/{branch}/agent-acp/info?type=` | ACP working directory, selected/recent sessions, and attachment limits |
| `POST` | `/api/environments/{branch}/agent-acp/session` | Select, title, or clear the current session for body `type` |
| `POST` | `/api/environments/{branch}/agent-acp/attachments?name=` | Stream an attachment into the agent checkout |
| `DELETE` | `/api/environments/{branch}/agent-acp/attachments/{upload_id}` | Delete an unsent attachment |

## Production endpoints

These routes exist only when production hosting is enabled. Destructive restore
and delete operations require explicit confirmation in their JSON body.

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/productions` | List productions and return webhook/backup state |
| `POST` | `/api/productions/create` | Create a production from repository/image/domain settings, optionally a template |
| `GET` | `/api/productions/backup-status` | Team backup, WAL-G, base-backup, and S3 health |
| `GET` | `/api/productions/{name}` | Detailed production information |
| `POST` | `/api/productions/{name}/start` | Start |
| `POST` | `/api/productions/{name}/stop` | Stop |
| `POST` | `/api/productions/{name}/restart` | Restart |
| `POST` | `/api/productions/{name}/update` | Start an asynchronous deploy; returns `202` |
| `POST` | `/api/productions/{name}/rollback?to_commit=` | Roll code back to a commit |
| `POST` | `/api/productions/{name}/auto-update` | Set body `enabled` for webhook deploys |
| `GET` | `/api/productions/{name}/logs?lines=200` | Read up to 2,000 log lines |
| `GET` | `/api/productions/{name}/deploys` | Read recent deploy history |
| `POST` | `/api/productions/{name}/delete` | Delete; body `confirm` must equal name, optional `drop_database` |
| `GET` | `/api/productions/{name}/snapshots?refresh=true` | List S3 snapshots, optionally bypassing cache |
| `POST` | `/api/productions/{name}/snapshot` | Take a snapshot now |
| `POST` | `/api/productions/{name}/restore` | Restore body `snapshot_id`; body `confirm` must equal name |
| `POST` | `/api/productions/{name}/backup-schedule` | Set body `schedule` to `HH:MM` or `off` |
| `POST` | `/api/webhooks/github` | Public HMAC-authenticated GitHub push webhook |

See [Production Hosting](production.md) for deploy, rollback, backup, and PITR
semantics.

## Browser routes

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/` | Dashboard application |
| `GET`, `POST` | `/login` | UI login form |
| `POST` | `/logout` | Clear the UI session |
| `GET` | `/oduflow-connect?token=` | One-time environment-host login handoff; sets Odoo `session_id` and redirects to `/web` |
| `GET` | `/favicon.ico`, `/logo.png`, `/static/{filename}` | Packaged dashboard assets |

---

# MCP Tools Reference

![Agent Instructions](img/agent_instructions.png)

All tools are accessible via MCP clients (Cursor, Cline, Amp, etc.) and the CLI (`oduflow call`). A subset is also available via the [REST API](web-api.md).

| Tool | Lock | Description |
|---|:---:|---|
| **Environment Management** | | |
| `create_environment` | ✓ | Provision an Odoo environment for a branch (clone, DB, container, filestore); optional `hostname` selects a short Traefik hostname and `env_vars` injects container environment variables |
| `delete_environment` | ✓ | Tear down all resources for a branch |
| `list_environments` | | List all managed environments with status and URLs |
| `get_environment_info` | | Full environment details: DB name, URL, repo, image, template, extra addons, workspace, container status, CPU/RAM stats |
| `start_environment` | | Start a stopped environment |
| `stop_environment` | | Stop a running environment |
| `restart_environment` | | Restart the Odoo container |
| `update_environment` | ✓ | Re-create the container, preserving DB and filestore; optional `odoo_image` switches the image and `env_vars` replaces the container environment variables |
| **Odoo Operations** | | |
| `pull_and_apply` | ✓ | Git pull + smart analysis → auto install/upgrade/restart |
| `install_odoo_modules` | ✓ | Install Odoo modules (`-i`) |
| `upgrade_odoo_modules` | ✓ | Upgrade Odoo modules (`-u`) |
| `export_module_translations` | ✓ | Export a module's `.pot`/`.po` with Odoo's own exporter, write it into the module's `i18n/`, and return a summary plus an HTTP download URL or local temporary path |
| `translation_status` | ✓ | Verdict per language (OK, PARTIAL, NOT LOADED, NOT TRANSLATED, IMPORT SILENTLY DROPPED, IMPORT ABORTS, NO FILE, NOT ACTIVATED) from the module's terms, the database and the committed `.po` files, including the sibling-POT metadata merge Odoo performs before import, plus the call that fixes it |
| `run_odoo_tests` | ✓ | Run Odoo tests for specific modules; `test_tags` narrows the run to one class or method, `upgrade=False` skips the `-u` for a fast re-run (collects `post_install` tests only and requires module-scoped positive tags) |
| `get_environment_logs` | | Retrieve recent container logs |
| `run_odoo_command` | ✓ | Execute an arbitrary shell command inside the Odoo container (runs through `sh -c`, so pipes, redirections and `&&` work; `shell=False` for exact argv) |
| `run_odoo_shell` | ✓ | Execute Python code in the Odoo shell context with full ORM access; `auto_commit=True` commits successful writes, while `False` leaves the shell transaction uncommitted |
| `odoo_search_read` | ✓ | Search and read records (XML-RPC `search_read`/`search_count`) as any user, with ACLs and record rules applied |
| `odoo_create` | ✓ | Create one or many records (XML-RPC `create`). Committed immediately |
| `odoo_write` | ✓ | Update records (XML-RPC `write`). Committed immediately |
| `odoo_unlink` | ✓ | ⚠️ Delete records (XML-RPC `unlink`). Committed immediately, not recoverable |
| `odoo_call` | ✓ | Call public model methods not covered by the dedicated CRUD tools (`read_group`, `name_search`, `action_*`, …) |
| `odoo_schema` | ✓ | Page through models, or describe one model's fields (XML-RPC `fields_get`) |
| `read_file_in_odoo` | | Read a text file or list a directory inside the Odoo container. Supports line ranges (e.g. `"1:50"`) |
| `write_file_in_odoo` | ✓ | Write a text file inside the container (CSV imports, scripts, configs) |
| `search_in_odoo` | | Search for a pattern (fixed-string grep) in files inside the Odoo container |
| `http_request_to_odoo` | | Make an HTTP request to the running Odoo instance (test controllers, JSON-RPC, REST) |
| `list_installed_modules` | | List Odoo modules and their states with name/state filtering |
| `run_db_query` | ✓ | Execute SQL against the environment's PostgreSQL database; supports `output_format="csv"` or `"json"` and caps returned rows with `max_rows` (default `100`) |
| `reset_admin_password` | ✓ | Reset the admin user password in the Odoo database (default: "test") |
| `connect_as_user` | ✓ | Mint a passwordless Odoo login session for a user (by login or id) and return the `session_id` cookie + URL — hand to Playwright to skip the login form and test as any role (incl. portal) |
| `read_output` | | Read from a cached tool output by ID (paginate, grep, errors, tail) |
| **Template Management** | | |
| `save_as_template` | ✓ | ⚠️ Save a branch DB + filestore as a new template |
| `list_templates` | | List available template profiles, including the branch/commit each database snapshot was taken from |
| `delete_template` | ✓ | ⚠️ Delete a template profile (DB + files) |
| `rename_template` | ✓ | Rename a template (directory + PostgreSQL template DB); refused if any environment uses it |
| `import_template_from_odoo` | ✓ | Import a template from a running Odoo instance via database manager API; optional `without_filestore` requests a database-only PostgreSQL custom dump |
| `refresh_template` | ✓ | ⚠️ Re-apply a template's filestore to live overlay environments (preserves env changes by default; `reset_env_changes=True` discards them — destructive) |
| `attach_filestore` | ✓ | Attach or replace a template filestore from a local directory, archive, `rsync://` URL, or SSH rsync source; normalizes wrapper paths and preserves live env changes by default |
| **Auxiliary Services** | | |
| `create_service` | ✓ | Create a managed service with exactly one exposure model: catch-all `port`, or restricted Traefik `routes` (`path`, backend `port`, optional `strip_prefix`). The two parameters are mutually exclusive; `port` remains required outside Traefik |
| `delete_service` | ✓ | Stop and remove a service container |
| `restart_service` | | Restart a service container |
| `update_service` | ✓ | Preflight configuration, pull the latest image and/or change settings. `routes` replaces the complete allowlist; use `routes=[]` with `port` only when switching back to catch-all mode |
| `list_services` | | List all managed service containers |
| `get_service_info` | | Full live state of a single service (image+digest, port/routes, hostname, host_mode, volumes, env, capabilities, restart count, preset). Call before recreating it |
| `get_service_logs` | | Retrieve service container logs |
| `run_service_command` | | Execute a shell command inside a service container (through `sh -c`; `shell=False` for exact argv) |
| **Volumes** | | |
| `create_volume` | ✓ | Create a named Docker volume for use with services |
| `list_volumes` | | List all managed Docker volumes and their usage by services |
| `inspect_volume` | | Get detailed information about a specific volume |
| `delete_volume` | ✓ | Delete a managed Docker volume (fails if in use) |
| `read_file_in_volume` | | Read a text file or list a directory inside a Docker volume |
| `write_file_in_volume` | ✓ | Write a text file inside a Docker volume |
| `search_in_volume` | | Search for a pattern (fixed-string grep) in files inside a Docker volume |
| `delete_file_in_volume` | ✓ | Delete a file or directory inside a Docker volume |
| **Service Presets** | | |
| `list_service_presets` | | List saved service presets (configurations that can be restored) |
| `restore_service` | ✓ | Restore a service from a saved preset |
| `delete_service_preset` | ✓ | Remove a saved service preset |
| **Repository Auth** | | |
| `setup_repo_auth` | ✓ | Cache git credentials for a private repository |
| **Extra Addons** | | |
| `add_extra_repo` | ✓ | Clone an extra addons repository (e.g. Odoo Enterprise) for use with environments |
| `list_extra_repos` | | List all cloned extra addons repositories |
| `update_extra_repo` | ✓ | Fetch latest changes from the remote for an extra addons repository |
| `delete_extra_repo` | ✓ | Delete a cloned extra addons repository |
| **Production Hosting** | | Requires `[production].enabled = true` |
| `create_production` | ✓ | Provision a long-lived production with its own domain and the dedicated production PostgreSQL cluster; optionally seed it from a template |
| `list_productions` | | List productions with status, domain, deployed commit, and auto-update state |
| `get_production_info` | | Detailed status, configuration, deployed commit, deploy history, and backup information |
| `start_production` | ✓ | Start a stopped production |
| `stop_production` | ✓ | Stop a production, taking it offline |
| `restart_production` | ✓ | Restart a production's Odoo container |
| `set_production_auto_update` | ✓ | Enable or disable GitHub push webhook deployments |
| `update_production` | ✓ | Deploy pulled commits with explicit/automatic actions, health verification, and automatic code rollback on failure |
| `rollback_production` | ✓ | Roll production code back to a selected commit and restart it; does not roll back the database |
| `production_deploys` | | Read deploy history, including actions, modules, trigger, and rollback status |
| `production_logs` | | Read production Odoo logs with line, substring, and level filtering |
| `snapshot_production` | ✓ | Create an S3 snapshot containing the database, deduplicated filestore, and deployed commit |
| `list_production_snapshots` | | List S3 snapshots; `refresh=True` bypasses the cached index |
| `restore_production` | ✓ | Restore one production's database and filestore; requires its name in `confirm` |
| `production_backup_status` | | Inspect snapshot schedules, WAL archiving, base backups, and S3 reachability |
| `set_production_backup_schedule` | ✓ | Set a production's daily snapshot time (`HH:MM`) or disable it with `off` |
| `prune_production_backups` | ✓ | Apply configured snapshot and chunk-store retention immediately |
| `restore_cluster_pitr` | ✓ | ⚠️ Restore the entire production PostgreSQL cluster from WAL-G; requires `confirm="RESTORE-CLUSTER"` |
| `delete_production` | ✓ | Remove a production; requires its name in `confirm`, and preserves its database unless `drop_database=True` |
| **Agent Instructions** | | |
| `get_agent_instructions` | | Load the compact Oduflow agent workflow once at the start of a session |
| `get_odoo_development_guide` | | Get Odoo development standards guide for a specific version (15–19) |
| **Feedback** | | |
| `report_issue` | | Build a prefilled link for the user to file a bug, feature request, or feedback about Oduflow on GitHub |

!!! info "Locking"
    Tools marked with ✓ acquire a per-branch or per-team lock. Operations on different branches run in parallel. If another operation on the **same branch** (or team, for team-level tools) is already in progress, the call is rejected with `BusyError`. The rejection names the operation holding the lock and how long it has held it (e.g. *"Another operation on environment 'main' (pull_and_apply, running for 4m12s) is in progress"*), so a long install is distinguishable from a hung one. A lock is released when its operation finishes — including when the client that started it timed out and stopped waiting, which is why restarting the environment is the wrong response.

The exact current signature and defaults for every tool are also available from
`oduflow list` (`oduflow list --verbose` adds descriptions). The production
workflow and disaster-recovery consequences are covered in
[Production Hosting](production.md).

---

# CLI Reference

## Global Options

```bash
# Show version
oduflow --version
```

## Running the Server

```bash
# Single-user / stdio mode (default — for local MCP clients)
oduflow
uvx oduflow

# Server / HTTP mode (for remote and multi-user deployments)
oduflow --transport http
oduflow -t http
uvx oduflow --transport http
uvx oduflow -t http
```

Shared infrastructure (Docker network, PostgreSQL, team directories) is initialized automatically on startup.

**stdio mode** — the server communicates over stdin/stdout. The MCP client starts the process directly; no network port is needed. Ideal for local clients like Claude Desktop, Windsurf, etc.

**HTTP mode** — starts a persistent HTTP server on `http://0.0.0.0:8000` by default. Exposes the MCP endpoint at `/mcp`, a Web Dashboard at `/`, and a REST API at `/api/`. MCP uses Bearer tokens; the dashboard uses a form/session cookie, while API clients may also use HTTP Basic auth.

Configuration is loaded from `oduflow.toml` (see [Installation](installation.md#configuration-reference)).
See [Quick Start](quick-start.md) for MCP client configuration examples for both modes.

To reconcile a declarative Stack before starting the server:

```bash
oduflow --stack /path/to/oduflow.yaml --stack-team 1 --transport http
```

Startup stops with a non-zero exit if Stack validation, preflight, or apply
fails. See [Declarative Stacks](stacks.md).

## Declarative Stack Commands

```bash
# Local syntax and schema validation (does not require Docker)
oduflow stack validate oduflow.yaml

# Read-only comparison with live resources
oduflow stack plan oduflow.yaml --team 1

# Reconcile under the team's lock
oduflow stack apply oduflow.yaml --team 1

# JSON status: drift plan plus the last successful apply record
oduflow stack status oduflow.yaml --team 1
```

Stack apply is additive and non-destructive in V1. Existing resources owned by
someone else and environment changes that require replacement are reported as
conflicts; no automatic deletion or pruning is performed.

## System Commands

```bash
# Destroy all shared infrastructure (requires no active environments)
oduflow destroy

# Three-way merge deployed files with the installed bundled versions
oduflow upgrade

# Skip the confirmation prompt (conflicts still fail safely)
oduflow upgrade --force

# Preview the unified host resource plan and managed config diffs
oduflow retune-postgres

# Back up and write configs; stage production Odoo configs in containers
oduflow retune-postgres --apply
```

`retune-postgres` accounts for `[production].enabled` and does not restart
containers. For existing productions, `--apply` also regenerates `odoo.conf`
with the planned worker count and copies it into the container; the command
then lists every PostgreSQL and Odoo container that should be restarted. It
refuses to replace a custom PostgreSQL config unless `--apply --force` is
given. See [PostgreSQL resource planning](installation.md#configuration-file-overrides).

`oduflow upgrade` reconciles each team's `odoo.conf`, agent guides, and bundled
sanitize script against a stored pristine baseline. It compares complete file
contents, updates an untouched file, preserves local-only changes, and uses
`git merge-file` when both the local and bundled versions changed. Before a live
update, the previous file is saved under
`<team-data>/.bundled_upgrade/backups/`.

On a clean merge the live file and baseline advance together. On conflict the
live file and accepted baseline stay untouched; the merge result is written to
`*.oduflow-merge`. Existing customized installations with no baseline receive
`*.oduflow-new` for a one-time manual reconciliation. Resolve/install the
sidecar and remove it; until then the command exits with status 1. `--force`
only skips the confirmation prompt and never overwrites a conflict. A first-line
`# KEEP` remains an unconditional opt-out.

This command is separate from upgrading the Python package (for example,
`uv tool upgrade oduflow`). It does not manage `postgresql.conf`; use
`oduflow retune-postgres` for PostgreSQL planning and updates.

## Template Commands

All template commands accept `--team` to specify the team ID (default: `1`).

```bash
# Generate a clean template from a Docker image
oduflow init-template --odoo-image odoo:19.0 --template-name myproject [--modules base,web,sale] [--force] [--team 1]

# Save a branch environment as the new template.
# Other environments on this template keep their filestore changes by default;
# pass --reset-env-changes to discard them and reset to the new baseline.
oduflow template-from-env <branch> --template-name myproject [--reset-env-changes] [--team 1]

# Re-apply a template's current filestore to live overlay environments
# (non-destructive by default; --reset-env-changes discards env deltas)
oduflow refresh-template <template_name> [--reset-env-changes] [--team 1]

# Attach or replace a template filestore from a local dir, archive, rsync://, or SSH rsync source
oduflow attach-filestore <template_name> <source> [--strip-prefix auto|none|PREFIX] [--reset-env-changes] [--team 1]

# Reload template DB from a dump file
oduflow reload-template <template_name> [--dump-path /path/to/new.dump] [--team 1]

# Sync template from S3 or local path and reload DB
oduflow reload-template <template_name> --source s3://bucket/path/ [--quiet] [--team 1]
oduflow reload-template <template_name> --source /backups/prod-latest/ [--team 1]

# List all template profiles
oduflow list-templates [--team 1]

# Delete a template profile
oduflow delete-template <template_name> [--team 1]

# Import a template from a running Odoo instance
oduflow import-template <odoo_url> <master_pwd> --template-name myproject [--db-name <db>] [--without-filestore] [--team 1]
```

`template-from-env`, `refresh-template`, `attach-filestore`, and `reload-template --source` are **non-destructive** for live overlay environments: each is unmounted and remounted against the new template filestore while keeping its `upper` changes. Use `--reset-env-changes` (on `template-from-env`/`refresh-template`/`attach-filestore`) to reset environments to the clean baseline instead. `import-template` creates a new template and refuses an existing template name.

## Service Commands

```bash
# List all managed services
oduflow list-services [--team 1]
```

## Maintenance Commands

```bash
# Show orphaned databases, workspaces, and port entries (dry-run by default)
oduflow cleanup [--team 1]

# Same as above — only show what would be removed
oduflow cleanup --dry-run [--team 1]

# Actually remove orphaned resources
oduflow cleanup --force [--team 1]
```

The `cleanup` command detects and removes resources that no longer have a corresponding running or stopped container:

- **Orphan databases** — PostgreSQL databases with the `oduflow_` prefix that have no matching environment container
- **Orphan workspaces** — workspace directories on disk that have no matching environment container
- **Orphan port entries** — entries in `ports.json` that have no matching environment container

By default, `cleanup` runs in **dry-run mode** and only reports what would be removed. Use `--force` to actually delete the orphaned resources.

## Systemd Service

```bash
# Install and enable systemd service
oduflow systemd-install

# Remove the systemd service
oduflow systemd-uninstall
```

The `systemd-install` command generates a unit file at `/etc/systemd/system/oduflow.service`, runs `daemon-reload`, and enables the service.

See [Auto-start with systemd](installation.md#auto-start-with-systemd) for the full setup guide.

## Tool Introspection

```bash
# List all registered MCP tools with parameters
oduflow list [--verbose]
```

## Direct Tool Invocation

You can invoke any registered MCP tool directly from the terminal using `oduflow call`, without running the server or connecting an MCP client. This is useful for scripting, debugging, and manual operations.

```bash
# List all available tools with their parameters
oduflow call

# Call a tool with positional arguments (mapped to parameters in order)
oduflow call create_environment dev "" "" https://github.com/owner/repo.git odoo:19.0
oduflow call delete_environment dev
oduflow call list_environments
oduflow call get_environment_logs main 50
oduflow call run_odoo_command dev "ls /mnt/extra-addons"
oduflow call create_service redis redis:7 6379

# Call a tool with JSON-encoded arguments
oduflow call create_environment '{"branch":"dev","repo_url":"https://github.com/owner/repo.git","odoo_image":"odoo:19.0","template_name":"myproject"}'

# Service with NET_ADMIN capability (VPN / tun / iptables)
oduflow call create_service '{"name":"vpn","image":"linuxserver/wireguard","port":51820,"net_admin":true}'

# Type coercion is automatic: int, bool, and float parameters are cast from strings
oduflow call get_environment_logs dev 500
```

---

# Traefik Routing (Auto-HTTPS)

By default Oduflow uses **port mode**: each environment gets a dedicated host port (e.g. `http://server:50001`). This is simple and works well for local or single-developer setups.

For production-like access with HTTPS, Oduflow can deploy a **Traefik** reverse proxy that gives every environment its own subdomain with an automatically issued Let's Encrypt certificate.

## Setup

1. **Configure a wildcard DNS record.** Point `*.dev.example.com` to your server's IP address:

   ```
   *.dev.example.com  →  A  →  203.0.113.10
   ```

   Every environment will get a subdomain: `feature-login.dev.example.com`, `fix-invoice.dev.example.com`, etc.

2. **Set the configuration** in `oduflow.toml`:

   ```toml
   [routing]
   mode = "traefik"
   acme_email = "admin@example.com"

   [team.1]
   hostname = "dev.example.com"
   environment_slots = 20
   ```

3. **Start (or restart) Oduflow.** On startup, Oduflow will create a Traefik v3 container that:
   - Listens on ports 80 and 443
   - Automatically redirects HTTP to HTTPS
   - Obtains a separate TLS certificate from Let's Encrypt for each reusable environment hostname via HTTP-01 challenge
   - Routes requests to the correct Odoo container based on the subdomain
   - Also routes the Oduflow server itself via the team `hostname`

## How certificates work

With `hostname = "dev.example.com"` and `environment_slots = 20`, Oduflow
allocates `dev1.example.com` through `dev20.example.com`: it removes the first
hostname label, adds the slot number to it, and keeps the parent domain.
Deleting an environment returns its hostname to the pool, so later environments
reuse the same certificates from Traefik's persistent ACME store instead of
continuously issuing certificates for branch names.
`create_environment(hostname="qa")` requests `qa.example.com`. Set
`environment_slots = 0` to retain the legacy branch-derived hostname behavior.
The configured hostname must include a distinct prefix (`dev.example.com`, not
bare `example.com`) so Oduflow has a label to number.

Wildcard certificates (`*.dev.example.com`) via DNS-01 validation are also possible but require additional Traefik configuration with a provider-specific plugin.

## OAuth on each team's hostname

In traefik mode the self-hosted [OAuth Authorization Server](security.md#self-hosted-oauth-for-claudeai-and-other-mcp-clients) is enabled **automatically** and runs on **each team's own hostname** — the OAuth issuer is derived per request from the incoming host, which already has a Let's Encrypt certificate. You do **not** need to set `oauth_base_url`: point Claude.ai at `https://<team-hostname>/mcp` and complete the OAuth flow there.

## Service routing with Traefik

Auxiliary services also get Traefik routing. A service named `meilisearch` with base domain `dev.example.com` becomes accessible at `https://meilisearch.dev.example.com`. Custom hostnames are also supported.

## Routing extra domains to external services

Traefik in Oduflow can also forward a domain to a service that Oduflow does
**not** manage — another Docker container, a process on the host, or a machine
elsewhere. There are two ways, from simplest to most flexible.

### 1. Declarative routes in `oduflow.toml`

For the common "this hostname → that URL" case, add a `[route.<name>]` section:

```toml
[routing]
mode = "traefik"
acme_email = "admin@example.com"

[team.1]
hostname = "dev.example.com"

[route.legacy-api]
host = "api.example.com"
url  = "http://127.0.0.1:3000"
```

On the next start Oduflow generates a Traefik router for `api.example.com` and
forwards it to `http://127.0.0.1:3000`. In TLS mode the route gets its own
Let's Encrypt certificate (point the domain's DNS at this server first), exactly
like a team hostname; behind a `tls = false` upstream it is served over plain
HTTP on port 80.

Notes:

- **`127.0.0.1` / `localhost` mean "on the Docker host".** Traefik runs in a
  container, so Oduflow rewrites an `http://` loopback upstream to
  `host.docker.internal` (mapped to the host gateway). So `http://127.0.0.1:3000`
  reaches a service listening on port 3000 of the host. Use the real IP/hostname
  for anything off the host. An `https://localhost` upstream is **not** rewritten
  (that would break backend TLS certificate verification) — for a TLS backend on
  the host, use its real hostname or a drop-in dynamic file with a
  `serversTransport`.
- `url` must be `http://…` or `https://…`; `host` must be a plain hostname (no
  path) and unique across all routes and team hostnames.
- These routes are declared once in config; the generated router set is
  rewritten on every restart, so hand-editing the generated file is pointless
  (use option 2 for custom Traefik config).

### 2. Drop-in Traefik dynamic files

For anything the simple `host → url` form can't express — middleware, header
rewrites, custom TLS options, sticky sessions, multiple services — Oduflow
mounts a **dynamic-config directory** that Traefik watches:

- On the host it is `<config-dir>/traefik-dynamic/` — `/etc/oduflow/traefik-dynamic/`
  when writable, otherwise `~/.oduflow/conf/traefik-dynamic/`.
- Oduflow writes and overwrites only `oduflow.yml` there (its own routers). Any
  **other** `*.yml`/`*.yaml`/`*.toml` file you place in that directory is loaded
  by Traefik and **never touched by Oduflow** — it survives restarts and
  upgrades.

For example, `<config-dir>/traefik-dynamic/custom.yml`:

```yaml
http:
  routers:
    my-app:
      rule: "Host(`app.example.com`)"
      entryPoints: ["websecure"]
      tls:
        certResolver: letsencrypt
      service: my-app
      middlewares: ["my-headers"]
  middlewares:
    my-headers:
      headers:
        customRequestHeaders:
          X-Forwarded-Proto: "https"
  services:
    my-app:
      loadBalancer:
        servers:
          - url: "http://host.docker.internal:9000"
```

Traefik picks it up within a second (no restart needed). This is the full
Traefik [file-provider dynamic configuration](https://doc.traefik.io/traefik/providers/file/),
so use it when you outgrow the declarative routes above.

## Behind a Cloudflare tunnel (or other TLS-terminating upstream)

If HTTPS is terminated upstream — for example by a **Cloudflare tunnel** (`cloudflared`) that already serves a valid certificate — Traefik should not obtain its own certificates or redirect to HTTPS. Set `tls = false`:

```toml
[routing]
mode = "traefik"
tls = false          # Traefik listens on plain HTTP :80 only

[team.1]
hostname = "dev.example.com"
```

With `tls = false` Traefik:

- Listens on **port 80 only** (443 is not published), serving plain HTTP.
- Does **not** redirect HTTP→HTTPS and does **not** request Let's Encrypt certificates (`acme_email` is not required).
- Routes by the same `Host` rules as before, so each environment keeps its own subdomain.

Point the tunnel at the server's port 80 and route the wildcard hostname to it (e.g. `*.dev.example.com → http://localhost:80`). Cloudflare provides the certificate and forwards requests over HTTP; the tunnel sets `X-Forwarded-Proto: https`. On the `web` entrypoint Oduflow enables `forwardedHeaders.insecure` so Traefik passes those headers through (by default Traefik would overwrite `X-Forwarded-Proto` with the plain-HTTP connection scheme), letting Oduflow see the request as secure — the dashboard's session cookie stays `Secure` and every environment/service URL Oduflow reports is still `https://…`. Because this entrypoint trusts all forwarded headers, expose port 80 **only** to the tunnel, not to the public internet.

> **Changing `tls` on a running deployment recreates Traefik but not your environments.** Each environment and service bakes its Traefik routing labels in at creation time — `entrypoints=websecure` (with Let's Encrypt) when `tls = true`, `entrypoints=web` when `tls = false`. Restarting Oduflow recreates the Traefik container in the new mode, but pre-existing environments and services keep their old labels: after the switch their routers point at an entrypoint that no longer matches, so they become unreachable until **recreated** (or, going `false → true`, get caught by the HTTP→HTTPS redirect). Treat `tls` as a deploy-time choice; if you must flip it on a live server, recreate the existing environments and services afterwards. The default is `tls = true` (Traefik terminates TLS with Let's Encrypt, as described above).

---

# Multi-Team Support

Oduflow supports running **multiple isolated teams** within a single server instance. Each team has its own environments, templates, services, credentials, port registry, Docker network, and PostgreSQL tablespace; the PostgreSQL and Traefik containers are the only shared infrastructure.

## Configuration

Define teams in `oduflow.toml` using `[team.*]` sections:

```toml
[team.1]
hostname = "team-a.example.com"
auth_token = "token-team-a"
ui_password = "pass-a"
port_range = [50000, 50050]

[team.2]
hostname = "team-b.example.com"
auth_token = "token-team-b"
ui_password = "pass-b"
port_range = [50050, 50100]
```

Each team gets a dedicated data directory under the base `data_dir`:

```
/srv/oduflow/
├── team_1/
│   ├── workspaces/
│   ├── templates/
│   ├── shared_repos/
│   ├── ports.json
│   ├── .git-credentials
│   └── agent_guides/
├── team_2/
│   ├── workspaces/
│   ├── templates/
│   ├── shared_repos/
│   ├── ports.json
│   ├── .git-credentials
│   └── agent_guides/
```

## Team Resolution

When an MCP tool is called, Oduflow resolves the team using the following priority:

1. **Auth token** — matches the Bearer token against `auth_token` values in team configs
2. **Host header** — matches the HTTP `Host` header against team `hostname` values
3. **Single team** — if only one team is configured, uses it automatically
4. **Default** — falls back to team `"1"`

Steps 3–4 apply to the stdio transport (implicit local single user) only. In
HTTP mode a request that matches no token and no hostname is rejected, so it
can never land in another team's context — unless `allow_insecure_http = true`
explicitly opts out (e.g. behind your own auth proxy). HTTP mode with multiple
teams also requires a non-empty `auth_token` for every team at startup.

## Quotas

Each team can carry resource quotas (`0` disables a quota):

```toml
[team.1]
db_quota_gb = 50      # default: 50
disk_quota_gb = 0     # default: 0 (off)
```

- `db_quota_gb` caps the combined size of the team's PostgreSQL databases —
  environments plus templates. It is checked before operations that create a
  *new* database (`create_environment`, `save_as_template` of a new template,
  `import_template_from_odoo`) with a single catalog query
  (`pg_database_size()`), so there is no per-file scanning in the hot path.
  Replacement operations (refresh/reload of an existing template) are not
  gated, so a team at its quota can still shrink or refresh what it has.
- `disk_quota_gb` caps the team's disk usage — its data dir (workspaces,
  filestores, template dumps) **plus** its PostgreSQL tablespace — enforced
  by the kernel via XFS project quotas. Requirements: Linux, `xfsprogs`
  installed, and the data dir on an XFS filesystem mounted with `prjquota`.
  Both directory trees get the same project ID, so one `bhard` limit covers
  files and databases together; writes beyond it fail with ENOSPC while the
  rest of the machine is unaffected. On filesystems without project-quota
  support the limit is not enforced (one warning at startup) and usage stays
  visible via the dashboard and `/api/usage`.

## Per-Team PostgreSQL Tablespaces

Each team's databases (environments and templates) live in a dedicated
PostgreSQL tablespace, `oduflow_team_{id}`, whose files sit under
`{data_dir}/pg_tablespaces/team_{id}/` on the host. Only that
`pg_tablespaces/` directory is mounted into the PostgreSQL container — never
the rest of the data dir.

This makes a team's disk consumption one visible number: assign
`team_{id}/` and `pg_tablespaces/team_{id}/` the same XFS project ID and a
single project quota covers the team's files *and* its databases. WAL stays
in the shared `PGDATA`, so a team hitting its quota gets aborted
transactions, not a server-wide outage.

Existing installs are converted automatically on server start (startup
migration `0002-team-pg-tablespaces`): the PostgreSQL container is recreated
once with the new mount (its data volume persists), then each team database
is physically moved with `ALTER DATABASE ... SET TABLESPACE`. Expect the
first start after the upgrade to take time proportional to the total
database size.

A second base-level directory, `{data_dir}/pg_exchange/`, is mounted the same
way (as `/exchange`). Database dumps are staged in `pg_exchange/team_{id}/`
so `pg_dump` writes them once, straight to their final filesystem, and a
restore reads them in place instead of having a full-size copy pushed into
the PostgreSQL container's writable layer. Give it the **same XFS project ID**
as the rest of the team: besides keeping the accounting right, XFS refuses to
rename a file into a project-inheriting directory with a different ID, which
would break moving a finished dump into the team's templates directory.

Unlike the tablespace change, this one is not migrated. The mount is attached
when the PostgreSQL container is created, and an existing container is left
alone; installs without it keep streaming dumps out through the Docker exec
API and pick up the faster path whenever that container is next recreated.

## Shared vs. Per-Team Resources

| Resource | Scope |
|---|---|
| Infra Docker network (`oduflow-net`) | Shared (PostgreSQL, Traefik) |
| Team Docker network (`oduflow-{team}-net`) | Per-team — env/service containers join only their team's network; shared infra is attached to every team network |
| PostgreSQL container (`oduflow-db`) | Shared |
| PostgreSQL tablespace (`oduflow_team_{id}`) | Per-team |
| Traefik container (`oduflow-traefik`) | Shared |
| Environments (workspaces, containers) | Per-team |
| Templates (DB snapshots, filestores) | Per-team |
| Extra addon repositories | Per-team |
| Auxiliary services | Per-team |
| Port assignments | Per-team |
| Git credentials | Per-team |

## Resource Naming

Databases and containers are namespaced by team ID:

- Environment DB: `oduflow_{team_id}_{slugified_branch}` (e.g. `oduflow_1_feature-login`)
- Template DB: `oduflow_template_{team_id}_{template_name}` (e.g. `oduflow_template_1_default`)
- Environment containers: `oduflow-{team_id}-{env}-{type}` (e.g. `oduflow-1-feature-login-odoo`)
- Service containers: `oduflow-{team_id}-svc-{name}` (e.g. `oduflow-1-svc-redis`)

Containers are additionally labeled with `oduflow.team={team_id}`; listing and
filtering are label-based, and container names are team-scoped so two teams
can use the same branch name without colliding. Containers created by older
versions are renamed to this scheme automatically on server start (startup
migration `0001-team-scoped-container-names`).

## CLI Team Selection

CLI template and service commands accept a `--team` flag:

```bash
oduflow init-template --odoo-image odoo:19.0 --template-name myproject --team 2
oduflow list-templates --team 2
oduflow cleanup --team 2
```

The default is `--team 1`.

---

# Authentication & Security

## MCP HTTP Auth

When `auth_token` is set for a team in `oduflow.toml`, the MCP endpoint (`/mcp`) requires a Bearer token:

```
Authorization: Bearer <your-token>
```

Each team can have its own auth token:

```toml
[team.1]
auth_token = "secret-token-team-1"

[team.2]
auth_token = "secret-token-team-2"
```

The token is used to both authenticate and identify the team. This is implemented via FastMCP's `StaticTokenVerifier`.

Fresh configs get a generated `auth_token` for `[team.1]` on first startup. The
value is printed in the startup log and stored in `oduflow.toml`; use it as
`Authorization: Bearer <auth_token>` when connecting HTTP MCP clients.

## Self-hosted OAuth (for Claude.ai and other MCP clients)

Oduflow can act as its own OAuth 2.1 Authorization Server, so MCP clients that require an OAuth flow (e.g. Claude.ai Remote MCP, MCP Inspector) can connect without any external identity provider.

The team's OAuth **`client_id`** is a non-secret identifier, `team_<id>` (e.g. `team_1` for `[team.1]`); the **`client_secret`** is the team's `auth_token`. Only the `client_id` appears in the authorization URL — the secret is sent solely in the token request body, so it never leaks into logs or browser history. When the OAuth flow completes, Oduflow issues an **independent, opaque access token that expires** (with a refresh token to obtain a new one) — the client never receives the `auth_token` itself, so a compromised OAuth token has a bounded lifetime and can be revoked. The `auth_token` stays valid as a plain Bearer token for CLI clients (see [Bearer-only mode](#bearer-only-mode-cli-automation)).

### Setup

**In [traefik mode](traefik.md) it's automatic.** The Authorization Server is enabled out of the box and runs on **each team's own hostname** — the OAuth issuer is derived per request from the incoming host (which already has a Let's Encrypt certificate). Just give each team an `auth_token`; no `oauth_base_url` is needed:

```toml
[routing]
mode = "traefik"
acme_email = "admin@example.com"

[team.1]
hostname = "team-a.example.com"
auth_token = "secret-token-team-1"
```

**In port mode** (no per-team TLS host), set `oauth_base_url` to the public https URL where this instance is reachable, so the issuer is a fixed, reachable endpoint:

```toml
[oauth]
oauth_base_url = "https://your-server.com"

[team.1]
auth_token = "secret-token-team-1"
```

Either way, Oduflow exposes:

- `GET /.well-known/oauth-authorization-server` — discovery metadata
- `GET /authorize` — authorization endpoint (Authorization Code + PKCE)
- `POST /token` — token endpoint (mints/rotates the access + refresh token pair)
- `POST /revoke` — revoke a minted access or refresh token

Dynamic Client Registration (`/register`) is **disabled** — clients must use the preregistered credentials.

### Connecting from Claude.ai

1. Go to Claude.ai Settings → Connectors → Add custom MCP
2. Enter your Oduflow URL: `https://your-server.com/mcp` (in traefik mode, the team's own hostname, e.g. `https://team-a.example.com/mcp`)
3. In the OAuth fields, use the team's id as `Client ID` and its `auth_token` as `Client Secret` (the `Client ID` is `team_<N>` for `[team.N]` — e.g. `team_1` for `[team.1]`):

   ```
   Client ID     = team_1
   Client Secret = secret-token-team-1
   ```

4. Claude.ai performs the OAuth flow against your Oduflow instance, receives an access token, and connects.

The issued access token is an independent, expiring token bound to that team (not the `auth_token`), so each team's claude.ai connector ends up scoped to its own workspaces, templates, and credentials while Claude never stores the master secret. Claude.ai transparently uses its refresh token to obtain a new access token when the old one expires; the connection also survives an Oduflow restart because minted tokens are persisted.

### Bearer-only mode (CLI / automation)

For curl, IDE clients, or anything that doesn't need OAuth, simply send the `auth_token` as a Bearer header:

```
Authorization: Bearer secret-token-team-1
```

This works whether or not `oauth_base_url` is configured.

## Scoped single-environment access (`/mcp/<env>`)

The team `auth_token` unlocks the **full** tool surface — create, delete, and stop
environments, manage templates, services, and volumes. To hand an AI agent a
*confined* handle to one environment only, Oduflow exposes a scoped endpoint:

```
https://your-server.com/mcp/<env>
```

On this endpoint only the in-environment tools are available — sync
(`pull_and_apply`), install/upgrade modules, run tests, open the Odoo shell, run
SQL, read and write records through the `odoo_*` ORM tools, read/write/search
files, fetch logs and info, and `restart`. The ORM tools grant no new privilege:
anything they can reach is already reachable through `run_odoo_shell` and
`run_db_query`, which the endpoint has always exposed. Lifecycle and
system tools (create/delete/stop/start/recreate, templates, services, volumes,
listing other environments) are **not exposed and cannot be called**. The
environment is taken from the URL, so the agent never passes — and cannot
override — which environment it operates on.

### Per-environment Secret Key

Every environment created after this feature gets its own access token, generated
at creation time and stored on the container. Use it as a **Bearer token** or as
an **OAuth** client credential — exactly like a team `auth_token`, but it only
unlocks its own `/mcp/<env>` endpoint:

```
Authorization: Bearer <environment-secret-key>
```

A per-environment token is rejected on the full `/mcp` endpoint and on any other
environment's URL, so the credential itself is the boundary.

### Getting the URL and Secret Key

In the web dashboard, open an environment's **More → MCP Access**. The dialog
shows the `/mcp/<env>` URL and the Secret Key (with copy buttons) ready to paste
into an agent's MCP configuration.

Environments created before this feature carry no Secret Key (Docker labels can't
be added to a live container); recreate the environment to issue one. Recreating
an environment also rotates its token.

## Web Dashboard Auth

The browser login form creates a signed, seven-day HTTP-only session cookie.
The REST API also accepts HTTP Basic authentication. Both use a **separate**
password:

- **Username**: `admin`
- **Password**: value of `ui_password` from `oduflow.toml`

This is independent from the MCP Bearer token (`auth_token`). Credentials are
compared using `hmac.compare_digest` to prevent timing attacks. State-changing
cookie-auth requests and WebSocket handshakes are additionally checked for a
same-origin `Origin`/`Referer` to prevent CSRF.

Fresh configs get a generated `ui_password` for `[team.1]` on first startup.
Older HTTP configs with an empty `ui_password` are also auto-filled on startup
and written back to `oduflow.toml`, so an upgrade does not expose the dashboard.

## When auth is disabled

MCP auth and Web UI auth are configured independently per team:

- If `auth_token` is empty, the MCP endpoint has no team Bearer token
- If `ui_password` is empty, the web dashboard has no login password

In HTTP mode, Oduflow refuses to start with an unauthenticated MCP endpoint or
dashboard unless the operator explicitly sets:

```toml
[server]
allow_insecure_http = true
```

Use that only behind your own authenticating proxy. In normal fresh HTTP
deployments, `auth_token` and `ui_password` are generated automatically and
startup logs show auth as enabled:

```
INFO  [team.1] http://localhost:8000/ (MCP token ON, OAuth OFF, UI auth ON)
```

When OAuth is enabled the status reads `OAuth ON (self-hosted)`.

## Git Credentials

![Credentials Management](img/credentials.png)

Private repository credentials are stored in the git credential store at `{team_data_dir}/.git-credentials` (per-team) via the `setup_repo_auth` tool. The clean URL (without credentials) is always used in Docker labels and logs — credentials are never exposed.

### Managing credentials via MCP

```bash
# Store credentials for a private repository
oduflow call setup_repo_auth https://user:PAT@github.com/owner/private-repo.git
```

The tool parses the URL, stores the credentials, and verifies access by running `git ls-remote`.

### Managing credentials via REST API and Web Dashboard

The Web Dashboard and REST API provide full credential lifecycle management:

| Action | REST API |
|---|---|
| **List** all stored credentials | `GET /api/credentials` |
| **Add** credentials for a repository | `POST /api/credentials/add` (body: `repo_url`) |
| **Delete** a stored credential | `POST /api/credentials/delete` (body: `host`, `username`) |
| **Validate** a credential against the provider | `POST /api/credentials/validate` (body: `host`, `username`) |

Validation checks the credential against the provider's API (GitHub, GitLab, Bitbucket). For other hosts, it reports `"valid"` if the credential exists. Tokens are always masked in API responses (e.g. `ghp_****`).

## iptables rule

On startup, an `iptables ACCEPT` rule is automatically added for the `oduflow-net` Docker bridge interface. This ensures that containers on the shared network can communicate with the host (required for Traefik `host.docker.internal` routing and PostgreSQL access). If `iptables` is not available, the rule is skipped with a warning.

## Odoo security defaults

The bundled `odoo.conf` template includes these security settings:

- `list_db = False` (hides database selector)
- `without_demo = all` (no demo data)
- `max_cron_threads = 0` (disables cron in dev environments)

A repository that ships its own `.oduflow/odoo.conf` replaces the template
entirely and is responsible for these settings itself.

---

# Running Oduflow in Docker

Oduflow can run as a Docker container. Since it manages other Docker containers (Odoo environments, PostgreSQL, etc.), it uses the **Docker-out-of-Docker** pattern — the host's Docker socket is mounted into the container.

## Build

```bash
docker build -t oduflow .
```

## Run

### Minimal example

```bash
docker run -d \
  --name oduflow \
  -p 8000:8000 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v oduflow_data:/srv/oduflow \
  oduflow
```

### Full example with all typical options

```bash
docker run -d \
  --name oduflow \
  --restart unless-stopped \
  -p 8000:8000 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v oduflow_data:/srv/oduflow \
  -v /etc/oduflow:/etc/oduflow \
  oduflow
```

## Volume Mounts

| Mount | Purpose |
|---|---|
| `/var/run/docker.sock` | **Required.** Gives Oduflow access to the host Docker daemon to manage Odoo containers, PostgreSQL, Traefik, etc. |
| `/srv/oduflow` | Oduflow data directory. Contains team directories with workspaces, templates, port registry. Use a named volume or a host path to persist data across container restarts. |
| `/etc/oduflow` | System configuration directory. Contains `oduflow.toml`, license key, `postgresql.conf`, default `odoo.conf`, and other configuration files. Mount to persist configuration across container restarts. |

## Networking

The Oduflow container must be on the same Docker network as the containers it creates. The simplest approach is to connect it to `oduflow-net` after initialization:

```bash
# 1. Start Oduflow (Docker image defaults to HTTP mode)
docker run -d --name oduflow -p 8000:8000 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v oduflow_data:/srv/oduflow \
  -v /etc/oduflow:/etc/oduflow \
  oduist/oduflow

# 2. Connect Oduflow to the shared network (created automatically on startup)
docker network connect oduflow-net oduflow
```

Alternatively, start with `--network oduflow-net` if the network already exists.

Shared infrastructure (Docker network, PostgreSQL, team directories) is initialized automatically on startup — no separate init step needed.

To set up a template database:

```bash
# From scratch (clean Odoo with specified modules)
docker exec oduflow oduflow init-template --odoo-image odoo:19.0 --template-name default --modules base,web,contacts

# Or import from a running Odoo instance
docker exec oduflow oduflow import-template https://my-odoo.example.com master_password --template-name default
```

## Configuration

Oduflow reads its configuration from `oduflow.toml`. When running in Docker, mount the config directory:

```bash
-v /etc/oduflow:/etc/oduflow
```

Key configuration settings in `oduflow.toml`:

```toml
[server]
host = "0.0.0.0"
port = 8000

[team.1]
hostname = "localhost"
auth_token = "your-secret-token"   # MCP Bearer token
ui_password = "your-ui-password"   # Web UI password for user admin
```

HTTP mode refuses empty MCP or dashboard credentials unless
`[server].allow_insecure_http = true` is set explicitly. Use that escape hatch
only behind another authenticating proxy.

See [Installation — Configuration Reference](installation.md#configuration-reference) for all options.

## Docker Compose

```yaml
services:
  oduflow:
    image: oduist/oduflow
    ports:
      - "8000:8000"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - oduflow_data:/srv/oduflow
      - oduflow_etc:/etc/oduflow
    restart: unless-stopped
    networks:
      - oduflow-net

volumes:
  oduflow_data:
  oduflow_etc:

networks:
  oduflow-net:
    name: oduflow-net
```

After `docker compose up -d`, Oduflow initializes shared infrastructure automatically.

## Security Notes

- Mounting the Docker socket gives the container **full control** over the host Docker daemon. This is equivalent to root access on the host. Only run Oduflow in trusted environments.
- Set `auth_token` in `[team.*]` to protect the MCP endpoint.
- Set `ui_password` in `[team.*]` to protect the Web UI.

## Privileged Mode and fuse-overlayfs

Oduflow uses `fuse-overlayfs` for efficient filestore sharing when templates exceed `overlay_threshold_mb` (default: 50 MB). This requires the `/dev/fuse` device inside the container.

If your templates are small (under the threshold), Oduflow falls back to simple file copy and no special privileges are needed.

For large templates, run with the fuse device:

```bash
docker run -d \
  --name oduflow \
  --device /dev/fuse \
  --cap-add SYS_ADMIN \
  -p 8000:8000 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v oduflow_data:/srv/oduflow \
  oduflow
```

Alternatively, set a high threshold in `oduflow.toml` to avoid overlayfs entirely:

```toml
[storage]
overlay_threshold_mb = 999999
```

---

# Internals

## Architecture

```
┌──────────────────────────────────────────────────┐
│                   MCP Clients                    │
│         (Cursor, Cline, Amp, Claude, …)          │
└────────────────────┬─────────────────────────────┘
                     │  MCP (stdio or Streamable HTTP)
┌────────────────────▼─────────────────────────────┐
│  server.py — FastMCP transport layer             │
│  • Public MCP tool definitions                    │
│  • Per-branch / per-team / system locking        │
│  • Unified error handler (FlowError → ToolError) │
│  • Web UI mount (Starlette)                      │
│  • Bearer auth (MCP) / session+Basic auth (UI)   │
│  • Team resolution (token → Host → default)      │
└────────────────────┬─────────────────────────────┘
                     │
     ┌───────────────┼───────────────────┬────────────────────┐
     │               │                   │                    │
     ▼               ▼                   ▼                    ▼
 system_ops      env_ops             service_ops       production_ops
 (infrastructure (dev environment    (services,        (production deploy,
  + templates)    lifecycle/sync)     presets/volumes)  rollback/lifecycle)
     │               │                                        │
     │               ▼                                        ▼
     │           odoo_ops                              backup_ops / WAL-G
     │           (modules, tests,                      (snapshots, retention,
     │            shell, ORM, SQL)                      cluster PITR)
     │               │                                        │
     └───────────────┴────────────────────────────────────────┘
                     │
              Docker SDK (docker-py)
                     │
     ┌───────────────┼────────────────────┐
     ▼               ▼                    ▼
oduflow-{team}-net oduflow-db      oduflow-{team}-{branch}-odoo
  (per-team net)   (PostgreSQL)    (Odoo containers)
                                   oduflow-{team}-svc-{name}
                                   (Service containers)
```

### Key Architectural Decisions

| Decision | Rationale |
|---|---|
| Single process, single uvicorn worker | Designed for a single developer or small team; no shared-state problems |
| Granular `LockManager` (per-branch, per-team, system) | Operations on different branches run in parallel; same-branch operations are serialised with `BusyError` |
| Docker SDK only (no subprocess for Docker) | Consistent error handling; `put_archive` replaces `docker cp` |
| fuse-overlayfs for filestore | Copy-on-write sharing of a large template filestore across all environments |
| Stable port registry (`ports.json`) | Port assignments survive container restarts; eliminates TOCTOU race conditions |
| Typed error hierarchy | `FlowError` base with `NotFoundError`, `BusyError`, `ConflictError`, `PrerequisiteNotMetError`, `ExternalCommandError`, `ProtectedError` — clients can distinguish error types |
| Traefik routing mode (optional) | Automatic HTTPS with Let's Encrypt for production-like setups |
| Dual dump format support | Accepts both plain SQL (`.sql`) and PostgreSQL custom format (`.pgdump`) dumps |
| Auto-detection of UID/GID | Resolves Odoo container's UID:GID from the image to set correct file permissions |
| TOML-based multi-team config | Per-team isolation with shared infrastructure; settings loaded from `oduflow.toml` |

## Project Structure

```
src/oduflow/
  server.py            # MCP transport: tool definitions, error handler, locking, CLI
  settings.py          # @dataclass Settings, loads from oduflow.toml (TOML)
  errors.py            # FlowError hierarchy (7 error classes)
  models.py            # EnvironmentRef dataclass
  naming.py            # Pure functions: slugify, db name, resource name, paths, URL sanitization
  locking.py           # LockManager with per-branch, per-team, and system locks
  git_ops.py           # Git clone, pull, credential management, manifest parsing
  git_analysis.py      # Classify changed files → install / upgrade / restart / refresh
  bundled_upgrade.py   # Three-way merge bundled files using persistent baselines
  port_registry.py     # Stable port allocation with JSON persistence
  web_ui.py            # Starlette dashboard, REST/WS API, session+Basic auth middleware
  extra_addons.py      # Extra addon repo management (clone, worktree, odoo.conf generation)
  env_credentials.py   # Per-environment PostgreSQL credentials
  sanitizer.py         # DB sanitization (SQL/Python scripts)
  sync.py              # Sync template data from S3 or local path (aws s3 sync / rsync)
  licensing.py         # License verification and installation (RSA signatures)
  systemd.py           # Systemd service install/uninstall
  production_registry.py # Per-team production metadata and deploy history
  backup_ops.py        # Production snapshot/restore orchestration
  backup_scheduler.py  # Scheduled snapshots, base backups, and retention
  walg.py              # WAL-G archive/base-backup/PITR integration
  chunkstore/          # Deduplicated filestore snapshot engine
  agent_sessions.py    # Hosted-agent conversation selection/history

  docker_ops/
    client.py           # docker.from_env() wrapper + UID/GID auto-detection
    system_ops.py       # init_system / destroy_system / reload_template / init_template /
                        # save_env_as_template / delete_template / list_templates
    env_ops.py          # create / delete / start / stop / restart / update / list / status / pull /
                        # apt/pip auto-install / filestore overlay mount
    production_ops.py   # production create/deploy/rollback/lifecycle
    odoo_ops.py         # install / upgrade / test / logs / shell / ORM / SQL / search / run_command
    service_ops.py      # create / delete / update / list / logs for auxiliary services
    service_presets.py  # Save / restore / list / delete service preset configurations
    volume_ops.py       # Managed Docker volume lifecycle
    volume_file_ops.py  # Read/write/search/delete files in managed volumes
    stats.py            # Container and system CPU/RAM stats (parallel collection)

  templates/
    oduflow.toml          # Default TOML configuration (copied on first startup)
    odoo.conf             # Odoo configuration template (addons path, limits, security)
    postgresql.conf       # PostgreSQL tuning (shared_buffers, WAL, autovacuum, etc.)
    dashboard.html        # Web dashboard UI (single-page application)
    favicon.ico           # Dashboard favicon
    agent_guides/         # AI agent guides (copied to team data dirs on init)
      agent_instructions.md # Main agent instructions for Oduflow MCP tools
      odoo_15_guide.md    # Odoo 15 development standards
      odoo_16_guide.md    # Odoo 16 development standards
      odoo_17_guide.md    # Odoo 17 development standards
      odoo_18_guide.md    # Odoo 18 development standards
      odoo_19_guide.md    # Odoo 19 development standards

tests/                  # Unit and integration tests (pytest)
```

## Environment Workspace Structure

Each branch gets an isolated workspace:

```
{data_dir}/team_{ID}/workspaces/{branch}/
  repo/                ← shallow git clone (--depth 1)
  filestore_upper/     ← overlay upper layer (branch-specific changes)
  filestore_work/      ← overlay work directory (required by overlayfs)
  filestore/           ← merged overlay mount (bound into the container)
  sessions/            ← Odoo session storage
```

When `template_name="none"` (no template), the filestore is a plain directory (no overlay).

You can verify active overlay mounts with `df -h` — each environment with a template gets its own `fuse-overlayfs` mount:

```
$ df -h
Filesystem                         Size  Used Avail Use% Mounted on
/dev/mapper/ubuntu--vg-ubuntu--lv   97G   74G   19G  81% /
fuse-overlayfs                      97G   74G   19G  81% /srv/oduflow/team_1/workspaces/manuf-plan/filestore
fuse-overlayfs                      97G   74G   19G  81% /srv/oduflow/team_1/workspaces/fixing-landing/filestore
```

## File Ownership (macOS vs Linux)

Odoo containers run as `uid=101 gid=101`. Oduflow must set this ownership on
workspace files so the container can read/write them. The behaviour differs
between platforms:

| | Linux | macOS (Docker Desktop) |
|---|---|---|
| **Docker runtime** | Native — UID/GID are shared between host and container | Runs inside a Linux VM; files are projected via VirtioFS |
| **Host file ownership** | Matches container UID (e.g. `101:101`) | Always shown as the macOS user regardless of in-container owner |
| **`os.chown` from host** | Works (when running as root) | Raises `PermissionError` — VirtioFS ignores host-side chown |

To handle both platforms transparently, Oduflow uses **`chown_recursive()`**
(`docker_ops/client.py`):

1. **Try host-side `os.chown`** — fast, works on Linux.
2. **On `PermissionError`** — fall back to `chown -R` inside a throwaway
   container with the target path bind-mounted. The chown happens inside the
   VM where it takes effect normally.

This means no manual ownership fixups are ever needed on either platform.

## Docker Resources

| Resource | Name | Description |
|---|---|---|
| **Network** | `oduflow-{team_id}-net` | Per-team isolated bridge network (only shared PostgreSQL and the Traefik bridge cross teams) |
| **DB container** | `oduflow-db` | PostgreSQL 15, shared across all environments |
| **DB volume** | `oduflow-db-data` | Persistent database storage |
| **Template DB** | `oduflow_template_{team_id}_{name}` | Created from the dump file, used as PostgreSQL template |
| **Environment DB** | `oduflow_{team_id}_{branch}` | Created from template DB via `CREATE DATABASE ... TEMPLATE` |
| **Odoo containers** | `oduflow-{team_id}-{branch}-odoo` | One per environment |
| **Service containers** | `oduflow-{team_id}-svc-{name}` | One per auxiliary service; also its internal DNS hostname on the team network |
| **Traefik** (optional) | `oduflow-traefik` | Reverse proxy with auto-HTTPS |
| **Traefik volume** (optional) | `oduflow-traefik-acme` | Let's Encrypt certificate storage |

All containers are labeled with `oduflow.managed=true` and `oduflow.team={team_id}` for discovery and management.

## Concurrency & Locking

Oduflow uses a granular `LockManager` (`locking.py`) with per-branch and per-team locks:

| Lock Level | Scope | Example Operations |
|---|---|---|
| **Per-branch** | One operation per branch at a time | `create_environment`, `delete_environment`, `install_odoo_modules`, `pull_and_apply`, `export_module_translations` |
| **Per-team** | One team-level operation at a time | `add_extra_repo`, `setup_repo_auth`, `create_service` |
| **System/cluster** | Cross-environment infrastructure operation | startup initialization, `destroy`, production cluster PITR |

Operations on **different branches** run in parallel. If a lock cannot be acquired, the tool immediately returns `BusyError` (no queuing).

## Error Handling

Oduflow uses a typed error hierarchy for clear error reporting:

| Error | Description |
|---|---|
| `FlowError` | Base error for all operations |
| `BusyError` | Another operation is in progress (lock not available) |
| `NotFoundError` | Environment, service, or resource not found |
| `ConflictError` | Resource already exists (e.g. environment already running) |
| `PrerequisiteNotMetError` | System not initialized, Docker not running, or dependency missing |
| `ExternalCommandError` | Git, psql, or Docker command failed (includes command, exit code, output) |
| `ProtectedError` | Environment or extra repo is protected and cannot be deleted |

MCP clients receive errors as `ToolError` with a descriptive message. REST API clients receive JSON with `{"ok": false, "error": "..."}`.

## PostgreSQL Tuning

`resource_plan.py` computes one deterministic host-wide budget from CPU/RAM
detected from Docker (then host stats, with a conservative fallback) plus
`[production].enabled`. The dev PostgreSQL, production PostgreSQL, and
production Odoo renderers consume that plan rather than independently claiming
the host. The dev profile remains deliberately lean for many single-user Odoo
containers:

- In dev-only mode, `shared_buffers` is about 10% of RAM, floored at 128 MB and
  capped at 1 GB; production mode coordinates 5% dev + 20% production targets.
- `work_mem` is derived from the 100-connection ceiling and clamped to 4–16 MB.
- Parallel workers and autovacuum workers scale conservatively with CPU count.
- Planner costs assume SSD storage; statements slower than one second are logged.

Production keeps its separate 200-connection profile, parallelism, and WAL-G
archiving hooks while taking its memory/CPU inputs from the same plan. The plan
also assigns a 45% RAM budget to production Odoo worker sizing. See
[Production Hosting](production.md).

Generated configs carry a planner-version fingerprint. Startup reports stale
managed configs but preserves the `# KEEP` contract; `retune-postgres` is the
explicit preview/apply boundary because several PostgreSQL settings require a
restart and operator-authored configs must never be silently replaced. Applying
the plan also stages regenerated worker settings in existing production Odoo
containers, while leaving every restart under operator control.

---

# Troubleshooting

Recovery playbooks for the operational issues most likely to hit a self-hosted
Oduflow deployment, organized by symptom. Commands assume the default data
directory `/srv/oduflow` and team `1`; adjust the paths for your setup.

Oduflow runs as **root** (it needs the Docker socket, `iptables`, host-side
`chown` and XFS project quotas). All commands below are run on the host.

---

## The server won't start / keeps restarting

`systemctl status oduflow` shows the service failing and restarting in a loop,
and `journalctl -u oduflow` ends in a traceback.

The most common cause is the **shared PostgreSQL container not being ready** when
Oduflow initializes. On startup Oduflow waits for `oduflow-db` with `pg_isready`;
if that container is not running — still starting, or crash-looping — the wait
now retries and, on timeout, fails with a clear message pointing at its logs
(older versions crashed with a raw `docker.errors.APIError: 409`).

```bash
# Is the DB container actually up?
docker inspect oduflow-db --format '{{.State.Status}} restarting={{.State.Restarting}} restarts={{.RestartCount}} exit={{.State.ExitCode}}'

# Why is PostgreSQL dying? (the decisive check)
docker logs --tail 200 oduflow-db

# Very common underlying cause — the disk is full:
df -h /srv/oduflow
```

The Oduflow version is irrelevant here — the blocker is Docker/container state,
so upgrading or downgrading Oduflow will not help until `oduflow-db` stays `Up`.
See [Disk full](#disk-full) below. Once the DB container is healthy:

```bash
systemctl restart oduflow
journalctl -u oduflow -f
```

---

## The server is "active" but nothing responds after a system upgrade

`systemctl status oduflow` says `active (running)`, yet the dashboard, `/mcp`
and `/healthz` all time out, and `journalctl -u oduflow` stops a few lines into
startup — typically right after `Initializing system` — with no error.

The cause is a Docker call that never returns. Startup (migrations,
`init_system`, quotas) runs **before** the HTTP listener binds, and docker-py
disables the socket timeout while reading exec output, so a daemon that is
restarting underneath Oduflow can block a readiness probe indefinitely. The
classic trigger: `unattended-upgrades` upgrades a library, and `needrestart`
restarts `oduflow.service` in the same batch as `containerd` and `docker`.

Current versions defend on three fronts, all applied by re-running
`oduflow systemd-install`:

- A **startup watchdog** aborts the process when startup emits no log line for
  15 minutes, dumping every thread's stack to the journal first, so systemd
  restarts the service instead of leaving it wedged.
- The **unit** is ordered after `containerd.service`, uses `Restart=always`, and
  has no start-rate limit.
- A **needrestart override** (`/etc/needrestart/conf.d/oduflow.conf`) keeps
  Oduflow out of automatic restart batches.

Immediate recovery is a plain restart — it completes in seconds once the daemon
is settled:

```bash
systemctl restart oduflow
journalctl -u oduflow -f

# Confirm the defenses are in place on this host:
systemctl cat oduflow | grep -E 'After=|Restart='
cat /etc/needrestart/conf.d/oduflow.conf
```

If the journal contains a `Startup made no progress for …s` line followed by
thread stacks, that is the watchdog reporting where the start hung — include it
in any bug report.

If a start is legitimately slower than the window (a very slow link pulling
images, say) and the watchdog keeps cutting it short, widen it — or set `0` to
switch it off — via the environment, e.g. in a
`systemctl edit oduflow` drop-in:

```ini
[Service]
Environment=ODUFLOW_STARTUP_STALL_SECONDS=3600
```

---

## Disk full

A full disk cascades: PostgreSQL cannot write and crash-loops, new
environments fail to provision, and Odoo reports "did not become ready".

```bash
df -h /srv/oduflow          # bytes
df -i /srv/oduflow          # inodes — can be exhausted even when bytes are free
```

Find what is using space. Note that **each environment always copies its
database** (a PostgreSQL `CREATE DATABASE ... TEMPLATE` is a full copy — a few GB
per env is normal and unavoidable), while the much larger **filestore is shared
via an overlay** and should cost only a small delta per env (see
[Overlay filestore](#overlay-filestore)):

```bash
# Per-environment on-disk cost (upper layer + repo + sessions; the shared
# template filestore is NOT counted here):
du -sh /srv/oduflow/team_1/workspaces/*/filestore_upper
du -sh /srv/oduflow/team_1/workspaces/*/repo

# Templates (the shared lower layers + dumps):
du -sh /srv/oduflow/team_1/templates/*
```

To reclaim space, delete unused environments or templates through Oduflow
(`delete_environment` / `delete_template`, the dashboard, or `oduflow call`) so
databases, overlays and workspaces are torn down cleanly. **Do not** `rm -rf` a
template directory by hand while environments still use it — see
[Deleting a template fails](#deleting-a-template-fails).

---

## An environment won't start / Odoo "did not become ready"

Work down this checklist:

```bash
# 1. Is the shared DB up and accepting connections?
docker exec oduflow-db pg_isready -U odoo

# 2. Is the Odoo container running, and what does it say?
docker ps -a --filter name=<env-slug>
docker logs --tail 200 oduflow-1-<env-slug>-odoo

# 3. Is the filestore mounted and readable inside the container?
docker exec oduflow-1-<env-slug>-odoo \
  sh -c 'ls /var/lib/odoo/.local/share/Odoo/filestore/*/ 2>&1 | head'
```

If step 3 reports `Transport endpoint is not connected` or an empty filestore,
the overlay mount is broken — see [Overlay filestore](#overlay-filestore).
Otherwise the failure is usually inside Odoo (a module install/upgrade error);
read the container logs.

---

## An environment runs out of database connections

Two different limits produce two different errors — read the message before
changing anything.

**`psycopg2.pool.PoolError: The Connection Pool Is Full`** — the *environment*
hit its own `db_maxconn` (default `8`). Raise it for that container and
restart it:

```bash
# 1. Read the current config
oduflow call read_file_in_odoo '{"env_name": "feature-login", "path": "/etc/odoo/odoo.conf"}'

# 2. Write it back with a higher db_maxconn (the write replaces the whole file)
oduflow call write_file_in_odoo '{"env_name": "feature-login", "path": "/etc/odoo/odoo.conf", "content": "[options]\n...\ndb_maxconn = 16\n", "user": "odoo"}'

# 3. Odoo reads the config only at startup
oduflow call restart_environment feature-login
```

The edit lives in the container's writable layer: it survives restarts and
`pull_and_apply`, but is lost when the container is recreated
(`update_environment`) or when the repository's `.oduflow/odoo.conf` changes
and is reapplied.

**`FATAL: sorry, too many clients already`** — the *shared PostgreSQL* hit
`max_connections`. Raising `db_maxconn` makes this worse. Stop idle
environments, or raise `max_connections` in the cluster config and restart it:

```bash
docker exec oduflow-db psql -U odoo -c \
  "SELECT count(*), datname FROM pg_stat_activity GROUP BY datname ORDER BY 1 DESC;"
$EDITOR /etc/oduflow/postgresql.conf     # or ~/.oduflow/conf/postgresql.conf
docker restart oduflow-db
```

To change the default for **all** of a team's environments instead of one
container, edit the team's `odoo.conf` in its data directory
(`<data_dir>/team_<id>/odoo.conf`, seeded from the bundled template at init).
Existing containers keep their current config until they are recreated
(`update_environment`) or their config is reapplied. A single repository can
override everything for its own environments with `.oduflow/odoo.conf`.

Budget the two limits together: `max_connections` must cover `db_maxconn` ×
the number of environments you expect to run at once, plus headroom for
productions and maintenance connections.

---

## Agent Chat: Claude returns `401 Invalid bearer token`

The ACP session may open successfully and fail only on the first prompt:

```text
Failed to authenticate. API Error: 401 ... Invalid bearer token
```

This is a Claude provider credential failure, not an Oduflow MCP-token failure.
Claude authentication is selected in this order:

1. `CLAUDE_CODE_OAUTH_TOKEN` (subscription setup token)
2. `ANTHROPIC_API_KEY` (Console API billing)
3. the interactive `/login` saved on the team's persistent agent home volume

A configured setup token or API key overrides the interactive login. Oduflow
does not automatically fall back after an authentication error because doing so
could silently switch the account or billing method.

To keep subscription authentication, generate a fresh token on a trusted
machine while signed in to the intended Claude account:

```bash
claude setup-token
```

Replace `CLAUDE_CODE_OAUTH_TOKEN` under `[team.<id>.agent_env]` in
`oduflow.toml`. In a single-team deployment, the Oduflow systemd service may
instead supply this variable through its server environment; update the source
that is actually in use. Do not print the token with `docker inspect`, `env`, or
diagnostic shell commands.

Restart Oduflow so the changed config hash recreates the agent container. Its
home and workspace volumes are persistent, so conversations, login state, and
checkouts survive:

```bash
systemctl restart oduflow
journalctl -u oduflow --since "5 minutes ago" \
  | grep -E 'Agent config changed|Claude auth:'
```

The log should report subscription auth. Send a real Agent Chat prompt to
verify the new token; local auth-status output alone does not prove that
Anthropic accepts it.

To use interactive authentication instead, remove both
`CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY` from the team config and, for
single-team deployments, from the server environment. Restart Oduflow, open
Agent CLI, run `/login`, complete sign-in, and reopen Agent Chat.

The separate `$/ping` "Method not found" line is harmless adapter noise and is
not the cause of the `401`.

---

## Overlay filestore

Large template filestores are shared with `fuse-overlayfs` instead of copied.
Per environment, under `/srv/oduflow/team_1/workspaces/<env-slug>/`:

| Path | Role |
|------|------|
| `filestore` | the **merged** mountpoint (bind-mounted into the container) |
| `filestore_upper` | this env's **own** writes (the only real disk it adds) |
| `filestore_work` | fuse-overlayfs work dir (kept tiny) |

The **lower** (read-only base) layer is the template's filestore at
`/srv/oduflow/team_1/templates/<template>/filestore`, shared by every
environment created from that template.

Inspect the mounts and sizes:

```bash
# Active overlay mounts and their lower layers:
grep fuse-overlayfs /proc/mounts

# The upper layer should be small; the merged view shows the full tree
# (lower + upper) and will look ~template-sized — that is expected:
du -sh /srv/oduflow/team_1/workspaces/<env-slug>/filestore_upper   # small = healthy
du -sh /srv/oduflow/team_1/workspaces/<env-slug>/filestore         # ~= template size
```

### A broken mount (`Transport endpoint is not connected`)

The `fuse-overlayfs` process for a mount died (e.g. it was killed when the disk
filled). Detach the stale mount, then bring the environment back up through
Oduflow (which remounts it):

```bash
umount /srv/oduflow/team_1/workspaces/<env-slug>/filestore \
  || umount -l /srv/oduflow/team_1/workspaces/<env-slug>/filestore
```

### fuse-overlayfs prerequisites

On Linux, Oduflow auto-installs `fuse-overlayfs` on first launch when it starts
as root on a Debian/Ubuntu host. If it is still missing (non-root, non-Debian, or
no network), install it by hand. On macOS the binary is never needed — overlays
fall back to a plain copy automatically.

```bash
which fuse-overlayfs          # install: sudo apt install fuse-overlayfs
ls -l /dev/fuse               # must exist (present by default on Ubuntu)
```

`fuse-overlayfs` is mounted with `allow_other` so the Odoo container's (non-root)
user can read it. Running Oduflow **as root** (the supported setup) needs no
further configuration. Only when running Oduflow as a **non-root user** must you
uncomment `user_allow_other` in `/etc/fuse.conf`.

!!! note "AppArmor `fusermount3` on Ubuntu 24.04+ (historical)"
    Older Oduflow unmounted overlays via the setuid `fusermount` helper, which
    the `fusermount3` AppArmor profile on Ubuntu 24.04+ **denies**, forcing a
    lazy fallback. Oduflow now unmounts with a direct root `umount` (not mediated
    by that profile), so this no longer affects root deployments. If you run a
    non-root/rootless setup and hit `apparmor="DENIED" ... fusermount3`, allow it
    with a local override — add `umount /srv/oduflow/**,` to
    `/etc/apparmor.d/local/fusermount3` and run `apparmor_parser -r
    /etc/apparmor.d/fusermount3`.

---

## Deleting a template fails

```
Cannot delete template 'X': used by environments: a, b. Delete those environments first.
```

This is intentional. A template's filestore is the overlay **lower layer** for
every environment built from it; deleting the template would pull the base out
from under those live overlays and break them. Delete the listed environments
first (or keep the template). The same guard applies to renaming a template.

---

## A brand-new environment fails its first upgrade

An environment is a *new* database cloned from a template plus *your* branch's
code. The template database is a snapshot of some branch at some commit, so the
two can drift in either direction — and the failure only surfaces on the first
`-u`.

```bash
# What the template was snapshotted from:
oduflow call list_templates
# → - prod: DB=loaded, ..., Source=prod @ c0ffee12 @ snapshot 2026-08-01
```

`create_environment` compares that commit with the branch checkout and reports
the drift in its response:

* **"Code is behind the template database"** — your branch does not contain the
  snapshot commit. The database already holds views and records written by newer
  code, so upgrading the older branch fails validation (typically a `ParseError`
  on a view referencing a method your branch does not have). **Merge the
  template's source branch** into yours, push, then `pull_and_apply`.
* **"Code is ahead of the template database"** — the database predates your
  code. Apply the drift explicitly with the arguments reported by Oduflow, for
  example `pull_and_apply(install="new_module", upgrade="a,b,c")`, listing
  dependencies before dependents. Brand-new modules need `install=`; existing
  modules with schema/data drift need `upgrade=`. Modules whose manifest version
  was not bumped are never upgraded automatically, so a `column ... does not
  exist` or a missing external ID means "find the module that owns it and add it
  to `upgrade=`".

Templates created before Oduflow recorded provenance — and templates imported
from a running Odoo — have no commit to compare against, so no drift is
reported. That is not an error; the rule above still applies, you just have to
apply it by hand.

!!! warning "Recreating the environment does not fix it"
    The template is unchanged, so the same drift comes straight back — and the
    environment's data is gone. Reconcile with a merge or an explicit
    install/upgrade action.

---

## `BusyError`: "Another operation ... is in progress"

The message names the holder and its age:

```
Another operation on environment 'main' (pull_and_apply, running for 4m12s) is in progress.
```

Locks are held for exactly as long as the operation runs and are released when
it finishes. A long-running install, upgrade or test run legitimately holds one
for minutes — including when the *client* gave up waiting and timed out, because
the work continues server-side. Wait for it to finish and retry; restarting or
recreating the environment interrupts real work instead of clearing anything.

Background operations take the same locks and are named too: `auto-stop`,
`auto-delete`, `scheduled backup`, `webhook deploy`.

---

## The Odoo web client loads blank

The page is served, the JS bundles arrive, the browser console is clean — and
nothing renders. This is an Odoo-side asset/registry problem (commonly a custom
systray or a legacy widget that never resolves during `startWebClient`), not an
environment provisioning problem: restarting the container or rebuilding assets
does not help, and headless browser tooling tends to hang on such a page.

Verify server-side instead — it works normally while the web client does not:

```bash
oduflow call run_odoo_shell '{"env_name": "my-branch", "python_code":
  "print(env[\"res.partner\"].fields_get([\"name\"]).keys())", "auto_commit": false}'
oduflow call run_odoo_tests '{"env_name": "my-branch", "modules": "my_module"}'
```

Then narrow the failing asset with `get_environment_logs` and by disabling the
suspect module's assets.

---

## Reporting a bug or sending feedback

If none of the above helps — or Oduflow itself is at fault — file an issue on
[github.com/oduist/oduflow](https://github.com/oduist/oduflow/issues).

Three ways to get there, all producing a prefilled issue form:

- **Dashboard** — the **Feedback** action in the header. Pick the kind (bug,
  feature request, feedback), describe it, and press *Open on GitHub*.
- **Coding agent** — ask your agent to report it; the `report_issue` MCP tool
  returns the same prefilled link for you to open.
- **CLI** — `oduflow call report_issue '{"kind": "bug", "details": "..."}'`.

In every case Oduflow only *builds the link*: it holds no GitHub credentials
and never files anything on your behalf. You submit it from your own GitHub
account and can edit it first — which matters, because the report reaches
maintainers under your name and stays public.

Attached automatically: Oduflow version, Python version, platform, transport
and routing mode. Never attached: hostnames, team names, repository URLs,
branch or database names. Add logs yourself where they help, and check them for
secrets before submitting.

---

# Licensing

Oduflow is source-available under the [Business Source License 1.1](https://github.com/oduist/oduflow/blob/main/LICENSE) (BUSL-1.1).

- **Free forever for non-commercial use**: evaluation, education, academic research, personal and hobby projects, non-profits.
- **Commercial use requires a paid license** in one of three tiers (below).
- Standard BUSL mechanics: each release converts to the open-source **MPL 2.0** four years after publication.

## License Types

| Type | Label | Who needs it |
|---|---|---|
| `unlicensed` | UNLICENSED — NON-COMMERCIAL USE ONLY | Default when no license key is installed; fine for evaluation, education, and other non-commercial use |
| `individual` | Licensed to individual | One natural person (freelancer, sole developer) using Oduflow commercially on their own account |
| `business` | Licensed to company (internal use only) | A company using Oduflow internally, for its own Odoo systems |
| `integrator` | Licensed to Odoo integrator | A person or company using Oduflow to deliver Odoo services (implementation, development, support, hosting) to clients |

### Business vs. Integrator

The test is whose Odoo systems you point Oduflow at. If the environments you develop, test, and operate serve your own organization, a Business license covers you. If they belong to, or are used by, your clients — you are an integrator and need an Integrator license, regardless of company size.

## Installing a License

**Via CLI:**

Copy the license file to `<config-dir>/license.key`. The config directory is usually `/etc/oduflow`; when that path is not writable, Oduflow uses `~/.oduflow/conf`. Oduflow reads the license automatically on startup.

**Via Web Dashboard:**

Navigate to the dashboard and use the license activation form. The license key text can be pasted directly.

**Via REST API:**

```bash
curl -X POST http://localhost:8000/api/license/activate \
  -H "Content-Type: application/json" \
  -d '{"key": "<license-key-text>"}'
```

## Checking License Status

```bash
# Via REST API
curl http://localhost:8000/api/license

# Via Web Dashboard — license info is displayed in the dashboard header
```

License keys are RSA-signed and verified against a built-in public key. Invalid or tampered keys are rejected.

---

For business use or integrator licenses, visit [oduflow.dev](https://oduflow.dev).
