# 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]
bind = "0.0.0.0"                     # HTTP listener; legacy "host" is accepted
port = 8000                           # HTTP port

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

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

[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.4.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 file is created with mode `0600` and the generated secrets are never printed to the log — read them from the config file itself.

### 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 the stdin
confirmation and resolves those cases in favour of the new bundle: the live
file is backed up under `.bundled_upgrade/backups/` and overwritten, so the
command needs no manual follow-up. A first-line `# KEEP` opts a file out
entirely, even under `--force`. 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). It is enabled automatically whenever a team has an `auth_token` and always derives the issuer from that team's incoming hostname. Behind Cloudflare Tunnel, publish the same hostname configured under `[team.*]`; use split DNS for direct LAN access if needed. 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, `hostname` changes the public Traefik hostname, `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 (`summary_only=True` returns only the final test count plus an `output_id`; `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 (`summary_only=True` returns one action/status line and caches command logs for `read_output`)
- `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, including current branch, last activity, stopped time/source, protection, Stack ownership and operator notes used for safe slot reuse
- `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_service_database` / `list_service_databases` / `get_service_database` / `rotate_service_database_password` / `delete_service_database` — persistent PostgreSQL storage for bridge-mode auxiliary services. Each database has a scoped non-superuser role, survives service deletion, and is removed only explicitly. Creation/get/rotation return `DATABASE_URL` and `PG*` variables; treat them as secrets
- `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`, and `delete_production`. Cluster-wide WAL control
and whole-cluster PITR are operator-only (the `/admin` console or the
`oduflow wal` / `oduflow restore-cluster` CLI), not MCP tools.
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. Every environment runs as its own Linux user inside the container, so environments cannot see each other's checkouts, conversations or tokens; interactive logins are per environment. 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(summary_only=True)` (auto-detects what to do); inspect its `output_id` only when the compact status reports a failure
4. Use `install_odoo_modules` / `run_odoo_tests(summary_only=True)`; inspect the cached output on failure, and 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/oduflow/oduflow>
- Documentation: <https://docs.oduflow.dev>
- License: PolyForm-Noncommercial-1.0.0 from v1.84.0 — public noncommercial and organizational permissions, a separate internal evaluation grant, and alternative annual commercial licenses at https://oduflow.dev/eula. Earlier releases retain their original terms.
- 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/oduflow/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**

    ---

    Use `oduflow call` for local in-process execution or `oduflow client` for a
    remote authenticated server from terminals, scripts, 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
- **New branches from the dashboard** — give a base branch and a branch that doesn't exist on the remote yet is created from it and pushed (the git credential needs write access)
- **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
- **Sidecar PostgreSQL storage** — persistent team-scoped databases with dedicated roles, password rotation, quota accounting, and declarative Stack wiring
- **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** — call every MCP tool locally via `oduflow call` or remotely via
  the schema-driven `oduflow client`
- **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 config file is created with mode `0600`. The generated secrets are never
printed to the log — read them from the file itself:

```bash
sudo grep -E 'auth_token|ui_password' /etc/oduflow/oduflow.toml 2>/dev/null \
  || grep -E 'auth_token|ui_password' ~/.oduflow/conf/oduflow.toml
```

## 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 login password
```

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/session 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. It is enabled automatically whenever a team has an `auth_token` and runs on that team's own hostname in both port and [traefik mode](traefik.md), so no separate OAuth URL is normally required:

```toml
[team.1]
hostname = "oduflow.example.com"
auth_token = "..."
```

Behind Cloudflare Tunnel, publish that same hostname and forward it to port 8000; use split DNS if LAN clients should reach it directly. 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"
      }
    }
  }
}
```

### Claude Desktop (remote server via `mcp-remote`)

Claude Desktop only launches MCP servers as local processes — it cannot call a
remote HTTP endpoint with a custom `Authorization` header on its own. Use the
[`mcp-remote`](https://www.npmjs.com/package/mcp-remote) bridge: Claude Desktop
starts it over stdio, and it forwards everything to Oduflow's `/mcp` endpoint
with the Bearer token attached. Node.js (which provides `npx`) must be installed.

Edit `claude_desktop_config.json` — **Settings → Developer → Edit Config** opens
it directly:

- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`

=== "Windows"

    ```json
    {
      "mcpServers": {
        "oduflow": {
          "command": "cmd.exe",
          "args": [
            "/c",
            "npx",
            "-y",
            "mcp-remote",
            "https://your.oduflow.server/mcp",
            "--header",
            "Authorization:${AUTH_HEADER}",
            "--transport",
            "http-only"
          ],
          "env": {
            "AUTH_HEADER": "Bearer TOKEN"
          }
        }
      }
    }
    ```

=== "macOS / Linux"

    ```json
    {
      "mcpServers": {
        "oduflow": {
          "command": "npx",
          "args": [
            "-y",
            "mcp-remote",
            "https://your.oduflow.server/mcp",
            "--header",
            "Authorization:${AUTH_HEADER}",
            "--transport",
            "http-only"
          ],
          "env": {
            "AUTH_HEADER": "Bearer TOKEN"
          }
        }
      }
    }
    ```

Replace:

- `https://your.oduflow.server/mcp` — your Oduflow MCP endpoint (in
  [traefik mode](traefik.md), the team's own hostname; in port mode,
  `http://<host>:8000/mcp`).
- `TOKEN` — the team's `auth_token` from `oduflow.toml`. Keep the `Bearer `
  prefix: the header value must read `Bearer <auth_token>`.

!!! note "Why the token lives in `env`"

    `mcp-remote` substitutes `${AUTH_HEADER}` into the `--header` value at
    startup. Keeping the secret in `env` instead of inline in `args` avoids
    both the shell-quoting problems of a space inside an argument and leaking
    the token into process listings and logs.

`--transport http-only` pins the bridge to Streamable HTTP, which is what
Oduflow serves; without it `mcp-remote` first probes for an SSE endpoint and the
connection can fail. Do **not** use the OAuth setup from
[Authentication & Security](security.md#self-hosted-oauth-for-claudeai-and-other-mcp-clients)
here — that flow is for Claude.ai custom connectors; Claude Desktop authenticates
with the static Bearer token above.

To scope the connection to a single environment, point the URL at
`https://your.oduflow.server/mcp/<env>` and use that environment's Secret Key as
the token instead — see
[Scoped single-environment access](security.md#scoped-single-environment-access-mcpenv).

After saving the file, quit Claude Desktop completely (not just close the
window) and start it again. The Oduflow tools then appear in the tools menu.

### 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/oduflow/oduflow.git
cd oduflow
uv sync          # or: python -m venv .venv && pip install -e .
```

### Upgrade

Click the version next to **Oduflow** in the dashboard header to see whether a
newer release exists. The dialog compares the installed version with the latest
published release, names it, and links to its release notes. The check runs only
on that click — Oduflow never polls GitHub on its own.

The one-command path chains everything below and restarts the service:

```bash
oduflow self-update
```

It detects how Oduflow was installed and uses that installer (`uv tool upgrade`
or `pip install --upgrade`), then reconciles bundled files and restarts the
systemd service. It exits with an error inside a Docker container — a package
upgraded inside the container would revert on the next recreate; pull the new
image and recreate the container instead (see [Docker](docker.md)). Source
checkouts, editable installs, and `uvx` runs are likewise refused with an
explanation. See [CLI reference](cli.md#system-commands) for details.

The manual steps, equivalent to what `self-update` runs:

```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 the stdin confirmation and
resolves legacy files, conflicts, and merge failures in favour of the new
bundle: the live file is copied to `.bundled_upgrade/backups/`, then
overwritten, the baseline advances, and any stale sidecar is removed. A forced
run therefore leaves no sidecar to review and exits 0. Clean merges are still
merged and local-only changes are still preserved.

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, including under `--force`.

#### Upgrading PostgreSQL

Oduflow supports PostgreSQL 15, 16 and 17; Odoo 20 needs 16 or newer, and the
`postgres:18` images use a different data layout. New installations use
`postgres:16`. An existing cluster keeps its major version until you raise
`[database].image`: a newer default never upgrades a cluster. If
`oduflow.toml` has no `image` key while a cluster holds older data, startup
asks you to set the key explicitly, to keep the cluster or to upgrade it.

Both clusters are upgraded together. Prepare the server first:

1. Delete all development environments, including stopped ones. Their
   databases are not carried over, so push their code first. Templates are
   kept and restored from their dumps on disk.
2. Back up anything you want to keep. With `[backup]` configured, the
   production cluster's WAL-G archive (`<backup.prefix>/walg/` in the bucket)
   is deleted during the upgrade, because its base backups cannot be restored
   into a newer major. Logical production snapshots stay restorable.
3. Set `[database].image` to the newer image, for example `postgres:16`, and
   restart Oduflow. `oduflow upgrade`, which `oduflow self-update` runs before
   restarting, lists anything that blocks the upgrade and exits with an error,
   so the running server stays up while you resolve it.

On startup, before changing anything, Oduflow checks that no environment
remains, that the clusters hold only databases it can restore, that the new
image provides every extension those databases use, and that there is room for
the dumps. It then stops services and production Odoo containers, and dumps
service databases and production databases (including productions deleted
with their data kept) together with all PostgreSQL roles and their passwords.
With `[backup]` configured, it then deletes the old WAL-G archive. Only then
does it remove the old clusters, create new ones and restore everything, so
service and production credentials stay valid. Productions and services start
again once their databases are restored (productions after WAL archiving is
verified), and a new base backup is taken shortly afterwards. Template
databases are restored from their dumps on disk last, and the dumps are
deleted. Productions and services are down while their databases are dumped
and restored.

A failure before an old cluster is removed, including a refused S3 delete,
leaves everything as it was, and setting the image back cancels the upgrade; if
the archive was already partly deleted, a new base backup is taken. After an
old cluster is removed, a restart resumes where it stopped, so keep the new
image configured. If a template cannot be restored, fix its dump and restart,
or delete the template with `oduflow delete-template <name> --team <id>` and
restart; productions and services keep running meanwhile.

Only the server upgrades, and only when no other Oduflow server runs on the
same data directory (an stdio MCP client starts its own): stop the others
first, or startup refuses before changing anything. `oduflow stack apply`
refuses while an upgrade is requested or under way, so restart the server
first.

## 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`,
`[team.1].ui_password` and `[admin].password`. The file is created with mode
`0600` and the generated secrets are never printed to the log — read them from
the config file:

```bash
sudo grep -E 'auth_token|ui_password' /etc/oduflow/oduflow.toml 2>/dev/null \
  || grep -E 'auth_token|ui_password' ~/.oduflow/conf/oduflow.toml
```

You do not have to edit this file by hand afterwards: in HTTP mode the
[Server settings console](admin.md) at `/admin` edits every section and every
team from the browser, validates each change with the same parser the server
boots with, keeps a history, and restarts Oduflow when a change needs it.

### Minimal configuration

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

### Full configuration reference

```toml
# ── Server ────────────────────────────────────────────
[server]
bind = "0.0.0.0"           # HTTP listener address; legacy "host" is accepted
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

# ── Server settings console ───────────────────────────
[admin]
password = ""               # /admin console password; empty = console disabled

# ── 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. {} = HTTPS without ACME (self-signed by default); false = plain HTTP on :80, no ACME (behind a Cloudflare tunnel / TLS proxy)
# public_scheme = "https"   # scheme of the URLs Oduflow hands out. Default: https (traefik) / http (port).
                            # Set "http" with tls = false when nothing terminates TLS in front.
                            # Overridable per team ([team.X] public_scheme) for mixed deployments

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

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

# ── 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)
prod_purge_hours = 0        # purge DB/files kept by a production deletion after N hours; 0 disables (opt-in; DESTRUCTIVE)

# ── 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.4.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

# Optional OpenAI-compatible gateway for OpenCode (Chat Completions); values
# of the named variables come from [team.X.agent_env].
# [agent.opencode_provider]
# id = "litellm"            # select models as "litellm/<model>"
# models = ["glm-5.3"]
# base_url_env = "OPENCODE_PROVIDER_BASE_URL"
# api_key_env = "OPENCODE_PROVIDER_API_KEY"

# ── Production hosting (optional) ─────────────────────
# [production]
# enabled = true            # opt in; requires routing.mode = "traefik"
# walg_version = ""         # empty = Oduflow's pinned WAL-G version
# workers_cap = 8           # upper bound for auto-tuned Odoo workers
# health_timeout = 180      # seconds a production may take to become healthy

# [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
# upload_threads = 16

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

[team.1]
hostname = "localhost"               # required and unique; OAuth issuer host for this team
                                     # port mode: http://{hostname}:{port}, traefik: https://{slug}.{hostname}
# base_domain = "demo.example.com"   # team DNS zone (traefik mode): envs/services live at {name}.{base_domain},
                                     # hostname defaults to oduflow.{base_domain}, productions default into the zone
environment_slots = 20               # maximum concurrent environments; 0 = unlimited
environment_hostname_mode = "branch" # "branch": feature.dev.example.com; "slots": dev1.example.com..devN.example.com
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
# [team.1.image_registry]            # enables the image build/publish MCP tools for this team
# repository_prefix = "acme"         # registry namespace all published images live under (required)
# host = "docker.io"                 # plain registry hostname, optionally with :port
# username = "acme-ci"               # optional; with token, request-scoped push credentials
# token = "<registry-token>"         # registry token/password stored in this config
# build_timeout_seconds = 1800       # per-build wall clock limit
# max_context_mb = 512               # sealed build context size cap
# max_log_mb = 16                    # persisted log cap per build
# max_concurrent_builds = 2          # active build workers allowed for this team
# keep_images = 10                   # local staging images kept per team; 0 disables pruning
```

### Server settings

| Key | Default | Description |
|---|---|---|
| `[server].bind` | `0.0.0.0` | HTTP server listener address. The legacy key `[server].host` remains accepted with a deprecation warning; if both are present they must have the same value |
| `[server].host` | *(legacy)* | Deprecated alias for `[server].bind` |
| `[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)) |

### Server settings console

| Key | Default | Description |
|---|---|---|
| `[admin].password` | *(empty; generated on fresh installs)* | Password of the deployment-wide [Server settings console](admin.md) at `/admin`, which edits this whole file from the browser. Empty = the console does not exist (404). Must differ from every team `ui_password`, `auth_token` and `production_token`. Enable it on an existing install with `oduflow admin enable` |

### 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). `{}`: HTTPS on :443 with the default certificate (self-signed unless supplied), redirect, no ACME/email requirement. `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 unless `public_scheme` says otherwise |
| `[routing].public_scheme` | *(derived)* | Scheme of every URL Oduflow hands out (dashboard links, MCP endpoints, share links, reported environment/service URLs). Derived by default: `https` in traefik mode, `http` in port mode. Set to `http` alongside `tls = false` when **nothing** terminates TLS in front — this also stops Traefik trusting inbound `X-Forwarded-*` on :80 (unless a per-team override still resolves to `https`). Overridable per team with `[team.X] public_scheme` |

### 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:16` | PostgreSQL Docker image for both the development and production clusters: PostgreSQL 15, 16 or 17. A newer major than a cluster's data upgrades it on the next start (see [Upgrading PostgreSQL](#upgrading-postgresql)); an older one is refused |

### 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 |
| `[lifecycle].prod_purge_hours` | `0` | Purge the database and workspace kept by `delete_production` N hours after the deletion (tombstoned leftovers only; a re-created production is never purged). Default `0` = **disabled** — leftovers are kept forever; `oduflow cleanup --purge-deleted-productions --force` purges them immediately |

### 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.4.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 |
| `[agent].opencode_provider` | *(absent)* | Optional `[agent.opencode_provider]` sub-table: a custom OpenAI-compatible OpenCode provider using Chat Completions (for example a LiteLLM gateway); absent = OpenCode unchanged. See [OpenAI-compatible gateway](agent.md#openai-compatible-gateway-for-opencode) |
| `[agent.opencode_provider].id` | — | Required provider id (lowercase letters, digits, `-`, `_`; not `anthropic`, `google`, `openai`, `opencode` or `openrouter`). Select its models as `<id>/<model>` |
| `[agent.opencode_provider].models` | — | Required list of model ids as the gateway names them; an `opencode_model` naming this provider must be one of them |
| `[agent.opencode_provider].name` | *(id)* | Display name shown in OpenCode |
| `[agent.opencode_provider].base_url_env` | `OPENCODE_PROVIDER_BASE_URL` | Name of the agent environment variable holding the base URL, including `/v1` |
| `[agent.opencode_provider].api_key_env` | `OPENCODE_PROVIDER_API_KEY` | Name of the agent environment variable holding the API key |

### Production settings

Production hosting is opt-in and is documented in detail in
[Production Hosting](production.md). Production dashboard REST routes and the dashboard tab
are registered only when `[production].enabled = true`. The `/production` MCP
surface remains discoverable with a production credential and reports disabled
hosting at call time.

| Key | Default | Description |
|---|---|---|
| `[production].enabled` | `false` | Enable long-lived production environments and their dedicated PostgreSQL cluster. Requires Traefik routing |
| `[production].walg_version` | *(empty)* | WAL-G release override. Empty uses the version pinned by Oduflow |
| `[production].odumcp_repo_url` | `https://github.com/oduflow/oduflow-client-addons.git` | Source of the managed OduMCP checkout when production repositories do not provide the addon, and of `sync_production_mcp(refresh=true)`, which re-clones it. Needs one branch per Odoo version (`16.0` … `20.0`); each production uses the branch of its own Odoo version |
| `[production].workers_cap` | `8` | Upper bound for automatically calculated Odoo workers; must be at least `1` |
| `[production].health_timeout` | `180` | Seconds a production may take to become healthy after a deploy, rollback, reconfigure, restore, Stack apply or recovery start/restart; raise it for workers-mode productions with many modules |
| `[production].wal` | *(defaults below)* | Nested `[production.wal]` table for cluster-wide WAL timeouts and disk protection; active whenever production hosting is enabled |
| `[production.wal].upload_timeout` | `120` | Seconds per WAL upload before termination; forced kill follows after 5 seconds |
| `[production.wal].warn_after` | `120` | Seconds without archive progress while a queue exists before warning |
| `[production.wal].stall_after` | `300` | Seconds without progress before error; must be at least `warn_after` |
| `[production.wal].stop_free_gb` | `2` | GiB available to postgres, excluding root reserve, at which production is stopped |
| `[production.wal].resume_free_gb` | `4` | Required GiB before recovery/release; must exceed `stop_free_gb` |
| `[production.wal].stop_within` | `300` | Stop early if measured disk consumption predicts reaching the reserve within this many seconds |
| `[production.wal].warn_queue_gb` | `2` | Warn when unarchived WAL reaches this size in GiB; recovery release requires a smaller queue |
| `[production.wal].stop_queue_gb` | `8` | Stop production at this queued WAL size in GiB, even with ample free disk; must exceed `warn_queue_gb` |

### 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` |
| `[backup].upload_threads` | `16` | Concurrent filestore chunk uploads per snapshot; `1` uploads sequentially. A running snapshot buffers up to `max(64 MiB, threads x 4 MiB)` of chunk data in memory, so lower it on small-RAM hosts |

### 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` | *(required)* | Unique team hostname and host-relative OAuth identity. In port mode environment URLs use `http://{hostname}:{port}`; in traefik mode they use `https://{slug}.{hostname}`. Behind Cloudflare Tunnel, publish this same hostname and use split DNS for direct LAN access when needed |
| `base_domain` | *(empty — legacy layout)* | The team's DNS zone (traefik mode only), e.g. `demo.example.com`. When set, environments and services get hostnames directly under it (`feature.demo.example.com`), `hostname` defaults to `oduflow.{base_domain}` (the dashboard), and production domains must be the zone apex or a subdomain of it (the apex is the default for the team's first production; client-owned domains go in a production's `extra_domains`). The zone is exclusive: another team's hostname or base_domain may not live inside it. Requires `*.{base_domain}` DNS pointing at this server. Existing environments move into the zone on their next update |
| `environment_slots` | `20` | Maximum concurrent development environments for the team in port or Traefik mode. Stopped environments count; deleting one frees its reservation. `0` disables the cap |
| `environment_hostname_mode` | `branch` | Traefik public hostname strategy. `branch` keeps environment-derived names such as `feature.dev.example.com`; `slots` reuses `dev1.example.com` through `devN.example.com` and requires `environment_slots > 0` |
| `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 |
| `production_token` | *(empty)* | Separate 32..512 character Bearer credential for `/production`; required for new production creation and synchronized to the Odoo administrator by OduMCP. Must differ from every dev and production token |
| `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 |
| `public_scheme` | *(empty — global value)* | Per-team override of `[routing].public_scheme` (`http` or `https`) for the URLs handed out for this team. Lets one `tls = false` deployment mix a plain-HTTP LAN team with a team fronted by a TLS-terminating upstream such as a Cloudflare tunnel — see [Traefik routing](traefik.md#mixing-http-and-https-teams-in-one-deployment). Same wire-reality rules as the global setting: `https` is rejected in port mode, `http` is rejected with `tls = true` or `tls = {}` |
| `[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 |
| `[team.X.image_registry]` | *(absent — image building disabled)* | Sub-table enabling the container image build/publish MCP tools for the team. `repository_prefix` (required) is the registry namespace agents may publish under — the authorization boundary; `host` (default `docker.io`) is a plain registry hostname; `username` + `token` (set together) provide request-scoped push credentials directly from the Oduflow config — omit both to use the host Docker daemon's own `docker login` credentials. Resource bounds are `build_timeout_seconds` (default `1800`, hard wall-clock deadline), `max_context_mb` (default `512`), `max_log_mb` (default `16`), and `max_concurrent_builds` (default `2`). `keep_images` (default `10`, `0` disables pruning) retains that many local staging builds; temporary publish tags are removed after push and older untagged image objects are deleted once unused. Protect the config file and use a least-privilege registry token restricted to the prefix |

`environment_hostname_mode = "slots"` requires Traefik and 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. The default `branch` mode works with the existing
`*.dev.example.com` DNS and wildcard-certificate layout.

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        # Environment capacity reservations and optional reusable hostnames
├── .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)
```

`pg_hba.conf` remains in the PostgreSQL data volume rather than this config
directory. On every startup Oduflow reads the active file reported by
PostgreSQL and reconciles a marked block containing password-authenticated
rules for the real Docker IPAM subnets of the shared and per-team networks.
All standard and operator-managed rules outside that block are preserved. This
also repairs an existing data volume whose original image initialization did
not add a rule for Docker clients.

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
```

To disable telemetry for a single run only (for example, a test or CI run that
should not count toward usage stats), pass the global `--no-telemetry` flag
before any subcommand:

```bash
oduflow --no-telemetry --transport http
oduflow --no-telemetry stack apply oduflow.yaml
```

## 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.

---

# Architecture

One picture of how Oduflow is put together: a single server process that
orchestrates everything over the Docker socket, two physically separate
PostgreSQL clusters (dev and production), per-branch dev environments,
production containers, shared auxiliary services, and an S3 bucket that holds
both logical snapshots and the WAL-G physical backup stream.

## System overview

```mermaid
flowchart TB
    agents["AI coding agents<br/>MCP — stdio / Streamable HTTP"]
    github["GitHub<br/>push webhooks"]
    users["Users / browsers<br/>dashboard, Odoo, services"]

    subgraph server["Oduflow server — one process, Docker-out-of-Docker"]
        core["server.py — FastMCP + Starlette UI<br/>bearer / session auth<br/>per-branch / per-team / system locks"]
        system_ops["system_ops<br/>infra + templates"]
        env_ops["env_ops<br/>dev env lifecycle"]
        odoo_ops["odoo_ops<br/>modules, tests, shell, SQL"]
        production_ops["production_ops<br/>deploy / rollback"]
        service_ops["service_ops<br/>auxiliary services"]
        build_ops["build_ops<br/>custom Odoo image builds"]
        backup_ops["backup_ops + WAL-G<br/>snapshots, retention, PITR"]
        core --> system_ops & env_ops & production_ops & service_ops & build_ops
        env_ops --> odoo_ops
        production_ops --> backup_ops
    end

    traefik["Traefik (optional)<br/>auto-HTTPS via Let's Encrypt"]

    subgraph dev["Dev — per team"]
        devdb[("oduflow-db<br/>shared PostgreSQL cluster:<br/>template DBs + per-branch DBs")]
        envodoo["oduflow-{team}-{branch}-odoo<br/>one container per git branch"]
        overlay["fuse-overlayfs filestore<br/>copy-on-write over template"]
    end

    subgraph prod["Production — per team"]
        proddb[("oduflow-prod-db<br/>dedicated, auto-tuned cluster<br/>physically separate from dev")]
        prododoo["Production Odoo container(s)<br/>auto-tuned odoo.conf, cron on,<br/>rollback on failed deploy"]
    end

    svc["Auxiliary services<br/>Redis / Meilisearch / custom<br/>oduflow-{team}-svc-{name}"]

    s3[("S3-compatible bucket<br/>AWS / MinIO / R2:<br/>snapshots + WAL-G stream")]

    agents --> core
    github --> core
    users --> traefik
    traefik --> envodoo & prododoo & svc

    env_ops --> devdb & envodoo
    envodoo --- overlay
    production_ops --> prododoo
    prododoo --> proddb
    service_ops --> svc
    envodoo -.same team network.- svc
    prododoo -.same team network.- svc

    backup_ops --> s3
    proddb -->|WAL archiving| s3
    s3 -.restore / PITR.-> proddb
```

How to read it, layer by layer:

- **Clients.** AI agents speak MCP to `server.py` (stdio for a single local
  user, Streamable HTTP for remote/multi-user); humans use the same process
  through the web dashboard and REST API; GitHub push webhooks drive
  auto-deploys. Everything funnels through one FastMCP + Starlette process
  guarded by granular locks — operations on different branches run in
  parallel, same-branch operations are serialised.
- **Ops modules.** The server never shells out to `docker`; each `*_ops`
  module drives the Docker SDK directly (Docker-out-of-Docker when Oduflow
  itself runs in a container). `build_ops` produces custom Odoo images used
  by templates and environments.
- **Dev.** One shared PostgreSQL cluster (`oduflow-db`) holds template
  databases plus a database per branch environment; each branch gets its own
  Odoo container, with large filestores shared copy-on-write via
  fuse-overlayfs. See [Environment Management](environments.md) and
  [Template Management](templates.md).
- **Production.** A dedicated, auto-tuned cluster (`oduflow-prod-db`) —
  physically separate from dev — backing production Odoo containers with
  deploy/rollback handled by `production_ops`. See
  [Production Hosting](production.md).
- **Auxiliary services.** Redis, Meilisearch, or any custom image, attached
  to the team network so both dev and production Odoo can reach them. See
  [Auxiliary Services](services.md).
- **Routing.** In Traefik mode, environments, productions, and services get
  automatic HTTPS hostnames; without it, Oduflow publishes stable per-branch
  ports. See [Traefik Routing](traefik.md).
- **Backups.** `backup_ops` and WAL-G push production data to an
  S3-compatible bucket on two independent paths — detailed in the next
  diagram.

## Backup and recovery

Production data leaves the host on two independent paths, both landing in the
same S3-compatible bucket (AWS S3, MinIO, Cloudflare R2, …):

```mermaid
flowchart LR
    proddb[("oduflow-prod-db<br/>production cluster")]
    filestore["Production filestores"]

    subgraph s3["S3-compatible bucket"]
        snaps["Snapshots<br/>pg_dump + deduplicated filestore<br/>daily / pre-deploy / on-demand"]
        wal["WAL-G<br/>continuous WAL archive +<br/>daily base backups"]
    end

    proddb -->|pg_dump| snaps
    filestore -->|chunkstore, content-defined dedup| snaps
    proddb -->|WAL archiving| wal
    snaps -.restore_production — one database.-> proddb
    wal -.cluster PITR (operator-only) — whole cluster,<br/>any point in time.-> proddb
```

- **Snapshots** are logical, per-database backups: a `pg_dump` plus the
  filestore deduplicated by the built-in chunkstore engine. They restore one
  production at a time (`restore_production`), including to a brand-new host.
- **WAL-G** continuously archives WAL and takes scheduled base backups of the
  whole production cluster. Cluster PITR — an operator action in the `/admin`
  console or `oduflow restore-cluster`, deliberately not an MCP tool — rewinds
  the entire cluster to an arbitrary point in time: the disaster-recovery path.
- Retention, scheduling, and pruning run in the server's background scheduler.
  Details in [Production Hosting](production.md#backups).

For the module-level view of the same system — file-by-file layout, locking,
error hierarchy — see [Internals](internals.md).

---

# 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`) for AI agents; pass `without_filestore=true` for a database-only import.

**From an S3 prefix (best for very large databases and filestores):**

Building a ZIP backup of a huge production is slow and needs double disk space on the source. Instead, upload the raw artifacts as-is and point the same command at the prefix — no master password needed:

```bash
# On the source host:
pg_dump -Fc mydb > dump.pgdump
aws s3 cp dump.pgdump s3://mybucket/backups/mydb/
aws s3 sync ~/.local/share/Odoo/filestore/mydb/ s3://mybucket/backups/mydb/filestore/

# On the Oduflow host:
oduflow import-template s3://mybucket/backups/mydb/ --template-name prod
```

Files are downloaded in parallel and one-to-one; an interrupted import resumes where it stopped. Re-running with `--overwrite` re-syncs an existing template incrementally: only changed filestore files are downloaded, and an unchanged dump skips the database reload entirely. Credentials come from `--s3-access-key`/`--s3-secret-key` (plus `--s3-endpoint` for MinIO and friends), or the `[backup]` settings when the bucket matches, or anonymous access for a public bucket. See [Template Management](templates.md#importing-from-an-s3-prefix) for details.

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 import-template --template-name myproject --refresh
```

**From another Oduflow workspace:**

Simply copy the entire template directory and refresh:

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

!!! warning
    The SQL dump is loaded into the shared PostgreSQL instance by the `--refresh` step. Without it, 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 import-template --template-name default --refresh
```

## 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.

## Create a Template from Production

A [production](production.md) can be published as a dev template — the reverse of seeding a production from a template. The production database is dumped out of the production cluster and restored into the dev cluster as the template database, and the production filestore becomes the template's baseline:

```bash
oduflow call save_production_as_template '{"prod_name":"erp","template_name":"erp-2026-09"}'
```

The production **keeps serving throughout**: the dump is a consistent `pg_dump` snapshot, nothing is stopped or changed on the production side (the same trade-off as `snapshot_production`).

The template records the production's `repo_url`, `odoo_image`, `git_user` and extra addons, plus [provenance](#template-metadata) — the production's branch, the commit its checkout is on, and the snapshot time — so environments created from it report code/database drift like any other template.

!!! danger "The template holds unsanitized production data"
    Real customer records, real email addresses, real API credentials. Sanitization happens **later**, when an environment is created from the template: `create_environment` runs Odoo's neutralization plus the repository's [sanitize scripts](environments.md#database-sanitization) by default. Treat the template itself — and its dump on disk — as production-confidential.

Like `template-from-env`, this refuses to overwrite an existing template. Pass `overwrite=true` to deliberately re-baseline one from the current production data:

```bash
oduflow call save_production_as_template '{"prod_name":"erp","template_name":"erp-2026-09","overwrite":true}'
```

Environments on that template keep their filestore changes (the overlay `upper` layer) unless you pass `reset_env_changes=true`, which is destructive.

!!! info "MCP copies can be switched off per production"
    A production whose administrator disabled copies to dev refuses this tool (and the first `create_environment(from_production=...)`, which would publish). A template that was already published stays usable — sanitized only — and the metadata records its `source_production`. The flag gates MCP/CLI agents only; the dashboard is never gated. See [Copying production data to dev](production.md#copying-production-data-to-dev).

## 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.

## Importing from an S3 Prefix

For very large databases and filestores, skip archives entirely: upload the raw
dump and a one-to-one filestore copy to any S3-compatible bucket and import
straight from it. No Odoo master password, no ZIP packing on the source, no
double disk space.

Expected layout under the prefix — a dump file (`dump.pgdump`, `dump.sql`,
`dump.sql.gz`, `dump.pgdump.gz`; the hand-made `db.dump` / `db.dump.gz` names
are accepted too and installed under the canonical name) plus an optional
`filestore/` tree. If several dump candidates are present, the same
precedence as the template directory applies: canonical names win over
`db.dump`:

```
s3://mybucket/backups/mydb/
├── dump.pgdump
└── filestore/
    ├── 60/609e7ca59cc05bf0de7233c6781a381b742a2931
    └── ...
```

```bash
# On the source host
pg_dump -Fc mydb > dump.pgdump
aws s3 cp dump.pgdump s3://mybucket/backups/mydb/
aws s3 sync ~/.local/share/Odoo/filestore/mydb/ s3://mybucket/backups/mydb/filestore/

# On the Oduflow host (CLI, MCP tool import_template, or the dashboard)
oduflow import-template s3://mybucket/backups/mydb/ --template-name prod
```

### In the dashboard

**Templates → Import from S3** fills the same request in a form. Enter the
connection (endpoint — empty for AWS — region, access key ID, secret key),
type a bucket or leave it empty, and **Browse**: the dialog walks the bucket
one folder at a time, newest files first, and marks the folder that holds a
recognized dump. **Use this folder** fills in the source and suggests a
template name. You can also type the prefix directly.

A template imported this way is tagged **S3** in the list and gains a **Sync**
button — the same incremental re-sync as `--overwrite`, with the connection
and the stored secret already filled in.

How it behaves, in the order it matters at scale:

- **Parallel + resumable.** Filestore files download on several connections;
  an interrupted import re-run skips everything already staged. Nothing
  touches the live template until the staged copy is complete — the final
  swap happens under the standard overlay remount, so live environments keep
  their filestore changes.
- **Incremental re-sync with `--overwrite`.** Re-running against an existing
  template downloads only new/changed filestore files (unchanged ones are
  hardlinked from the current template), removes files deleted on S3, and —
  if the dump's S3 ETag is unchanged since the last sync — skips the dump
  download *and* the template DB reload. This makes a periodic
  "pull the latest production backup into dev" job cheap.
- **Credentials, remembered.** `--s3-access-key`/`--s3-secret-key` (with
  `--s3-endpoint` for MinIO or other S3-compatible stores, and `--s3-region`
  when required) win; otherwise the secret remembered for this template is
  reused; otherwise the `[backup]` settings when the bucket matches;
  otherwise the bucket is read anonymously (public bucket).

    **The secret key is never written into template metadata.** A raw value is
    stored as the team secret `s3-template-<template>` (visible under
    **Credentials → Secrets** in the dashboard, where it can be rotated) and
    the metadata records only a `secret:<name>` reference, so no template
    listing, metadata editor or MCP tool can echo it back. Pass
    `secret:<name>` yourself to sign with an existing team secret instead —
    Oduflow then leaves that secret alone.

    So a re-sync of a private bucket needs **no key at all**: omit
    `--s3-secret-key` and the stored one signs the request. It is required
    again when the endpoint or the access key changes, so a stored secret is
    never sent to a different endpoint or key pair. To keep the key out of
    `~/.bash_history`, `--s3-secret-key` defaults to `$ODUFLOW_S3_SECRET_KEY`.

    A secret Oduflow created is deleted together with the template, and
    released when the template is re-published from an environment or a
    production (its data no longer comes from the bucket).
- **Metadata.** The Odoo version, image, and installed modules are read from
  the restored database; `snapshot_at` records the dump's upload time, so
  template freshness reflects the data, not the import.

`--without-filestore` skips the `filestore/` tree for a database-only import.

### An Odoo backup `.zip` already in the bucket

If what you have is a backup `.zip` — written by Odoo's own database manager,
or downloaded from Odoo.sh — point the import straight at that object instead
of a folder:

```bash
oduflow import-template s3://mybucket/backups/acme-2026-10-13.zip --template-name prod
```

A key ending in `.zip` is unpacked as an Odoo backup (`dump.sql` plus
`filestore/`); anything else is read as a prefix holding the raw layout. A
re-sync with `--overwrite` still costs nothing while the object's ETag is
unchanged.

Prefer the raw layout for a large database when you can choose. A zip has to
be fetched whole every time it changes, its filestore replaces the template's
wholesale instead of syncing file by file, and an interrupted download starts
over — all of which the raw layout avoids. The `.zip` shape exists for the
archive you already have, not as the recommended way to make one.

## Importing from a Local Path

The same engine and layout work for a directory on the Oduflow host — useful
when a backup job or rsync already delivers the artifacts locally:

```bash
# Directory with dump.* + filestore/ — files are hardlinked into the
# template (near-instant on the same filesystem)
oduflow import-template /backups/mydb/ --template-name prod

# Periodic re-sync of the same template (only changed files, unchanged dump
# skips the DB reload)
oduflow import-template /backups/mydb/ --template-name prod --overwrite

# A single dump file imports database-only (format sniffed automatically)
oduflow import-template /backups/nightly.dump --template-name prod --overwrite
```

For a local source the dump-unchanged check uses size + mtime instead of an
S3 ETag; everything else (staging, atomic promote, overlay remount, metadata
refresh) behaves exactly like the S3 import.

## Refreshing from the Template's Own Files

If an external process writes `dump.*` and `filestore/` **straight into the
template directory** (an rsync drop point, scp from a backup host), apply
them with `--refresh` — no source argument:

```bash
rsync -a --delete backup-host:/srv/backups/mydb/ {data_dir}/team_{ID}/templates/prod/
oduflow import-template --template-name prod --refresh
```

This reloads the template DB from the dump found in the directory, re-reads
the Odoo version and module list from the restored database, fixes filestore
ownership, recomputes sizes/overlay mode in `metadata.json`, and remounts
live overlay environments against the updated filestore (keeping their
changes). Environment databases are **not** reloaded. Copy-mode environments
are unchanged. Retained generations created by scheduled sync are unchanged.
The dashboard exposes this operation as **Templates → More → Refresh**.
`refresh-template` is a separate command that only remounts the filestore.
For an already-used template, prefer staging via a local source and
`--overwrite`: direct writes happen before Oduflow can stop live overlays.

## Scheduled SSH sync (system cron)

Open **Templates → More → Source / schedule / log**. Set the SSH host, user,
port, absolute backup directory and five-field cron schedule; enable the
schedule and save. Times use the **host cron timezone**. **Sync now** queues a
request for cron's next minute tick using the saved settings. It works even
with the schedule disabled. The web server never starts the sync worker.

Prerequisites:

- A running system cron daemon and write access as root to `/etc/cron.d`.
  Oduflow manages only its own `oduflow-template-*` files; existing jobs remain
  untouched. Remove the configuration in the dialog before renaming or
  deleting the template; removing it also deletes the downloaded backup copy
  (the inbox) but keeps status and logs, and deleting the template then
  removes those too. Disabling its schedule keeps the manual-request job.
- `rsync` and OpenSSH on the Oduflow host, and rsync on the source host.
- Add the team's public SSH key (dashboard **Credentials**) to the source
  account's `authorized_keys`. Verify the remote SSH host key independently
  and put it in `{team.data_dir}/ssh/known_hosts`. Sync uses strict host-key
  verification, the team's key,
  and noninteractive authentication. For a non-default port the known_hosts
  entry uses `[host]:port`. Key rotation requires updating the source account.

The source must be a **completed, immutable backup**, not a live PostgreSQL
storage directory:

```text
/backups/completed/
├── dump.pgdump          # or dump.sql / dump.sql.gz / dump.pgdump.gz / db.dump
├── filestore/           # matching Odoo attachments; required, may be empty
└── READY                # unique snapshot ID, e.g. 2026-10-07T030000Z
```

The client is responsible for producing a consistent dump and filestore.
Publish `READY` last, containing 1–256 ASCII letters/digits or `_ . : -`.
Do not change the published directory while it is being fetched. Publish a
new unique ID for every backup. The worker verifies the ID before and after
rsync and in the downloaded copy, then imports from its local inbox. Failed
or changing transfers never reach the live template. Symlinks are not copied.
No remote dump commands are executed by Oduflow.

The worker runs `rsync → import → optional environment refresh`, writes
`{team.data_dir}/template_sync/{template}/status.json` and `run.log`, and
rotates the log at 2 MiB with three retained files. The dialog reads the last
64 KiB and shows timestamps, queued/running/success/partial/failed/interrupted
state, and errors. **Refresh status / log** reads current files. If no worker
has ever started, check the host cron service/log for configuration or CLI
startup errors. Repeated execution of an active job is rejected; scheduled
failures retry on the next scheduled run or a manual request.

The minute tick that picks up manual requests first checks for the queued
request file (`test -e`, through `docker exec` under Docker), so an idle
template costs no Python start. A run is never dropped for being busy: a
scheduled run that finds the template in use (another sync, a dashboard save,
an MCP template operation) is queued for the next minute instead, and a run
whose environments stay busy for five minutes after the transfer is reported
as **deferred** and queued again.

Deployment settings in `oduflow.toml`:

```toml
[template_sync]
cron_dir = "/etc/cron.d"
cron_user = ""          # empty: the user running Oduflow (natively the only one allowed)
cron_container = ""     # native host install; see Docker setup below
```

Natively the worker must run as the same user as the server: everything it
writes (locks, status, pins, imported template files) has to stay readable and
replaceable by the server, so a different `cron_user` is rejected.

The generated command pins the current Python executable and `ODUFLOW_TOML`
path. Re-save schedules after moving the installation/configuration. The
worker can also be run directly with the same saved configuration:

```bash
oduflow sync-template prod --team 1             # enabled schedule
oduflow sync-template prod --team 1 --pending   # consume a queued UI request only
```

### Optional environment data refresh

Use **Environment → More → Auto-refresh data: Off** to opt in. The confirmation
covers subsequent resets: local database and filestore changes are discarded.
Code, container settings, credentials, URL and ports stay in place. Protected
environments are skipped and reported. Stopped environments stay stopped.

Before updating a shared lower filestore, cron pins existing overlay
workspaces to a retained generation. This initial pin briefly stops/remounts
those containers, including ones whose database auto-refresh is off. Subsequent
syncs leave them on their old generation. Copy-mode environments already have
an independent filestore. This keeps old databases and attachments together.

Template refreshes, filestore attaches and imports leave pinned environments
on their retained generation and name them in their result, instead of
remounting them silently. To move a pinned environment onto the current
template, enable its data auto-refresh (the next sync resets it) or recreate
it. Saving an environment as its template unpins that environment: what it
published is now the template's filestore.

An environment cloned from a template that a sync last imported remembers the
snapshot it came from, so a later sync of the same snapshot leaves it alone
even if it opted in after it was created. Any other import into the template
clears that record.

An opted-in environment receives a staged database clone and a fresh
filestore. A separate maintenance container uses its code/configuration to
install configured modules, neutralize Odoo and run team/project sanitization
before the serving container can access the new data. Preparation or
sanitization errors leave the old environment intact. The worker stops the
serving container for the switch, verifies readiness if it was running, and
rolls back both database and filestore on a switch/startup error. Odoo major
versions must match; environments created without a managed filestore mount
must first be recreated from a template that includes one.

Workspace pins survive container updates and restarts. Retained generations
use hardlinks for Odoo's content-addressed attachments; unused generations are
removed after a sync, and when the last environment pinned to one is deleted.
A sync only hardlinks a new generation when an opted-in environment actually
moves to the new snapshot. **Never edit attachment files in place** or use rsync's
`--inplace` against template/inbox/generation trees. Use staged imports. Old
generations consume space for attachments no longer present in the current
snapshot; preparation also needs space for an additional database and any
copy-mode filestore. Busy operations are excluded through shared file locks
under `{data_dir}/.locks`; never delete these files to "unlock" a job.

An OS/process crash during refresh can leave a workspace
`.template-refresh.json` recovery journal. Oduflow blocks start/restart/update/delete
and overlay recovery for that workspace until an operator reconciles the
recorded database names, old/new filestore paths and pin, and restores the
recorded restart policy. In this case it retains recovery files/generations;
do not simply delete the journal. Ordinary caught failures roll back
automatically. A new sync will report the affected environment as failed.

### Cron when Oduflow runs in Docker

Run cron **on the host**. Bind-mount its cron directory into the Oduflow
container, adding `-v /etc/cron.d:/etc/cron.d` to the existing deployment, and
set:

```toml
[template_sync]
cron_dir = "/etc/cron.d"
cron_user = ""              # host user running `docker exec`; empty means root
cron_container = "oduflow"  # actual container name
```

Generated host jobs use `/usr/bin/docker exec` to run the worker in the
existing Oduflow container, sharing its configuration, filesystem, Docker
socket and mount namespace. The container must run as root to write
root-owned cron files. The host needs cron and the Docker CLI at that path;
no cron daemon is started inside the Oduflow server/container. **Do not run a
separate worker container**: overlay mounts must be created in the same mount
namespace as the server. The native installation uses the configured Python
executable directly.

## 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"},
  "env_vars": {"WORKERS": "2", "LIMIT_TIME_CPU": "600"},
  "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.

### Environment variables

`env_vars` holds `{"NAME": "value"}` pairs injected into the Odoo container of every environment created from the template — the same mechanism as the `env_vars` argument of `create_environment`, but recorded once on the template instead of being repeated at each call. Saving a template from a live environment (`save_as_template`) carries that environment's variables over automatically.

The two sets are **merged per key**, with the values passed at creation time winning:

```bash
# Template records WORKERS=2 and LIMIT_TIME_CPU=600
oduflow call create_environment '{
  "branch": "19.0",
  "template_name": "default",
  "env_vars": "WORKERS=8"
}'
# Container gets WORKERS=8 (overridden) and LIMIT_TIME_CPU=600 (inherited)
```

Edit them in the dashboard under **Templates → Settings → Environment variables** (one `KEY=VALUE` per line), or directly in `metadata.json`. Names must be valid shell identifiers (`[A-Za-z_][A-Za-z0-9_]*`); the dashboard rejects anything else on save, and a hand-edited file with an invalid entry is ignored with a warning rather than blocking environment creation.

Variables set here are applied on top of the database connection variables (`HOST`, `USER`, `PASSWORD`) that Oduflow injects itself, so reusing those names overrides the connection settings.

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 | `oduflow import-template /path/to/dump.pgdump --template-name default` (or place it in the template dir and `--refresh`) |
| 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 import-template /path/to/new.dump --template-name default --overwrite` |
| Sync template from S3 or a local dir | `oduflow import-template s3://bucket/prod/ --template-name default --overwrite` |
| 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 Traefik hostname 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).

Creating an environment that already exists is not an error over MCP: the call
returns that environment's URL and details right away, starts it when it was
stopped, and provisions nothing. So an agent can call `create_environment`
first, with no `list_environments` lookup before it. The one refusal is a
**branch mismatch** — an existing environment tracking another branch is left
alone, because its database and URL are in use; move it deliberately with
[`switch_branch`](#reusing-an-environment-for-the-next-branch), pick another
`env_name`, or delete it. The dashboard and the REST API keep the strict
behaviour and report the conflict instead.

`environment_slots = N` caps concurrent environments without changing their
public names. The default Traefik strategy is
`environment_hostname_mode = "branch"`, so `feature-login` remains available at
`feature-login.dev.example.com` and continues to work with
`*.dev.example.com` DNS or wildcard certificates.

Set `environment_hostname_mode = "slots"` explicitly when Let's Encrypt
certificate reuse is more important than descriptive URLs. For
`hostname = "dev.example.com"`, that mode allocates `dev1.example.com` through
`devN.example.com`; assignments survive stops and updates and return to the
pool on deletion. Pass `hostname="qa"` to request `qa.example.com` in either
mode. A bare team domain has no prefix to replace, so with
`hostname = "example.com"` the same call gives `qa.example.com` (the Public
Suffix List decides what is bare, so `example.co.uk` is also kept whole).
Explicit hostnames still consume environment capacity.

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` in a short-lived container *before* step 7, with the same generated `odoo.conf`, so options that only matter when a database is created — demo data (`without_demo`, or `with_demo` on Odoo 19+) — follow the repo's `.oduflow/odoo.conf`. The default template sets `without_demo = True`

### Starting a New Branch

The branch must exist on the remote: Oduflow clones only what origin has, so
push it first (`git push -u origin <branch>`). Over MCP, `create_environment`
always works this way.

The dashboard's **Create Environment** dialog can start the branch for you.
Fill in **Base branch**: when the branch name does not exist on origin yet,
Oduflow clones the base branch, creates the new branch from its tip and pushes
it to origin before building the environment, so `pull_and_apply`,
`switch_branch` and the coding agent's checkout all find it there. The push
uses the team's git credential, which therefore needs write access (an SSH
deploy key needs *Allow write access*). When the branch already exists, the
base branch is ignored.

The dialog pre-fills the base branch with the template's source branch (the
code its database snapshot was taken from) or, when creating from a
production, with the production's branch. Leave it empty to require an
existing branch. A pushed branch stays on origin even if a later provisioning
step fails; a retry then simply clones it.

### Creating an Environment from Production

`from_production` builds a development environment out of a [production](production.md)'s real data — database, filestore, and the production's code origin (repo, image, extra addons):

```bash
oduflow call create_environment '{"branch":"bugfix-invoice","from_production":"erp"}'
```

It is mutually exclusive with `template_name` and `local_path`: the production supplies all of them.

In the dashboard, **Create environment** in a production card's menu opens the
same dialog with the production's repository, Odoo image, git credential and
extra addons filled in (all editable) and the base branch set to the
production's branch.

The copy always goes through **one managed template per production**, named `prod-<name>`. It is published on the first call and **reused** by every later one, so a second environment from the same production is an instant `CREATE DATABASE ... TEMPLATE` clone plus an overlay mount — the production is dumped once, not once per environment. The result line tells you which of the two happened, including the snapshot's age when the template was reused.

Refresh the copy when it gets stale — the next `from_production` call then reuses the fresh snapshot:

```bash
oduflow call save_production_as_template '{"prod_name":"erp","template_name":"prod-erp","overwrite":true}'
```

The environment is [sanitized](#database-sanitization) on creation like any other template-based environment (`sanitize=false` to skip it — with real production data, do so deliberately). The `prod-<name>` template itself holds **unsanitized** production data; see [Create a Template from Production](templates.md#create-a-template-from-production).

An administrator can disable production→dev copies over MCP per production; publishing a new copy then refuses, while an already published `prod-<name>` template stays usable with `sanitize=true` only. See [Copying production data to dev](production.md#copying-production-data-to-dev).

### Private repository authentication

For private repos, store an access token first:

```bash
oduflow call setup_repo_auth '{"repo_url": "https://github.com/owner/private-repo.git", "token": "ghp_..."}'
```

The token is stored in the team's git credential store, keyed by host, so one
token covers every repository on that host. `create_environment` then uses the
plain URL without credentials. The legacy inline form
`oduflow call setup_repo_auth https://user:PAT@github.com/owner/private-repo.git`
still works.

#### SSH deploy key

Alternatively, use SSH instead of a token. Oduflow keeps one SSH deploy key
per team (an ed25519 keypair generated automatically at server start; the
dashboard, API and MCP tools expose only the public key). Copy the public key from the
dashboard's **Credentials** tab (SSH deploy key section) — or fetch it with
`oduflow call get_ssh_public_key` — and register it with your git hosting as
a repository **deploy key** (read access is enough) or on a machine-user
account. After that, SSH repository URLs work everywhere a repository URL is
accepted:

```bash
oduflow call create_environment '{"branch": "feature-x", "repo_url": "git@github.com:owner/private-repo.git"}'
```

Note: GitHub allows a given deploy key on **one** repository only; to reach
several repositories with the same key, attach it to a machine-user account
instead. Regenerating the key (dashboard, *Regenerate*) invalidates the old
one everywhere it was registered.

### 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
```

Both files are also read from every mounted [extra addons](extra-addons.md)
repository, with the same lookup rules — declare an extra module's
dependencies in its own repo and they are installed alongside the main
repo's. A change to any of these files (main repo or extra repo) picked up
by `pull_and_apply` / `update_production` reinstalls the dependencies and
restarts the container.

## 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 an example script, **`01_disable_mail.sql`**. All of its statements are commented out, so it runs without changing anything. On Odoo 16+, the native neutralization already deactivates outgoing and incoming mail servers. The example shows the format:

```sql
-- Examples:
-- UPDATE ir_mail_server SET active = false WHERE active = true;
-- UPDATE fetchmail_server SET active = false WHERE active = true;
```

Each `.sql` file runs as one transaction. If any statement fails, for example on a table whose module is not installed (`fetchmail_server` is absent until `mail` is installed), the whole file is rolled back.

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

# Rename it (keeps database, filestore and a pooled or explicit hostname)
oduflow call update_environment '{"env_name": "feature-login", "new_name": "login"}'

# Change the public Traefik hostname
oduflow call update_environment '{"env_name": "feature-login", "hostname": "qa"}'

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

The `hostname` parameter of `update_environment` accepts the same short hostname
as `create_environment`: for team `dev.example.com`, `qa` routes to
`qa.example.com`. It requires Traefik mode. Empty or omitted values keep the
current hostname policy. Conflicting addresses are rejected before stopping the
container. The database and filestore are preserved. You can also edit Hostname
in the dashboard's **Update environment** dialog; leaving the field unchanged
preserves the current policy, including name-derived routing during a rename.

### Reusing an Environment for the Next Branch

An environment is not tied for life to the branch it was created from. When a
branch is finished — PR merged, worktree gone — point the same environment at the
next branch instead of deleting it and provisioning a new one.

This is the answer to a **full slot pool**, not the default way to start a task.
While the team still has free `environment_slots`, create a new environment; it
costs a clone and a template copy and keeps the branches independent. Reuse when
`create_environment` reports "No free environment slots", or when that specific
database and URL are worth carrying over:

```bash
# Same environment, next branch. The branch must already exist on origin.
oduflow call switch_branch '{"env_name": "dev1", "branch": "feature/next-task"}'
```

Everything except the code stays: the environment name, its database and
filestore, its hostname and URL, its ports, its database credentials and its
scoped MCP endpoint and token. This matters twice over — provisioning is skipped
(no fresh clone, no template database copy), and the MCP client you already
pointed at `/mcp/dev1` keeps working, because the address never changed.

`list_environments` prints the evidence needed to choose a reusable slot:
current git branch, creation and last-activity timestamps, stopped time and
source, protection, Stack ownership and the operator note. Prefer an exact
branch match. Otherwise do not switch a running, protected, Stack-managed or
noted-as-reserved environment. If repository policy permits reclaiming idle
slots, rank the remaining stopped candidates by oldest activity; `unknown` is
not proof that a legacy slot is abandoned. GitHub returning no PR for a branch
is also inconclusive — confirm completion from a merged PR/branch or an explicit
operator instruction, and create a new environment when ownership is unclear.

#### Renaming While Switching

The environment name is a slot label, not a description of what the slot
currently serves — but a name left over from a finished branch can get
confusing. Pass `new_name` to relabel the slot in the same operation:

```bash
oduflow call switch_branch '{"env_name": "dev1", "branch": "feature/next-task", "new_name": "next-task"}'
```

(To rename without moving to another branch, pass `new_name` to
`update_environment` — see [Renaming an Environment](#renaming-an-environment).)

The database, filestore, ports, credentials and the environment's URL all move
with it, so the browser tab you have open keeps working. Two things to know:

- **The scoped MCP endpoint moves too**, from `/mcp/dev1` to `/mcp/next-task`
  (the token itself is unchanged). Re-point any MCP client configured against
  the old path.
- **Environments using branch-derived hostnames** get a new URL because the
  hostname follows the environment name. Pooled and explicit hostnames remain
  stable across the rename.

The name must be free: a rename onto a name that already has an environment,
workspace directory or database is refused before anything is touched.
Environments managed by a [Stack](stacks.md) are refused as well — there the
name comes from the stack definition.

After the switch, Oduflow diffs the two branch tips and applies the same logic
as [Smart Pull](#smart-pull--intelligent-change-detection). Pass
`install` / `upgrade` / `restart` when you know what changed, or leave them empty
to let Oduflow classify the difference:

```bash
oduflow call switch_branch '{"env_name": "dev1", "branch": "feature/next-task", "upgrade": "sale_custom"}'
```

Since the database is kept, it can outlive the code that created it.
`switch_branch` deliberately does not inspect installed module state before it
moves the branch. It applies the requested action and returns any real failure
from install, upgrade or restart; incompatibilities that only surface at runtime
remain visible in the normal Odoo logs. `strict: true` still refuses an explicit
action that looks incomplete for the detected file changes.

Two limits are worth knowing:

- **The branch must be pushed.** Oduflow switches its own managed clone, which
  can only fetch from origin. A branch that exists only on your machine fails
  with a "push it first" message and changes nothing.
- **Live-mounted environments are rejected.** With `local_path` the checkout is
  yours, not Oduflow's; switch the branch there and call `pull_and_apply`.

On the dashboard the branch chip (`⎇ branch`) on each environment card is also
the control: click it to switch. Extra addon repositories can move along with
the main repo by passing `extra_addons` (`"enterprise:19.0"`).

Reuse and recreate answer different questions: switch the branch when the
database is still a reasonable starting point, and **Recreate** (below) when you
want it replaced from the template.

### Renaming an Environment

A name that no longer describes what the slot holds does not have to be lived
with, and it does not need a branch switch to fix. `update_environment` takes a
`new_name`:

```bash
oduflow call update_environment '{"env_name": "dev1", "new_name": "next-task"}'
```

The container is re-created either way — it carries the environment name in its
own name, its labels and its bind mounts — so the rename rides on that same
recreate, and it can be combined with an image or env-var change in one call. On
the dashboard the **Update environment** dialog has an *Environment name* field
that does the same thing.

What moves with the name: the database, the filestore, the workspace directory,
the allocated port, the environment's PostgreSQL credentials, its activity clock
and Agent Chat history, and the coding agent's checkout (uncommitted work
included — the checkout is moved, never re-cloned). The URL is kept for pooled
and explicit hostnames.

Two things to know — the same two as for a
[rename while switching](#renaming-while-switching):

- **The scoped MCP endpoint moves**, from `/mcp/dev1` to `/mcp/next-task` (the
  token itself is unchanged). Re-point any MCP client configured against the old
  path.
- **Environments using branch-derived hostnames** get a new URL, because the
  hostname follows the environment name. Pooled and explicit hostnames stay.

The target name must be free and Oduflow's to set: a rename onto a name that
already has an environment, workspace directory or database is refused before
anything is touched. So is a rename of a production environment (its name is
recorded in `productions.json`) or of an environment managed by a
[Stack](stacks.md) (there the name comes from the stack definition).

### Recreating Environments

The **Recreate** action (available via the Web Dashboard, the REST API and the `recreate_environment` MCP tool) 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.

Recreate also preserves the environment's **identity**, which a manual delete +
create does not: the [share link](security.md#shared-single-environment-dashboard-envname)
already handed to a client keeps opening
the scoped dashboard, and the scoped `/mcp/<env>` endpoint keeps the same
Secret Key, so configured MCP clients reconnect without changes. Agents should
therefore prefer `recreate_environment` over `delete_environment` +
`create_environment` whenever the environment is being replaced rather than
retired.

### 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)
prod_purge_hours = 0    # purge leftovers of deleted productions after N hours; 0 disables
```

`prod_purge_hours` concerns [productions](production.md#deleting-a-production),
not dev environments: it reclaims the database and files that
`delete_production` keeps on disk.

## 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

# Upgrade every installed module (odoo -u all)
oduflow call upgrade_odoo_modules feature-login all
```

## Running Tests

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

For agent loops, pass `summary_only=true` to keep the complete Odoo log out of
the MCP response:

```bash
oduflow call run_odoo_tests '{"env_name":"feature-login","modules":"sale,crm","summary_only":true}'
```

The response is one `N failed, M error(s) of K tests` line plus an `output_id`.
The full log remains server-side and can be inspected with `read_output` only
when the summary reports a failure or Odoo aborts before producing a test count.

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: 16.0 renamed it to `--gevent-port` and kept the old name as a
deprecated alias, and 18.0 removed that alias for good. Oduflow detects the environment's Odoo
version — from the image reference, falling back to `odoo --version` inside the container for
custom images — and uses the right flag automatically; when the version cannot be determined it
assumes `--gevent-port`. `--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
```

`summary_only=true` suppresses install/upgrade logs and changed-file names from
the MCP response. It returns one line containing the applied action, changed
file count and exit status; when command output exists, the line includes an
`output_id` for targeted inspection with `read_output`.

### 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`) |
| `*.md` (nothing else changed) | Documentation, never loaded by Odoo | **None** — nothing is applied |

### Action priority

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

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. If the pull brought Markdown files only, nothing is applied at all — in development and in production alike, since the container is not restarted either.

!!! 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 — plus optional **extra domains** routed to
  the same production (e.g. the client's own public domain);
- an **auto-tuned production `odoo.conf`** (workers from host CPU/RAM, cron
  enabled, proxy mode) — never the dev profile;
- a choice of **server mode**: sync multi-process workers or one async gevent
  process, with Traefik routing to the matching Odoo port;
- **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.

## Separate production MCP access

Developer credentials connect to `/mcp`; production administrators connect to
`/production` on the same Oduflow host using a **different** Bearer credential.
The production endpoint accepts the team's `production_token`, not the dev token
or dev OAuth tokens. Token validation uses local configuration, so start, restore
and other infrastructure operations do not depend on a working Odoo API.
The shared `read_output` helper remains available for long production logs and
deploy output; cached production results are bound to their team and cannot be
read with development credentials. Production tools remain listed on this
endpoint when hosting is disabled; calls
return a configuration error. They are not callable from the developer endpoint.
The dashboard and local CLI remain owner administration interfaces. On a
multi-team server, cluster-wide PITR and WAL controls require these owner
interfaces because they affect other teams; a team production token cannot
perform those global mutations. Deliberate
production-to-dev copies through `create_environment(from_production=...)` retain
the existing per-production `allow_copy_to_dev_mcp` policy.

```toml
[team.1]
auth_token = "<existing-development-token>"
production_token = "<separate-random-token-at-least-32-characters>"
```

Generate a random production credential (for example with
`python3 -c 'import secrets; print(secrets.token_urlsafe(32))'`), store it in the
private TOML configuration, restart Oduflow and configure the MCP client to send
it as a Bearer token to `/production`. Production tokens must be unique across
teams and distinct from every development token. One production token authorizes
all productions in its team; it does not identify individual administrators.

### Automatic OduMCP installation

New production creation requires a configured production token. Until one is set,
the dashboard's Production tab and its Create Production dialog show a setup
notice, and the dialog cannot be submitted. Creation automatically
attempts to install `odumcp` if it is absent. Automatic provisioning supports
**Odoo 16 and later**. Older productions can still be managed with infrastructure
tools.

**Oduflow ships `odumcp` as a managed component by default.** When neither the
main nor the extra repositories contain the module, Oduflow clones the connector
repository at the branch of the production's own Odoo version (`18.0` for
`odoo:18.0`) into a managed checkout and mounts **only** its `odumcp` addon
read-only. The version comes from the image tag; for a custom image without a
version in its tag, Oduflow asks the image's `odoo --version`. This managed
checkout survives container recreation and is never silently updated: neither
production creation nor deployments pull newer connector code. It changes in
exactly two cases:

- **Odoo version change.** When `reconfigure_production` moves a production to
  another Odoo version, the checkout is replaced with the new version's branch
  before the container is recreated; run `sync_production_mcp` afterwards to
  upgrade the installed module. A synchronization also replaces a checkout made
  for another version.
- **Explicit refresh.** `sync_production_mcp(name, refresh=true)` re-clones the
  same version's branch from `odumcp_repo_url`, recreates the container and
  upgrades the module (even at an unchanged manifest version, so data and view
  changes land). This is the only way the managed module is updated, and it is
  an operator action — nothing refreshes it automatically. A refresh requires
  an explicit `name`: one production per call, never the whole team.

**A client takes the module over by committing `odumcp` into their own
repository** (main or extra, where Odoo scans it) **and deploying.** The deploy
that brings the module in completes the takeover by itself: once the new code is
verified healthy, Oduflow recreates the container without the managed mount and
removes its managed checkout, records `odumcp_takeover: completed` in the deploy
history entry and says so in the deploy result (`odumcp now comes from the
repository; the managed copy was removed`). This holds for manual,
webhook and auto-update deploys alike, and also when the deploy classified the
change as nothing to apply (the detection keys on the repository state after the
pull, not on the changed files). `sync_production_mcp` does the same for a
production whose takeover is still pending (for example because that recreate
failed). From then on the module is client code: Oduflow never touches it again —
`refresh=true` is a documented no-op for a client-shipped module, and
installs/upgrades follow the repository's deployments like any other addon.
Removing the module from the repository again puts Oduflow back in charge on the
next synchronization.

Whether the managed copy is mounted at all is decided by the repository state
whenever a container is built: any recreate (reconfiguration, image change,
synchronization) leaves the mount out as soon as the client repositories ship
`odumcp`. A synchronization recreates the container whenever the container does
not see the current checkout (or still mounts a removed one), which repairs an
earlier synchronization whose recreation failed. Provisioning refuses to install
an addon built for a different Odoo version than the one running:

```toml
[production]
enabled = true
odumcp_repo_url = "https://github.com/oduflow/oduflow-client-addons.git"
```

The addon must implement `_set_oduflow_key`; an older release cannot synchronize
credentials. Source checkout failures, unsupported Odoo versions, unavailable or
incompatible modules and failed key provisioning do not fail production creation.
Production remains available with a warning and an OduMCP status of `sync_failed`,
which the dashboard shows as an **MCP FAILED** badge on the production card. A
production on Odoo 15 or older gets the status `unsupported` instead, shown as
**MCP N/A**: no synchronization can help there. Infrastructure tools continue to work. The
`production_odoo_*` tools remain listed but return a configuration error until
`sync_production_mcp` succeeds. Fix the addon source or compatibility issue and
retry synchronization; recreating production is unnecessary.

A fixed internal Odoo shell operation registers the configured production token
as an MCP-only key on `base.user_admin`. It does not expose arbitrary production
shell execution through MCP. Odoo stores a password hash, not the plaintext key.
Personal keys are preserved. An existing MCP profile and suspended MCP access
are preserved; an administrator without a profile receives a read-only profile.
Configure write/model/method policies in Odoo before requesting business changes.

### Business operations and approval

No standalone `odumcp_server` is needed. Oduflow calls `/odumcp/v1/execute`
directly using the configured production key. Model and field policies, Odoo ACLs,
approvals and audit records remain enforced by the addon. Calls authenticated
with the managed key have `source = oduflow` in the Odoo audit log.

- `production_odoo_info(name)` returns Odoo/profile information.
- `production_odoo_read(name, operation, params)` supports schema, records,
  counts, aggregates, attachments and reports.
- `production_odoo_preview_change(name, action, payload, idempotency_key)` stores
  an exact change plan. Optional `batch_key` groups plans for review.
- `production_odoo_change_status(name, approval_id)` retrieves its state/result.
- `production_odoo_execute_change(name, approval_id)` executes an approved plan.

Approve plans in Odoo. Explicit Odoo auto-approval policies still apply; Oduflow
never grants approval itself. After a timeout, check the existing approval status
before making another plan. Infrastructure actions (deploy, delete, restore)
remain Oduflow operations and are not governed by Odoo business approvals.

### Existing productions and rotation

After changing `production_token` in TOML and restarting Oduflow, connect with the
new token and call `sync_production_mcp(name)`; omit `name` to process all team
productions. The call installs/configures the addon and replaces only its managed
key. It reports each production independently; start stopped productions and retry
failures. Adding a missing managed addon mount — or dropping it once the client
repository provides `odumcp` — recreates the container briefly. Reapplying the
same key is a no-op for the key itself.

Each result states where the addon comes from (`addon_source`: `managed` or
`repository`), what happened to the managed checkout (`checkout`: `kept`,
`cloned`, `replaced`, `refreshed`, `removed` or `repository`), whether the
container was recreated and, for a managed checkout, the connector branch and
commit it is at. Pass `refresh=true` (with an explicit `name`) to re-clone the
managed checkout from `odumcp_repo_url` for the same Odoo version and upgrade the
module; the result reports `refreshed` and, for a client-shipped module, a note
that nothing was refreshed.

The new token immediately controls the Oduflow endpoint after restart. Each Odoo
accepts the new key only after its synchronization succeeds; until then its old
managed key may still authenticate directly to Odoo. Rotation is not atomic across
multiple databases. Also synchronize after restoring an older database backup,
which can restore an old key or old module state. Disabling or removing a token
in Oduflow alone does not revoke the stored Odoo API key; revoke that key in Odoo
when retiring the integration.

## 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
workers_cap = 8         # upper bound for auto-tuned Odoo workers
health_timeout = 180    # seconds a production may take to become healthy

[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)
# upload_threads = 16          (parallel filestore chunk uploads; buffers
#                               up to max(64 MiB, threads x 4 MiB) in RAM)
```

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,
    allow_copy_to_dev_mcp=True,  # may agents copy this production into dev?
    server_mode="workers",       # or "gevent", see Server mode below
)
```

### Domains

In a team with [`base_domain`](traefik.md#team-base-domain) configured, the
primary `domain` must lie in the team zone — the zone apex
(`demo.example.com`) or a subdomain (`erp.demo.example.com`) — and may be
omitted: the team's **first** production defaults to the apex, later ones to
`<name>.<base_domain>`. Without a base domain, `domain` is required and may be
any FQDN.

`extra_domains` adds further public FQDNs routed to the same production —
typically the client's own domain alongside the team-zone name:

```text
create_production(name="erp", domain="demo.example.com",
                  extra_domains=["myodoo.pl"], ...)
```

All domains share the production's Traefik routes (`Host(a) || Host(b)`),
each with its own Let's Encrypt certificate; every DNS record must point at this server.
Extra domains may be arbitrary FQDNs but must not fall inside another team's
zone, and every domain — primary or extra — must be unused anywhere else in
the deployment (other productions, team hostnames, static routes).

`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.

### Server mode

`server_mode` picks how Odoo serves HTTP and where Traefik sends the traffic:

| Mode | Odoo | Traefik routes |
|---|---|---|
| `workers` (default, sync) | auto-tuned HTTP worker processes on 8069, plus the gevent process on 8072 | pages to **8069**; the live bus (`/websocket`, and `/longpolling` on Odoo ≤ 15) to **8072** |
| `gevent` (async) | one HTTP worker on 8069 (kept for in-container calls) and the gevent process on 8072 | **everything to 8072** |

Both modes run Odoo's multi-process server, so cron workers run either way,
and the gevent process is always present. In `workers` mode it only holds the
bus connections: a worker process refuses a websocket ("Is the connection
opened on the evented port?"), so without the bus route Discuss and live
notifications would not work. In `gevent` mode a single cooperative process
serves every request: it handles many concurrent long-lived or I/O-bound
requests (long polling, slow external calls) cheaply, but one CPU-heavy
request stalls everyone else, and it uses one CPU core. `db_maxconn` keeps its
full auto-tuned size in both modes; in `gevent` mode it is the connection
pool that bounds how many requests that process serves at once. Because that
one process is the whole site, `gevent` mode also raises its memory limits
(`limit_memory_soft_gevent`/`limit_memory_hard_gevent`, and the general
`limit_memory_*` for Odoo ≤ 15) to half the Odoo RAM budget instead of a
worker's 2 GB, so it does not recycle itself — dropping every connection — under
normal load.

The listen settings are pinned in the generated `odoo.conf` whatever the base
conf (team `odoo.prod.conf`) says: `http_interface = 0.0.0.0` (Odoo 20
otherwise listens only on `127.0.0.1`, which Traefik cannot reach),
`http_port = 8069`, `gevent_port`/`longpolling_port = 8072`,
`http_enable = True`, and the legacy `xmlrpc`, `xmlrpc_port` and
`xmlrpc_interface` keys are dropped; a base conf that set them is logged.
The deploy health check requires **both** ports to answer `/web/health` in
either mode, so a dead gevent process (a broken bus in `workers` mode, a
dead site in `gevent` mode) fails the deploy and triggers the code rollback.

Change the mode with `reconfigure_production(name="erp", server_mode="gevent")`
(the container is recreated). The listen ports are managed keys (see
[odoo.conf overrides](#odooconf-overrides)), and a `workers` override must stay
at least 1: with `workers = 0` Odoo would switch to its threaded server, which
does not listen on 8072.

### Promoting a dev environment

`from_environment` turns an existing dev environment into the seed — the
promotion path from "it works on the branch" to "it serves customers":

```text
create_production(name="erp", domain="erp.acme.com", from_environment="feature-x")
```

The environment's database and filestore are copied (its Odoo container is
briefly stopped so the pair is consistent, then restarted), and omitted
`repo_url` / `branch` / `odoo_image` / `git_user` / `extra_addons` default to
the environment's own — explicit arguments still win. Unlike the
save-as-template detour, no intermediate template is created and the source
environment is **not reset** — it keeps living as a dev environment.
`from_environment` and `template_name` are mutually exclusive.

Promotion also inherits the source environment's user environment variables.
`secret:<name>` references stay in the production registry and container labels;
values are resolved from the team's secret store only for the container runtime.
They survive domain/image/branch reconfiguration. Missing secrets are rejected
before the source is stopped. Pass `env_vars={...}` to replace the inherited set,
or `{}` to inherit none. `reconfigure_production(env_vars={...})` replaces the
stored set; omitting it preserves the existing variables. The managed database
variables `HOST`, `PORT`, `USER`, and `PASSWORD` cannot be overridden.

Every process in the container inherits these variables: the server, the
deploy's `odoo -u` and the pip installs. For that reason, a new set may not
contain `HOME`, `PYTHONHOME`, `PYTHONUSERBASE` or `PYTHONNOUSERSITE`. Odoo
keeps its filestore and sessions under `HOME`, and the repositories'
requirements are installed into the `odoo` user's pip `--user` site.
Overriding these variables hides the installed packages or moves data out of
the mounted volumes. `PYTHONPATH` may not list a `site-packages` or
`dist-packages` directory either. Python searches `PYTHONPATH` before the
standard library, so a backport installed there (such as `dataclasses`)
shadows the stdlib module and Odoo fails to start. A production that already
stores such a variable keeps it and still recreates; only setting a new
variable set is refused.

No sanitization happens — the data goes *into* production. One caveat: if
the source environment was itself created from a production
(`from_production`), its data was sanitized on that copy, and the new
production starts with that sanitized data (the result warns about this).

The dashboard offers the same via **More → Promote to Production** on an
environment card, which opens the create-production form pre-filled.

To promote into a production that **already exists**, use
`restore_production(from_environment=…)` instead — see
[Backups](#backups) for the restore mechanics.

## Reconfiguring a production

A production's settings are not frozen at creation.
`reconfigure_production` changes any of the domain, extra domains, Odoo
image, deployed branch, repository URL, git user, the extra addon repos, or
the [server mode](#server-mode), then **recreates the container** to match — the database and filestore live outside the
container and are preserved; expect a brief downtime:

```text
reconfigure_production(name="erp", domain="erp.newcustomer.com")
reconfigure_production(name="erp", extra_domains=["myodoo.pl"])  # [] removes all
reconfigure_production(name="erp", branch="18.0-stable")
reconfigure_production(name="erp", extra_addons={"acme-addons": "production"})
reconfigure_production(name="erp", server_mode="gevent")
```

Omitted arguments are left unchanged (`git_user=""` explicitly clears the
git user). The registry record is updated first and the workspace/container
are converged to it, so re-running the same call after a mid-way failure
repairs a missing container or checkout instead of reporting a no-op. The same
applies to a container whose Traefik routing no longer matches the record
(e.g. a production created before the bus route existed). Such a container is
also recreated, without a separate maintenance window, by the next deploy
that changes code (`update_production` or an auto-deploy) and by
`restart_production`; a plain `reconfigure_production(name="erp")` does it
immediately.

After recreating the container, reconfigure waits up to
`[production] health_timeout` seconds (default 180) for Odoo to become healthy. A container that crash-loops, for example on an environment
variable that breaks Python, fails the wait early: Docker restarts it twice
during the wait, and the production lock is released for the next call. The
same rule applies to every production health wait, including deploy
verification and the rollback restart.

**Applying a rotated secret.** Secret values reach the container only when it
is created, so `restart_production` keeps running with the old value. After
replacing a secret's value in the dashboard, call
`reconfigure_production(name="erp")`: it compares the current value of every
`secret:<name>` reference with what the container runs with, recreates the
container when one differs and lists the affected variables by name. A
reference to a deleted secret is not treated as a change; the next recreate
reports it. `recreate=true` recreates the container even when nothing differs.

Two things reconfigure deliberately does **not** do:

- Changing `odoo_image` does not migrate the database. A minor image refresh
  is safe; a major Odoo version bump additionally needs an explicit module
  upgrade plan.
- Changing `branch`/`repo_url` deploys the new code as-is (restart only).
  Run `update_production(install=..., upgrade=...)` afterwards if the new
  code needs module changes.

The dashboard offers the same settings on each production card under
**More → Settings**, together with the *agent copy to dev* gate
(dashboard-only, see [Copying production data to dev](#copying-production-data-to-dev)).

### odoo.conf overrides

The generated production `odoo.conf` merges a base conf chain (team
`odoo.prod.conf` > bundled) with [auto-tuned](#configuration) worker/limit
settings. Per-production overrides sit on top of both and survive deploys,
retunes and reconfigures:

```text
set_production_odoo_conf(name="erp", options={"limit_time_real": "300"})
set_production_odoo_conf(name="erp", unset="limit_time_real")
```

The repository is never part of the chain: a `.oduflow/odoo.prod.conf`
(honoured by earlier versions) is ignored and logged, so pushing to the
deployed branch cannot change how the production server runs. Move such
settings into overrides or the team `odoo.prod.conf`.

An explicit override beats the auto-tuned value (e.g. pin `workers`; it must
be at least 1, see [Server mode](#server-mode)). Keys managed by Oduflow are
refused: `addons_path` and `data_dir` are generated, the `db_*` connection
keys come from container env vars, and the listen settings (`http_interface`,
`http_port`, `gevent_port`, `longpolling_port`, `http_enable` and the legacy
`xmlrpc`, `xmlrpc_port`, `xmlrpc_interface`) are pinned to what Traefik
routes to.
Such keys saved before they became managed are ignored (and logged) and are
removed from the record by the next override save or reconfigure (option names
are compared case-insensitively and stored lowercased, matching how Odoo
reads them). Current overrides are shown by `get_production_info`; the
dashboard edits them in the same **Settings** panel. Applying restarts the
container (brief downtime) unless `restart=false`; a call that leaves the
overrides unchanged skips the restart entirely.

#### Database manager and the master password

The generated conf sets `list_db = False`, which is what closes Odoo's
database manager: Odoo gates every database management function (create,
duplicate, drop, backup, restore) on that setting, and nothing else stands
between that page and the databases except the **master password**
(`admin_passwd`). Oduflow never writes one, so Odoo's compiled-in default —
the literal `admin` — applies.

Turning the manager on therefore requires choosing a real master password in
the same override set (or in the team `odoo.prod.conf`):

```text
set_production_odoo_conf(name="erp", options={
    "list_db": "True",
    "admin_passwd": "<at least 12 characters, not 'admin'>",
})
```

`list_db = True` without one is refused, and so is dropping `admin_passwd`
while the manager stays on. A `list_db` value other than `True` or `False` is
refused too: Odoo's conf loader converts only those spellings to a boolean, so
`list_db = 0` (or `no`, or `FALSE`) would read as a non-empty, truthy string
and *enable* database management. A hand-edited `productions.json` that opens
the manager unlocked is overruled on the next conf rebuild, which logs the
reason and keeps `list_db = False`.

Credential-valued overrides (`admin_passwd`, `smtp_password`) are reported
back masked by `get_production_info` and `set_production_odoo_conf`, so an
agent with production MCP access never receives them; the dashboard, which is
the operator's own console, keeps showing the values. The generated conf is
written owner-only (`0600`) on the host, since it is the one managed file that
can carry a credential — the `db_*` connection keys travel as container
environment variables instead.

## Deploys and rollback

Module preflight and post-install checks use the production PostgreSQL cluster
and the production's own database role. Development requests retain their
separate cluster. An exception after source synchronization, including a module
preflight SQL failure, triggers code rollback just like a failed module command.

`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 auto mode (no explicit
`install`/`upgrade`/`restart`), only modules **installed in the database** are
upgraded: an extra-addon repo typically bundles many modules of which a few
are installed, and a routine fast-forward touching uninstalled ones must not
fail the deploy — their changes are dropped with a warning and the rest is
classified again, so a JS or view change in an installed module still
reloads and a changed `.py` still restarts. A module already in the database
is upgraded even when it looks new (moved between directories or repos), and
new modules are never auto-installed on a production. Explicit
`install=`/`upgrade=` requests keep the strict behaviour. A changed `.oduflow/requirements.txt`
or `.oduflow/apt_packages.txt` — in the main repo or an extra-addon repo —
reinstalls the pip/apt dependencies into the container before the restart
(they are also installed from scratch whenever the container is recreated:
create, reconfigure). 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 — but only once the pull has found something to
apply: a deploy with nothing new on the branch, or with Markdown-only changes
(and no explicit `install` / `upgrade` / `restart`), skips it. The snapshot is
taken with the checkout still on the pre-deploy commit, so it records the code
its database ran against.

While a deploy runs, its current step (fetching code, pre-update snapshot with
file progress, applying changes, verifying health, rolling back code) is shown
next to the production's status on the dashboard, returned as `progress` by
`list_productions` / `get_production_info`, and quoted in the `BusyError` a
concurrent call on the same production receives.

A deploy started from the dashboard (or by a GitHub push) while another
operation holds the production — a scheduled snapshot, a restart, another
deploy — queues instead of failing: the production shows a `queued` badge with
the waiting time and what it waits for, and a second click while one is already
queued is refused. If the production stays busy past the wait limit (5 min for
the dashboard, 30 min for webhooks), the deploy is not started and a `skipped`
entry with the reason lands in the deploy history.

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

The health checks after a deploy, rollback, reconfigure, restore or Stack
apply wait up to `[production] health_timeout` seconds (default 180). Raise it
for workers-mode productions with many modules — they can take minutes to
start serving. A production stuck with an `unhealthy` flag after a timed-out
rollback recovers with `restart_production` or `start_production`: on a
flagged production either one re-judges health and clears the flag once the
service answers again (and says so when it still does not).

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

### Diagnosing a failed deploy

A large `-u` can outlast the MCP client's request timeout. The deploy still
finishes on the server, but the client never receives the response or its
Odoo output. So a failed deploy keeps its evidence in the deploy record
itself:

| Field | Content |
|---|---|
| `failed_step` | `dependencies` (apt/pip reinstall), `install` (`odoo -i`), `upgrade` (`odoo -u`), `health` (every command succeeded, but Odoo did not answer `/web/health`), or `exception` (Oduflow raised an exception, for example a module preflight query) |
| `exit_code` | The exit code of that step |
| `dependencies_exit_code` | The apt/pip reinstall's own exit code, when one ran: a failed upgrade next to a failed reinstall usually means a missing Python package |
| `failure_excerpt` | The Odoo log record that explains the failure, with its traceback: the last `CRITICAL` record, else the last `ERROR` one, else the last bare Python traceback |
| `output_tail` | The last 200 lines (16 KB at most) of the failed step's output. For a failed health check, this is the server log instead |
| `error` | One line: the step, the exit code, the rollback, and the cause (the exception that closes the traceback) |

`production_deploys` returns `failure_excerpt` by default. Pass
`include_output=true` for `output_tail` too. The dashboard shows `error`
under the deploy's status. The server log gets the same excerpt on the
`deploy failed at step …` line.

What the exit codes mean:

| Exit code | Meaning |
|---|---|
| `255` from `install`/`upgrade` | Odoo could not load or upgrade the registry: `preload_registries()` caught an exception, logged `CRITICAL Failed to initialize database` with the traceback, and returned `-1`. The excerpt says why |
| `1` from `dependencies` | An apt or pip install failed. Oduflow reports 1 for any failed dependency step |
| `1` from `exception` | Oduflow raised an exception after the code moved. The exception's text is in `error` |
| `137` | The command was killed by `SIGKILL`, typically by the container's memory limit |
| `0` with `failed_step: health` | The commands succeeded, but the restarted server did not answer within `[production] health_timeout` seconds |

**Where the upgrade runs.** `odoo -i` / `odoo -u` is a `docker exec` into the
production's running container: `/entrypoint.sh odoo -d <db>
--stop-after-init --no-http -u <modules>`. It uses the same image, the same
`/etc/odoo/odoo.conf`, the same environment variables (including
`env_vars`) and the same `odoo` user with `HOME=/var/lib/odoo`, so it sees
the same pip `--user` packages under `/var/lib/odoo/.local` as the server.
Two things differ from the server process:

- **The upgrade runs next to the live server.** The production's HTTP
  workers and its cron worker keep using the same database during the
  upgrade. A cron job or request can hold locks on rows or tables the upgrade
  rewrites. PostgreSQL then aborts the upgrade's transaction with
  `could not serialize access due to concurrent update` or
  `deadlock detected`, and Odoo exits with 255. A dev environment never has
  this concurrency, because it runs with `workers = 0` and
  `max_cron_threads = 0`, so the same `-u` can pass there. The production
  PostgreSQL cluster logs lock waits (`log_lock_waits = on`).
- With `workers > 0`, the upgrade is Odoo's prefork master process, which
  does not apply `limit_memory_hard` to itself. It shares the container's
  memory limit with the workers.

If the team's `odoo.prod.conf` sets `logfile`, Odoo writes its log there
instead of to the command's output, and the excerpt stays empty.

## 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.

**Restoring from a dev environment.** The same tool also promotes a dev
environment's data into an *existing* production — the counterpart of
`create_production(from_environment=…)` for productions that already live:

```text
restore_production(name="erp", from_environment="feature-x", confirm="erp")
```

The environment's database and filestore are copied while its Odoo is
briefly stopped (the environment is **not reset**), staged, and swapped in
with the same all-or-nothing mechanics as a snapshot restore. No
sanitization happens — the data goes *into* production — and no `[backup]`
configuration is required. The production's code checkout is not touched;
the result warns when the environment's commit differs from the deployed
one. Take a `snapshot_production` first if the current production data may
still be needed. `snapshot_id` and `from_environment` are mutually
exclusive.

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. Because it rewrites **every team's** productions at once, it is
an operator action, not an MCP tool: run it from the server settings console
(**/admin → Cluster DR**) or the owner CLI:

```bash
oduflow restore-cluster --target-time "2026-07-10 12:00:00+00" --confirm RESTORE-CLUSTER
```

Either surface restores the **entire cluster** (every production database at
once — including auxiliary service databases created with `cluster="prod"`)
from the newest suitable base backup + WAL replay — optionally to a point in
time (empty `--target-time` replays the whole archive). 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 with the CLI command above.

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

### WAL-G certificates and storage health

Oduflow writes a CA bundle alongside `walg.json` in
`<base_data_dir>/walg/` and configures WAL-G to read it through the existing
read-only `/etc/walg` directory mount. This works even when the PostgreSQL
image has no system CA bundle, and covers WAL uploads, base backups, and PITR
helpers. Restarting Oduflow refreshes these files for existing containers;
PostgreSQL does not need to be recreated for this fix.

The CA source is `AWS_CA_BUNDLE`, then `SSL_CERT_FILE`, then the server's
default CA file, with the boto3/botocore bundle as a fallback when there is no
default file. Set an explicit override in the Oduflow service environment for
a private certificate authority. Invalid overrides fail rather than disabling
TLS verification or replacing a working bundle.

The background WAL monitor checks storage list access using WAL-G **inside
production PostgreSQL, as the postgres user**. The dashboard's **WAL-G** health
chip and `/healthz` use its cached result. The
diagnostic command has a five-second timeout, with forced termination after
another two seconds. A TLS/access error degrades `/healthz` even when the
separate S3 check from the Oduflow server succeeds. List access does not prove
upload permissions, successful archiving, or a complete PITR chain. The backup
status API retains local archiver statistics when the remote inventory query
fails; inventory commands have a 30-second timeout.

### Production readiness at deployment

The production cluster uses the same image as the development cluster,
`[database].image` (default: the official `postgres:16`). WAL-G trusts the
persistent mounted CA bundle described above, so no custom PostgreSQL image is
needed. Existing containers are reused and keep their major version until
`[database].image` names a newer one. The upgrade then carries every production,
including its roles and passwords, into a new cluster and deletes the old
WAL-G archive, as described in
[Upgrading PostgreSQL](installation.md#upgrading-postgresql). Odoo 20 needs
PostgreSQL 16 or newer: creating a production with an Odoo 20 image, or
switching one to it, is refused on an older cluster.

With `[backup]` configured, provisioning and production start/restart/deploy
require a successful check **inside PostgreSQL as the postgres user**:

1. Check the actual disk and queue safety thresholds.
2. Check the mounted CA bundle, executable and credentials file, then use
   WAL-G to list storage, upload a unique probe, read it back, compare its
   content and delete the probe. The storage phase is limited to 30 seconds,
   with bounded cleanup/forced termination. The credentials need read, list,
   write and delete permissions for the probe under
   `<backup.prefix>/walg/oduflow-preflight/`; backup objects are not modified.
3. Enable the managed archive command and confirm PostgreSQL applied it.
4. Generate a restore point and switch WAL, then wait up to
   `upload_timeout + 75` seconds for that segment to be archived. Only then
   allow the production application to start. This confirms current archive
   delivery; a recoverable PITR chain additionally requires a base backup.

Step 4 is skipped for an already admitted production — `restart_production`,
a deploy or a rollback — when the sample from step 1 already proves archiving
is healthy: the managed archive command is active, archiving is not stalled,
and the storage listing in that same sample succeeded. A hung production stays
restartable during a brief storage outage instead of waiting minutes for a new
segment. Admitting a new production, or starting a stopped one, always runs
the full check.

An error blocks the operation and appears as **Last startup check** in the
WAL panel. It stops driving the overall status once a newer healthy sample
supersedes it; live sampling then reports the current state. It does not convert an existing archive command to `/bin/true` or
delete retained WAL. An old no-op command is changed to an empty, retaining
command before preflight; fresh generated configurations also retain WAL
until provisioning decides. Existing working archiving continues during a
preflight failure; the disk/queue guard remains responsible for emergency
shutdown. Development environments can still start if production is unready.

No package installation is performed inside PostgreSQL during deployment or
emergency recovery.

### WAL monitoring and automatic disk protection

PostgreSQL records every change in 16 MiB WAL segments, and WAL-G uploads each
finished segment to S3; that stream is what makes point-in-time recovery
possible. PostgreSQL cannot delete or recycle a segment until it has been
archived, so when uploads stop, WAL keeps accumulating on the same disk as the
production databases. The monitor watches two things: whether archiving is
making progress, and whether the disk will run out before it does.

The **PostgreSQL WAL** panel in Production is shared by every production and
team. Free space is measured on the actual WAL filesystem; space reserved for
root is excluded. A dedicated daemon samples
locally every 15 seconds, independently of backup jobs and dashboard polling;
the storage probe runs only after the local protection decision. Missing or
older-than-60-second samples are errors, never healthy results.

An empty queue is idle, not stalled. With a queue, lack of successful archive
progress triggers warning/error thresholds. A draining but old backlog stays
visible. WAL upload attempts have a hard timeout and return failure on timeout
or termination; PostgreSQL retains the segment and retries. Failed downloads
of the WAL-G executable never switch configured archiving to a success no-op.

#### Reading the WAL panel

The badge is `ok`, `warn`, `error`, `off` (archiving not configured, or
production PostgreSQL not provisioned) or `unknown` (the sample is stale, or
the browser could not fetch the status), always with a text summary such as
*Archiving is idle* (empty queue), *Archiving WAL*, or the reason for a
warning or error.

| Field | Meaning |
| --- | --- |
| **Queue** | Segments waiting for upload (`.ready` files in `pg_wal/archive_status`) and their total size. |
| **Queue stop limit** | `stop_queue_gb`. A queue this large trips protection even with ample free disk. A queue above `warn_queue_gb` only warns. |
| **Oldest waiting** | Age of the oldest segment still waiting for upload. |
| **No progress** | How long archiving has made no progress while a queue exists. Warns after `warn_after`, errors after `stall_after`. |
| **WAL on disk** | Total size of `pg_wal`, including already-archived segments that PostgreSQL keeps for reuse. With an empty queue, anything between `min_wal_size` and `max_wal_size` (512 MiB to a few GiB with the default tuning) is normal. |
| **Available to PostgreSQL** | Free space on the filesystem holding `pg_wal` (the WAL filesystem), as available to the non-root postgres user. The status warns below 15% of the filesystem. |
| **Safety reserve** | `stop_free_gb`. When free space reaches it, protection stops the cluster. Releasing protection requires `resume_free_gb`. |
| **Disk consumption** | How fast free space is shrinking: the larger of the five-minute average and the slope since the previous sample. It covers the whole filesystem, not just WAL. |
| **Estimated time to reserve** | `(available − reserve) / consumption`: how long until the reserve is reached at the current rate. |
| **Archived** | `pg_stat_archiver.archived_count`: segments archived since the statistics were last reset. |
| **Last archive** | Time of the last successful upload, in the browser's local time. It should keep advancing while WAL is generated. |
| **Failed attempts** | `pg_stat_archiver.failed_count`: failed `archive_command` runs. |
| **PG restarts** | Docker restart count of the shared PostgreSQL container. |
| **Current upload** | Shown only while an upload runs: the segment and its elapsed time. |
| **Last upload failed** | Shown only when the last `wal-push` exited non-zero: its exit code. Details are in the PostgreSQL logs. |
| **Storage probe** | How storage access was verified: a WAL-G list run inside production PostgreSQL (see above). |
| **Last startup check** | Result of the deployment preflight, when one has run. |
| **Sample** | When the shown data was collected, in the browser's local time. |

**Pause archiving** / **Resume archiving**, **Retry upload**, **Start
PostgreSQL recovery** and **Release protection** correspond to the `pause` /
`resume`, `retry`, `recover` and `release` actions below.

Defaults can be changed in TOML:

```toml
[production.wal]
upload_timeout = 120    # seconds; TERM, then KILL after another 5 seconds
warn_after = 120        # seconds without archive progress, with a queue
stall_after = 300
stop_free_gb = 2        # GiB available to postgres: safety reserve
resume_free_gb = 4      # recovery headroom; must exceed stop_free_gb
stop_within = 300       # estimated seconds until the reserve is reached
warn_queue_gb = 2       # GiB of unarchived WAL before warning
stop_queue_gb = 8       # GiB of unarchived WAL before protective stop
```

Protection is active whenever production hosting is enabled, even without
S3 backups or while archiving is paused. It trips as soon as free space
reaches the reserve. A prediction alone is not enough: consumption measured
between the two most recent samples must predict reaching the reserve within
`stop_within`, and that prediction must hold for two consecutive samples.
`df` covers the whole filesystem, so a finished burst from an unrelated
consumer never stops the cluster, while a continuing leak still does.
It also trips when the unarchived queue reaches
`stop_queue_gb`, even on a large disk. Protection saves a persistent latch,
disables Docker restart policies, stops managed production applications,
then stops the shared PostgreSQL container. Failed stops are reported and retried. The latch blocks production
starts, deploys, restores and new backup jobs, including after Oduflow or
Docker restarts. Protection can interrupt in-flight jobs; it does not wait for
their locks while the disk fills.

Set the reserve for peak write volume and shutdown time. The monitor requires
Oduflow and Docker to be responsive; it cannot guarantee protection against
arbitrarily fast disk exhaustion or other host processes filling the same
filesystem after PostgreSQL stops. Monitor `/healthz` externally as well.

The server settings console (**/admin → Cluster DR**) and the owner CLI
(`oduflow wal <action> --confirm ALL-PRODUCTIONS`) offer these cluster-wide
actions:

| Action | Effect |
| --- | --- |
| `pause` | Retains unarchived WAL and interrupts the active upload. The queue can grow; disk protection stays active. |
| `resume` | Refreshes WAL-G config/certificates and resumes archiving. Does not restart stopped PostgreSQL. |
| `retry` | Terminates only the current `wal-push`, including a legacy upload predating the timeout wrapper; PostgreSQL retries it. |
| `recover` | Requires recovery headroom; starts only PostgreSQL, keeping applications stopped and rejecting network database connections. Local maintenance and outbound S3 access remain available. Requests a WAL switch to verify real archiving. |
| `release` | Requires recovery headroom, a safe consumption rate, working storage access, and a successful archive after recovery began. Restores normal connections and restart policies; applications remain stopped until explicitly started. |

Use `production_wal_status()` (MCP), `GET /api/productions/wal-status`
(dashboard) or `oduflow wal status` for cached read-only diagnostics;
mutating actions are operator-only — the `/admin` console or
`oduflow wal <action> --confirm ALL-PRODUCTIONS`. Environment-scoped access
is excluded even from the read-only status.
Queue size warns at `warn_queue_gb` and protects at `stop_queue_gb`. During
fenced PostgreSQL-only recovery the queue may exceed the stop threshold so
it can drain; disk pressure still stops PostgreSQL. Releasing protection
requires the queue to fall below `warn_queue_gb` as well as free-space headroom
and confirmed archive progress.

The monitor's latch is stored in `<base_data_dir>/wal_guard.json`; do not
delete it to bypass recovery checks. Recovery temporarily replaces the managed
`PGDATA/pg_hba.conf`, preserving the original alongside it, and restores it on
release. Custom HBA paths require operator intervention.

### Recovering from a full WAL disk

Production tables and WAL live together in the `oduflow-prod-db-data` Docker
volume, unlike the per-team tablespaces used for development. Inspect free
space on the filesystem containing that volume, including the space available
to the non-root postgres user. `max_wal_size` is not a hard limit when WAL
cannot be archived.

If logs report `No space left on device` and WAL-G reports an unknown
certificate authority:

1. Stop application writers to reduce additional WAL generation. Free several
   GiB from known disposable files **outside PostgreSQL data**, or expand the
   volume's filesystem. Never manually delete files from `pg_wal`.
2. Deploy the fix and restart Oduflow to refresh the mounted CA bundle and
   WAL-G configuration. If protection is active, use **Resume archiving** if
   paused, then **Start PostgreSQL recovery** after sufficient space is free.
   No PostgreSQL recreation is required.
3. Check the WAL-G health result and PostgreSQL logs. Confirm actual progress:
   `pg_stat_archiver.archived_count` increases and the queue of `.ready` files
   in `pg_wal/archive_status` drains. A successful list probe alone is not
   enough. An already-running WAL-G attempt uses its old configuration until
   it exits; **Retry upload** interrupts it without discarding the segment.
4. Allow PostgreSQL to recycle eligible WAL itself and verify free space
   recovers before releasing protection and explicitly starting the desired
   applications. Other retention requirements, such as replication slots,
   can also keep WAL on disk.

Do not switch `archive_command` to `/bin/true` to drain the queue: it reports
success without saving the segments and can break PITR continuity.

## Copying production data to dev

Productions are seeded from templates; the same road runs backwards, so a
developer can reproduce a bug on real data:

```text
save_production_as_template(prod_name="erp", template_name="erp-2026-09")
create_environment(branch="bugfix-invoice", from_production="erp")
```

Both dump the production database out of the production cluster with a
consistent `pg_dump` and restore it into the **dev** cluster, and snapshot the
production filestore as the template's baseline. **The production keeps
serving** — nothing is stopped or modified on its side.

`create_environment(from_production=…)` routes through one managed template per
production, `prod-<name>`, published on first use and reused afterwards; refresh
it with `save_production_as_template(name, "prod-<name>", overwrite=True)`. See
[Create a Template from Production](templates.md#create-a-template-from-production)
and [Creating an Environment from Production](environments.md#creating-an-environment-from-production).

!!! danger "The copy is unsanitized until an environment is created"
    The template carries real customer data and credentials. Environments made
    from it are neutralized and run the repository's sanitize scripts by
    default; the template itself is production-confidential.

**`allow_copy_to_dev_mcp`** (default `true`, set at `create_production`) gates
**new copies**: when it is `false`, an MCP/CLI agent asking for either tool gets
a refusal — and no MCP tool can turn the flag back on. It is a gate on *agents*,
not on people: the dashboard's Production tab is never gated and is the only
place the flag can be toggled, so an agent cannot re-enable its own access.
Productions created before the flag existed behave as `true`.

The flag does **not** revoke a copy that already exists. A `prod-<name>` (or
any) template published from the production stays usable through
`create_environment(template_name=...)` like every other template — its data is
neutralized on the way into each environment. The one thing agents lose is the
raw form: `sanitize=false` on a template whose `source_production` has the flag
off is refused. To withdraw the data itself, `delete_template` the copy.

## 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.

## Deleting a production

`delete_production` (or **Delete** in the dashboard) removes the container and
the registry record, but **keeps the database and the workspace** (filestore,
repo, deploy history) on disk — productions are precious, deleting bytes is
opt-in. Pass `drop_database=true` over MCP/CLI to remove everything at once.

Kept leftovers are *tombstoned* (a `deleted.json` marker in the workspace) so
they can be reclaimed later:

- **Deferred purge** — set `[lifecycle] prod_purge_hours = N` in
  `oduflow.toml` and the background sweep permanently purges the leftovers
  (database, PostgreSQL role, workspace) N hours after the deletion. `0`
  (default) keeps them forever.
- **Immediate purge** — `oduflow cleanup --purge-deleted-productions`
  lists tombstoned leftovers; add `--force` to purge them now, regardless of
  age.

Only tombstoned leftovers are ever purged: a workspace without the marker is
presumed alive and is never touched (`oduflow cleanup` skips the whole
`prod-*` namespace for the same reason).

A deleted production's name stays taken while its leftovers are kept:
`create_production` refuses a name whose database is still in the production
cluster, or whose workspace is still on disk. Creating over a kept workspace
would collide with its repository and mix the old filestore and deploy history
into the new production. Copy out anything you still need, purge the
leftovers, and create again.

A failed `create_production` rolls back what it created. If the rollback
cannot drop the new database, it tombstones the attempt, so the same purge
reclaims it. The refusal names the remedy that applies: purge for tombstoned
leftovers; for unmarked ones (a production deleted before tombstones existed,
for example), a manual `DROP DATABASE` and removal of the workspace, after you
check that they hold nothing you need.

## 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 |
| `reconfigure_production` | Change domain/image/branch/repo/extra addons; recreates the container |
| `set_production_odoo_conf` | Per-production odoo.conf overrides on top of auto-tuning |
| `snapshot_production` / `list_production_snapshots` | Snapshots to S3 |
| `restore_production` | Restore DB + filestore from a snapshot or a dev environment |
| `set_production_backup_schedule` | Per-production snapshot time / off |
| `production_backup_status` | Backup posture (snapshots + WAL-G + S3) |
| `save_production_as_template` | Publish the production's DB + filestore as a dev template (unsanitized) |
| `prune_production_backups` | Apply retention now |
| `delete_production` | Remove (database/files kept unless `drop_database`) |

Cluster-wide disaster recovery (whole-cluster PITR) and WAL archiving control
are deliberately **not** MCP tools: they rewrite or pause every team's
productions at once, so they live on the operator surfaces only — the server
settings console (**/admin → Cluster DR**) and the owner CLI
(`oduflow restore-cluster`, `oduflow wal`).

---

# 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. Inside the agent this server is named `development`, so
its tools appear as `development_*` in OpenCode and `mcp__development__*` in
Claude Code and Codex. Checkouts created by earlier releases had it under
`oduflow`; that entry is replaced before the next session.

One container serves the whole team, but **every environment runs as its own
Linux user**. Each environment's sessions, checkout, chat attachments and HOME
(logins, conversation transcripts, settings) are private to it: an agent
working in one environment cannot see other environments' checkouts, sessions
or credentials. Other branches remain readable through git
(`git fetch`, `git show origin/<branch>:<path>`).

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, kept
in that environment's own HOME on the persistent agent 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
identity and checkout, `delete_environment` removes them, together with the
environment's HOME and running sessions. If the agent container is stopped or
gone at that moment, the removal is completed the next time it runs, before any
environment (including a new one with the same name) is set up, so a later
environment never inherits a deleted one's files. 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.

The coder image includes OpenSSH client tools for Git remotes authenticated
with a team SSH deploy key. Use an SSH repository URL for deploy-key access;
HTTPS remotes still require HTTPS credentials.

## 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.4.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; replaces a ChatGPT sign-in
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 in the environment's own HOME on the persistent
agent 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 in the environment's own HOME on the
persistent agent 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. The same applies to Codex: Agent CLI starts it with the startup update
check turned off.

### Codex with a ChatGPT subscription

Instead of `OPENAI_API_KEY`, Codex can use a ChatGPT plan (Plus, Pro, Business
or Enterprise). Sign in once per environment (each environment keeps its own
login):

1. With Codex as the team's default agent (`agent_default = "codex"`), open
   **More ▾ → Agent CLI** on the environment.
2. Choose **2. Sign in with Device Code**. Option 1, **Sign in with ChatGPT**,
   does not work here: its callback goes to `localhost` inside the agent
   container, which your browser cannot reach.
3. Open <https://auth.openai.com/codex/device> in any browser, sign in to
   ChatGPT and enter the one-time code that Codex shows. The code expires after
   15 minutes.
4. Codex reports **Signed in to Codex**. Agent CLI and Agent Chat of that
   environment now use the subscription.

Business and Enterprise workspaces may need a workspace admin to enable
device-code sign-in for Codex first.

The sign-in is stored in the environment's own HOME on the persistent agent
home volume and survives container recreation. A configured `OPENAI_API_KEY`
overrides it: every environment signs in with the key
(`codex login --with-api-key`) when the container starts and when the key
changes. The key's sign-in stays in the HOME after the key is removed, so to
switch to the subscription, remove the key, restart Oduflow, then run `/logout`
in Agent CLI and sign in again. To check or reset an environment's sign-in from
the host, run Codex as that environment's user (the container is
`<prefix><team>-agent`; the environment's uid is in the team's
`agent_uids.json`):

```bash
uid=20000   # from <team data_dir>/agent_uids.json
docker exec -u $uid:$uid -e HOME=/home/agent/envs/$uid \
    -e CODEX_HOME=/home/agent/envs/$uid/.codex oduflow-1-agent codex login status
```

Use `codex logout`, or `docker exec -it ... codex login --device-auth` to sign
in without Agent CLI. Many concurrent sessions of one environment can fail a
token refresh with `refresh_token_reused`; run `/logout` in Agent CLI and sign
in again, or use an API key for a busy team. When Codex is not signed in, Agent Chat's error
message includes these recovery steps.

### OpenAI-compatible gateway for OpenCode

OpenCode's built-in `openai` provider uses the OpenAI Responses API. Many
OpenAI-compatible gateways (LiteLLM, vLLM, self-hosted proxies) accept only
Chat Completions. Declare such a gateway once as a custom OpenCode provider:

```toml
[agent]
opencode_model = "litellm/glm-5.3"   # "<provider id>/<model>"

[agent.opencode_provider]
id = "litellm"
name = "LiteLLM"                     # optional; defaults to id
models = ["glm-5.3"]
# base_url_env = "OPENCODE_PROVIDER_BASE_URL"   # default; the URL must include /v1
# api_key_env = "OPENCODE_PROVIDER_API_KEY"     # default

[team.1.agent_env]
OPENCODE_PROVIDER_BASE_URL = "https://llm.example.com/v1"
OPENCODE_PROVIDER_API_KEY = "sk-…"
```

The default variable names are deliberately not `OPENAI_BASE_URL` and
`OPENAI_API_KEY`: Codex and OpenCode's built-in `openai` provider read those
from the same agent container, so pointing them at the gateway would break
Codex and send the real OpenAI key to the gateway.

Every OpenCode session that Oduflow starts, in both Agent CLI and Agent Chat,
gets the provider through OpenCode's bundled `@ai-sdk/openai-compatible`
package. Requests go to `POST <base URL>/chat/completions`, and OpenCode's
session-title requests use the same gateway. Agent Chat's model picker lists
the declared models.

The section holds only the *names* of the variables. Their values stay in
each team's `[team.X.agent_env]`, and OpenCode receives `{env:…}` placeholders
that resolve inside the agent container, so no URL or key is written into the
OpenCode config. The settings are validated when Oduflow starts and when you
save in the settings console:

- `id` must be lowercase and must not be `anthropic`, `google`, `openai`,
  `opencode` or `openrouter`. Other ids from OpenCode's provider catalog are
  allowed: Oduflow pins the provider to the declared models and variables, so
  the catalog adds no models or keys of its own.
- The two `*_env` values must be variable names.
- `models` must be a non-empty list.
- Keys such as `api_key` are rejected.
- If `opencode_model` names this provider, it must have the form
  `<id>/<model>` and the model must be listed in `models`.

When `opencode_model` selects this provider and a team's agent environment
lacks either variable, Agent Chat shows a warning and Agent CLI waits for
Enter before it starts OpenCode. The variables come only from
`[team.X.agent_env]`; the server environment is forwarded only for the known
provider keys of a single-team server. Changes to the section apply to new
sessions without a restart. Changes to `agent_env` recreate the agent
container on the next restart. Without the section, OpenCode behaves exactly
as before.

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.
- **Per-environment isolation.** Inside the team's container, each
  environment's sessions run as that environment's own unprivileged uid (never
  reused, even after the environment is deleted). Its checkout, attachments and
  HOME are private to it, and so are its temporary files (a private `TMPDIR`,
  and sessions start with umask `077`). No session can read another
  environment's process environment (where its scoped token lives). The
  npm-installed Claude Code is shared read-only, so no environment can alter
  what another runs.
  Not separated between environments: the container network (local ports,
  browser sessions) and the team-wide provider credentials from
  `[team.X.agent_env]`.
- **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 environment's unprivileged uid 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 environment'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.
- Interactive logins (Claude `/login`, `codex login`, `opencode auth login`) are
  per environment: log in once in each environment, or set the team's
  credentials in `[team.X.agent_env]`, which every environment uses
  automatically.
- On the upgrade to per-environment users, existing Claude and Codex
  conversations move to their environment once, on the agent container's first
  start; OpenCode conversations from before the upgrade are not carried over.
- 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 — and refreshed in the
background on every later container start.

---

# 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. A value "secret:<name>" references a
# write-only team secret set in the dashboard — see Security → Secrets.
oduflow call create_service meilisearch getmeili/meilisearch:v1.6 7700 "" "MEILI_MASTER_KEY=secret:meili-master-key,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"

# MinIO — the image expects its start arguments as the container command
oduflow call create_service '{"name":"minio","image":"minio/minio","port":9000,"command":"server /data"}'

# 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.

### Start Command

Some images ship an `ENTRYPOINT` that expects arguments, and their default
`CMD` is not what you want (`minio/minio` needs `server /data`, for example).
The optional `command` replaces the image `CMD`:

```bash
oduflow call create_service '{
  "name":"minio",
  "image":"minio/minio",
  "port":9000,
  "command":"server /data --console-address :9001"
}'
```

The string is split with shell quoting rules, so `--address ":9001"` stays one
argument; the resulting argv is what `get_service_info` reports and what the
preset stores. The image `ENTRYPOINT` is never changed. Leave `command` out to
run the image's own `CMD`.

On `update_service` the parameter is tri-state: omitted keeps the current
command, a new string replaces it, and an **empty string drops the override**
so the container falls back to the image `CMD`. (This differs from `env_vars`
and `image`, where an empty value means "keep".)

```bash
# Change the command
oduflow call update_service '{
  "name":"minio",
  "command":"server /data --console-address :9090"
}'

# Drop it and use the image default again
oduflow call update_service '{"name":"minio","command":""}'
```

### 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, command, 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 (its preset is kept, so restore_service can bring it back)
oduflow call delete_service redis

# Delete a service and its saved preset, leaving nothing behind
oduflow call delete_service '{"name":"redis","save_preset":false}'

# 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`, `command`, `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.

In the dashboard, **Update** on a service card opens the same editor as the MCP tool: image, command, exposure (a single port or restricted path routes), hostname, environment variables, host network mode, volumes and capabilities, all prefilled with the service's current configuration (from its preset, or from the container for a service created before presets). Confirm to apply the changes and pull the latest image in one go; confirm without editing anything to just pull and recreate. Only the fields you actually edited are sent, so an open dialog cannot revert a setting changed concurrently over MCP. Clearing the env or volumes field removes every variable or unmounts every volume; clearing the command falls back to the image `CMD`. An empty hostname keeps the current one. Only `runtime` has no form field — change it over MCP or the CLI.

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`, `command`, `volumes`, `cap_add`, `privileged`) so you do not lose anything that `list_services` truncates or that lived only inside the preset.

### Protecting a Service

A service can be **protected** from the dashboard (the **Protect** button on its
card). While protected, `delete_service`, `update_service`, and
`restore_service` are refused — over MCP, the CLI, and the dashboard alike —
so an agent cannot recreate or remove a service that backs something
important. Restart and logs remain available. Protection can only be toggled
in the dashboard; there is no MCP tool for it, so an agent cannot lift it.

## 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`, `command`, `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 or updated, its configuration (image, port or routes, hostname, environment variables, volumes, `host_mode`, `command`, `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.

Deleting a service keeps its preset by default — `delete_service` takes
`save_preset` (the dashboard's delete dialog has a **Save as preset** checkbox,
ticked by default). Pass `save_preset=false` to drop the preset together with
the container. Services created before presets existed get theirs backfilled
once at server start (migration `0008-backfill-service-presets`), so old and
new services read from the same store; the delete result reports whether a
preset actually remains on disk.

```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
```

## Container lifecycle settings

MCP/REST `create_service` and `update_service` accept a `runtime` mapping.
It is also available on Stack services. Supported keys are `tmpfs` (only `/run`,
`/run/lock`, `/tmp`), `cgroupns: private`, `stop_signal` (`SIGTERM` or
`SIGRTMIN+3`) and `stop_timeout` (1–3600 seconds). For example:

```json
{
  "tmpfs": {"/run": "rw,nosuid,nodev,mode=755", "/tmp": "rw,nosuid,nodev,mode=1777"},
  "cgroupns": "private",
  "stop_signal": "SIGRTMIN+3",
  "stop_timeout": 240
}
```

In Stack manifests the keys follow the manifest-wide camelCase convention
(`stopSignal`, `stopTimeout`); the snake_case spellings shown above are also
accepted there.

Updates preserve these settings when omitted; `{}` clears the explicit overrides.
Presets and stack planning retain them. Stop/restart/replacement honors the old
container's timeout so changing an image does not truncate its shutdown grace
period. These settings do not grant privileges or make a systemd image healthy: an
image must implement its own readiness check, and its cgroup/capability requirements
must be verified on the deployment host. The `runtime` field does not accept
arbitrary Docker options or bind mounts from the host.

---

# 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 repo, only the branches in use (shared)
    custom-themes/       ← bare git repo, only the branches in use (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

Add 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 — store an access token first
oduflow call setup_repo_auth '{"repo_url": "https://github.com/odoo/enterprise.git", "token": "ghp_..."}'
oduflow call add_extra_repo enterprise https://github.com/odoo/enterprise.git
```

Adding a repository downloads **no code**. Oduflow checks access, reads the
remote branch list (`git ls-remote`) and creates an empty shared bare repo, so
even a multi-gigabyte repository with a branch per Odoo version is added in
seconds.

Each branch is downloaded the first time an environment or production uses it:
only that branch's latest commit, without history (`--depth 1`). A repository
that holds `16.0` to `19.0` therefore costs only the versions your environments
actually use. Once downloaded, a branch is kept up to date by
[`update_extra_repo`](#updating-extra-repos).

The first environment on a new branch waits for that download. To download a
branch ahead of time, select it when adding the repository (the dashboard's
**Load branches** picker, or `branches="18.0"` in `add_extra_repo`). For a
repository that is already added, open its **Branches** dialog in the
dashboard, or run `update_extra_repo(name, add_branch=["17.0", "18.0"])`.

### Managing branches in the dashboard

The **Branches** button on a repository card in the **Extra Addons** tab opens
a dialog that lists every branch: the downloaded ones first, with the
environments and productions that use each of them, then the rest of the
remote list.

- **Download selected** downloads the selected remote branches now and keeps
  them updated by later updates.
- **Refresh list** re-reads the remote branch list (`git ls-remote`) without
  downloading anything.
- **Remove** deletes a downloaded branch to free disk space: its ref, the
  cached checkouts that no environment pins, and the git objects nothing else
  references. A branch that an environment or production uses cannot be
  removed, and neither can any branch of a protected repository. Cached
  checkouts handed out in the last six hours are kept, since an environment
  being created holds one before it shows up as a user; so are objects
  downloaded within the last hour. A later removal reclaims them. A removed
  branch is downloaded again when an environment next uses it.

Removing a branch from a repository added by an older Oduflow version (one that
fetched every branch) switches that repository to the downloaded branches only,
so the next update does not bring the branch back.

A stalled HTTPS download is aborted after two minutes without progress; a slow
but progressing one may run up to an hour.

### Importing several OCA repositories

In the dashboard, open **Extra Addons → Import OCA**. Search the OCA catalogue
by name or description and sort by stars or name. Archived repositories are
hidden unless **Show archived** is enabled.

Like **Add Repo**, importing downloads no code by default: each branch
downloads when an environment first uses it. To have branches ready, enter
**Common versions**, for example `18.0, 19.0`. Each repository you select
starts from the current Common versions, and changing the field updates every
selected repository. Oduflow reads the branch list of each selected repository
without downloading anything; adjust its branch checkboxes as needed. Versions
a repository does not have are shown as unavailable: uncheck them or choose
another version before importing.

Click **Import N repositories** to start the import on the server. It adds up
to three repositories at a time, and each repository shows its own result. You
can close the dialog or reload the page: the import continues, and reopening the
dialog shows its progress. A team runs one import at a time. **Retry failed**
starts a new import of only the failed repositories, with the same branches.

Imported names use an `oca-` prefix, such as `oca-server-tools`. If two OCA
repositories would get the same name (for example `foo-bar` and `foo.bar`, or
very long names that are shortened), the second one gets a numeric suffix, such
as `oca-foo-bar-2`. Repositories the team already has are recognised by their
origin in any URL form (HTTPS, SSH, with or without `.git`) and cannot be
selected; neither can an import name that another repository already uses.
Import makes repositories available to the team; choose them separately when
configuring an environment.

The public catalogue is cached for 15 minutes. If GitHub cannot be reached or
limits requests, the dialog shows the error, or the last loaded catalogue with a
notice when one is available; no GitHub token is required. Import progress is
kept in server memory: after a server restart, repositories that were added
appear as already added, and the rest can be imported again.

## 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"
```

In the dashboard, the branch next to each repo is filled in from the **Odoo
image** of the environment being created (`odoo:19.0` → `19.0`), for every repo
that really carries such a branch — the usual case for a repository branched
per Odoo version, such as the OCA sets or enterprise. An online-release image
(`odoo:saas~19.1`) only ever gets its own `saas-19.1` branch, never the stable
`19.0`. A repo whose branches are not known yet shows the expected name as a
placeholder instead, and a repo that does not have that branch is left empty.
Anything a template, a production or you typed is never overwritten, and a
branch you cleared stays empty. Each selected repo whose branch was filled in
this way is marked **guessed**, and a line under the list counts them, so you
can check them before creating the environment. The same applies to the extra
repos in a template's **Settings**.

For each development environment Oduflow automatically:

1. Fetches the specified branch (downloading it on first use) 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`
   — modules may live either at the repository root or in a top-level
   `addons/` directory (the same convention as the main repo); in the latter
   case `addons_path` points at that subdirectory automatically
5. Installs the repo's `.oduflow/requirements.txt` / `.oduflow/apt_packages.txt`
   (with the same lookup rules as the [main repo's](environments.md#auto-dependency-installation)),
   so an extra repo declares its own Python/apt dependencies

## Managing Extra Repos

```bash
# List extra repos with downloaded and remote 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 refreshes the remote branch list and fetches every **downloaded** branch
into the **shared bare repository**, pruning branches deleted on the remote.
Branches that no environment has used yet stay undownloaded. It does **not**
change the checkout mounted by any running environment.

Downloaded branches are fetched incrementally, so the update reports how many
commits arrived and older commits stay available for production rollback.

Repositories added before on-demand downloads (Oduflow versions that cloned
every branch) keep that behaviour: their update still fetches all branches.

In the Web Dashboard's "Extra Addons" tab, each repo has an **Update** button,
and **Update all** fetches every repo that has a remote, one after another,
then shows a single summary of updated, unchanged and failed repos.

### 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 repo 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 or production 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. `plan`, `apply`, and `status` also accept
`--env-file` to supply `fromEnv` values from a dotenv file; see
[Secrets from a `.env` file](#secrets-from-a-env-file).

`apply` logs its progress at the `info` level. For output that shows only the
resulting plan plus any warnings, pass the global
`oduflow --log-level warning stack apply oduflow.yaml`
(see [CLI Reference](cli.md#global-options)).

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

      # Optional: replaces the image CMD. A shell-quoted string
      # ("server /data") is accepted and split into the same argv.
      command: ["freeswitch", "-nonat"]

      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
```

### Secrets from a `.env` file

`fromEnv` values do not have to come from exported shell variables. If a
`.env` file sits next to the manifest, `stack plan`, `stack apply`,
`stack status`, and the `--stack` startup reconciliation parse it and use it
for `fromEnv` lookups. Pass `--env-file path/to/file` to read a different
file instead; an explicitly named file must exist.

```dotenv
# .env — keep this file out of version control
FS_ESL_PASSWORD=s3cret
export ACME_PRIVATE_API_KEY="matching surrounding quotes are stripped"
```

The format is deliberately dumb: `KEY=VALUE` lines, blank lines and `#`
comments, an optional `export ` prefix. There is no `${VAR}` interpolation,
no escape processing, and no multi-line values; malformed lines and duplicate
keys are rejected. Real process environment variables override file values,
so CI can override a checked-in default without editing the file.

The file only feeds `fromEnv:` references. It never defines container
variables by itself, is never persisted anywhere, and never enters the
manifest hash. Add `.env` to `.gitignore`: it is the one file in a stack
directory meant to hold secrets.

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

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

Managed PostgreSQL credentials can be wired into an auxiliary service
without putting the generated secret in YAML:

```yaml
databases:
  events: {}

services:
  worker:
    image: example/worker:1
    port: 8080
    env:
      DATABASE_URL:
        database: events
        databaseField: url
      PGPASSWORD:
        database: events
        databaseField: password
```

Supported database fields are `url`, `host`, `port`, `database`, `username`,
and `password`. The database must be declared under `spec.databases`, and these
references are accepted only in auxiliary service environments. They cannot be
injected into the Odoo environment.

`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, volume, or database
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. managed PostgreSQL databases;
4. the Odoo environment;
5. text files in volumes;
6. auxiliary services;
7. 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, databases, 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.

Service definitions also accept the explicit [`runtime` lifecycle mapping](services.md#container-lifecycle-settings).
Stack planning detects changes to it and replacement preserves the declared settings.

## Production targets

Use `spec.production` instead of `spec.environment` for a long-lived production.
Exactly one target is required. The host must have `[production] enabled = true`
and Traefik routing. Production databases use the dedicated production cluster;
auxiliary `spec.databases` still use the shared development service database
cluster (the `cluster` option of `create_service_database` is not yet available
in Stack specs).

```yaml
apiVersion: oduflow.dev/v1alpha1
kind: Stack
metadata:
  name: control
spec:
  production:
    name: control
    domain: demo.example.org
    repoUrl: https://github.com/acme/control.git
    branch: production
    odooImage: odoo:19.0
    autoUpdate: false
    allowCopyToDevMcp: false
    serverMode: workers   # or gevent; see Production Hosting → Server mode
    env:
      APP_KEY: secret:control-key
    odooConf:
      workers: "2"
  services:
    gateway:
      image: acme/gateway:1
      port: 8080
      env:
        ODOO_URL:
          productionField: url
        ODOO_HOST:
          productionField: containerName
```

`productionField` supports `url`, `containerName` and `database`. Productions do
not have a development scoped MCP token; `environmentField` is rejected with a
production target. Production variables accept literals, `fromEnv` and `secret:`
references. Target variables cannot reference their own target or a service DB.
Named secret references remain references in the production registry.

A fresh production is created through the normal production lifecycle. Optional
`template` seeds it once. Stack does not promote or stop an existing development
environment. Use the production promotion API first if existing data must move.
Modules and application revisions are delivered through `update_production`;
Stack reconciliation does not pull branch commits or run schema migrations.

### Bringing an existing production under Stack management

First describe the existing production exactly, including its domain, source,
image, environment variables, extra repositories, update policy, copy policy and
configuration overrides. Add `adoptExisting: true`, then review `stack plan`.
`adopt production` records ownership in `productions.json` without restarting
Odoo or copying its database/filestore. An absent production, configuration drift,
a stopped/missing/foreign container, another Stack owner or an active deploy
blocks adoption. The one exception is a container whose Traefik routing
predates the current [server mode](production.md#server-mode) routes: the plan
shows the adoption as recreating the container, and apply does so from the
production's (matching) record. Remove `adoptExisting` after adoption if desired; doing so does
not trigger an update. The flag never creates a missing production.

Owned productions reconcile domain, image, variables, server mode, update/copy
policies and `odooConf` overrides through production operations.
Image/domain/environment/server-mode/conf changes can restart Odoo; database and filestore persist. Major Odoo version
changes still require a separate migration. Source repository, branch, git user,
extra repositories and seed-template changes are conflicts: use an explicit
production workflow for these changes instead of silently running different code.

Ownership lives in registry metadata, so container replacement retains it.
Stack holds the production lock as well as the team lock. It records an incomplete
apply before mutation and a completed fingerprint only after success; a retry
cannot mistake updated registry intent for a successfully replaced container.
`plan` and `status` remain read-only. A repeated successful apply is a no-op.

Service and volume ownership rules remain unchanged. Auxiliary resources managed
outside a Stack must remain outside its manifest; production adoption does not
implicitly adopt those resources. Removing declarations never deletes resources,
including a retained dev environment from a previous deployment layout.

---

# 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,
single-environment share links, and save-as-template actions.

Server configuration is not edited here: the deployment-wide
[Server settings console](admin.md) at `/admin` has its own password and its
own JSON API under `/admin/api/`. A browser signed in to the console sees a
**Server settings** link in the dashboard header.

The header's **Feedback** action opens a prefilled issue form on
`github.com/oduflow/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 (password from
`[team.*].ui_password`, plus TOTP when enabled). The login form creates an
HTTP-only session cookie; HTTP Basic credentials are rejected. See
[UI 2FA](security.md#enable-authenticator-app-2fa) for setup and recovery. 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` (merged per key over the template's), `git_user`, `from_production` (build from a dev copy of that production, through its managed `prod-<name>` template — published on first use; mutually exclusive with `template_name`), `base_branch` (when the branch does not exist on origin, create it from this branch and push it; the result's `branch_created_from` names the base when that happened) |
| `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 |
| `GET` | `/api/environments/{branch}/modules` | List installed modules (name and version) — the source for the dashboard's Upgrade modules picker |
| `POST` | `/api/environments/{branch}/modules` | Install or upgrade modules. Body: `action` (`install` or `upgrade`), `modules` (comma-separated string). A successful action returns `modules_installed` or `modules_upgraded` plus `container_restarted`; if the follow-up restart fails, the module action remains successful with `container_restarted: false` plus a warning. Every result includes `modules_attempted`; a non-zero Odoo exit code omits the success field and includes the command output |
| `POST` | `/api/environments/{branch}/switch-branch` | Move the environment onto another git branch, keeping DB, filestore and URL, then apply the difference. Body: `branch`, optional `new_name` to rename the environment at the same time. The response's `env_name` is the name to address it by afterwards |
| `POST` | `/api/environments/{branch}/update` | Recreate the container while preserving DB and filestore. Optional body: `env_vars` (full replacement of the user-supplied container variables — either an object mapping names to values, or a `KEY=VALUE` string separated by newlines or commas; an empty object/string clears them, an absent key keeps them). The dashboard sends an object, so a value may contain commas, `odoo_image`, `new_name` (rename the environment; a pooled or explicit hostname is kept, its scoped MCP endpoint moves to `/mcp/<new name>`; productions and stack members are refused) |
| `GET` | `/api/environments/{branch}/env-vars` | Return the user-supplied container environment variables |
| `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}/share` | Return the environment's share link status: `shared`, `url` (with its key) and `created_at` |
| `POST` | `/api/environments/{branch}/share` | Start sharing the environment, or return the existing link |
| `POST` | `/api/environments/{branch}/share/rotate` | Issue a new link; the previous one and every session opened with it stop working |
| `POST` | `/api/environments/{branch}/share/revoke` | Stop sharing the environment |
| `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.

The four `share` routes are operator-only: a shared session cannot read, reissue
or revoke the link it arrived on. See
[Shared environment links](#shared-environment-links).

## Shared environment links

`/env/{name}` is the same dashboard reduced to a single environment. An operator
opens **Share UI** on an environment card, which mints a link
`https://<team-host>/env/<name>?key=<secret>` and hands it to a client. Opening
it exchanges the key for an HTTP-only, SameSite=Strict cookie and redirects to
the clean `/env/<name>`, so the key leaves the address bar and browser history.
The link stays valid until it is regenerated or revoked from the same modal;
both act immediately on sessions already opened with it.

A shared session may only do the following, enforced server-side as a
default-deny allowlist (`src/oduflow/ui_scope.py`) and re-checked on every
request:

- see that environment's card, status, storage and logs;
- start, stop, restart and sync it, and install or upgrade modules;
- open its Odoo shell and `psql` consoles, use Connect As, and read its scoped
  MCP endpoint and Secret Key;
- use **Agent Chat**.

Everything else is out of scope: any other environment, every team-wide surface
(templates, services, volumes, extra addons, credentials, productions, host
statistics, license), the provisioning actions on the shared environment itself
(create, delete, update, recreate, switch branch, protect, save as template),
the share routes, and **Agent CLI**. API calls and state-changing requests are
refused with HTTP 403 and WebSockets close with 1008; a browser *navigating* to
another page — the full dashboard at `/`, a bookmark — is sent back to
`/env/<name>` with a 303 instead of being shown a raw error.

Leaving the shared view (**Logout**) clears the scoped cookie and returns to
`/env/<name>`, which then explains how to get back in: reopen the full share
link, or sign in at `/login` if you are an operator who was previewing it.

Agent CLI is excluded because it is a terminal in the *per-team* agent
container, whose workspace holds a checkout of every environment of the team.
Agent Chat runs in that same container, so the scope of a shared link is a
policy boundary on the dashboard — it is not the cryptographic confinement that
the per-environment token gives on `/mcp/<env>`. Share links with people you
would let work in the environment.

## 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 imports

Scheduled sync uses system cron; these routes save configuration or queue requests, never spawn a worker.

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/templates` | List template profiles. A template imported from a bucket carries an `s3_source` object (`url`, `endpoint`, `region`, `access_key`, `has_secret`, `dump`, `synced_at`) — the connection needed to re-sync it, never the secret key; `null` for every other template |
| `GET` | `/api/templates/{name}/sync` | Sync config, status, pending flag, cron-file presence and bounded log tail |
| `POST` | `/api/templates/{name}/sync` | Save `host`, `user`, `port`, absolute `path`, five-field `schedule`, boolean `enabled`; install managed cron entries |
| `DELETE` | `/api/templates/{name}/sync` | Remove managed cron entries, saved config, pending request and the downloaded backup copy; keep template data, status and logs |
| `POST` | `/api/templates/{name}/sync-now` | Queue a manual sync for cron's next minute tick |
| `POST` | `/api/environments/{branch}/template-sync` | Persist boolean `auto_refresh`; defaults to false; true authorizes recurring DB/filestore reset |
| `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-from-odoo` | UI-authenticated: import a template. Body: `source` (http(s) Odoo URL — then `master_pwd` required — or `s3://bucket/prefix` or a local path; `odoo_url` accepted as a legacy alias), `template_name`, optional `db_name`, booleans `without_filestore` / `overwrite` / `refresh`, optional `s3_endpoint` / `s3_access_key` / `s3_secret_key` / `s3_region` |
| `POST` | `/api/s3/browse` | UI-authenticated: list one level of an S3 bucket for the import dialog's object browser, or the buckets when `bucket` is empty. Body: `bucket`, `prefix`, `continuation_token`, `endpoint`, `region`, `access_key`, `secret_key`, and `template` to sign with that template's stored secret. Returns `buckets`, `folders`, `files` (newest first), `truncated`, `next_token`. Read-only, so not team-locked |
| `POST` | `/api/templates/import-token` | UI-authenticated: mint a 15-minute Odoo.sh import token. Body: `template_name`, optional booleans `with_enterprise` / `with_themes` / `with_extra_addons` (add the matching `--with-*` flags to the returned `command`) and `without_filestore` (adds `--without-filestore`; stored with the token, so finalize is database-only), optional `addon_error_policy` (`strict`, the default, or `best_effort`) |
| `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. `?without_filestore=1` (or `true`), sent by `--without-filestore`, or a token minted with `without_filestore` makes it database-only: staged filestore chunks are not promoted and an existing template keeps its filestore; other values are rejected. The result's `filestore` is `imported`, `kept` or `none`, next to `includes_filestore` |
| `GET` | `/import-odoo.sh` | Download the token-authenticated Odoo.sh import client |

The pull-import endpoint accepts HTTP and HTTPS source URLs. HTTP sends the Odoo
master password without transport encryption, so HTTPS remains recommended.
Loopback, link-local, metadata, and private-network targets remain blocked by
the outbound URL safety check.

Odoo.sh 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, PostgreSQL databases, 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. `command` accepts a shell-quoted string or an argv array and replaces the image `CMD` |
| `GET` | `/api/services/{name}/config` | Return the full configuration an update keeps (image, `port`/`routes`, hostname, env vars, `host_mode`, volumes, capabilities, `command`) — what the dashboard's Update dialog prefills. Env values configured as team secrets come back as their `secret:<name>` reference |
| `POST` | `/api/services/{name}/update` | Pull/change settings and recreate safely; `env_vars`, `volumes`, and `routes` are full replacements when supplied — omitting the key keeps the current value, sending an empty one clears it. Omit `command` to keep it, send `""`/`[]` to fall back to the image `CMD` |
| `POST` | `/api/services/{name}/restart` | Restart a service |
| `POST` | `/api/services/{name}/delete` | Delete a service (refused while protected). The optional body `{"save_preset": false}` removes the saved preset too; by default it is kept for `restore_service`. The result's `preset_kept` reports what is actually on disk afterwards |
| `POST` | `/api/services/{name}/protect` | Protect a service: Update, Restore and Delete are refused until unprotected |
| `POST` | `/api/services/{name}/unprotect` | Remove service protection |
| `GET` | `/api/services/{name}/logs?n=200` | Read service logs |
| `GET` | `/api/service-databases` | List managed sidecar databases without passwords |
| `POST` | `/api/service-databases/create` | Create a database and scoped role; body: `name`, optional `cluster` (`dev` default, or `prod` for the dedicated production cluster) |
| `POST` | `/api/service-databases/{name}/credentials` | Explicitly reveal connection credentials and `DATABASE_URL` |
| `POST` | `/api/service-databases/{name}/rotate` | Rotate the database role password |
| `POST` | `/api/service-databases/{name}/protect` | Protect a database: deletion is refused until unprotected |
| `POST` | `/api/service-databases/{name}/unprotect` | Remove database protection |
| `POST` | `/api/service-databases/{name}/delete` | Permanently drop the database and role, terminating connections (refused while protected) |
| `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 |

Service databases live in the shared development PostgreSQL cluster, inside the
current team's tablespace. They have a dedicated non-superuser owner and persist
independently of service containers. Bridge-mode services reach them through
the returned PostgreSQL container hostname; host-network services are not
supported because the cluster is not published on a host port.

## Extra addons and credentials

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/extra-repos` | List extra-addon repositories |
| `GET` | `/api/extra-repos/oca` | Public OCA catalogue (cached for 15 minutes) in `repos`: names, descriptions, stars, archive flags, unique import names, clone URLs, `existing_as` (the team repository with the same origin, if any) and `name_taken`; `stale` is true when an expired copy is served because GitHub is unavailable; `job` is the team's latest import (see below) |
| `GET` | `/api/extra-repos/oca/import` | The team's latest OCA import job, or `null`: `status` (`running`/`finished`) and per-repository `items` with `branches`, `status` (`queued`, `adding`, `done`, `error`, `skipped`) and `error` |
| `POST` | `/api/extra-repos/oca/import` | Start a server-side OCA import (202); body: `repos`, a list of `{"name": "<catalogue name>", "branches": [...]}` — `branches` are downloaded now, an empty list downloads each branch on first use. Clone URLs and import names come from the catalogue. 409 while the team's previous import is running |
| `POST` | `/api/extra-repos/add` | Add one; body: `name`, `repo_url`, optional `git_user`, optional `branches` (list of names to download now; empty = download each branch on first use) |
| `POST` | `/api/extra-repos/ls-remote` | List a remote's branches before adding; body: `repo_url`, optional `git_user` |
| `POST` | `/api/extra-repos/{name}/pull` | Fetch remote changes |
| `GET` | `/api/extra-repos/{name}/branches` | Downloaded branches (`downloaded`), the remote list (`available`; `available_known` is false until it was first read), whether the repo is `protected`, and the environments/productions using each branch (`usage`) |
| `POST` | `/api/extra-repos/{name}/branches` | Download branches now and keep them updated; body: `branches` (non-empty list of names) |
| `POST` | `/api/extra-repos/{name}/branches/remove` | Remove a downloaded branch, its unused cached checkouts and unreferenced objects; body: `branch`. Refused for a protected repo and while an environment or production uses it |
| `POST` | `/api/extra-repos/{name}/remote-branches` | Refresh the remote branch list (`git ls-remote`); downloads nothing |
| `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 an access token for a git host: body `token`, `host` (default `github.com`), optional `username`, optional `repo_url` to verify with `git ls-remote`; legacy body `repo_url` with inline `user:PAT@` |
| `POST` | `/api/credentials/delete` | Delete by body `host` and `username` |
| `POST` | `/api/credentials/validate` | Validate by body `host` and `username` |
| `GET` | `/api/ssh-key` | The team's SSH public key and fingerprint (only the public key is returned) |
| `POST` | `/api/ssh-key/generate` | Create the team SSH key if absent; body `{"force": true}` regenerates it (the old key stops working) |
| `GET` | `/api/secrets` | List team secret names, types (`value_type`) and timestamps; stored values are never returned by any endpoint |
| `POST` | `/api/secrets/{name}/set` | Create or replace a secret's value from string body field `value` (write-only); optional `value_type`: `text` or `json`. Omitted/null type preserves the existing type, defaulting to `text` for new secrets. A secret saved before types existed has the type of its stored value: a JSON object or array is `json`, so replacing it without `value_type` requires valid JSON — pass `value_type: "text"` to store plain text. Invalid JSON secret values or unknown types return HTTP 400 without changing the stored value |
| `POST` | `/api/secrets/{name}/update-json` | Create or replace one object key in an existing JSON secret, or replace an existing array element; body: `path` (dot notation such as `environment.OPENROUTER_API_KEY`, or JSON Pointer such as `/environment/OPENROUTER_API_KEY`) and `value` (a JSON value, including `null`). Returns `name`, `created` and `updated` flags; `created: true` means a new key was added, otherwise `updated: true`. Missing parents, invalid paths/values and text secrets return HTTP 400; missing secrets return HTTP 404. Arrays are never extended. Errors leave storage unchanged. See [JSON element updates](security.md#updating-one-json-element) |
| `POST` | `/api/secrets/{name}/delete` | Delete a secret; existing `secret:<name>` references stop resolving on the next create/update |

## 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 |
| `POST` | `/api/cleanup` | Dashboard **Cleanup** button. `{"force": false}` (default) is a read-only preview, like `oduflow cleanup --dry-run`: the team's orphaned databases, workspaces, port entries and PostgreSQL roles, plus server-wide unused Docker images (`id`, `tags`, `size_bytes`). `{"force": true}` removes only the reviewed names in `orphans` (`{"orphan_databases": [...], ...}`) that are still orphaned, and only the full image IDs in `image_ids` (opt-in). Images used by any container, base images of other images, image build staging images and images an operation requested in the last 6 hours are always kept. Failures are listed in `errors` and `images.errors` |
| `GET` | `/healthz` | Public health report; returns `200` when healthy, `503` when degraded |
| `GET` | `/api/version` | Installed version versus the latest GitHub release. Runs one live lookup per call, only when the dashboard version dialog asks for it |
| `GET` | `/api/license` | License information |
| `POST` | `/api/license/activate` | Activate body `key` |
| `POST` | `/api/license/checkout` | Exchange an expired manual license for a short-lived hosted annual checkout link; requires dashboard authentication and same-origin mutation checks |
| `POST` | `/api/license/refresh` | Manually check the installed annual license for a paid renewal and save its signed extension; returns `license`, `renewed` and `subscription_required` (the license server found no active subscription, so checkout is the next step). Requires dashboard authentication and the normal same-origin mutation checks. No request body is needed |
| `POST` | `/api/feedback/link` | Build a prefilled `github.com/oduflow/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 |

Under an active [Custom license](licensing.md#custom-license-white-label) the
`/api/license*`, `/api/version` and `/api/feedback/link` endpoints answer `404`:
the operator manages the license with `oduflow license` on the server.

## 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 (each with its OduMCP `mcp_status`: `ready`, `sync_failed`, `unsupported`, or empty) and return webhook/backup state and whether the team's `production_token` is set (`production_token_set`) |
| `POST` | `/api/productions/create` | Create a production from repository/image/domain settings, optionally seeded from a template, or promote a dev environment via body `from_environment` (inherits its repo/branch/image). Optional `server_mode`: `workers` (default) or `gevent`. Optional body `env_vars` (object mapping names to values, or a `KEY=VALUE` string separated by newlines or commas) overrides the inherited/template variables; an absent key inherits them, an empty object/string means none. `HOST`, `PORT`, `USER` and `PASSWORD` are reserved |
| `GET` | `/api/productions/backup-status` | Team backup, WAL-G, base-backup, and S3 health |
| `GET` | `/api/productions/wal-status` | Cached shared-cluster WAL queue, progress, disk headroom, sample age and protection latch; does not probe Docker or S3 on request. Read-only: cluster-wide WAL control (pause/resume/retry/recover/release) and cluster PITR are operator actions in the [server settings console](admin.md) |
| `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 |
| `POST` | `/api/productions/{name}/save-as-template` | Copy the production database and filestore into the dev template named by body `template_name`; optional `overwrite` re-baselines an existing template |
| `POST` | `/api/productions/{name}/copy-to-dev-mcp` | Set body `enabled` to allow or refuse agent-initiated (MCP) copies of this production into dev; the dashboard itself is never gated |
| `POST` | `/api/productions/{name}/reconfigure` | Change any of body `domain`, `extra_domains` (list or comma-separated string), `odoo_image`, `branch`, `repo_url`, `git_user`, `extra_addons`, `server_mode` (`workers`/`gevent`), `env_vars` (full replacement of the user-supplied container variables — object or `KEY=VALUE` string, same form as `/api/productions/create`), `recreate` (JSON `true` forces a recreate); recreates the container (database and filestore preserved) and also when a team secret it uses got a new value — the response lists those variables in `rotated_secrets`. A present-but-empty `git_user`, `extra_domains` or `env_vars` clears it; an absent key leaves it unchanged |
| `POST` | `/api/productions/{name}/odoo-conf` | Set body `options` and remove body `unset` per-production `odoo.conf` overrides; optional `restart` (default true) and `replace` (body `options` become the complete override set) |
| `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)

Oduflow exposes **108 tools**. They are reachable from any MCP client (Cursor,
Cline, Amp, Claude Code, …), locally with `oduflow call`, against a remote
Oduflow server with `oduflow client`, and — for a subset — over the
[REST API](web-api.md).

Every tool below is documented with its parameters, defaults and the situations
it is meant for. Jump straight to one from the index, or read a category
end to end.

!!! info "Locking"
    Many tools acquire a lock on exactly what they touch: **one environment**,
    **one production**, **one service / volume / database / template**, the
    **team's credential store** — or the **whole team**, for the few operations
    that really are team-wide (the template mutations that remount other
    environments' filestores). Each tool's section states its lock. Operations on *different* resources run in parallel. If another
    operation on the **same** resource is already in progress, the call is
    rejected with `BusyError`, naming 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.

!!! tip "Signatures straight from the server"
    `oduflow list` prints the current tool list, and `oduflow list --verbose`
    adds descriptions — always in sync with the running version.

## Tool index

<div class="grid cards odu-tool-index" markdown>

-   __[Environment Management](#environment-management)__

    ---

    [`create_environment`](#create_environment)&nbsp;·
    [`delete_environment`](#delete_environment)&nbsp;·
    [`recreate_environment`](#recreate_environment)&nbsp;·
    [`list_environments`](#list_environments)&nbsp;·
    [`get_environment_info`](#get_environment_info)&nbsp;·
    [`start_environment`](#start_environment)&nbsp;·
    [`stop_environment`](#stop_environment)&nbsp;·
    [`restart_environment`](#restart_environment)&nbsp;·
    [`update_environment`](#update_environment)&nbsp;·
    [`switch_branch`](#switch_branch)

-   __[Code Sync, Modules & Tests](#code-sync-modules-tests)__

    ---

    [`pull_and_apply`](#pull_and_apply)&nbsp;·
    [`install_odoo_modules`](#install_odoo_modules)&nbsp;·
    [`upgrade_odoo_modules`](#upgrade_odoo_modules)&nbsp;·
    [`run_odoo_tests`](#run_odoo_tests)&nbsp;·
    [`list_installed_modules`](#list_installed_modules)&nbsp;·
    [`get_environment_logs`](#get_environment_logs)&nbsp;·
    [`read_output`](#read_output)

-   __[Odoo Data Access](#odoo-data-access)__

    ---

    [`odoo_schema`](#odoo_schema)&nbsp;·
    [`odoo_search_read`](#odoo_search_read)&nbsp;·
    [`odoo_create`](#odoo_create)&nbsp;·
    [`odoo_write`](#odoo_write)&nbsp;·
    [`odoo_unlink`](#odoo_unlink)&nbsp;·
    [`odoo_call`](#odoo_call)&nbsp;·
    [`run_db_query`](#run_db_query)

-   __[Inside the Odoo Container](#inside-the-odoo-container)__

    ---

    [`read_file_in_odoo`](#read_file_in_odoo)&nbsp;·
    [`write_file_in_odoo`](#write_file_in_odoo)&nbsp;·
    [`search_in_odoo`](#search_in_odoo)&nbsp;·
    [`run_odoo_command`](#run_odoo_command)&nbsp;·
    [`run_odoo_shell`](#run_odoo_shell)&nbsp;·
    [`http_request_to_odoo`](#http_request_to_odoo)&nbsp;·
    [`reset_admin_password`](#reset_admin_password)&nbsp;·
    [`connect_as_user`](#connect_as_user)

-   __[Translations](#translations)__

    ---

    [`export_module_translations`](#export_module_translations)&nbsp;·
    [`translation_status`](#translation_status)

-   __[Template Management](#template-management)__

    ---

    [`save_as_template`](#save_as_template)&nbsp;·
    [`save_production_as_template`](#save_production_as_template)&nbsp;·
    [`list_templates`](#list_templates)&nbsp;·
    [`rename_template`](#rename_template)&nbsp;·
    [`delete_template`](#delete_template)&nbsp;·
    [`import_template`](#import_template)&nbsp;·
    [`refresh_template`](#refresh_template)&nbsp;·
    [`attach_filestore`](#attach_filestore)

-   __[Auxiliary Services](#auxiliary-services)__

    ---

    [`create_service`](#create_service)&nbsp;·
    [`update_service`](#update_service)&nbsp;·
    [`delete_service`](#delete_service)&nbsp;·
    [`restart_service`](#restart_service)&nbsp;·
    [`list_services`](#list_services)&nbsp;·
    [`get_service_info`](#get_service_info)&nbsp;·
    [`get_service_logs`](#get_service_logs)&nbsp;·
    [`run_service_command`](#run_service_command)

-   __[Service PostgreSQL Databases](#service-postgresql-databases)__

    ---

    [`create_service_database`](#create_service_database)&nbsp;·
    [`list_service_databases`](#list_service_databases)&nbsp;·
    [`get_service_database`](#get_service_database)&nbsp;·
    [`rotate_service_database_password`](#rotate_service_database_password)&nbsp;·
    [`delete_service_database`](#delete_service_database)

-   __[Volumes](#volumes)__

    ---

    [`create_volume`](#create_volume)&nbsp;·
    [`list_volumes`](#list_volumes)&nbsp;·
    [`inspect_volume`](#inspect_volume)&nbsp;·
    [`delete_volume`](#delete_volume)&nbsp;·
    [`read_file_in_volume`](#read_file_in_volume)&nbsp;·
    [`write_file_in_volume`](#write_file_in_volume)&nbsp;·
    [`search_in_volume`](#search_in_volume)&nbsp;·
    [`delete_file_in_volume`](#delete_file_in_volume)

-   __[Service Presets](#service-presets)__

    ---

    [`list_service_presets`](#list_service_presets)&nbsp;·
    [`restore_service`](#restore_service)&nbsp;·
    [`delete_service_preset`](#delete_service_preset)

-   __[Secrets](#secrets)__

    ---

    [`list_secrets`](#list_secrets)

-   __[Container Image Builds](#container-image-builds)__

    ---

    [`start_image_build`](#start_image_build)&nbsp;·
    [`get_image_build`](#get_image_build)&nbsp;·
    [`publish_image_build`](#publish_image_build)&nbsp;·
    [`cancel_image_build`](#cancel_image_build)

-   __[Repository Auth](#repository-auth)__

    ---

    [`setup_repo_auth`](#setup_repo_auth)&nbsp;·
    [`get_ssh_public_key`](#get_ssh_public_key)

-   __[Extra Addons](#extra-addons)__

    ---

    [`add_extra_repo`](#add_extra_repo)&nbsp;·
    [`list_extra_repos`](#list_extra_repos)&nbsp;·
    [`update_extra_repo`](#update_extra_repo)&nbsp;·
    [`delete_extra_repo`](#delete_extra_repo)

-   __[Production Hosting](#production-hosting)__

    ---

    [`create_production`](#create_production)&nbsp;·
    [`list_productions`](#list_productions)&nbsp;·
    [`get_production_info`](#get_production_info)&nbsp;·
    [`production_logs`](#production_logs)&nbsp;·
    [`start_production`](#start_production)&nbsp;·
    [`stop_production`](#stop_production)&nbsp;·
    [`restart_production`](#restart_production)&nbsp;·
    [`reconfigure_production`](#reconfigure_production)&nbsp;·
    [`set_production_odoo_conf`](#set_production_odoo_conf)&nbsp;·
    [`delete_production`](#delete_production)

-   __[Production Deployment](#production-deployment)__

    ---

    [`update_production`](#update_production)&nbsp;·
    [`rollback_production`](#rollback_production)&nbsp;·
    [`set_production_auto_update`](#set_production_auto_update)&nbsp;·
    [`production_deploys`](#production_deploys)

-   __[Production Backup & Recovery](#production-backup-recovery)__

    ---

    [`snapshot_production`](#snapshot_production)&nbsp;·
    [`list_production_snapshots`](#list_production_snapshots)&nbsp;·
    [`restore_production`](#restore_production)&nbsp;·
    [`set_production_backup_schedule`](#set_production_backup_schedule)&nbsp;·
    [`prune_production_backups`](#prune_production_backups)&nbsp;·
    [`production_backup_status`](#production_backup_status)&nbsp;·
    [`production_wal_status`](#production_wal_status)

-   __[Production Odoo API](#production-odoo-api)__

    ---

    [`sync_production_mcp`](#sync_production_mcp)&nbsp;·
    [`production_odoo_info`](#production_odoo_info)&nbsp;·
    [`production_odoo_read`](#production_odoo_read)&nbsp;·
    [`production_odoo_preview_change`](#production_odoo_preview_change)&nbsp;·
    [`production_odoo_change_status`](#production_odoo_change_status)&nbsp;·
    [`production_odoo_execute_change`](#production_odoo_execute_change)

-   __[Agent Guidance & Feedback](#agent-guidance-feedback)__

    ---

    [`get_agent_instructions`](#get_agent_instructions)&nbsp;·
    [`get_odoo_development_guide`](#get_odoo_development_guide)&nbsp;·
    [`report_issue`](#report_issue)

</div>

## Environment Management

Ephemeral, isolated Odoo environments — one per git branch. See
[Environment Management](environments.md) for the concepts behind them.

### `create_environment`

Provision a new ephemeral Odoo environment: clone the repository, copy the
template database, mount the filestore, start the container and route it.

Safe to call first, without listing environments: if one already exists under
this name it is **returned as is** — with its URL, and started when it was
stopped — and nothing is recreated. The single refusal is a branch mismatch: an
environment tracking another branch is left alone, since its database and URL
are in use. Move it with [`switch_branch`](#switch_branch), pass a different
`env_name`, or delete it first.

**Parameters**

`branch`
:   *str · required* — The git branch to clone (e.g. `19.0`, `feature/my-feature`).

`env_name`
:   *str · default empty* — Environment name. Empty defaults to the branch name. Use it to create several environments from the same branch (e.g. `env_name="client-a"` with `branch="19.0"`).

`template_name`
:   *str · default empty* — Template profile to use as the database template. Pass `"none"` to skip the template and initialise Odoo from scratch with `-i base`. When a template is given, `repo_url` and `odoo_image` are loaded from its metadata (and can still be overridden), and installed modules whose manifest `version` in the checkout is higher than in the template database are upgraded before the environment is reported ready.

`repo_url`
:   *str · default empty* — Git repository URL. Optional when `template_name` supplies it.

`odoo_image`
:   *str · default empty* — Full Docker image with tag (e.g. `odoo:19.0`). Optional when `template_name` supplies it.

`extra_addons`
:   *str · default empty* — Comma-separated extra addon repos **with branches**, e.g. `"enterprise:19.0,custom-themes:main"`. The branch after the colon is mandatory.

`sanitize`
:   *bool · default `True`* — Run Odoo's native neutralization (deactivates outgoing mail servers and crons, disables payment providers, scrubs third-party API credentials, sets `database.is_neutralized`) plus any custom scripts in the repository's `.oduflow/odoo_sanitize/`. Only applies to environments created from a template.

`auto_install_modules`
:   *str · default empty* — Comma-separated modules to install right after provisioning (e.g. `"sale,purchase,stock"`). Loaded from template metadata when a template is used and this is empty.

`env_vars`
:   *str · default empty* — Comma- or newline-separated `KEY=VALUE` pairs injected into the container. Commas inside values are preserved unless what follows looks like another `KEY=`; put one pair per line when in doubt. Merged per key over the template's own values, with these winning. A value `secret:<name>` references a [team secret](#list_secrets).

`hostname`
:   *str · default empty* — Short Traefik hostname (e.g. `"qa"` → `qa.example.com`). Replaces the team prefix in either hostname mode; a bare team domain (`example.com`) has no prefix, so the name goes under it — again `qa.example.com`.

`from_production`
:   *str · default empty* — Build this environment from a production's real data (database + filestore + repo/image/extra addons). Mutually exclusive with `template_name` and `local_path`. The copy goes through one managed `prod-<name>` template, published on first use and reused afterwards.

`local_path`
:   *str · default empty* — **Local fast path.** Absolute path to a checkout on this host: Oduflow skips the clone and bind-mounts the directory live, so file edits are visible instantly with no git round-trip. `repo_url` is not required. Gated by `allow_local_path`.

**Use it when**

- A new feature branch needs its own running Odoo with realistic data.
- You want a throwaway copy of production data to reproduce a customer bug (`from_production`).
- You are iterating on code on this machine and want edits live in the container (`local_path`).
- An agent needs a safe target: calling it repeatedly is idempotent, not destructive.

```bash
oduflow call create_environment '{
  "branch": "feature/invoice-report",
  "template_name": "acme-19",
  "extra_addons": "enterprise:19.0",
  "auto_install_modules": "sale,account"
}'
```

### `delete_environment`

Stop and remove every resource associated with an environment — container,
database, filestore, ports and routing.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment to tear down.

**Use it when**

- A branch is merged and its environment is no longer needed.
- You need to free an environment slot and no existing environment is worth keeping (otherwise prefer [`switch_branch`](#switch_branch)).
- The environment itself is being replaced, not retired — then use [`recreate_environment`](#recreate_environment) instead, so its share link and MCP token survive.

### `recreate_environment`

Tear an environment down and provision it again from its recorded
configuration (git branch, repo URL, image, template, extra addons, env vars,
hostname) — a clean slate without re-entering anything.

Unlike `delete_environment` + `create_environment`, the environment's identity
survives: the dashboard **share link** already handed out stays valid, and the
scoped `/mcp/<env>` endpoint keeps the **same Bearer token**, so connected MCP
clients need no reconfiguration.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment to tear down and provision again.

**Use it when**

- A database or filestore is beyond repair and you want a fresh start from the same template/branch.
- The environment is shared with a client or wired into MCP clients — never delete + create it by hand in that case.

### `list_environments`

List all managed environments with status, URL, current git branch,
creation / last-activity / stopped timestamps, stop source, protection, Stack
ownership and operator note.

**Parameters**

*None.*

**Use it when**

- Taking stock before creating another environment (slot pressure).
- Finding which environments are idle and can be reused or reclaimed.

### `get_environment_info`

Full details for one environment: lifecycle and reuse metadata, database name,
URL, repository, image, template, extra addons, workspace path, container
status, and CPU/RAM stats. Fails with "does not exist" when the environment
has neither an Odoo container nor a workspace.

**Parameters**

`env_name`
:   *str · required* — The environment to inspect.

**Use it when**

- You need the URL or database name to hand to someone or to another tool.
- Diagnosing "is it actually running, and what is it running?" before digging into logs.
- Checking which template and extra addons an environment was built from.

### `start_environment`

Start all containers for a stopped environment.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment to start.

`wait`
:   *bool · default `True`* — Wait for Odoo to become ready, polling `/web/health` every 2 seconds for up to 120 seconds.

**Use it when**

- Resuming work on an environment the idle reaper stopped.
- A scripted flow must not continue until Odoo answers (`wait=True`).

### `stop_environment`

Stop the Odoo container, keeping the database, filestore and configuration.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment to stop.

**Use it when**

- Freeing RAM and CPU on a busy host without losing the environment.
- Parking an environment you will come back to.

### `restart_environment`

Restart the Odoo container. Python code is reloaded; the database is untouched.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment to restart.

`wait`
:   *bool · default `True`* — Wait for Odoo to become ready, polling `/web/health` every 2 seconds for up to 120 seconds.

**Use it when**

- Only Python logic changed and no database-backed definition moved (otherwise upgrade the module — see [`pull_and_apply`](#pull_and_apply)).
- Odoo is wedged and you want a clean process before investigating further.

### `update_environment`

Re-create the Odoo container **without losing the database or filestore**.
Pulls the target image and rebuilds the container.

With no arguments it simply rebuilds from the current image and configuration —
the fix for a broken container (packages accidentally removed, system files
corrupted) reconnected to the existing data.

If APT/PIP setup fails after recreation, the response reports the failure and
setup logs together with the resulting environment identity. A completed rename
is retained, including the agent checkout and its MCP configuration; use the
reported new name and endpoint even when dependency setup failed.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment to update.

`env_vars`
:   *str · default empty* — Comma- or newline-separated `KEY=VALUE` pairs that **fully replace** the current user-supplied variables. Empty keeps them. Database connection variables (`HOST`/`USER`/`PASSWORD`) are always preserved. `secret:<name>` references are supported.

`odoo_image`
:   *str · default empty* — New Docker image with tag to pull and run (e.g. `odoo:19.0`). Empty keeps the current image.

`hostname`
:   *str · default empty* — New short Traefik hostname (e.g. `"qa"` → `qa.example.com`). Changes the public URL.

`new_name`
:   *str · default empty* — Rename the environment. The database, filestore, ports and credentials move with it. A pooled or explicit hostname is kept, so the URL stays; a name-derived hostname follows the new name. **The scoped MCP endpoint moves** from `/mcp/<old>` to `/mcp/<new>`, so MCP clients must be re-pointed. Productions and stack members cannot be renamed here.

**Use it when**

- Bumping the Odoo image (e.g. `odoo:18.0` → `odoo:19.0`) while keeping the data.
- Changing container environment variables or wiring in a new secret.
- The container is damaged and you want a fresh one on the same data.
- The environment's name no longer describes what it holds. (If it should also move onto another branch, use [`switch_branch`](#switch_branch) — it renames along the way.)

```bash
oduflow call update_environment '{"env_name": "main", "odoo_image": "odoo:19.0"}'
```

### `switch_branch`

Move an existing environment onto another git branch. Everything except the
code stays: the database, filestore, URL / hostname, ports, database
credentials and the scoped MCP token.

Reach for this when the team has **no free environment slots** and a previous
branch is finished (merged), or when that database and URL are worth keeping —
it replaces "delete the old environment, create a fresh one", which re-clones
the repository and re-copies the template database. With slots still free,
[`create_environment`](#create_environment) is simpler.

The branch must already exist on origin, so push it first. Oduflow diffs the old
and new tips and applies exactly the logic of [`pull_and_apply`](#pull_and_apply).
Errors and tracebacks come back in the response — do not chase them in the logs.

Switching recreates the container and restores declared APT/PIP dependencies
from the target branch and every mounted extra-addons repository, even when
the dependency files are unchanged or the branches have identical code.
Renaming on the same branch also restores dependencies. Packages installed
manually without a corresponding requirements/APT entry are not preserved.

`strict=True` blocks incomplete module actions; it does not roll back a branch
switch that has already happened. Dependencies for the currently mounted code
are still restored before returning the refusal. If dependency installation
fails, module actions are skipped and the error is returned. Fix the dependency
error in the environment checkout or package source, run
[`update_environment`](#update_environment) to restore packages, then retry the
module actions using the explicit `pull_and_apply` arguments included in the
failure message. The structured result also retains `pending_actions` and
`retry`: a plain automatic retry cannot recover the consumed Git diff.

On a same-branch rename, any extra-addons mount changes share one container
recreation and dependency installation. A strict refusal prevents the rename;
an installation or module error after the rename reports the new identity.
Repositories without dependency files do not trigger an extra restart merely
because their dependencies were checked.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment to move.

`branch`
:   *str · required* — Target git branch; it must already exist on origin.

`install`
:   *str · default empty* — Comma-separated modules to install (`-i`). Empty leaves classification to Oduflow.

`upgrade`
:   *str · default empty* — Comma-separated modules to upgrade (`-u`). Empty leaves classification to Oduflow.

`restart`
:   *bool · default `False`* — Restart the container (for Python-only differences).

`strict`
:   *bool · default `False`* — Refuse instead of warning when the requested action looks incomplete for the diff.

`extra_addons`
:   *str · default empty* — Extra addon repos with branches (e.g. `"enterprise:19.0"`) to switch along with the main repo. Empty keeps the current ones.

`new_name`
:   *str · default empty* — Rename the environment in the same operation. The URL is kept, but the scoped MCP endpoint moves to `/mcp/<new name>`.

**Use it when**

- Out of environment slots and an old branch is merged.
- A long-lived QA database and URL should follow a new branch.
- Keeping stakeholders on a stable link while the code underneath changes.

!!! warning "Database compatibility is not checked"
    Switching does not inspect which modules are installed in the retained
    database. If the target code is incompatible, the apply command returns the
    real failure or Odoo reports it at runtime. Live-mounted environments are
    rejected — there the checkout is yours, so switch the branch in it and call
    [`pull_and_apply`](#pull_and_apply).

## Code Sync, Modules & Tests

### `pull_and_apply`

Sync the latest code into an environment and apply the right Odoo action. This
is the main development loop tool.

It works for both code-delivery modes, chosen automatically per environment:

- **git** — pulls the branch and resolves extra addons to shared immutable SHA checkouts before applying changes.
- **live-mount** (from `create_environment(local_path=...)`) — your edits are already on disk; this just applies them, no git needed.

There are two ways to drive it:

- **Explicit** *(recommended — you know what you changed)*: pass `install` / `upgrade` and/or `restart=True`. A guardrail compares your request against the detected changes and appends non-blocking warnings if something looks missing. `strict=True` refuses instead of warning.
- **Auto** *(leave everything empty)*: Oduflow classifies the changed files and decides install / upgrade / restart / refresh itself. Best when pulling commits you did not author.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment to apply changes to.

`install`
:   *str · default empty* — Comma-separated modules to install (`-i`).

`upgrade`
:   *str · default empty* — Comma-separated modules to upgrade (`-u`).

`restart`
:   *bool · default `False`* — Restart the Odoo container (for Python-only changes).

`strict`
:   *bool · default `False`* — Refuse to apply when the guardrail finds a likely missing action, instead of warning and applying anyway.

`summary_only`
:   *bool · default `False`* — Return a compact one-line action/status summary plus an `output_id` instead of command logs and changed-file names. The raw output stays available through [`read_output`](#read_output).

**What to pass for which change**

| You changed | Pass |
|---|---|
| View/QWeb XML, JS, CSS only | nothing — refresh the browser |
| Python logic / methods (no new fields or models) | `restart=True` |
| A field, model, security rule, data record, `ir.cron`, mail template, or manifest `data`/`depends` | `upgrade="module"` |
| A brand-new module | `install="module"` |
| `requirements.txt`, `.oduflow/requirements.txt`, `.oduflow/apt_packages.txt` | nothing — dependencies are reinstalled and the container restarted automatically |

!!! note "Removed dependencies"
    Packages deleted from a requirements file are not uninstalled until the
    container is rebuilt with [`update_environment`](#update_environment).

**Use it when**

- Every time you push code and want it live in the environment.
- Pulling someone else's commits and you are not sure what they touched (auto mode).
- A CI-style flow needs a single call that both syncs and applies.

!!! tip "Errors come back inline"
    Errors and tracebacks are returned directly in the response — do **not**
    call [`get_environment_logs`](#get_environment_logs) to look for them. With
    `summary_only=True` they are not inlined: read the full log with
    [`read_output`](#read_output).

```bash
oduflow call pull_and_apply '{"env_name": "main", "upgrade": "sale_custom"}'
```

### `install_odoo_modules`

Install Odoo modules (`odoo -i`) in an environment.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment.

`modules`
:   *str · required* — Comma-separated modules to install (e.g. `"sale,crm,web"`).

**Use it when**

- Adding a module to an existing environment without pulling new code.
- Setting up prerequisites before [`run_odoo_tests`](#run_odoo_tests) — testing an uninstalled module yields "0 of 0 tests".

### `upgrade_odoo_modules`

Upgrade already-installed Odoo modules (`odoo -u`).

Unknown or uninstalled modules are rejected with a suggestion to use
[`install_odoo_modules`](#install_odoo_modules) or
`pull_and_apply(install=...)` instead.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment.

`modules`
:   *str · required* — Comma-separated modules to upgrade (e.g. `"sale,crm,web"`), or `"all"` on its own to upgrade every installed module (`odoo -u all`).

**Use it when**

- A model, field, view, security rule or data record changed and must be reloaded into the database.
- Re-applying a module's data files after editing them by hand.

### `run_odoo_tests`

Run Odoo tests for specific modules. The modules must already be installed —
by default the run happens through an upgrade (`-u`).

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment.

`modules`
:   *str · required* — Comma-separated already-installed modules to test.

`test_tags`
:   *str · default empty* — Odoo `--test-tags` expression narrowing the run: `"/my_module:TestInvoice"` (one class), `"/my_module:TestInvoice.test_total"` (one method), `"-slow"` (exclude a tag). Comma-separated, no spaces. Empty runs every test of the listed modules. With `upgrade=False`, positive selectors must include one of the requested modules (e.g. `"slow/my_module"`); exclusion-only selectors are scoped automatically.

`upgrade`
:   *bool · default `True`* — Upgrade the modules before testing. Set `False` for a much faster re-run when the code is already loaded — but note Odoo then collects only **`post_install`** tests, so plain `TransactionCase` classes at the default `at_install` position report "0 tests". If a class you expect does not run, re-run with `upgrade=True`.

`summary_only`
:   *bool · default `False`* — Return only Odoo's aggregate `N failed, M error(s) of K tests` line and an `output_id`; the full output stays available through [`read_output`](#read_output).

**Use it when**

- Verifying a change before opening a pull request.
- Iterating on one failing test — narrow with `test_tags` rather than spending minutes on a full module upgrade.
- Driving CI from an agent: `summary_only=True` keeps the response small and the log reachable.

```bash
oduflow call run_odoo_tests '{
  "env_name": "main",
  "modules": "sale_custom",
  "test_tags": "/sale_custom:TestInvoice.test_total",
  "upgrade": false,
  "summary_only": true
}'
```

### `list_installed_modules`

List Odoo modules and their states as a table of name, state and installed
version. By default only installed modules are shown.

**Parameters**

`env_name`
:   *str · required* — The environment.

`name_filter`
:   *str · default empty* — Substring match on the module name (e.g. `"sale"` matches `sale`, `sale_management`, `pos_sale`).

`state_filter`
:   *str · default `"installed"`* — Module state: `installed`, `uninstalled`, `to upgrade`, `to install`. Pass an empty string for all states.

**Use it when**

- Checking whether a dependency is present before installing or testing.
- Auditing what a template-provisioned database actually has enabled.
- Finding modules stuck in `to upgrade` after a failed run.

### `get_environment_logs`

Retrieve the last N lines from the environment's Odoo container log.

**Parameters**

`env_name`
:   *str · required* — The environment.

`n_lines`
:   *int · default `100`* — Number of recent log lines to retrieve.

`grep`
:   *str · default empty* — Case-insensitive substring filter — useful for finding a specific error, module or message.

`level`
:   *str · default empty* — Odoo log level filter: `ERROR`, `WARNING` or `CRITICAL`. Combines with `grep`.

**Use it when**

- Odoo is running but misbehaving at runtime (a cron, a request, a worker).
- Tracking a warning that does not surface in a tool response.

!!! note "Not for apply failures"
    [`pull_and_apply`](#pull_and_apply), [`switch_branch`](#switch_branch) and
    the module tools already return their errors and tracebacks inline. Reach
    for the container log for *runtime* problems, not for those.

### `read_output`

Read from a cached tool output by ID. Tools that can produce large output
(installs, upgrades, tests, `pull_and_apply`) cache it server-side and return
an `output_id`; this tool explores it without re-running anything.

**Parameters**

`output_id`
:   *str · required* — The cached output ID (e.g. `"a3f7c012"`) returned by the original tool.

`mode`
:   *str · default `"lines"`* — `lines` (a range, paginated with `start`/`end`, default first 200), `errors` (only ERROR/WARNING/CRITICAL with ±5 lines of context), `grep` (case-insensitive substring search with line numbers), `info` (metadata only — line count, char count, error count, source tool), `tail` (last 100 lines).

`start`
:   *int · default `1`* — First line to return, 1-indexed. Used with `lines` and `grep`.

`end`
:   *int · default `0`* — Last line to return. `0` means `start+200` for `lines`, all results for `grep`.

`grep`
:   *str · default empty* — Search pattern for `mode="grep"`; case-insensitive substring.

**Use it when**

- A `summary_only=True` call reported a failure and you need the traceback.
- A test run produced thousands of lines and you want only the errors (`mode="errors"`).
- Paging through a long install log without flooding the conversation.

```bash
oduflow call read_output '{"output_id": "a3f7c012", "mode": "errors"}'
```

## Odoo Data Access

The `odoo_*` tools talk to the live Odoo HTTP server, exactly like an external
RPC client. Two consequences worth internalising:

- Edited **Python** code stays invisible until the environment is restarted ([`pull_and_apply`](#pull_and_apply) / [`restart_environment`](#restart_environment)); XML views do reload.
- Every call is **its own committed transaction**. Use [`run_odoo_shell`](#run_odoo_shell) when you need a fresh registry, `sudo()`, private methods, a rollback, or several steps in one transaction.

### `odoo_schema`

Inspect the Odoo schema: list models, or describe one model's fields
(XML-RPC `fields_get`).

Call this **before** writing a domain or a values dict — guessing field names
is the most common cause of an empty result set or a confusing error.

**Parameters**

`env_name`
:   *str · required* — The environment.

`model`
:   *str · default empty* — Technical model name (e.g. `sale.order`). Empty lists models instead.

`name_filter`
:   *str · default empty* — Substring filter — on model names when listing models, on field names when describing a model.

`attributes`
:   *str · default `"string,type,relation,required,readonly,selection"`* — Comma-separated field attributes to return. Odoo prunes them server-side, so a short list keeps the response small. Empty string returns every attribute.

`as_user`
:   *str · default empty* — Login or numeric id to inspect as (empty = admin). Field visibility can differ per user.

`limit`
:   *int · default `200`* — Maximum models when listing (`0` = no limit). Ignored when describing one model.

`offset`
:   *int · default `0`* — Models to skip when listing, for paging.

**Use it when**

- You need the exact technical name of a field before querying or writing.
- Finding which models a custom addon added.
- Checking whether a field is required, readonly, or a relation — and to what.

### `odoo_search_read`

Search and read records — the ORM equivalent of XML-RPC `search_read`, with
access rights and record rules applied.

Prefer this over [`run_odoo_shell`](#run_odoo_shell) for reading data: it is far
faster and returns JSON you can parse.

**Parameters**

`env_name`
:   *str · required* — The environment.

`model`
:   *str · required* — Technical model name (e.g. `res.partner`).

`domain`
:   *str · default `"[]"`* — Odoo search domain as JSON (e.g. `'[["state","=","sale"]]'`). A single bare leaf is accepted and wrapped for you.

`fields`
:   *str · default empty* — Comma-separated field names, or a JSON array. **Always pass this** — reading every field pulls binary columns and blows up the response.

`limit`
:   *int · default `80`* — Maximum rows, applied server-side.

`offset`
:   *int · default `0`* — Rows to skip, for paging.

`order`
:   *str · default empty* — SQL-style ordering (e.g. `"date_order desc, id"`).

`count_only`
:   *bool · default `False`* — Return only the number of matching records (`search_count`); `fields` and `limit` are ignored.

`as_user`
:   *str · default empty* — Login or numeric user id to run as. Empty = the environment's admin.

`context`
:   *str · default empty* — JSON object added to the call context (e.g. `'{"lang": "fr_FR", "active_test": false}'`).

**Use it when**

- Reading business data to verify a change took effect.
- Checking what a *particular* user can see (`as_user="portal@example.com"`) — permissions bugs show up here and nowhere else.
- Counting records cheaply (`count_only=True`).
- Including archived records (`context='{"active_test": false}'`).

```bash
oduflow call odoo_search_read '{
  "env_name": "main",
  "model": "sale.order",
  "domain": "[[\"state\",\"=\",\"sale\"]]",
  "fields": "name,partner_id,amount_total",
  "limit": 10
}'
```

### `odoo_create`

Create one or many records — the ORM equivalent of XML-RPC `create`. Returns
the new ids.

!!! warning "Committed on success — no dry run"
    For a deliberate rollback, or several steps that must succeed or fail
    together, use [`run_odoo_shell`](#run_odoo_shell). If the call times out,
    **verify with a read before retrying** — a repeat can create duplicates.

**Parameters**

`env_name`
:   *str · required* — The environment.

`model`
:   *str · required* — Technical model name (e.g. `res.partner`).

`values`
:   *str · required* — JSON object of field values, or a JSON array of such objects to create several records in one call.

`as_user`
:   *str · default empty* — Login or numeric user id to run as. Empty = admin.

`context`
:   *str · default empty* — JSON object added to the call context.

**Use it when**

- Seeding test data for a scenario.
- Reproducing a customer record that triggers a bug.

### `odoo_write`

Update records — the ORM equivalent of XML-RPC `write`.

!!! warning "Committed on success — no dry run"
    Use [`run_odoo_shell`](#run_odoo_shell) when you need a rollback or one
    transaction across several steps.

**Parameters**

`env_name`
:   *str · required* — The environment.

`model`
:   *str · required* — Technical model name.

`ids`
:   *str · required* — Record ids: `"42"`, `"1,2,3"` or `"[1,2,3]"`.

`values`
:   *str · required* — JSON object of field values to set.

`as_user`
:   *str · default empty* — Login or numeric user id to run as. Empty = admin.

`context`
:   *str · default empty* — JSON object added to the call context.

**Use it when**

- Flipping a record into the state a test needs.
- Archiving instead of deleting (`values='{"active": false}'`) — usually what is actually wanted.

### `odoo_unlink`

Delete records — the ORM equivalent of XML-RPC `unlink`.

!!! danger "Destructive and immediate"
    The records are gone when this returns, and there is no rollback. Confirm
    the target set with [`odoo_search_read`](#odoo_search_read) first.
    **Archiving** (`active = false` via [`odoo_write`](#odoo_write)) is usually
    what is actually wanted.

**Parameters**

`env_name`
:   *str · required* — The environment.

`model`
:   *str · required* — Technical model name.

`ids`
:   *str · required* — Record ids to delete: `"42"`, `"1,2,3"` or `"[1,2,3]"`.

`as_user`
:   *str · default empty* — Login or numeric user id to run as. Empty = admin.

`context`
:   *str · default empty* — JSON object added to the call context.

**Use it when**

- Cleaning up records you created for a test, in a throwaway environment.

### `odoo_call`

Call a public Odoo model method — the XML-RPC `execute_kw` escape hatch for
everything the dedicated tools do not cover: `read_group`, `name_search`,
`default_get`, `copy`, `message_post`, `action_confirm`, and any method a
custom addon exposes.

`ids` is prepended as the first positional argument, so `model="sale.order"`,
`method="action_confirm"`, `ids="42"` sends `args=[[42]]`. Leave `ids` empty for
model-level (`@api.model`) methods.

**Parameters**

`env_name`
:   *str · required* — The environment.

`model`
:   *str · required* — Technical model name (e.g. `sale.order`).

`method`
:   *str · required* — Public method name. The CRUD mutations `create`, `write` and `unlink` are **rejected** here — use their dedicated tools.

`ids`
:   *str · default empty* — Record ids prepended as the first positional argument. Empty for model-level methods.

`args`
:   *str · default `"[]"`* — JSON array of the remaining positional arguments.

`kwargs`
:   *str · default `"{}"`* — JSON object of keyword arguments.

`as_user`
:   *str · default empty* — Login or numeric user id to run as. Empty = admin.

`context`
:   *str · default empty* — JSON object added to the call context.

**Use it when**

- Grouping and aggregating: `method="read_group"`, `args='[[], ["amount_total:sum"], ["partner_id"]]'`.
- Autocomplete lookups: `method="name_search"`, `kwargs='{"name": "Acme"}'`.
- Triggering business logic: `method="action_confirm"`, `ids="42"`.

!!! note "Private methods are rejected"
    Methods with a leading underscore are refused here (Odoo 19 refuses them
    server-side too). Use [`run_odoo_shell`](#run_odoo_shell) for those.

### `run_db_query`

Execute SQL directly against the environment's PostgreSQL database.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment.

`query`
:   *str · required* — SQL to execute (e.g. `"SELECT id, name FROM res_partner LIMIT 10"`).

`output_format`
:   *str · default `"csv"`* — `csv` (compact, good for agents) or `human` (pretty table — use when relaying results to a person).

`max_rows`
:   *int · default `100`* — Maximum rows returned. The query itself is **not** modified — truncation happens on the output, and a note suggests adding `LIMIT`.

**Use it when**

- Inspecting data the ORM hides or makes awkward (`ir_model_data`, raw join tables, orphan rows).
- Diagnosing a migration or a broken `ir.module.module` state.
- Fast aggregate checks that would be slow through the ORM.

!!! note "The ORM is usually the right layer"
    SQL bypasses computed fields, record rules and constraints. Prefer
    [`odoo_search_read`](#odoo_search_read) unless you specifically need the
    raw tables.

## Inside the Odoo Container

### `read_file_in_odoo`

Read a text file, or list a directory, inside the Odoo container. A directory
path returns a listing (like `ls -la`); a text file returns its contents (first
100 KB by default). Binary files are not supported — use
[`run_odoo_command`](#run_odoo_command) for those.

Prefer this over `run_odoo_command` with `cat` or `ls`.

**Parameters**

`env_name`
:   *str · required* — The environment.

`path`
:   *str · required* — Absolute path inside the container (e.g. `/mnt/extra-addons/my_module/__manifest__.py`).

`read_range`
:   *str · default empty* — Line range `"START:END"` (e.g. `"1:50"`, `"100:200"`). Omitted returns the whole file, up to 100 KB.

**Use it when**

- Reading Odoo core source to understand a method you are overriding.
- Inspecting the addon layout actually mounted at `/mnt/extra-addons/`.
- Checking `/etc/odoo/odoo.conf`.
- Verifying a file landed after [`pull_and_apply`](#pull_and_apply).

### `write_file_in_odoo`

Write a text file inside the Odoo container. Parent directories are created,
existing files are overwritten, and content is transferred via container stdin
so shell escaping is never an issue.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment.

`path`
:   *str · required* — Absolute path inside the container (e.g. `/tmp/import_data.csv`).

`content`
:   *str · required* — Text content to write.

`user`
:   *str · default `"odoo"`* — OS user to own the file. Use `"root"` for system paths.

**Use it when**

- Writing a CSV for a data import.
- Creating or amending `odoo.conf` settings.
- Dropping a one-off Python script for `odoo shell` to execute.
- Placing test fixture files (demo data, config).

!!! warning "Not for source code"
    Do **not** edit repository source this way. All code changes must go
    through git commit → push → [`pull_and_apply`](#pull_and_apply).

### `search_in_odoo`

Recursive fixed-string grep inside the Odoo container, returning matching lines
with file paths and line numbers.

**Parameters**

`env_name`
:   *str · required* — The environment.

`pattern`
:   *str · required* — Search pattern (fixed string, case-sensitive). Regex is deliberately unsupported to avoid escaping problems.

`path`
:   *str · default `"/mnt/extra-addons"`* — Directory to search. Use `/usr/lib/python3/dist-packages/odoo/addons` to search Odoo core.

`glob`
:   *str · default `"*.py"`* — File glob. Use `"*.xml"` for views and data, `"*.js"` for frontend, `"*"` for everything.

`max_results`
:   *int · default `50`* — Maximum matching lines to return.

**Use it when**

- Finding where a field is defined across all addons.
- Locating a model class in Odoo core (`pattern="class SaleOrder"`).
- Finding every import of a module, or an XML record id.

### `run_odoo_command`

Execute an arbitrary shell command inside the Odoo container. The command runs
through `sh -c`, so pipes, redirections, `&&`, `cd x && y`, `$VAR` and quoting
all behave as written — one call, one shell line.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment.

`command`
:   *str · required* — The shell command (e.g. `"ls /mnt/extra-addons | head"`).

`user`
:   *str · default `"odoo"`* — OS user to run as. Use `"root"` for privileged operations.

`shell`
:   *bool · default `True`* — Run via `sh -c`. Pass `False` for exact argv semantics: the string is split on whitespace and executed directly, so `|`, `>`, `&&`, `*` and `$VAR` reach the program as literal arguments.

**Use it when**

- Installing a Python package ad hoc to test a hypothesis.
- Inspecting processes, disk usage or file permissions inside the container.
- Running a binary tool the dedicated file tools cannot cover.

### `run_odoo_shell`

Execute Python inside `odoo shell` with full ORM access — `self.env`, all
models, and the environment's database. Use `print()` to produce output.

**Transaction handling.** `odoo shell` rolls back its cursor when the piped
script finishes, so ORM writes would otherwise be discarded. With
`auto_commit=True` (the default) the transaction is committed after your code
runs, so a successful run persists; if the code raises, the commit is never
reached and the transaction is rolled back with the traceback returned.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment.

`python_code`
:   *str · required* — Python to execute. Use `print()` for output.

`auto_commit`
:   *bool · default `True`* — Commit after a successful run. Set `False` for a read-only or dry-run inspection where nothing should persist.

**Use it when**

- You need a fresh registry, `sudo()`, a private method, a rollback, or several steps in one transaction — none of which the `odoo_*` RPC tools can give you.
- Testing computed fields, or debugging workflow transitions and access rights.
- Running a data-fix script.
- Inspecting without persisting (`auto_commit=False`).

```bash
oduflow call run_odoo_shell '{
  "env_name": "main",
  "python_code": "print(self.env[\"sale.order\"].search_count([]))",
  "auto_commit": false
}'
```

### `http_request_to_odoo`

Make an HTTP request to the running Odoo instance, from the host to the
container's mapped port.

**Parameters**

`env_name`
:   *str · required* — The environment.

`path`
:   *str · required* — URL path (e.g. `/web/health`, `/jsonrpc`, `/my/invoices`).

`method`
:   *str · default `"GET"`* — One of `GET`, `POST`, `PUT`, `DELETE`.

`body`
:   *str · default empty* — Request body, typically JSON. Empty for GET.

`headers`
:   *str · default empty* — Comma-separated `KEY:VALUE` pairs (e.g. `"Content-Type:application/json,Accept:text/html"`).

`session_id`
:   *str · default empty* — Odoo session ID for authenticated requests. Obtain one by POSTing to `/web/session/authenticate`, or mint one with [`connect_as_user`](#connect_as_user).

**Use it when**

- Testing a custom web controller or REST endpoint.
- Making a JSON-RPC call.
- Verifying access rights by checking 200 vs 403.
- A quick health check (`GET /web/health`).

### `reset_admin_password`

Reset the `admin` user's password in the environment's Odoo database. The
password is hashed with passlib (pbkdf2_sha512) inside the container and
written to the `res_users` record where `login = 'admin'`.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment.

`new_password`
:   *str · default `"test"`* — The new admin password.

**Use it when**

- A template or production copy carries an admin password nobody knows.
- Handing a demo environment to someone who needs to log in normally.

### `connect_as_user`

Mint a passwordless Odoo login session for a user and return its `session_id`
cookie plus a URL — the same authenticated state a password login produces,
without setting or transmitting any password.

Hand the cookie to a browser automation tool (e.g. Playwright
`context.add_cookies([...])` then `page.goto(url)`) to land directly in an
authenticated session, skipping the login form.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment.

`user`
:   *str · required* — The target user's login (e.g. `"jane@acme.com"`) or numeric id.

**Use it when**

- Driving end-to-end browser tests without scripting the login form.
- Exercising a feature across roles in one run — admin, sales manager, portal. Portal users are supported: they land on `/web` and Odoo redirects them to their portal.
- Reproducing a "works for me, not for them" permissions report.

!!! warning "The session id is a live credential"
    It is shown in the tool's output (and therefore in the transcript) — treat
    it like a password. The tool grants no new privilege: whoever can call it
    can already [`run_odoo_shell`](#run_odoo_shell).

## Translations

### `export_module_translations`

Export a module's translation catalogue using Odoo's own exporter.

Without `lang` this produces the `.pot` template: every translatable term with
an empty translation, including the `_()` / `_lt()` messages from the module's
Python sources. With `lang` it produces a `.po` whose translations are filled
from what the database currently holds — useful for seeing what actually got
applied.

The file is written into the module's own `i18n/` directory inside the
container, which is a read-write mount of the environment's checkout — so in
live-mount mode it lands directly in your working tree. The response carries a
summary plus a one-time download URL, never the file body.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment.

`module`
:   *str · required* — A single installed module's technical name (e.g. `sale_custom`).

`lang`
:   *str · default empty* — Locale to fill translations from (e.g. `pl_PL`). Omit for a `.pot` template.

**Use it when**

- Getting the authoritative term list before writing translations.
- Checking that `_()` messages are being picked up — look at the `code` count.
- Snapshotting what the database holds for one language.

### `translation_status`

Report whether a module's translations actually landed, and what to do next.

It compares the term template Odoo derives from the module, the translations
stored in the database, and the committed `i18n/<lang>.po` files, returning a
**verdict per language** rather than three catalogues to reconcile. Use it
after loading translations: Odoo's importer is silent about the two ways a
`.po` fails, and this is what makes them visible.

- Entries with no `#:` reference line import as **zero** translations, with no warning at all, unless a sibling `<module>.pot` supplies the metadata.
- Entries with no `#. module:` comment **abort the import** outright unless that sibling template supplies it.

Verdicts: `OK`, `PARTIAL`, `NOT LOADED`, `NOT TRANSLATED`,
`IMPORT SILENTLY DROPPED`, `IMPORT ABORTS`, `NO FILE`, `NOT ACTIVATED` — each
with the coverage behind it and the call that fixes it.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment.

`module`
:   *str · required* — A single installed module's technical name.

`langs`
:   *str · default empty* — Comma-separated locales to check (e.g. `"pl_PL,ru_RU"`). Omit to check every language activated in the database except `en_US`.

**Use it when**

- A translation "was loaded" but the UI is still in English.
- Reviewing translation coverage before a release.
- Diagnosing a `.po` import that reported success and did nothing.

## Template Management

Templates are reusable database + filestore snapshots that make environment
creation fast. See [Template Management](templates.md).

### `save_as_template`

Save an environment's database and filestore as a template.

By default this creates a **new** template and refuses to overwrite an existing
one — pick a fresh `template_name`. `overwrite=True` deliberately re-baselines
an existing template: its database and filestore are replaced, and other
environments using it with overlay-mounted filestores are remounted against the
new baseline. On re-baseline their filestore changes (the overlay upper layer)
are **preserved** by default; `reset_env_changes=True` discards them. The source
environment itself is always reset — its data just became the new template.

Lock: team + template. Destructive when `overwrite=True` or `reset_env_changes=True`.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment whose database and filestore become the template.

`template_name`
:   *str · required* — Template profile to publish into.

`reset_env_changes`
:   *bool · default `False`* — Discard other environments' filestore deltas (destructive). Default preserves them.

`overwrite`
:   *bool · default `False`* — Allow re-baselining an existing template. Default refuses if it already exists.

**Use it when**

- A configured environment (modules installed, data set up) should become the starting point for future branches.
- Re-baselining a team template after a round of configuration work.

!!! danger "Requires explicit user permission"
    If the user has not clearly and unambiguously asked to save a *specific*
    environment as a template, do not call this. Both `overwrite=True` and
    `reset_env_changes=True` require an explicit request of their own.

### `save_production_as_template`

Save a **production's** database and filestore as a dev template. The
production keeps serving throughout — the dump is a consistent snapshot and
nothing is stopped or modified on the production side.

Overwrite and reset semantics are identical to
[`save_as_template`](#save_as_template).

Lock: team + template. Requires production hosting. Destructive when `overwrite=True` or `reset_env_changes=True`.
{ .odu-tool-meta }

**Parameters**

`prod_name`
:   *str · required* — The production whose database and filestore are copied.

`template_name`
:   *str · required* — Template profile to publish into.

`reset_env_changes`
:   *bool · default `False`* — Discard other environments' filestore deltas (destructive).

`overwrite`
:   *bool · default `False`* — Allow re-baselining an existing template.

**Use it when**

- Developers need to work against realistic production data.
- Refreshing the managed `prod-<name>` template that backs `create_environment(from_production=...)`.

!!! danger "The template holds unsanitized production data"
    Real customer records, real email addresses, real API credentials.
    Sanitization happens *later*, when an environment is created from it
    ([`create_environment`](#create_environment) runs Odoo's neutralization
    plus the repository's custom sanitize scripts by default). Treat the
    template itself as production-confidential. Requires explicit user
    permission. Refused when the production has MCP copy-to-dev disabled.

### `list_templates`

List available template profiles (database + filestore snapshots), including
the branch and commit each database snapshot was taken from.

**Parameters**

*None.*

**Use it when**

- Choosing a `template_name` for [`create_environment`](#create_environment).
- Checking how stale a template is before building on it.

### `rename_template`

Rename a template profile — its directory and its PostgreSQL template
database.

Refused if any environment was created from this template: the template
reference is fixed at creation time and cannot be updated on a running
environment. Delete those environments first, or leave the template as is.

Lock: team + template (both the old and the new name).
{ .odu-tool-meta }

**Parameters**

`template_name`
:   *str · required* — Current name.

`new_name`
:   *str · required* — New name.

**Use it when**

- A template's name no longer reflects the Odoo version or customer it holds.

### `delete_template`

Permanently remove a template profile — its template database and its files on
disk.

Lock: team + template. Destructive and irreversible.
{ .odu-tool-meta }

**Parameters**

`template_name`
:   *str · required* — Template profile to delete.

**Use it when**

- A template is genuinely obsolete and disk space must be reclaimed.

!!! danger "Never on your own initiative"
    Every environment depending on this template loses its baseline and cannot
    be recreated until a new template is set up. Requires explicit user
    permission and confirmation.

### `import_template`

Import or refresh a template from an Odoo database manager, an S3 prefix,
a local directory, or a local dump file. See [Templates](templates.md)
for the raw dump plus `filestore/` layout and migration examples.

Lock: template; overwrite and refresh also take the team lock to protect live
filestore overlays. Fresh imports can run alongside environment operations.
{ .odu-tool-meta }

**Parameters**

`source`
:   *str · default empty* — An http(s) Odoo URL, `s3://bucket/prefix` (raw dump + `filestore/` layout), `s3://bucket/path/backup.zip` (a single Odoo backup archive), or an absolute local path. Omit with `refresh=True`.

`master_pwd`
:   *str · default empty* — Required only for the Odoo database-manager source.

`db_name`
:   *str · default empty* — Odoo database to back up. Empty auto-detects, and fails if several databases exist.

`template_name`
:   *str · default `"default"`* — Template profile to create or update.

`without_filestore`
:   *bool · default `False`* — Import only the dump. Existing attachments are preserved on overwrite; a single local dump file also preserves them.

`overwrite`
:   *bool · default `False`* — S3/local only: incrementally replace template data. Unchanged dumps skip restore; deleted source attachments are removed, including when the source tree becomes empty. Requires explicit permission.

`refresh`
:   *bool · default `False`* — With no source, restore from the template's own files and refresh metadata and overlays. Requires explicit permission.

`s3_endpoint`, `s3_access_key`, `s3_secret_key`, `s3_region`
:   *str · default empty* — S3 source connection settings. Supply access and secret keys together. `s3_secret_key` also accepts `secret:<name>` to sign with an existing team secret; a raw value is stored as the team secret `s3-template-<template>` and the template records only the reference, so a re-sync needs no key. Credentials resolve most-explicit-first: these parameters, then the secret remembered for this template, then matching `[backup]` settings, then anonymous access. The key is required again when the endpoint or access key changes.

**Use it when**

- Onboarding a customer whose Odoo runs elsewhere.
- Importing a raw backup without building an archive.
- Periodically synchronizing a backup into a development template.

### `refresh_template`

Re-apply a template's current filestore to live overlay environments: unmount
and remount every overlay-mounted environment using this template against the
template's current on-disk filestore.

By default each environment's filestore changes (the overlay upper layer) are
**preserved** — non-destructive.

Lock: team + template. Destructive when `reset_env_changes=True`.
{ .odu-tool-meta }

**Parameters**

`template_name`
:   *str · required* — Template profile to re-apply.

`reset_env_changes`
:   *bool · default `False`* — Discard environments' filestore deltas and reset every affected environment to the template baseline (destructive).

**Use it when**

- The template filestore was changed on disk and live environments should see it.
- Re-syncing an environment that was busy and got skipped during an import or save.

!!! danger "Requires explicit user permission"
    Especially with `reset_env_changes=True`.

### `attach_filestore`

Attach or replace a template's filestore from a directory, an archive, or a
remote rsync source.

Archive and directory sources are normalized to the Odoo filestore layout
(`XX/<sha1>`). Live environments' changes are preserved by default.

Lock: template throughout; the team lock only for the remount-and-swap, so staging a large source does not block the team.
{ .odu-tool-meta }

**Parameters**

`template_name`
:   *str · required* — Template profile to attach the filestore to.

`source`
:   *str · required* — A local directory, a local `.zip` / `.tar` / `.tar.gz` archive, an `rsync://` URL, or an SSH-style rsync source such as `user@host:/path`.

`reset_env_changes`
:   *bool · default `False`* — Discard environments' filestore deltas (destructive).

`strip_prefix`
:   *str · default `"auto"`* — Wrapper directory to strip. `"auto"` detects one such as the database name; pass an explicit prefix when auto-detection is ambiguous.

**Use it when**

- A database-only import ([`import_template`](#import_template) with `without_filestore=True`) needs its attachments.
- The filestore arrives separately, e.g. rsynced from the customer's server.

## Auxiliary Services

Managed side-car containers — Redis, Meilisearch, MinIO, anything your stack
needs. See [Auxiliary Services](services.md).

### `create_service`

Create a managed auxiliary service container.

A service has **exactly one exposure model**: a catch-all `port`, or a
restricted Traefik `routes` allowlist. The two are mutually exclusive, and
`port` remains required outside Traefik mode.

Lock: service.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — Short service name (e.g. `redis`, `meilisearch`).

`image`
:   *str · required* — Docker image with tag (e.g. `redis:7`, `getmeili/meilisearch:v1.6`). A staging tag from [`start_image_build`](#start_image_build) (`oduflow-build/team-<id>:<build-id>`) is taken from the local Docker daemon without a pull — see [Container Image Builds](#container-image-builds).

`port`
:   *int · default `0`* — Catch-all exposure: forward every path to this one container port. Required outside Traefik. Mutually exclusive with `routes`.

`routes`
:   *list · default none* — Traefik exposure allowlist. Each object has `path`, a backend `port`, and optional `strip_prefix`. Unlisted paths return Traefik 404. Mutually exclusive with `port`.

`hostname`
:   *str · default empty* — Custom hostname for Traefik routing (Traefik mode only).

`env_vars`
:   *str · default empty* — Comma- or newline-separated `KEY=VALUE` pairs. Commas inside values are preserved unless what follows looks like another `KEY=` — so `"CONNECT_MCP_TOOL_GROUPS=write,collaboration,documents"` is one variable; put one pair per line when in doubt. A value `secret:<name>` references a [team secret](#list_secrets).

`host_mode`
:   *bool · default `False`* — Run in host network mode instead of the shared Docker network, for services needing direct host network access. Traefik routing still works.

`volumes`
:   *str · default empty* — Comma-separated mounts, each `volume_name:/container/path[:ro|rw]`. Volumes must exist first ([`create_volume`](#create_volume)).

`privileged`
:   *bool · default `False`* — Full host access; implies all Linux capabilities. Mutually exclusive with `net_admin`.

`net_admin`
:   *bool · default `False`* — Add the `NET_ADMIN` capability — required for VPN/WireGuard, tun/tap devices and iptables inside the container.

`command`
:   *str · default empty* — Start command overriding the image `CMD`, as a shell-quoted string (e.g. `"server /data --console-address :9001"`). The image `ENTRYPOINT` is not affected.

`runtime`
:   *dict · default none* — Explicit Docker `tmpfs`, private cgroupns, `stop_signal` and `stop_timeout` settings.

**Use it when**

- Odoo needs a cache, search engine, object store or message broker alongside it.
- You want a scratch container on the team network for an experiment.

!!! note "Reserved mount in Traefik TLS mode"
    The system ACME volume is mounted automatically at `/etc/traefik:ro`. Do
    not include it in `volumes` — `/etc/traefik` is reserved.

```bash
oduflow call create_service '{
  "name": "redis",
  "image": "redis:7",
  "port": 6379,
  "volumes": "redis-data:/data"
}'
```

### `update_service`

Preflight the configuration, pull the latest image and optionally change any
setting. The container is recreated when the image or a setting changes;
settings that are not overridden are preserved. This is the **preferred** way
to change a service — no manual delete-and-recreate.

Three parameters are **tri-state**: omitted keeps the current value, a value
fully replaces it, and an empty value clears it.

Lock: service.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The service to update.

`env_vars`
:   *str · default empty* — `KEY=VALUE` pairs that **fully replace** existing variables. Empty keeps them.

`image`
:   *str · default empty* — New image with tag (e.g. `redis:8`). Empty keeps the current image. A staging tag from [`start_image_build`](#start_image_build) (`oduflow-build/team-<id>:<build-id>`) is taken from the local Docker daemon without a pull — see [Container Image Builds](#container-image-builds).

`port`
:   *int · default `0`* — New container port. `0` keeps the current one.

`hostname`
:   *str · default empty* — New Traefik hostname. Empty keeps the current one.

`host_mode`
:   *bool · default none* — Host network mode. Unset keeps the current mode.

`volumes`
:   *str · default none* — Mounts that **fully replace** existing user volumes. Unset keeps them; an **empty string unmounts every volume** (the volumes keep their data). The implicit Traefik TLS mount at `/etc/traefik:ro` is preserved separately.

`privileged`
:   *bool · default none* — Privileged mode. Unset keeps the current setting. Mutually exclusive with `net_admin`.

`net_admin`
:   *bool · default none* — Add (`true`) or remove (`false`) the `NET_ADMIN` capability. Unset keeps current capabilities.

`routes`
:   *list · default none* — Full replacement route list. Unset preserves it. Pass `[]` together with `port` to return to a single catch-all port.

`command`
:   *str · default none* — New start command as a shell-quoted string. Unset keeps the current command; an **empty string drops the override** and falls back to the image `CMD`. Note this differs from `env_vars`/`image`, where empty means "keep".

`runtime`
:   *dict · default none* — Replace lifecycle settings; omit to preserve, pass `{}` to clear.

**Use it when**

- Pulling a new image tag for a running service.
- Rotating a service's credentials or pointing it at a new database.
- Recreating a legacy service so it picks up newly implicit system mounts.

!!! tip "Read before you write"
    Call [`get_service_info`](#get_service_info) first. `volumes` and
    `env_vars` are full replacements, so a partial argument silently drops
    what you left out. Protected services refuse updates until an
    administrator unprotects them in the dashboard.

### `delete_service`

Stop and remove a service container, keeping its preset so
[`restore_service`](#restore_service) can bring it back.

Lock: service.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The service to delete.

`save_preset`
:   *bool · default `True`* — Keep the saved configuration. Pass `false` to delete the preset along with the container.

**Use it when**

- Freeing resources while keeping the ability to restore the exact configuration.
- Retiring a service for good (`save_preset=false`).

!!! note "Protected services"
    A protected service refuses to be deleted until an administrator
    unprotects it in the dashboard.

### `restart_service`

Restart a managed service container.

Lock: service.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The service to restart (e.g. `redis`, `meilisearch`).

**Use it when**

- The service is wedged, or picked up a configuration file you rewrote in its volume.

### `list_services`

List all managed service containers.

**Parameters**

*None.*

**Use it when**

- Taking stock of what is running alongside your environments.

### `get_service_info`

Full live state of one service: image with digest, runtime status, port and
routes, hostname, URL, `host_mode`, start command, volumes, environment
variables, capabilities, privileged flag, restart count, `started_at`, and
whether a saved preset exists.

In Traefik TLS mode this also reports the implicit `/etc/traefik:ro` ACME mount,
which is not stored in the preset.

**Parameters**

`name`
:   *str · required* — The service to inspect (e.g. `redis`, `fs`).

**Use it when**

- **Before** [`update_service`](#update_service) — so full-replacement arguments preserve what you are not changing.
- Confirming which image digest is actually running.
- Debugging routing: catch-all port vs. route allowlist.

### `get_service_logs`

Retrieve recent logs from a managed service container.

**Parameters**

`name`
:   *str · required* — The service.

`n_lines`
:   *int · default `100`* — Number of recent log lines.

**Use it when**

- A service starts and immediately exits.
- Checking whether a service accepted its configuration.

### `run_service_command`

Execute a shell command inside a service container. The command runs through
`sh -c`, so pipes, redirections and `&&` work as written.

Lock: service.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The service (e.g. `redis`, `meilisearch`).

`command`
:   *str · required* — The shell command (e.g. `"redis-cli ping"`, `"ls /data | wc -l"`).

`user`
:   *str · default `"root"`* — OS user to run as.

`shell`
:   *bool · default `True`* — Run via `sh -c`. Pass `False` for exact argv semantics, or when the image ships no shell at all (scratch/distroless).

**Use it when**

- Probing the service with its own CLI (`redis-cli`, `mc`, `psql`).
- Verifying a mounted volume actually contains what you expect.

## Service PostgreSQL Databases

Persistent, team-scoped databases for auxiliary services — separate from Odoo's
own databases and from the environment lifecycle.

### `create_service_database`

Create a persistent PostgreSQL database with a dedicated **non-superuser**
owner. It belongs to the current team, survives service updates and deletion,
and is reachable from bridge-mode team services at the returned host and port.
Returns `DATABASE_URL` and the individual `PG*` credentials.

Lock: database.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — Stable lowercase resource name using letters, digits, `-` or `_`.

`cluster`
:   *str · default `"dev"`* — `dev` (the shared development cluster) or `prod` (the dedicated production cluster; requires production hosting). Databases on `prod` are covered by the cluster-wide WAL-G backups and are **not** counted against the development disk quota.

**Use it when**

- A service (n8n, Keycloak, a custom app) needs durable storage that outlives its container.
- You want backup coverage for a non-Odoo database (`cluster="prod"`).

### `list_service_databases`

List managed database names, live status, size and connection count — without
passwords.

**Parameters**

*None.*

**Use it when**

- Auditing what the team has provisioned and how much space it uses.
- Checking whether anything is still connected before a deletion.

### `get_service_database`

Explicitly reveal the connection credentials for one managed database.

Lock: database.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The database.

**Use it when**

- Wiring the credentials into a service's `env_vars`.
- Recovering a `DATABASE_URL` nobody wrote down.

!!! warning "The response contains a plaintext password"
    Treat it as a secret, and pass only the variables the target service
    actually needs. Consider storing it as a [team secret](#list_secrets)
    instead of inlining it.

### `rotate_service_database_password`

Rotate the owner password and return replacement credentials.

Lock: database.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The database.

**Use it when**

- A credential leaked, or appeared somewhere it should not have.
- Routine rotation before handing a project over.

!!! note "Containers keep the old value"
    Existing containers keep the old password until their environment
    variables are updated and the containers are recreated or restarted —
    see [`update_service`](#update_service).

### `delete_service_database`

Permanently drop the database and its login role, terminating active
PostgreSQL connections first. Service containers are **not** modified and will
fail to reconnect until reconfigured.

Lock: database. Destructive and irreversible.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The database to drop.

**Use it when**

- The owning service is gone for good and the data is genuinely disposable.

!!! danger "Protected databases"
    A protected database cannot be deleted until an administrator unprotects
    it in the dashboard.

## Volumes

Named Docker volumes for use with services. See
[Auxiliary Services](services.md).

### `create_volume`

Create a named Docker volume.

Lock: volume.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — Short volume name (e.g. `redis-data`, `meilisearch-data`).

`description`
:   *str · default empty* — What this volume is for.

**Use it when**

- A service needs durable storage. Volumes must exist **before** they can be mounted by [`create_service`](#create_service).

### `list_volumes`

List all managed Docker volumes and which services use them.

**Parameters**

*None.*

**Use it when**

- Finding orphaned volumes to reclaim.
- Checking what a volume is attached to before deleting it.

### `inspect_volume`

Detailed information about one volume, including which services use it.

**Parameters**

`name`
:   *str · required* — The volume to inspect.

**Use it when**

- Confirming a volume is unused before [`delete_volume`](#delete_volume).

### `delete_volume`

Delete a managed Docker volume. Fails if any service still uses it.

Lock: volume. Destructive — the data goes with it.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The volume to delete.

**Use it when**

- A service was retired and its data is no longer needed.

### `read_file_in_volume`

Read a text file, or list a directory, inside a Docker volume. A temporary
helper container is spun up to access the contents. Directories return an
`ls -la`-style listing; text files return contents up to 100 KB. Binary files
are detected and rejected.

**Parameters**

`name`
:   *str · required* — The volume (e.g. `redis-data`).

`path`
:   *str · required* — Path inside the volume (e.g. `data/dump.rdb`, `config/redis.conf`). A leading `/` is optional — paths are relative to the volume root.

`read_range`
:   *str · default empty* — Line range `"START:END"` (e.g. `"1:50"`). Omitted returns the full file, up to 100 KB.

**Use it when**

- Checking a service's configuration file without starting the service.
- Confirming data landed in the volume after an import.

### `write_file_in_volume`

Write a text file inside a Docker volume, creating parent directories and
overwriting an existing file.

Lock: volume.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The volume.

`path`
:   *str · required* — Path inside the volume (e.g. `config/my.conf`). Leading `/` optional.

`content`
:   *str · required* — Text content to write.

**Use it when**

- Seeding a service's configuration before its first start.
- Fixing a config that keeps the service from booting (then [`restart_service`](#restart_service)).

### `search_in_volume`

Recursive fixed-string grep inside a Docker volume, returning matching lines
with paths and line numbers.

**Parameters**

`name`
:   *str · required* — The volume.

`pattern`
:   *str · required* — Search pattern (fixed string, case-sensitive).

`path`
:   *str · default empty* — Directory relative to the volume root. Default searches the entire volume.

`glob`
:   *str · default `"*"`* — File glob (e.g. `"*.conf"`, `"*.xml"`).

`max_results`
:   *int · default `50`* — Maximum matching lines.

**Use it when**

- Locating which config file in a volume carries a setting.
- Finding a stale hostname or credential across a service's data.

### `delete_file_in_volume`

Delete a file or directory inside a Docker volume. Cannot delete the volume
root — use [`delete_volume`](#delete_volume) for that.

Lock: volume. Destructive.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The volume.

`path`
:   *str · required* — Path inside the volume to delete (e.g. `data/old-dump.rdb`). Leading `/` optional.

**Use it when**

- Clearing a corrupt cache or an outdated dump so the service rebuilds it.

## Service Presets

A preset is a service's saved configuration. [`delete_service`](#delete_service)
keeps one by default, so a service can be brought back exactly as it was.

### `list_service_presets`

List saved service presets.

**Parameters**

*None.*

**Use it when**

- Checking what can be restored after a cleanup.

### `restore_service`

Recreate a service container from its saved preset, with the same
configuration.

Lock: service.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The saved preset to restore.

**Use it when**

- Bringing back a service deleted to free resources.
- Rebuilding a service stack on a fresh host.

### `delete_service_preset`

Remove a saved service preset.

Lock: service.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The preset to delete.

**Use it when**

- A service is retired permanently and its configuration should not linger.

## Secrets

### `list_secrets`

List the **names** of the team's named secrets. Values are write-only: a human
sets them in the Oduflow dashboard, and they can never be read back through
MCP.

Reference a secret in any `env_vars` argument —
[`create_environment`](#create_environment),
[`update_environment`](#update_environment),
[`create_service`](#create_service),
[`update_service`](#update_service) — as `KEY=secret:<name>`. The real value is
substituted only inside the container, while every stored or displayed
configuration keeps the reference.

**Parameters**

*None.*

**Use it when**

- Finding out which secret names exist before wiring one into a container.
- Keeping an API key out of tool arguments, transcripts and stored configuration.

## Container Image Builds

Requires a `[team.X.image_registry]` section in `oduflow.toml`.

A succeeded build is kept in the host's Docker daemon under a **staging tag**,
`oduflow-build/team-<id>:<build-id>` (reported by
[`get_image_build`](#get_image_build)). That tag can be passed directly as the
`image` of [`create_service`](#create_service) or
[`update_service`](#update_service): it is resolved from the local daemon, not
pulled from a registry, so a build can be run and checked as an auxiliary
service *before* [`publish_image_build`](#publish_image_build) pushes it
anywhere. These service operations accept only the team's own staging images.
Automatic build retention preserves tags referenced by running or stopped
containers. Unused staging tags remain temporary and can be pruned; use a
published tag to restore a service after pruning or move it to a new host.

### `start_image_build`

Build a container image from the environment checkout's **current HEAD commit**
— sealed with `git archive` under the environment lock, so the source cannot
shift mid-build. The build runs asynchronously server-side and this call
returns a `build_id` immediately. Hard timeouts, log-size caps and per-team
concurrency limits apply.

Lock: environment.
{ .odu-tool-meta }

**Parameters**

`env_name`
:   *str · required* — The environment whose checkout to build from.

`dockerfile`
:   *str · default `"Dockerfile"`* — Dockerfile path relative to the context.

`context`
:   *str · default `"."`* — Build context directory relative to the repository root.

`target`
:   *str · default empty* — Multi-stage build target.

`build_args`
:   *str · default empty* — Comma-separated `KEY=VALUE` Docker build arguments.

**Use it when**

- Producing a deployable image from the exact code you just tested.
- Building a release candidate from a tagged commit.

!!! warning "Build arguments are not secret"
    Their values reach the Dockerfile and the image history. Never pass
    credentials. Push your commits and [`pull_and_apply`](#pull_and_apply)
    first — the build uses the checkout's HEAD at call time.

### `get_image_build`

Status, source commit, publication history and a build log tail for one build.

**Parameters**

`env_name`
:   *str · required* — The environment the build belongs to.

`build_id`
:   *str · required* — The build to inspect.

`tail_lines`
:   *int · default `100`* — Trailing build log lines to include.

**Use it when**

- Polling an asynchronous build to completion.
- Reading the failing Dockerfile step.

### `publish_image_build`

Push a **succeeded** build's exact image — never a rebuild — to one or more
tags under the team's configured registry namespace.

Any syntactically valid tag is accepted, including `latest` and semver.
Overwriting an existing tag is intentional and last-writer-wins. Tags are
pushed one by one, so partial success is possible and each tag reports its own
outcome.

**Parameters**

`env_name`
:   *str · required* — The environment the build belongs to.

`build_id`
:   *str · required* — A build in status `succeeded`.

`repository`
:   *str · required* — Destination repository below the configured prefix (e.g. `"app"`).

`tags`
:   *str · required* — Comma-separated tags to publish, e.g. `"1.4.0,latest"`.

**Use it when**

- Promoting a verified build to `latest` and a version tag in one call.
- Publishing the identical bits you tested, with no risk of a rebuild drifting.

### `cancel_image_build`

Terminate a running build and its Docker connection — even when the current
Dockerfile step produces no output — and move the job to `cancelled`.

**Parameters**

`env_name`
:   *str · required* — The environment the build belongs to.

`build_id`
:   *str · required* — The build to cancel.

**Use it when**

- A build is hung on a silent step and is holding team build concurrency.
- You started a build from the wrong commit.

## Repository Auth

### `setup_repo_auth`

Cache git credentials for a private git host. The token is stored in the team's
git credential store and verified. Git matches credentials by host (and
username), so **one entry covers every repository on that host**; afterwards
[`create_environment`](#create_environment) and
[`add_extra_repo`](#add_extra_repo) can clone with a plain `https://` URL.

Access is verified with `git ls-remote` against `repo_url` when one is given,
otherwise against the provider's API (GitHub, GitLab, Bitbucket).

Lock: team credential store.
{ .odu-tool-meta }

**Parameters**

`repo_url`
:   *str · default empty* — Repository HTTPS URL, used to derive the host and to verify access.

`token`
:   *str · default empty* — Personal access token / app password. **Preferred form** — pass the token here rather than inline in the URL.

`username`
:   *str · default empty* — Account name stored with the token. Optional for GitHub, GitLab and Azure DevOps (defaults to `x-access-token`); **required for Bitbucket app passwords**. Use distinct usernames to keep several tokens for the same host.

`host`
:   *str · default empty* — Git host such as `github.com` or `git.example.com:8443`. Only needed when `repo_url` is omitted.

**Use it when**

- Onboarding a private repository for the first time.
- Replacing an expired token.

!!! note "Legacy form"
    A `repo_url` with inline credentials
    (`https://user:PAT@github.com/owner/repo.git`) and no `token` is still
    accepted, but the explicit `token` form is preferred.

```bash
oduflow call setup_repo_auth '{
  "repo_url": "https://github.com/owner/repo.git",
  "token": "ghp_..."
}'
```

### `get_ssh_public_key`

Return the team's SSH public key. Oduflow maintains one SSH deploy key per team,
generated automatically at server start. Register it with your git hosting — as
a repository deploy key (read access is enough) or on a machine-user account —
and SSH repository URLs (`git@github.com:owner/repo.git`) work in
[`create_environment`](#create_environment),
[`add_extra_repo`](#add_extra_repo) and productions without a token.

Lock: team credential store.
{ .odu-tool-meta }

**Parameters**

*None.*

**Use it when**

- You prefer deploy keys over tokens, or the host mandates them.
- Setting up access to a self-hosted git server.

!!! note "One repository per GitHub deploy key"
    GitHub allows a given deploy key on only one repository. To reach several
    repositories with the same key, attach it to a machine-user account
    instead.

## Extra Addons

Shared addon repositories — Odoo Enterprise, OCA, your own theme repo — cloned
once and mounted into environments. See
[Extra Addons Repositories](extra-addons.md).

### `add_extra_repo`

Register an extra addons repository. Registration checks access and reads the
remote branch list; **no code is downloaded**. The first environment that uses a
branch downloads only that branch's latest commit (no history), so a large
multi-version repository costs only the versions in use.

**Parameters**

`name`
:   *str · required* — Short name for the repo (e.g. `enterprise`, `custom-themes`).

`repo_url`
:   *str · required* — HTTPS (`https://github.com/owner/repo.git`) or SSH (`git@github.com:owner/repo.git`, needs the team deploy key — see [`get_ssh_public_key`](#get_ssh_public_key)).

`branches`
:   *str · default empty* — Comma-separated branch names to download now instead of on first use. Empty downloads nothing yet. The dashboard provides a remote branch picker; REST accepts a list.

**Use it when**

- Making Odoo Enterprise available to environments.
- Sharing an OCA collection or an internal theme repo across the team.

### `list_extra_repos`

List extra addons repositories with their downloaded branches and the branch
list last read from the remote.

**Parameters**

*None.*

**Use it when**

- Finding the exact name and branch to use in an `extra_addons` argument.

### `update_extra_repo`

Refresh the remote branch list and fetch the latest changes of every downloaded
branch, pruning branches deleted on the remote. Branches never used stay
undownloaded (repositories added by older Oduflow versions still fetch all
branches).

**Parameters**

`name`
:   *str · required* — The extra repo to update (e.g. `enterprise`).

`add_branch`
:   *str or list of str · default empty* — A branch, or a list of branches, to download now and keep updated (e.g. `17.0` or `["17.0", "18.0"]`), instead of waiting for their first use. A branch missing on the remote fails the call before anything is downloaded; a failed fetch leaves the tracked set unchanged.

**Use it when**

- Warming up a branch before the first environment needs it.
- An environment needs a fix that landed in the shared addons repo.

### `delete_extra_repo`

Delete an extra addons repository.

**Parameters**

`name`
:   *str · required* — The extra repo to delete.

**Use it when**

- A shared repo is no longer used by any environment.

## Production Hosting

Long-lived Odoo instances with their own domain, on a dedicated production
PostgreSQL cluster. Every tool in this section and the two that follow requires
`[production].enabled = true`. Read [Production Hosting](production.md) for the
workflow and the disaster-recovery consequences.

### `create_production`

Provision a production Odoo environment: long-lived, its own domain, the
dedicated production PostgreSQL cluster, auto-tuned workers, and **no
sanitization**. Requires `routing_mode = "traefik"`.

Productions are rarely created and rarely deleted — they live on and get
updated with [`update_production`](#update_production).

Lock: production.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — Production name, e.g. `erp` (lowercase letters, digits, dashes).

`repo_url`
:   *str · default empty* — HTTPS (`https://github.com/owner/repo.git`) or SSH (`git@github.com:owner/repo.git`, needs the team deploy key — see [`get_ssh_public_key`](#get_ssh_public_key)). Required unless `from_environment` supplies it.

`branch`
:   *str · default empty* — Git branch to deploy (full history is kept). Required unless `from_environment` supplies it.

`domain`
:   *str · default empty* — The public domain; DNS must point at this server, TLS via Let's Encrypt. In a team with `base_domain` configured it must be the zone apex or a subdomain of it (e.g. `erp.demo.example.com`). Empty defaults to the apex for the team's first production and `<name>.<base_domain>` afterwards. Client-owned domains go in `extra_domains`.

`extra_domains`
:   *list · default none* — Additional public FQDNs routed to the same production (e.g. the client's own `erp.customer.com`). Each gets its own Let's Encrypt certificate; DNS must point here.

`odoo_image`
:   *str · default empty* — Docker image, e.g. `odoo:18.0`. Required unless `from_environment` supplies it.

`git_user`
:   *str · default empty* — Git username for credential matching.

`extra_addons`
:   *dict · default none* — Extra addon repos as `{repo_name: branch}`.

`auto_update`
:   *bool · default `False`* — Deploy automatically on GitHub push webhooks.

`allow_copy_to_dev_mcp`
:   *bool · default `True`* — Allow MCP/agent-initiated copies of this production's data into dev ([`save_production_as_template`](#save_production_as_template) and the first `create_environment(from_production=...)`). When `False`, those tools refuse to publish a new copy; an already published template stays usable, but only with `sanitize=True`. The dashboard UI is never gated, and **no MCP tool can change this flag afterwards** — an administrator toggles it in the dashboard.

`template_name`
:   *str · default empty* — Template to seed the database and filestore from (e.g. an import of the customer's existing production). Empty starts a fresh database (`odoo -i base`). Mutually exclusive with `from_environment`.

`from_environment`
:   *str · default empty* — Dev environment to **promote**: its database and filestore are copied (Odoo briefly stopped for a consistent copy, then restarted — the environment is not reset), and empty `repo_url` / `branch` / `odoo_image` / `git_user` / `extra_addons` default to the environment's own. **No sanitization** — the data goes *into* production.

`env_vars`
:   *dict · default none* — User environment variables; values may be `secret:<name>` references. Omit to inherit the source environment's variables; pass `{}` to inherit none. Managed `HOST`/`PORT`/`USER`/`PASSWORD` cannot be overridden. `HOME`, `PYTHONHOME`, `PYTHONUSERBASE`, `PYTHONNOUSERSITE` and a `PYTHONPATH` that lists a `site-packages` directory are refused: they hide the installed pip packages or move data out of the mounts. References survive reconfiguration.

`server_mode`
:   *str · default `workers`* — How Odoo serves HTTP. `workers` (sync): worker processes on port 8069 serve the pages and Traefik sends only the live bus (`/websocket`, `/longpolling`) to the gevent port 8072. `gevent` (async): Traefik sends **all** traffic to the single gevent process on 8072 — suits many long-lived or I/O-bound requests, but one CPU-heavy request slows everyone. Crons run in both modes. See [Server mode](production.md#server-mode).

**Use it when**

- Going live with a customer after development settles.
- Migrating a customer's existing Odoo onto Oduflow (`template_name` from an import).
- Promoting a validated dev environment straight into production (`from_environment`).

### `list_productions`

List the team's productions with status, domain, deployed commit,
auto-update state and last deploy result.

**Parameters**

*None.*

**Use it when**

- A quick overview of what is live and whether anything is behind.

### `get_production_info`

Detailed information about one production: status, health, deployed commit,
recent branch commits, deploy history, current `odoo.conf` overrides, and
backup state.

**Parameters**

`name`
:   *str · required* — The production name.

**Use it when**

- Checking what is deployed versus what is on the branch.
- Confirming backup coverage and health before a risky change.
- Reviewing which `odoo.conf` overrides are in force.

### `production_logs`

Read a production's Odoo container logs.

**Parameters**

`name`
:   *str · required* — The production name.

`n_lines`
:   *int · default `100`* — Number of log lines to return.

`grep`
:   *str · default empty* — Case-insensitive substring filter.

`level`
:   *str · default empty* — Log level filter (e.g. `ERROR`, `WARNING`).

**Use it when**

- Investigating a customer-reported error at a known time.
- Watching for errors after a deploy.

### `start_production`

Start a stopped production. On a production flagged `unhealthy`, it waits for
the health check (`[production] health_timeout`) and clears the flag once
Odoo answers.

Lock: production.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The production name.

**Use it when**

- Bringing a production back online after maintenance.
- Restarting applications after a WAL disk-protection recovery (recovery itself is an operator action in the [server settings console](admin.md)).

### `stop_production`

Stop a production.

Lock: production. **Takes the production offline.**
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The production name.

**Use it when**

- A planned maintenance window requires the application down.
- Something is actively causing damage and must be halted.

### `restart_production`

Restart a production's Odoo container — brief downtime. A container whose
Traefik routing is outdated is recreated instead (same downtime). On a
production flagged `unhealthy`, it waits for the health check and clears the
flag once Odoo answers; the reply warns when it still does not.

Lock: production.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The production name.

**Use it when**

- Odoo is wedged or leaking, and a clean process is the fastest remedy.
- A configuration change needs to be picked up.

### `reconfigure_production`

Change a production's infrastructure settings and recreate its container to
match. Omitted or empty arguments are left unchanged. The **database and
filestore are preserved**; expect brief downtime while the container is
replaced.

Changeable: the public domain (Traefik host rule + Let's Encrypt), the extra
domains, the Odoo image, the deployed branch or repository URL, the git
credential user, the extra addon repos, the user environment variables, and
the server mode. A call that changes nothing still recreates a container whose
state drifted from the record (missing container or checkout, outdated
Traefik routing, or a team secret whose value changed since the container was
created).

Lock: production.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The production name.

`domain`
:   *str · default empty* — New public domain. DNS must point at this server. With `base_domain` configured it must be the zone apex or a subdomain of it; client-owned domains go in `extra_domains`.

`extra_domains`
:   *list · default none* — New **full set** of additional FQDNs. Pass `[]` to remove all; omit to leave unchanged.

`odoo_image`
:   *str · default empty* — New Docker image, e.g. `odoo:19.0`.

`branch`
:   *str · default empty* — New git branch to deploy.

`repo_url`
:   *str · default empty* — New git repository URL (HTTPS or SSH).

`git_user`
:   *str · default none* — New git username for credential matching. Pass `""` to clear it; omit to leave unchanged.

`extra_addons`
:   *dict · default none* — New **full set** of extra addon repos `{repo_name: branch}`. Pass `{}` to remove all; omit to leave unchanged.

`env_vars`
:   *dict · default none* — **Full replacement** user environment variables, including `secret:<name>` references. Omit to preserve; `{}` clears them. A changed set may not contain `HOME`, `PYTHONHOME`, `PYTHONUSERBASE`, `PYTHONNOUSERSITE` or a `PYTHONPATH` that lists a `site-packages` directory.

`server_mode`
:   *str · default empty* — `workers` (sync: pages on 8069, bus on 8072) or `gevent` (async: everything on 8072). Re-tunes the `odoo.conf` workers and re-points Traefik. Omit to leave unchanged.

`recreate`
:   *bool · default false* — Recreate the container even when no setting, secret value or runtime state differs from the record.

**Use it when**

- A team secret used by the production got a new value: call it with just the
  name. Secret values reach the container only when it is created, so
  [`restart_production`](#restart_production) keeps the old value; this call
  detects the changed value, recreates the container and lists the affected
  variables (names only).
- A customer's domain changes, or they bring their own.
- Moving production onto a release branch.
- Adding an addons repository production now depends on.

!!! warning "Changing the image does not migrate the database"
    A major Odoo version bump additionally needs an explicit module upgrade
    plan. After a branch or repository change, run
    [`update_production`](#update_production) with `install=` / `upgrade=` if
    the new code needs module changes.

### `set_production_odoo_conf`

Set or remove `odoo.conf` `[options]` overrides for a production and re-apply
the configuration. Overrides are stored per production, **win over the
auto-tuned worker settings**, and survive deploys and retunes. Current
overrides are shown by [`get_production_info`](#get_production_info).

Lock: production.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The production name.

`options`
:   *dict · default none* — Options to set, e.g. `{"limit_time_real": "300"}`.

`unset`
:   *str · default empty* — Comma-separated option names to remove, reverting them to the managed or base value.

`restart`
:   *bool · default `True`* — Restart the container so Odoo picks the change up (brief downtime).

**Use it when**

- A long-running report needs a higher `limit_time_real`.
- The auto-tuned worker count does not suit this workload.

!!! note "Managed keys are refused"
    `addons_path`, `data_dir` and the `db_*` keys are managed by Oduflow and
    cannot be overridden.

### `delete_production`

Delete a production. The container and registry record are removed; the
**database and workspace** (filestore, repository, deploy history) are **kept**
unless `drop_database=true`.

Kept leftovers are tombstoned: the reaper purges them `[lifecycle]
prod_purge_hours` after deletion (`0` = keep forever, the default), and
`oduflow cleanup --purge-deleted-productions --force` purges them immediately.
The name stays taken while leftovers are kept: `create_production` refuses it
until they are purged.

Lock: production. Destructive.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The production name.

`confirm`
:   *str · required in practice* — Must equal the production name (safety check).

`drop_database`
:   *bool · default `False`* — Also drop the database and delete the workspace.

**Use it when**

- A customer engagement ended and the hosting is being wound down.
- A production was created by mistake and nothing depends on it.

## Production Deployment

### `update_production`

Deploy the latest commits of a production's branch — **with automatic code
rollback on failure**.

It pulls the branch (and extra-addon worktrees), decides or applies the Odoo
action, then verifies the deploy (module exit codes plus a health check). If
the deploy fails, the checkout is reset to the previous commit, the config is
re-applied and the container restarted.

Drive it like [`pull_and_apply`](#pull_and_apply): **explicit** (pass
`install` / `upgrade` / `restart=True`) or **auto** (all empty — changed files
are classified automatically). Note that in production a "refresh"-class change
(XML/JS) still restarts the container, because there is no `--dev=xml` in
production.

A large upgrade can outlast the client's request timeout; the deploy still
finishes on the server. A failed deploy records the failed step, the Odoo
error with its traceback, and the output tail in
[`production_deploys`](#production_deploys).

Lock: production.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The production name.

`install`
:   *str · default empty* — Comma-separated modules to install (`-i`).

`upgrade`
:   *str · default empty* — Comma-separated modules to upgrade (`-u`).

`restart`
:   *bool · default `False`* — Restart the container (for Python-only changes).

**Use it when**

- Shipping a release to a customer.
- Applying a hotfix you have already validated in a dev environment.

!!! danger "The database is never rolled back automatically"
    Code rollback is automatic; data is not. If module upgrades left the
    database inconsistent, restore a snapshot manually — take
    [`snapshot_production`](#snapshot_production) **before** a risky deploy.

### `rollback_production`

Manually roll a production's **code** back to a previous commit and restart.

Lock: production.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The production name.

`to_commit`
:   *str · default empty* — Target commit sha, or any git ref present in the checkout. Empty rolls back to the previous deploy's starting commit.

**Use it when**

- A deploy succeeded technically but the change is wrong in production.
- Reverting to a known-good commit while you investigate.

!!! warning "Code only"
    The database is not touched. For a data rollback, restore a snapshot with
    [`restore_production`](#restore_production).

### `set_production_auto_update`

Enable or disable automatic deployment from GitHub push webhooks. When enabled,
a push to the production's branch triggers
[`update_production`](#update_production) in the background, with automatic
code rollback on failure.

Lock: production.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The production name.

`enabled`
:   *bool · required* — `True` to deploy automatically on push.

**Use it when**

- A mature project should ship continuously from a protected branch.
- Freezing deploys during a change window (`enabled=false`).

### `production_deploys`

Deploy history, newest last: commits, actions, modules, status
(`success` / `rolled_back` / `rollback_failed` / `skipped`) and errors.

A failed deploy also records `failed_step` (`dependencies` / `install` /
`upgrade` / `health` / `exception`) and `failure_excerpt`: the Odoo error
record with its traceback. Exit code 255 from `install`/`upgrade` means Odoo
could not load or upgrade the registry; the excerpt says why. See
[Diagnosing a failed deploy](production.md#diagnosing-a-failed-deploy).

**Parameters**

`name`
:   *str · required* — The production name.

`limit`
:   *int · default `20`* — Maximum number of records.

`include_output`
:   *bool · default `False`* — Also return `output_tail`: the last 200 lines of the failed step's output, or of the server log for a failed health check.

**Use it when**

- Correlating "it broke on Tuesday" with what was deployed.
- Finding out why an `update_production` failed after the client timed out
  before the response arrived.
- Finding the commit to pass to [`rollback_production`](#rollback_production).
- Auditing whether an auto-update deploy silently rolled back.

## Production Backup & Recovery

Snapshots are the per-production restore unit; WAL-G covers the whole cluster.
Both require a `[backup]` section in `oduflow.toml`.

### `snapshot_production`

Take a snapshot of a production to S3: a database dump, a deduplicated
filestore revision, and a manifest recording the deployed commit sha.

Lock: production + backup store.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The production name.

`note`
:   *str · default empty* — Free-form note stored in the manifest.

**Use it when**

- **Before** any risky deploy, module upgrade or data migration.
- Capturing a known-good state you may want to return to.

```bash
oduflow call snapshot_production '{"name": "erp", "note": "before v2 migration"}'
```

### `list_production_snapshots`

List a production's snapshots, oldest first: id, `created_at`, sizes and commit
sha.

**Parameters**

`name`
:   *str · required* — The production name.

`refresh`
:   *bool · default `False`* — Re-list S3 (the source of truth) instead of using the local cache.

**Use it when**

- Picking a `snapshot_id` for [`restore_production`](#restore_production).
- Verifying that scheduled snapshots are actually landing (`refresh=True`).

### `restore_production`

Restore a production's **database and filestore** from a snapshot, or replace
them with a dev environment's data (promotion into an *existing* production).

The restore is swap-based, so a failed restore leaves the previous state in
place. The **code checkout is not touched** — a warning is returned if it does
not match the source's commit.

Lock: production + backup store. Destructive for current data.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The production name.

`snapshot_id`
:   *str · default empty* — Snapshot to restore (see [`list_production_snapshots`](#list_production_snapshots)). Mutually exclusive with `from_environment`.

`from_environment`
:   *str · default empty* — Dev environment whose database and filestore replace this production's. The environment's Odoo is briefly stopped for a consistent copy, then restarted — the environment itself is not reset. **No sanitization** — the data goes *into* production.

`confirm`
:   *str · required in practice* — Must equal the production name (safety check).

**Use it when**

- A bad migration or data loss needs the last good snapshot back.
- Promoting a rebuilt dataset from dev into an existing production.

!!! danger "Take a snapshot first"
    This replaces the production's current data. If that data may still be
    needed, call [`snapshot_production`](#snapshot_production) before
    restoring.

### `set_production_backup_schedule`

Override a production's daily snapshot time.

Lock: production.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — The production name.

`schedule`
:   *str · required* — `"HH:MM"` in server-local time, or `"off"` to disable scheduled snapshots for this production. Unset (the default) follows `[backup] snapshot_time`.

**Use it when**

- A customer's quiet hours differ from the team default.
- Staggering snapshots so several productions do not back up at once.

### `prune_production_backups`

Apply the retention policy (`[backup] keep`) to the team's snapshots and
filestore chunk store immediately. This also runs weekly on schedule.

Pruning uses safe two-step fossil collection: chunks are only permanently
deleted on a *later* prune, after every production has produced a newer
revision.

Lock: backup store.
{ .odu-tool-meta }

**Parameters**

*None.*

**Use it when**

- S3 costs need reining in before the weekly run.
- You just lowered `keep` and want it applied now.

### `production_backup_status`

Backup posture for the team: per-production snapshot state (schedule, last
snapshot, last error) and cluster WAL-G state (base backups, WAL archiver
health, S3 reachability).

**Parameters**

*None.*

**Use it when**

- The weekly "are we actually backed up?" check.
- Diagnosing why a scheduled snapshot did not appear.

### `production_wal_status`

Cached shared-cluster WAL queue, upload progress, disk reserve, stale-data and
protection state.

**Parameters**

*None.*

**Use it when**

- WAL archiving is falling behind and the PostgreSQL volume is filling up.
- Confirming the cluster has left disk-protection mode.

Cluster-wide WAL control (pause/resume/retry/recover/release) and
whole-cluster PITR are deliberately **not** MCP tools: they act on every
team's productions at once. They live on the operator surfaces — the
[server settings console](admin.md) (**/admin → Cluster DR**) and the owner
CLI ([`oduflow wal`](cli.md) and [`oduflow restore-cluster`](cli.md)).

## Production Odoo API

Read and change production Odoo records through OduMCP, the connector addon
Oduflow installs into the production itself. Reads are policy-bounded and
changes go through an approval plan that a human approves in Odoo, so no
arbitrary ORM call ever reaches a live database. Every tool here requires
`[production].enabled = true` and — over HTTP — the `/production` endpoint with
the team's `production_token`; see
[Production access and rotation](production.md#separate-production-mcp-access).

### `sync_production_mcp`

Install or upgrade the `odumcp` addon on a production and synchronize the
team's configured key with Odoo. An empty `name` processes every production of
the team and reports each one separately, so a single failure does not hide the
rest.

New productions are provisioned automatically; this tool is for existing ones,
for retrying a failed setup, and for rotating the key after `production_token`
changes in `oduflow.toml` (change the value, restart Oduflow, then run this).
No secret is accepted or returned. Stopped productions must be started first.

Oduflow ships `odumcp` as a managed checkout unless the production's own
repositories (main or extra) contain the module. Once they do, the deploy that
brought the module in already completes the takeover (container recreated
without the managed mount, managed checkout removed, noted in the deploy
history); this tool does the same for a production whose takeover is still
pending. Oduflow never touches the module again; the client's deployments own
it from then on. Adding or removing the managed mount, or replacing/refreshing
the checkout, recreates the container. Each result reports `addon_source`
(`managed` or `repository`), `checkout` (`kept`, `cloned`, `replaced`,
`refreshed`, `removed` or `repository`), `container_recreated` and, for a
managed checkout, `connector_branch` and `connector_commit`.

Lock: production (each one in turn).
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · default empty* — One production, or empty for every production of
    the team. Required when `refresh` is true.

`refresh`
:   *bool · default false* — Re-clone the managed checkout from
    `[production].odumcp_repo_url` at the production's Odoo version even when
    that version is unchanged, recreate the container and upgrade the module.
    The only way the managed module is updated; nothing refreshes it
    automatically. Requires an explicit `name`: the call is rejected before
    touching anything when `name` is empty, so one call never re-clones and
    recreates every production of the team. A no-op for a module shipped in
    the client repository (the result says so in `note`).

**Use it when**

- Adopting a production that predates automatic provisioning.
- The connector reports itself unavailable after a failed setup.
- The production moved to another Odoo version.
- Rotating `production_token`.
- Picking up a new connector release for a managed module (`refresh=true`).
- The repository now ships `odumcp` and the managed copy should go.

### `production_odoo_info`

Odoo identity, profile and the capabilities the deployed policy actually
enables — the cheapest way to confirm the connector is reachable and what it
will allow.

**Parameters**

`name`
:   *str · required* — Production name.

**Use it when**

- Verifying a fresh install or a rotated key.
- Discovering which operations the production's policy permits before planning work.

### `production_odoo_read`

Policy-governed reads: `models.list`, `models.describe`, `records.search`,
`records.read`, `records.count`, `records.aggregate`, `attachments.read` and
`reports.render`. Parameters follow the OduMCP API (`model`, `domain`,
`fields`, `ids`, `limit`, …). There is no arbitrary ORM call.

Start from `models.describe`: it reports the fields this production is willing
to expose, which is not the full Odoo schema.

**Parameters**

`name`
:   *str · required* — Production name.

`operation`
:   *str · required* — One of the read operations listed above.

`params`
:   *dict · default none* — Operation parameters as defined by the OduMCP API.

**Use it when**

- Answering a question about live data without copying the database into dev.
- Rendering a production report for a customer.

### `production_odoo_preview_change`

Store a change plan in Odoo and return its `approval_id`. **This does not
execute the business change.** Actions include `record.create` / `update` /
`delete`, `method.call`, `message.post`, `activity.schedule` / `update` /
`done` and `attachment.create`.

Approval is a human act in Odoo, unless that production's own policy explicitly
permits auto-approval. Retrying the same intent means reusing the same
`idempotency_key`, never composing a second plan.

**Parameters**

`name`
:   *str · required* — Production name.

`action`
:   *str · required* — The planned action, e.g. `record.update`.

`payload`
:   *dict · required* — Action payload as defined by the OduMCP API.

`idempotency_key`
:   *str · required* — Stable key for this intent; reuse it when retrying.

`batch_key`
:   *str · default empty* — Groups plans that must be approved and executed together.

**Use it when**

- A production record genuinely has to change and the change needs an audit trail.

### `production_odoo_change_status`

Read a plan's approval state and, once executed, its stored result.

This is also the correct response to an uncertain execute: the change tools
never retry writes on their own, so the status is what tells you whether the
earlier call landed.

**Parameters**

`name`
:   *str · required* — Production name.

`approval_id`
:   *str · required* — The ID returned by `production_odoo_preview_change`.

**Use it when**

- Waiting on a human approval.
- A previous execute failed in transport and the outcome is unknown.

### `production_odoo_execute_change`

Execute the stored, approved plan by ID. It never grants approval and never
substitutes a payload — the plan that runs is the plan that was approved.

Check the returned state: `pending`, `expired`, `rejected` and `failed` all
mean the change did **not** happen. After a transport failure, check the status
before creating any new plan.

Lock: production.
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · required* — Production name.

`approval_id`
:   *str · required* — The approved plan to execute.

**Use it when**

- A human has approved the plan in Odoo and the change should now be applied.

## Agent Guidance & Feedback

### `get_agent_instructions`

Load the compact Oduflow agent workflow and the active code-delivery mode.

**Parameters**

*None.*

**Use it when**

- Once at the start of an agent session, before other Oduflow tools. Not before every call — the guide holds for the session.

### `get_odoo_development_guide`

Get the Odoo development standards and constraints guide for a specific Odoo
version (15–19).

**Parameters**

`version`
:   *str · required* — Odoo version number. Both `"19"` and `"19.0"` are accepted.

**Use it when**

- Before writing or refactoring Odoo module code. Determine the version from the request, from [`get_environment_info`](#get_environment_info), or from the `odoo_image` value — `odoo:18.0` means `version="18"`.
- Immediately after [`create_environment`](#create_environment) tells you to.

### `report_issue`

Build a prefilled link the user can follow to file a bug, feature request or
feedback about **Oduflow itself** on GitHub.

The tool does **not** create the issue: it returns a prefilled link to the
`oduflow/oduflow` issue form. Show the link to the user and let them submit it
from their own GitHub account, so the report is attributable to them and they
can edit it first. Oduflow version, Python version, platform, transport and
routing mode are attached automatically.

**Parameters**

`details`
:   *str · required* — The report body: what happened, what was expected, or the feedback.

`kind`
:   *str · default `"feedback"`* — One of `bug`, `feature`, `feedback`. Selects the issue form and its labels.

`title`
:   *str · default empty* — One-line summary used as the issue title.

**Use it when**

- The user hits a bug in Oduflow, wants a feature, or wants to send feedback — **not** for problems in their own Odoo code.

!!! warning "Never include identifying or customer data"
    No hostnames, repository URLs, branch or database names, credentials, or
    customer data in the text.

---

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); the CLI equivalents are in the
[CLI Reference](cli.md).

---

# CLI Reference

## Global Options

```bash
# Show version
oduflow --version

# Disable anonymous usage telemetry for this run only (e.g. test or CI runs);
# place it before any subcommand
oduflow --no-telemetry --transport http
oduflow --no-telemetry stack apply oduflow.yaml
```

`--no-telemetry` overrides `disable_telemetry` in `oduflow.toml` for the
current process only. See [Telemetry](installation.md#telemetry).

```bash
# Log only warnings and errors (default: info); also before any subcommand
oduflow --log-level warning stack apply oduflow.yaml
```

`--log-level` takes `debug`, `info`, `warning` or `error` and applies to the
server too, its HTTP access log included. `warning` leaves a one-shot command
such as `stack apply` printing just its result; at `info` the progress
messages of long steps (image pulls, environment creation) are shown as well.
A command that fails on invalid input prints one error line; `debug` adds the
traceback.

## 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 and its REST/WS API use session cookies obtained through the login form (with TOTP when enabled). HTTP Basic is not accepted.

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).

## Dashboard Two-Factor Authentication

Run locally on the server as the Oduflow service user, with the server's existing
configuration and persistent data directory. Docker users should execute these
commands inside the running Oduflow container with an interactive terminal.

```bash
# Print a local QR code and enable TOTP after confirming an authenticator code
oduflow ui-2fa setup --team 1

# Confirm disabling TOTP and revoke full operator UI sessions
oduflow ui-2fa reset --team 1
```

`--team` defaults to `1`. Setup requires a configured `ui_password`; an existing
factor must be reset before replacement. Reset returns the UI to password-only
login until setup runs again. Commands use the same `ODUFLOW_TOML` configuration
lookup as the server, do not require Docker access, and do not start the server.
Changes take effect without a restart. Shared links and MCP clients are
unaffected. See [UI 2FA](security.md#enable-authenticator-app-2fa) for enrollment,
session expiry, and recovery details.

## License Commands

Manage the commercial license on the server. This is the only license interface
under a [Custom license](licensing.md#custom-license-white-label), where the
dashboard shows no license dialog to your clients.

```bash
# Holder, validity, and for a Custom license the brand, domains and white-label state
oduflow license status

# Verify and install a key file into the config directory
oduflow license install ./license.key

# Install a renewed key from the license server, if one was paid
oduflow license refresh

# Print a payment link when the license period ended without a subscription
oduflow license subscribe
```

The commands read the existing configuration only and do not require Docker.
Restart the service after installing or renewing a license so MCP clients see
the change; the dashboard picks it up within a minute.

## Server Settings Console

The [Server settings console](admin.md) at `/admin` edits `oduflow.toml` from
the browser. Its password is the one setting it cannot create for itself:

```bash
# Generate a console password, store it as [admin] password, print it once
oduflow admin enable

# Replace the password (signs every console session out)
oduflow admin enable --reset

# Remove the password; /admin answers 404 again
oduflow admin disable

# Optional: authenticator-app 2FA for the console
oduflow ui-2fa setup --admin
oduflow ui-2fa reset --admin
```

`enable` and `disable` write through the same validated, history-keeping writer
as the console. Restart Oduflow afterwards so the running server picks the
password up.

## 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 and overwrite conflicts with the new bundle
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

# Upgrade the Oduflow package itself, reconcile bundled files, restart
oduflow self-update

# Non-interactive: overwrite bundle conflicts; or skip the service restart
oduflow self-update --force
oduflow self-update --no-restart
```

### Cluster Disaster Recovery

Cluster-wide WAL control and whole-cluster PITR are operator actions: they
affect every team's productions at once, so they are not MCP tools. They are
available in the [server settings console](admin.md) (**/admin → Cluster DR**)
and on the owner CLI:

```bash
# Cached cluster WAL state (queue, disk reserve, protection latch)
oduflow wal status

# Archiving control / disk-protection recovery (see Production Hosting docs)
oduflow wal pause   --confirm ALL-PRODUCTIONS
oduflow wal resume  --confirm ALL-PRODUCTIONS
oduflow wal retry   --confirm ALL-PRODUCTIONS
oduflow wal recover --confirm ALL-PRODUCTIONS
oduflow wal release --confirm ALL-PRODUCTIONS

# DISASTER RECOVERY: restore the whole production PostgreSQL cluster from
# WAL-G (optionally to a point in time). Also the "resurrect production
# elsewhere" path: a fresh install with the same [backup] section rebuilds
# the cluster from S3.
oduflow restore-cluster --confirm RESTORE-CLUSTER
oduflow restore-cluster --target-time "2026-07-10 12:00:00+00" --confirm RESTORE-CLUSTER
```

`restore-cluster` stops every production Odoo container, restores the cluster
from the newest suitable base backup plus WAL replay, and restarts the
productions it stopped. The displaced data directory is kept inside the Docker
volume for manual cleanup. See
[Production Hosting](production.md#backups) for the full semantics.

`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` makes the command fully non-interactive: it skips the confirmation
prompt and resolves conflicts, legacy files, and merge failures in favour of
the new bundle. The replaced live file is saved under
`<team-data>/.bundled_upgrade/backups/`, the baseline advances, and any stale
sidecar is removed, so a forced run leaves nothing to review and exits 0.
Clean merges still merge, local-only changes are still left untouched, and 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.

Before reconciling, `oduflow upgrade` checks that the next server start
accepts the configured `[database].image` and can complete a PostgreSQL upgrade
it requests (see [Upgrading PostgreSQL](installation.md#upgrading-postgresql)).
If environments or unexpected databases block the upgrade, or the image needs
attention, the command lists the problems and exits with an error without
changing anything, and `self-update` does not restart the service.

`oduflow self-update` chains the whole documented upgrade: it compares the
installed version with the latest GitHub release, upgrades the package through
its own installer (`uv tool upgrade oduflow` for a uv tool install, otherwise
`pip install --upgrade oduflow` in the same environment), verifies the installed
version, runs the bundled-file reconciliation above through a fresh process in
that Python environment, and restarts the systemd service when the unit
installed by `oduflow systemd-install` exists and the command runs as root.
`--force` is forwarded to the reconciliation, and it
also finishes an interrupted upgrade: if the package is already at the latest
version — for example after a first run stopped on a bundle conflict — the
command reconciles and restarts instead of reporting "already up to date" and
doing nothing. `--no-restart` leaves the running server on the old version
until you restart it yourself.

If the installer succeeds but the advertised release is not installed (for
example, uv has a version pin or the package index has not received the release
yet), the command exits with an error before reconciliation or restart. Check
the installer's constraints and index, then retry. A uv tool upgrade also checks
that uv's tool directory contains the running installation; use the installing
user and original `UV_TOOL_DIR` if they differ.

It refuses, with an error, installations it cannot upgrade durably: **a
container** (a package upgraded inside the `oduist/oduflow` container reverts
when the container is recreated — pull the new image and recreate it instead,
see [Docker](docker.md)), a source checkout or editable install (update those
with `git pull`), an ephemeral `uvx` run (use `uvx oduflow@latest` to refresh the
cached version), and an environment pip cannot upgrade in place — a virtualenv
created without pip, or a `site-packages` the current user cannot
write, where `pip install --upgrade` would install a second copy into
`~/.local` that the running service never loads. Re-run those as the user that
owns the installation.

## 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]

# 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]

# Import (or incrementally re-sync with --overwrite) from an S3 prefix or a
# local path holding dump.* + filestore/; a single dump file imports DB-only
oduflow import-template s3://bucket/backups/mydb/ --template-name myproject [--overwrite] [--team 1]
oduflow import-template /backups/mydb/ --template-name myproject [--overwrite] [--team 1]

# A single Odoo backup .zip already sitting in a bucket
oduflow import-template s3://bucket/backups/mydb-2026-10-13.zip --template-name myproject [--team 1]

# Reload the template DB from files already placed in the template directory
# (an external rsync/scp drop) and refresh its metadata
oduflow import-template --template-name myproject --refresh [--team 1]

# Run a saved, enabled SSH sync schedule (cron normally invokes this)
oduflow sync-template myproject [--team 1]
# Only consume a manual request queued by the dashboard
oduflow sync-template myproject --pending [--team 1]
```

For a private bucket, pass `--s3-access-key` with `--s3-secret-key` (plus
`--s3-endpoint`/`--s3-region` where needed). The secret key is stored as the
team secret `s3-template-<template>` and the template records only a
`secret:<name>` reference, so a later `--overwrite` re-sync needs no key —
omit `--s3-secret-key` and the stored one is reused. It is required again when
the endpoint or access key changes. `--s3-secret-key` defaults to
`$ODUFLOW_S3_SECRET_KEY`, which keeps the key out of your shell history; pass
`secret:<name>` to sign with an existing team secret instead.

`template-from-env`, `refresh-template`, `attach-filestore`, and `import-template` with `--overwrite`/`--refresh` 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. Without `--overwrite`, `import-template` creates a new template and refuses an existing template name.

Scheduled SSH sync uses retained filestore generations and opt-in environment
DB/filestore resets. Ordinary template refresh leaves pinned environments on
their previous generation and lists them in its output. See [Scheduled SSH sync](templates.md#scheduled-ssh-sync-system-cron)
for source preparation, cron installation and recovery.

## Service Commands

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

# List persistent PostgreSQL databases for auxiliary services (no passwords)
oduflow list-service-databases [--team 1]
```

Create, inspect, rotate, and delete databases through the matching MCP tools
with `oduflow call`, for example:

```bash
oduflow call create_service_database '{"name":"events"}'
# Or on the dedicated production PostgreSQL cluster (requires production hosting)
oduflow call create_service_database '{"name":"events","cluster":"prod"}'
oduflow call get_service_database '{"name":"events"}'
oduflow call rotate_service_database_password '{"name":"events"}'
oduflow call delete_service_database '{"name":"events"}'
```

## 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; anything that could not be removed is reported on an `Error:` line. The dashboard's **Cleanup** button shows the same preview and can also remove unused Docker images.

## 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
```

## Remote Tool Invocation

`oduflow client` calls the same registered tools on a running Oduflow HTTP
server through FastMCP. Unlike `oduflow call`, it does not load the local
`oduflow.toml`, access local Docker, or execute server functions in the client
process.

Configure the exact full or scoped MCP endpoint and its Bearer credential:

```bash
export ODUFLOW_MCP_URL="https://oduflow.example.com/mcp"
export ODUFLOW_MCP_TOKEN="<team-auth-token>"

# Tools advertised by this endpoint
oduflow client list
oduflow client list --verbose

# Read live help generated from a tool's input schema
oduflow client create_environment --help

# Call team-wide tools
oduflow client list_environments
oduflow client create_environment \
  --repo-url https://github.com/owner/addons.git \
  --odoo-image odoo:19.0 \
  --template-name myproject
```

The client reads the live `tools/list` response, converts kebab-case flags to
MCP parameter names, validates basic scalar/JSON types, and then calls the tool.
A single JSON object is also accepted for complex arguments:

```bash
oduflow client create_service '{
  "name": "redis",
  "image": "redis:7",
  "port": 6379
}'
```

Client options must appear before the tool name:

```bash
oduflow client --timeout 1200 --json list_environments
oduflow client --env demo pull_and_apply --upgrade sale_custom --strict
```

When a remote schema requires `env_name`, the client uses `--env`, then
`ODUFLOW_ENV_NAME`, then the current Git branch. When `create_environment`
requires `branch`, it uses the current Git branch unless `--branch` is supplied;
other tools that take a required branch, such as `create_production`, always
need it spelled out.
An explicit environment override is useful when an environment name differs
from its source branch.

For confined development access, use the environment's scoped endpoint and
Secret Key from **More → MCP Access**:

```bash
export ODUFLOW_MCP_URL="https://oduflow.example.com/mcp/feature-x"
export ODUFLOW_MCP_TOKEN="<environment-secret-key>"

# env_name is absent from the scoped schema and injected by the server
oduflow client get_environment_info
oduflow client pull_and_apply --upgrade sale_custom --strict
oduflow client run_odoo_tests --modules sale_custom
```

The scoped server advertises only its allowlisted single-environment tools, so
team-wide commands such as `list_environments` and `create_environment` are not
available. The client does not maintain a second authorization list; the live
server schema remains authoritative.

For scripts and CI, `--json` emits the complete MCP result and tool failures
return a non-zero exit code. To avoid storing a token in the environment, pass
it on standard input:

```bash
printf '%s\n' "$TOKEN_FROM_SECRET_STORE" | \
  oduflow client --token-stdin --json list_environments
```

In summary, `oduflow call <tool>` is local in-process execution, while
`oduflow client <tool>` is remote authenticated MCP execution.

---

# 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
   environment_hostname_mode = "branch"
   ```

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 TLS certificate from Let's Encrypt for each routed 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`

## Hostname and certificate strategies

The default `environment_hostname_mode = "branch"` keeps descriptive and
backward-compatible routes: `feature-login.dev.example.com`,
`fix-invoice.dev.example.com`, and so on. `environment_slots = 20` limits how
many environments may exist but does not change those names. This mode matches
the `*.dev.example.com` DNS record shown above and works with a Cloudflare or
other wildcard certificate for `*.dev.example.com`.

For Traefik HTTP-01 installations that need to bound Let's Encrypt issuance,
opt into a reusable pool:

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

Oduflow then allocates `dev1.example.com` through `dev20.example.com` and
returns names to the pool on deletion. These names are one DNS level higher
than branch-derived routes: configure individual `dev1`…`dev20` records or a
wildcard record for `*.example.com`. A `*.dev.example.com` record or certificate
does not cover `dev1.example.com`.

`create_environment(hostname="qa")` requests `qa.example.com` in either mode:
the short name replaces the team hostname's prefix. A bare team domain such as
`example.com` has no prefix to replace, so `qa` goes under it —
`qa.example.com`, the same layer as the branch-derived names. "Bare" is decided
with the Public Suffix List, not by counting labels: a registrable domain under
a multi-label suffix (`example.co.uk`) is also kept whole, so `qa` gives
`qa.example.co.uk` — never `qa.co.uk`. The pooled
`slots` mode still needs a distinct prefix (`dev.example.com`, not bare
`example.com`) to number.

## Team base domain

Setting `base_domain` gives the team one flat DNS zone instead of nesting
everything under the dashboard hostname:

```toml
[team.1]
base_domain = "demo.example.com"
# hostname defaults to "oduflow.demo.example.com" (the dashboard)
```

With a base domain, environments and services live **directly under the
zone** — `feature-login.demo.example.com`, `meilisearch.demo.example.com` —
instead of `feature-login.oduflow.demo.example.com`, and production domains
default into the zone too (the apex `demo.example.com` for the team's first
production, `<name>.demo.example.com` afterwards; see
[Production Hosting](production.md#domains)). One `*.demo.example.com` DNS
record (plus the apex, if a production uses it) covers everything.

The zone is exclusive to the team, and every name handed out is checked
against the whole routed namespace — the dashboard hostname, production
domains of all teams, static `[route.*]` hosts, other teams' zones and the
names live environment and service containers currently serve — so two
resources can never claim the same FQDN. Existing environments keep their old
nested hostname until their next `update_environment`, which moves them into
the zone; until then that is the name they are reported at and checked
against, because a container's Traefik rule is fixed when it is created.

A service with `routes` is the one deliberate exception: it publishes only
`Host() && PathPrefix()` routers and no catch-all, so it may share the team's
dashboard hostname to expose a URL prefix beside the dashboard.

## OAuth on each team's hostname

The self-hosted [OAuth Authorization Server](security.md#self-hosted-oauth-for-claudeai-and-other-mcp-clients) is enabled automatically whenever a team has an `auth_token` and runs on **each team's own hostname** in every routing mode. With `tls = true`, the incoming host has a Let's Encrypt certificate; with `tls = false`, the upstream tunnel provides it. There is no separate OAuth section: 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` under team hostname `dev.example.com` becomes accessible at `https://meilisearch.dev.example.com`; with a team `base_domain` it attaches to the zone instead (`meilisearch.demo.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`. With `tls = true`, the route gets its own
Let's Encrypt certificate (point the domain's DNS at this server first), exactly
like a team hostname. With `tls = {}`, it uses the default certificate. 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.

## HTTPS with a self-signed certificate

To serve HTTPS without Let's Encrypt, set an empty TLS table in `oduflow.toml`:

```toml
[routing]
mode = "traefik"
tls = {}
```

Traefik listens on **:443** and redirects **:80** to HTTPS. Oduflow enables TLS
on every generated router without a certificate resolver, so no certificates
are requested for Oduflow-managed hostnames and `acme_email` is not required.

`acme_email` still matters with `tls = {}`: when it is set, the Let's Encrypt
resolver is *declared* in Traefik (and the ACME certificate store is created and
mounted read-only into auxiliary services), but only routes that reference it
explicitly — your own [drop-in dynamic-config files](#2-drop-in-traefik-dynamic-files) —
obtain certificates through it. Note that the store contains no `acme.json`
until the first issuance. Without `acme_email` there is no resolver, no store
and no implicit service mount at all. The four combinations:

| Settings | Behavior |
| --- | --- |
| `tls = {}` + `acme_email` set | Resolver declared, used only by explicitly configured routes |
| `tls = {}` + `acme_email` empty | HTTPS without ACME |
| `tls = true` + `acme_email` set | Resolver declared and automatically assigned to every managed route |
| `tls = false` | TLS and ACME off |

Unless you provide your own certificates through Traefik's dynamic configuration,
Traefik generates and serves its default self-signed certificate. Browsers and
clients do not trust it automatically; this is suitable for local or test setups.
See [Traefik's default certificate documentation](https://doc.traefik.io/traefik/reference/routing-configuration/http/tls/tls-certificates/#default-certificate).
The generated certificate is not exported into `acme.json` for auxiliary services.

Because that certificate has no trust anchor, Oduflow skips certificate
verification when it calls its *own* public URLs — `http_request_to_odoo` and
the environment readiness check that `start_environment` / `restart_environment`
wait on. This applies to `tls = {}` only; `tls = true` and every outbound
request to a third-party host keep full verification. Your own clients
(browsers, `curl`, MCP clients) still need the certificate trusted or the check
disabled on their side.

The supported values are `true` (HTTPS with Let's Encrypt, the default),
`{}` (HTTPS without ACME), and `false` (HTTP only). Nonempty TLS tables are
rejected; this setting does not pass arbitrary options through to Traefik.

Switching modes recreates Traefik on the next Oduflow startup whenever its
command line changes (TLS on/off, or the resolver appearing/disappearing);
`tls = true` ↔ `tls = {}` with the same `acme_email` reuses the running
container and only rewrites the route configuration. Disabling ACME never
deletes the certificate store: issued certificates and the Let's Encrypt
account key survive a later re-enable. Recreate existing environments,
productions and services too: their Docker routing labels retain the previous
entrypoint and certificate resolver until their containers are recreated.

## 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).

## Plain HTTP, with nothing terminating TLS

`tls = false` on its own means "**someone else** terminates TLS", so the links
Oduflow hands out — environment and service URLs, dashboard share links, MCP
endpoints — stay `https://`. If there is no such upstream (a LAN box, an
internal staging server, a local demo), those links point at an endpoint nobody
serves. Say so explicitly with `public_scheme`:

```toml
[routing]
mode = "traefik"
tls = false
public_scheme = "http"   # no TLS anywhere: hand out http:// links

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

`public_scheme` sets the scheme of every URL Oduflow reports; it defaults to
`https` in traefik mode and `http` in port mode, and is independent of `tls`
(which only decides whether *Traefik* terminates TLS). It also switches off
`forwardedHeaders.insecure` on the `web` entrypoint: with no trusted terminator
in front, that entrypoint is directly reachable and must not believe a
client-supplied `X-Forwarded-Proto`.

Changing `public_scheme` recreates the Traefik container (the forwarded-headers
argument changes) but not your environments — only the reported URLs change, so
no environment or service needs recreating.

!!! warning "Plain HTTP is unencrypted"
    Odoo logins, session cookies, the dashboard password and MCP bearer tokens
    all travel in cleartext. Use this only on a trusted network, never on a
    public-facing server.

## Mixing HTTP and HTTPS teams in one deployment

`public_scheme` can be overridden per team. The typical shape: one team is
reached directly over the LAN in plain HTTP, another is published through a
**Cloudflare tunnel** that terminates TLS — same server, same Traefik on plain
`:80`:

```toml
[routing]
mode = "traefik"
tls = false              # Traefik listens on plain HTTP :80 only
public_scheme = "http"   # default for teams without an override (the LAN team)

[team.1]
hostname = "dev.internal.example.com"   # LAN, http:// links

[team.2]
hostname = "dev.example.com"            # via Cloudflare tunnel
public_scheme = "https"                 # its links are https://
```

Point the tunnel at the server's port 80 for the second team's hostnames
(e.g. `dev.example.com` and `*.dev.example.com → http://localhost:80`); the
first team's clients resolve its hostname to the server directly. Every URL
Oduflow hands out — dashboard links, MCP endpoints, environment and service
URLs — uses each team's resolved scheme. No `acme_email` is involved: with
`tls = false` Traefik never talks to Let's Encrypt, and the tunnel's
certificate comes from Cloudflare.

!!! warning "Forwarded-header trust is deployment-wide"
    Because at least one team resolves to `https`, the `web` entrypoint trusts
    inbound `X-Forwarded-*` headers (as in the tunnel setup above) — so the
    tunnel's `X-Forwarded-Proto: https` survives. That trust is
    **entrypoint-wide**, not per team: any client that can reach port 80
    directly can forge `X-Forwarded-Host`, `X-Forwarded-Proto` and
    `X-Forwarded-For` on requests to *any* hostname this Traefik serves —
    including the other team's environments and productions. Production Odoo
    runs in proxy mode and uses those headers to rebuild absolute URLs
    (`web.base.url`, password-reset links) and for IP logging and login
    throttling. Only mix schemes when every network that can reach port 80 is
    trusted for **all** teams on the deployment; otherwise split the teams
    onto separate deployments.

The per-team value obeys the same rules as the global one: `https` is invalid
in port mode, and `http` is invalid while TLS is enabled (`true` or `{}`) (the :80→:443 redirect
would break the links).

Production URLs are reported with the **owning team's** scheme. A production's
domain is free-form (it need not live under the team's hostname), so make sure
each production domain is fronted the same way as the rest of its team — a
plain-HTTP team's production published through the other team's tunnel would be
reported as `http://` even though only `https://` answers.

---

# 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]
```

Every team must declare a unique `hostname`. Besides routing requests, that
hostname is the team's OAuth issuer identity when reached through a
TLS-terminating proxy such as Traefik or Cloudflare Tunnel.

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`) 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 |
| Auxiliary-service PostgreSQL databases and roles | Per-team — stored in the team's tablespace |
| 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`)
- Service DB: `oduflow_service_{team_id}_{name}` (e.g. `oduflow_service_1_events`)
- Service DB role: `svc_{team_id}_{name}` (e.g. `svc_1_events`)

Service database identifiers carry a `.<digest>` suffix whenever the readable
form would be ambiguous — a team id containing `_` or uppercase, or one that
would overflow PostgreSQL's 63-byte identifier limit. The suffix is derived
from the exact team/database pair, so two teams can never end up sharing a
database or a role.
- 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 list-service-databases --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]
hostname = "team-a.example.com"
auth_token = "secret-token-team-1"

[team.2]
hostname = "team-b.example.com"
auth_token = "secret-token-team-2"
```

The token is used to both authenticate and identify the team. The self-hosted
OAuth provider also accepts it directly as a non-expiring Bearer credential.

Fresh configs get a generated `auth_token` for `[team.1]` on first startup. The
value is stored in `oduflow.toml` (created with mode `0600`) and never written to
the log; read it from the file and use it as
`Authorization: Bearer <auth_token>` when connecting HTTP MCP clients.

## Server settings console

The [Server settings console](admin.md) at `/admin` edits `oduflow.toml`,
including every team's tokens and passwords, so it is not part of any team's
dashboard. It has its own `[admin] password` (plus optional TOTP via
`oduflow ui-2fa setup --admin`), its own 12-hour session cookie, and refuses
team credentials; a console session does not open a team dashboard either.
While the password is empty, `/admin` answers 404. Secrets reach the browser
only through an explicit, logged **Reveal**.

## 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

The Authorization Server is enabled automatically whenever a team has an
`auth_token`. It runs on **each team's own hostname** in both port and
[traefik mode](traefik.md): the OAuth issuer is derived per request from the
incoming host after validating it against configured team hostnames. No
separate OAuth configuration 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 behind Cloudflare Tunnel or another TLS proxy, publish the same
hostname configured for the team and forward it to the HTTP listener:

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

For direct LAN access while retaining that public hostname, use split DNS so
`oduflow.example.com` resolves to the server's LAN address internally. Local
Bearer clients may also connect by IP; OAuth discovery is intentionally served
only for a recognized team hostname.

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.

### Connecting from Claude Desktop

Claude Desktop does **not** use this OAuth flow — it can only start MCP servers
as local processes. Connect it through the `mcp-remote` stdio bridge with the
team's `auth_token` as a plain Bearer header; the full
`claude_desktop_config.json` example is in
[Quick Start → Claude Desktop](quick-start.md#claude-desktop-remote-server-via-mcp-remote).

### 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 uses the same team identity as the OAuth flow.

The built-in remote CLI uses the same Bearer authentication and live MCP tool
schemas:

```bash
export ODUFLOW_MCP_URL="https://your-server.com/mcp"
export ODUFLOW_MCP_TOKEN="secret-token-team-1"

oduflow client list_environments
```

`oduflow client` does not use the dashboard's `ui_password`. For an automation
that needs only one development environment, prefer its scoped `/mcp/<env>` URL
and per-environment Secret Key instead of the team token. The server then hides
team-wide tools and injects the environment target itself.

## 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 gets its own access token, minted at creation time and kept
in the team's data directory (`mcp_tokens.json`, mode 0600), keyed by the
environment name. It survives **rename and recreate** — an MCP client
configured once keeps working for as long as an environment of that name
exists — and is revoked when the environment is deleted. 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 or the built-in remote CLI:

```bash
export ODUFLOW_MCP_URL="https://your-server.com/mcp/<env>"
export ODUFLOW_MCP_TOKEN="<environment-secret-key>"
oduflow client get_environment_info
```

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.

## Shared single-environment dashboard (`/env/<name>`)

The dashboard equivalent of `/mcp/<env>`: a link that opens the dashboard
reduced to one environment, for a client or collaborator who has no team
password.

Open **More → Share UI** on an environment card and Oduflow mints

```
https://your-server.com/env/<env>?key=<share-secret>
```

Opening it trades the key for a signed, HTTP-only, SameSite=Strict cookie and
moves on to the clean `/env/<env>`, so the key does not linger in the address
bar or browser history. The move is a navigation started by a small landing
page rather than an HTTP redirect: when the link is opened from another site,
such as a webmail button, the browser treats a redirect chain as cross-site and
withholds the Strict cookie, while a navigation started by the landing page is
same-site. The same modal regenerates or revokes the link; both
take effect immediately, including for sessions already opened with it, because
the cookie carries a fingerprint of the secret it was minted from. Links have no
expiry of their own; a session cookie lasts seven days and re-opening the link
renews it.

A shared session sees that environment's card, logs, storage and status; can
start, stop, restart and sync it, install and upgrade modules, open its Odoo
shell and `psql` consoles, use Connect As and its **Agent Chat**, and read its
`/mcp/<env>` URL and Secret Key. Everything else is refused server-side by a
default-deny allowlist re-checked on every request: the full dashboard, any
other environment, all team-wide surfaces (templates, services, volumes, extra
addons, credentials, productions, host statistics, license), the provisioning
actions on the environment itself (create, delete, update, recreate, switch
branch, protect, save as template), the share routes themselves, and **Agent
CLI**.

Agent Chat runs in the *per-team* agent container, but as this environment's
own Linux user: it reaches only this environment's checkout, attachments and
HOME, and only this environment's scoped MCP token. Other environments'
checkouts, conversations and tokens are closed to it (see
[Coding Agent → Security model](agent.md#security-model)). What it still shares
with the rest of the team is the git credential store and deploy key (below),
the team-wide provider credentials in `[team.X.agent_env]`, and the container
network. Agent CLI stays excluded from share links. Share with people you would
let work in the environment, and revoke when they are done.

Share secrets live in the team's data directory (`shares.json`, mode 0600), not
in a container label, so environments that already exist can be shared, and a
link survives recreating the environment. Deleting an environment drops its
share; renaming one carries it over.

## Web Dashboard Auth

The browser login form checks the team's `ui_password` and, when enabled, its
TOTP authenticator code. It creates a signed, seven-day HTTP-only session cookie
for the dashboard, UI REST API, and WebSocket handshakes. Opening the dashboard
does not extend that expiry. HTTP Basic authentication is no longer accepted.

The UI password is independent from the MCP Bearer token (`auth_token`). Use
`oduflow client` for remote automation; it does not need the UI password or TOTP.
Password comparisons use `hmac.compare_digest`. State-changing cookie-auth
requests and WebSocket handshakes have a same-origin `Origin`/`Referer` check;
the login POST also rejects cross-origin submissions.

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.

### Enable authenticator-app 2FA

UI 2FA is optional and uses standard six-digit TOTP codes from Google
Authenticator, Microsoft Authenticator, or another compatible app. It protects
the **full operator UI**. Shared environment links keep their separate,
restricted access without OTP; MCP clients and import/webhook authentication
are unchanged. Odoo's own login is separate.

1. On the Oduflow server, run the command as the **same OS user as the Oduflow
   service**, with its existing configuration and persistent data directory:

   ```bash
   oduflow ui-2fa setup --team 1
   # For a non-default configuration:
   ODUFLOW_TOML=/path/to/oduflow.toml oduflow ui-2fa setup --team 1
   ```

   For Docker installations, run the command inside the running Oduflow
   container using an interactive terminal (`docker exec -it <container> ...`).

2. Scan the QR code printed in the terminal with your authenticator. A manual
   setup key is printed as a fallback. Both contain the secret: do not put them
   in logs, tickets, screenshots, or source control. QR generation is local.
3. Enter the current authenticator code in the CLI. Only a correct code enables
   2FA. A failed or cancelled setup leaves the previous state unchanged.
4. Wait for the next code, then sign in to the dashboard with the existing
   password and the **Authenticator code** field. The setup code is already
   consumed. If 2FA is disabled, leave that field empty.

No server restart is needed. Enabling 2FA revokes existing full UI cookies;
shared-link cookies are unaffected. One secret belongs to the **team**, matching
its existing shared UI password; this is not a personal-user account system.
A code can be accepted only once, including simultaneous requests. Keep the
server and phone clocks synchronized; verification tolerates one 30-second
step in either direction. Failed logins are limited by IP, and ten failed TOTP
attempts within five minutes lock further attempts for the team until that
window clears. Team attempts and consumed time steps survive server restarts.

### Lost phone or replacing an authenticator

Use the local server CLI (over SSH if necessary):

```bash
oduflow ui-2fa reset --team 1
oduflow ui-2fa setup --team 1
```

Reset asks for confirmation, disables the factor, and revokes full UI cookies.
Until setup completes again, the UI accepts the team password alone. There is
no web or MCP reset endpoint and no recovery-code system in this version.
Revocation is checked on subsequent HTTP requests and new WebSocket handshakes;
it does not disconnect an already established terminal connection.

The secret, revocation generation, replay counter, and failed attempts live in
`<team-data-dir>/.ui_totp.json` (permissions `0600`). Updates are locked across
processes and atomically replaced. Keep this file on the persistent data volume
and protect backups as credentials. An unreadable or malformed file blocks full
UI authentication; restore it or use CLI reset. Do not delete the file to reset
2FA: absence represents a team that has never enrolled, whereas CLI reset keeps
a fresh generation so old cookies remain revoked.

### Migrating scripts from HTTP Basic

Use `oduflow client create_environment ...` and `oduflow client pull_and_apply ...`
instead of the removed `scripts/create_env.py` and `scripts/sync_env.py` helpers.
Other scripts using Basic against `/api/` must migrate to the corresponding MCP
tools. The browser continues to use these API routes with its session cookie.
The session format change signs out existing operators once on upgrade, even
for teams without 2FA; shared links keep working.

## 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 ON (self-hosted), UI auth ON)
```

## 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 a personal access token for a git host (verified with git ls-remote against repo_url)
oduflow call setup_repo_auth '{"repo_url": "https://github.com/owner/private-repo.git", "token": "ghp_..."}'

# Host only — verified against the provider API (GitHub, GitLab, Bitbucket)
oduflow call setup_repo_auth '{"host": "github.com", "token": "ghp_..."}'

# Legacy inline form
oduflow call setup_repo_auth https://user:PAT@github.com/owner/private-repo.git
```

Git matches stored credentials by host and username, not by repository, so a
single token covers every repository on that host. `username` is optional (it
defaults to `x-access-token`; GitHub, GitLab and Azure DevOps accept any name
with a token) and only has to be the real account name for Bitbucket app
passwords. Use different usernames to keep several tokens for one host; storing
again with the same username replaces the token.

### 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** a credential for a git host | `POST /api/credentials/add` (body: `token`, `host` = `github.com`, optional `username`, optional `repo_url` to verify against; legacy: `repo_url` with inline `user:PAT@`) |
| **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_****`).

### SSH deploy key

As an alternative to tokens, each team has an SSH deploy key: an ed25519
keypair generated automatically at server start and stored at
`{team_data_dir}/ssh/id_ed25519` with owner-only permissions. The dashboard's
**Credentials** tab, `GET /api/ssh-key`, and the `get_ssh_public_key` MCP tool
expose only the public key. Register it with your git hosting (repository
deploy key or machine-user key) and SSH repository URLs
(`git@github.com:owner/repo.git`) work for environments, extra addon repos and
productions.

Like the team's git credential store, the private key is also provisioned
into the team's coding-agent container (a copy in every environment's HOME) so
agent-side clones work over SSH.
Anyone who can drive that agent — including a visitor holding a scoped
environment share link, via Agent Chat — can therefore read it. Treat the
deploy key as a team-level credential: prefer registering it read-only and
per-repository, and regenerate it when a share should no longer grant repo
access.

Git runs SSH with `BatchMode=yes` (it can never block on a prompt) and
`StrictHostKeyChecking=accept-new` with a per-team `known_hosts` file, so a
host key is pinned on first contact and a later change is refused.
`POST /api/ssh-key/generate` with `{"force": true}` regenerates the keypair;
the old key stops working everywhere it was registered.

## Secrets for Environment Variables

Environment variables on services and environments are visible to coding agents through `get_service_info`, `list_services`, `get_environment_info` and the dashboard — so putting a password or API key directly into `env_vars` leaks it into every agent conversation that inspects the resource.

**Secrets** are team-scoped named values that avoid this. A human operator creates them in the dashboard (**Credentials** tab → **Secrets**); values are *write-only* — they can be replaced or deleted, but no MCP tool or REST endpoint ever returns a stored value. Agents can list the names with `list_secrets`.

The secret form starts with **Name**, followed by the independent **Hide value** and **JSON value** switches. **Hide value** masks the ordinary text value and the individual key's **Value** by default. In JSON mode, **Replace Data** is always visible, even with masking enabled, and validates JSON while you type. Its warning makes clear that saving replaces all data in the secret. Text and JSON replacement drafts stay separate when switching modes. JSON secrets also undergo server-side validation before saving; invalid input leaves the previous value intact. The stored type (`text` or `json`) persists across replacements. Secrets saved before types existed are typed by their stored value: a JSON object or array is `json`, anything else (including scalars such as `123`) is `text`; an upgrade records that type once. JSON values retain their original formatting and may contain any valid JSON value, including arrays and scalars; `NaN` and `Infinity` are rejected.

To use one, set the env-var value to a reference:

```bash
oduflow call create_service '{
  "name": "meili",
  "image": "getmeili/meilisearch:v1.6",
  "port": 7700,
  "env_vars": "MEILI_MASTER_KEY=secret:meili-master-key,MEILI_ENV=production"
}'
```

The real value is substituted only into the container's environment at creation time. Everything that stores or displays the configuration — the service preset, the environment's Docker label, template metadata, `get_service_info`/`get_environment_info` output — keeps the `secret:<name>` reference. Because only the reference travels, secrets migrate automatically when a service is restored from a preset, an environment is renamed, or an environment is saved as a template and new environments are created from it.

A dangling reference (secret deleted or never created) fails the create/update with a clear error before anything is touched; running containers keep their resolved value until recreated. After replacing a secret's value, recreate the services, environments and productions that use it (`update_service` / `update_environment` / `reconfigure_production`): a rotated value counts as a config change, so `update_service` and `reconfigure_production` recreate the container even when the image and every other setting are unchanged. `restart_production` and `restart_environment` keep the old value.

The store lives at `{team_data_dir}/secrets.json` with owner-only (0600) file permissions, like the other credential stores. Note the boundary: code running *inside* a container can always read its own environment — secrets protect the MCP/REST/dashboard read surfaces, not the container itself.

### Updating one JSON element

In the dashboard, choose **Edit secret** on an existing JSON secret. Above
**Replace Data**, enter a path in **Update Key** (for example,
`environment.OPENROUTER_API_KEY`) and the new non-empty text in **Value**, then
click **Save key**. The value is saved as a string exactly as entered, without
JSON quotes or automatic conversion of text such as `true` or `123`.
**Hide value** controls masking for this field independently of the visible
replacement JSON.

**Update Key** and **Value** stay disabled for a secret stored as text, even
with **JSON value** checked. Paste the full JSON into **Replace Data** and click
**Replace data** once; the form closes and the secret is stored as JSON. Open
**Edit secret** again to update individual keys.

After saving, the form stays open and displays **Key created** or **Key updated**
with the submitted path. The value field is cleared, and the path remains for
reuse. The result comes from the server, which checks whether the key existed
under the same lock as the write. Editing the key or value clears the previous
result. Stored secret values are never displayed.

**Save key** and **Replace data** are separate actions: saving a key never
submits or clears the replacement draft, and replacing data never submits the
key fields. Individual key saves are disabled for new or text secrets until
they have been saved as JSON.

To save a field through the API, send an authenticated request to
`POST /api/secrets/{name}/update-json`:

```json
{"path": "environment.OPENROUTER_API_KEY", "value": "new-api-key"}
```

The secret must already have type `json`. A missing final object key is created;
an existing key or array element is replaced. All parent containers must exist:
`environment.OPENROUTER_API_KEY` requires an existing `environment` object.
Text secrets return HTTP 400 even when their contents happen to be valid JSON;
the method never changes a secret's type. A missing secret returns HTTP 404.

`path` accepts either syntax:

- Dot notation: `password` selects a top-level key,
  `environment.OPENROUTER_API_KEY` selects a nested key, and `accounts.0.token`
  selects a field in the first array element. Segments must be non-empty;
  leading/trailing dots, consecutive dots and slashes are rejected.
- JSON Pointer: `/password`, `/environment/OPENROUTER_API_KEY`, and
  `/accounts/0/token` select the same paths. In this syntax dots are literal:
  `/environment.OPENROUTER_API_KEY` selects one top-level key whose name contains
  a dot. Escape `/` within a key as `~1`, and `~` as `~0`. `/` selects an
  empty-string key.

Array indices start at zero and cannot have leading zeros. Empty paths, missing
parents, scalar parents, out-of-range array indices and array append (`-`) are
rejected with HTTP 400 without modifying the secret. Arrays are never extended.

`value` is the new JSON value itself, not a JSON-encoded string. Strings,
numbers, booleans, objects, arrays, and `null` are supported; `null` replaces
the element with null rather than deleting it. Replacing an object or array
replaces that entire selected subtree. Other elements retain their values,
though the stored JSON is reformatted with two-space indentation and non-ASCII
characters are escaped as `\uXXXX` sequences (surrogate pairs where needed).
JSON parsing restores the same characters. `NaN` and `Infinity` are rejected.

The server performs the read/modify/write under the same team lock as other
secret writes. It preserves `created_at` and updates `updated_at`. Responses
contain only the secret name and creation/update flags, never secret values.
`created: true` means a new key was added; `created: false` means an existing
element was replaced (`updated` is the inverse). For example, an update returns:

```json
{"ok": true, "result": {"name": "my-secret", "created": false, "updated": true}}
```

Recreate consumers with `update_service` / `update_environment` to apply the
updated value to their containers.

## 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 = True` (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. Either way, the
generated conf always sets `http_interface = 0.0.0.0`: Odoo 20 otherwise
listens only on `127.0.0.1`, which neither Traefik nor a published port can
reach.

### The master password (`admin_passwd`)

Oduflow never writes `admin_passwd`, so Odoo's compiled-in default (the
literal `admin`) is in effect. What keeps that from mattering is
`list_db = False`: Odoo gates every database management function on it, so
`/web/database/*` is unreachable regardless of the password.

Productions make the pair explicit. `list_db = True` is refused unless the
same override set (or the team `odoo.prod.conf`) also provides an
`admin_passwd` of at least 12 characters that is not `admin`, and a `list_db`
value other than `True`/`False` is refused because Odoo would read it as a
truthy string and enable database management. See
[odoo.conf overrides](production.md#odooconf-overrides).

Development environments keep `list_db = False` from the bundled template and
are not exposed to the public internet by default; a repository that ships its
own `.oduflow/odoo.conf` owns that setting — if it enables `list_db`, set a
master password there too.

---

# Server Settings Console

The Server settings console is a browser editor for the whole of
`oduflow.toml`: every global section and every team. It is served at `/admin`
in HTTP mode, so after the first start you never have to SSH into the server
to change the configuration by hand.

It is a deployment-wide tool for whoever operates the server, not part of the
team dashboard. It has its own password, separate from every team login,
because it can read and change every team's tokens and passwords.

## Enable it

Fresh installs generate a console password on first start. Read it from the
config file:

```bash
sudo grep -A1 '^\[admin\]' /etc/oduflow/oduflow.toml
```

On an existing install, enable the console once from the server shell, then
restart Oduflow:

```bash
oduflow admin enable          # prints the generated password once
sudo systemctl restart oduflow
```

Open `/admin` on any hostname the server answers on, for example
`https://dev.example.com/admin` (Traefik) or `http://server:8000/admin`
(port mode). While `[admin] password` is empty the console does not exist:
every `/admin` path answers 404.

For authenticator-app 2FA, run `oduflow ui-2fa setup --admin` on the server
(see [CLI Reference](cli.md#server-settings-console)).

## What you can edit

| Section | What it covers |
|---|---|
| Server | Listener, local-path mounts, insecure-HTTP switch, tracing, telemetry |
| Routing | Port or Traefik mode, TLS, ACME e-mail, public URL scheme |
| Extra routes | `[route.*]` Traefik routes to services Oduflow does not manage |
| Teams | Add and remove teams; per team: hostname, base domain, credentials, port range, slots, quotas, coding agent and its environment, image registry |
| Lifecycle | Auto-stop, auto-delete, deleted-production purge |
| Coding agent | Agent image and model overrides |
| Production | Production hosting, workers cap, WAL safety thresholds |
| Backups | S3 backups: enable, credentials, schedule, retention |
| Cluster DR | Cluster WAL archiving status and control, base-backup inventory, whole-cluster PITR |
| Storage, Database | Shown read-only (see below) |
| Console access | The console's own password |
| Raw TOML | The whole file, for anything the forms do not cover |
| History | Every save, with a diff and one-click revert |

Every field shows its TOML key, the default that applies when the key is
absent, and a badge saying when a change takes effect. Clearing a field
removes the key from the file, so the default applies again. The file keeps
your comments and layout: a key that the bundled template documents as a
commented-out line is uncommented in place.

## When a change takes effect

| Badge | Meaning |
|---|---|
| **Live** | Applied the moment you save — slots, quotas, lifecycle hours, team dashboard passwords, WAL thresholds, backup schedule and more |
| **Restart** | Saved now, used after Oduflow restarts — auth tokens, new teams, hostnames, ports, agent settings, production and backup enablement, extra routes |
| **Recreate** | Needs a restart, and existing environments, services and productions keep the old value until each is recreated — routing mode and TLS |
| **Read-only** | Changing the file alone would break the deployment — the PostgreSQL user, password and image, and the data directory |

The console applies live changes on its own and keeps everything else out of
the running process until a restart, so the server never runs half on the old
configuration and half on the new one. When saved changes are waiting, a
banner lists them with a **Restart server** button. The restart stops the
server gracefully and starts it again with the same command line;
environments, services and productions keep running. The console refuses to
restart while an operation (a deploy, an environment build) holds a lock.

Read-only keys need work outside Oduflow first, for example `ALTER ROLE` for
the database password or moving the data directory. Do that, then change the
key in **Raw TOML**.

## Saving safely

1. Edits collect in a draft. **Review & save** shows each change, when it
   takes effect, and the file diff with secrets masked.
2. The candidate file is validated with the same parser and checks the server
   runs at startup, including the HTTP authentication rules. A configuration
   the next start would refuse cannot be saved.
3. The file is replaced atomically, with its owner and owner-only mode kept.
   If it changed since you opened it, by a hand edit or another admin, the
   save is refused and you reload instead of overwriting.
4. The previous and new versions go to `.oduflow-history/` next to the config
   (owner-only, last 50 saves). **History** shows each save's diff, and
   **Revert** restores the file as it was before that save, through the same
   review.

If the file on disk is invalid (for example after a broken hand edit), the
console says so and opens **Raw TOML** so you can fix it. The running server
keeps its last good configuration in the meantime.

## Teams

**Add team** suggests the next team ID and the next free 100-port block, and
generates a dashboard password and an MCP token. Copy them before saving. The
new team becomes reachable after a restart.

**Remove team** removes the `[team.<id>]` section only. The team's data
directory, databases and running containers are not deleted; clean them up
separately.

## Secrets

Secrets (passwords, tokens, S3 keys, registry tokens, agent environment
values) never reach the browser until you press **Reveal** on one field, and
each reveal is logged with the client address. Leave a secret field empty to
keep the stored value, or press **Generate** for a new random one. Diffs and
history show `********` instead of values, plus `(changed)` where a secret
changed.

## Cluster disaster recovery

The **Cluster DR** section operates on the shared production PostgreSQL
cluster — every team's productions at once — which is why it lives here and
not on any team dashboard or MCP surface:

- **WAL archiving**: the cached monitor state (queue, disk reserve,
  disk-protection latch, storage preflight) with the cluster-wide actions
  `pause` / `resume` / `retry`, and `recover` / `release` while disk
  protection is latched. Every action asks you to type `ALL-PRODUCTIONS`.
- **Cluster restore (PITR)**: the base-backup inventory from S3 and a restore
  form (optional target time). The restore stops every production, rewrites
  the cluster from WAL-G, and restarts the productions it stopped; it asks you
  to type `RESTORE-CLUSTER`. The previous data directory is displaced inside
  the Docker volume, not destroyed.

The same actions exist on the owner CLI (`oduflow wal`,
`oduflow restore-cluster`) for headless servers and for resurrecting a cluster
on a fresh install — see [CLI](cli.md#cluster-disaster-recovery) and
[Production Hosting](production.md#backups).

## Security model

- Separate credential: `[admin] password` must differ from every team
  `ui_password`, `auth_token` and `production_token`. The console accepts a
  new password only if it has at least 12 characters.
- Separate session: a signed cookie with its own salt, valid for 12 hours,
  `HttpOnly`, `SameSite=Strict`. Changing the console password or its 2FA
  signs every console session out.
- Team sessions never reach `/admin`, and a console session does not open the
  team dashboard.
- State-changing requests from another origin are rejected, and failed
  sign-ins are rate-limited per client address.
- Saves, reveals, sign-ins and restarts are written to the server log without
  values.
- The console does not exist for MCP clients. Agents cannot change the server
  configuration.

To turn the console off, run `oduflow admin disable` on the server and
restart.

## REST API

The console page uses a small JSON API under `/admin/api/`. It needs the
console session cookie; every POST must be same-origin.

| Method | Path | Purpose |
|---|---|---|
| GET | `/admin/api/state` | Schema, current values with secrets masked, revision, pending changes |
| POST | `/admin/api/preview` | Validate a candidate and return changes, apply classes and the masked diff |
| POST | `/admin/api/apply` | Save a candidate if `revision` still matches; apply live values |
| POST | `/admin/api/reload` | Apply live values from the file on disk (after a hand edit) |
| POST | `/admin/api/reveal` | Return one secret value |
| GET | `/admin/api/raw` | The whole file and its revision |
| GET | `/admin/api/history` | Saved versions, newest first |
| GET | `/admin/api/history/{id}` | One save's masked diff |
| GET | `/admin/api/wal` | Cluster WAL monitor state plus the WAL-G base-backup inventory |
| POST | `/admin/api/wal/control` | Body `{"action": "pause\|resume\|retry\|recover\|release", "confirm": "ALL-PRODUCTIONS"}` — cluster-wide WAL action under the system lock |
| POST | `/admin/api/pitr` | Body `{"target_time": "...", "confirm": "RESTORE-CLUSTER"}` — whole-cluster PITR under the system lock |
| POST | `/admin/api/restart` | Restart Oduflow (refused while operations hold locks) |

`preview` and `apply` take one candidate: `{"changes": [{"path": [...],
"value": ...} | {"path": [...], "unset": true}], "revision": "..."}`, or
`{"raw": "<whole file>", "revision": "..."}`, or `{"restore": "<history id>",
"revision": "..."}`.

---

# 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.

Oduflow also reconciles the active PostgreSQL `pg_hba.conf` with the actual
subnets reported by Docker IPAM for `oduflow-net` and every per-team network.
Only a marked `ODUFLOW MANAGED NETWORKS` block is changed; the standard local,
replication, and operator-managed rules remain intact. This works the same way
when Oduflow runs directly on the host or through Docker-out-of-Docker because
the file is updated through the Docker API rather than another host bind mount.

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]
bind = "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 login password
```

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
```

## Scheduled template sync

System cron runs on the Docker host and invokes the worker via `docker exec`
in this existing container. Add a writable `/etc/cron.d:/etc/cron.d` mount and
set `[template_sync] cron_container = "oduflow"` to the actual container name.
See [the complete setup](templates.md#cron-when-oduflow-runs-in-docker), including
SSH source requirements. The Oduflow web process does not launch sync workers.

---

# 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/TOTP 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/TOTP auth middleware
  extra_addons.py      # Extra addon repo management (on-demand branch fetch, worktree, odoo.conf generation)
  env_credentials.py   # Per-environment PostgreSQL credentials
  pg_hba.py            # Managed PostgreSQL host rules rendered from Docker IPAM
  sanitizer.py         # DB sanitization (SQL/Python scripts)
  template_import.py   # Template import engine: S3 prefix / local path / in-place refresh
  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-17 (`[database].image`, default 16), 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.
The PostgreSQL containers are attached to every team network. At startup,
Oduflow reads those networks' real IPAM subnets and reconciles only its marked
block in each cluster's active `pg_hba.conf`; standard and operator rules
outside the block are preserved.

## Concurrency & Locking

Oduflow uses a granular `LockManager` (`locking.py`) that locks the smallest
resource an operation actually touches:

| 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-resource** | One operation per service / volume / production / template / credential store | `create_service`, `delete_volume`, `setup_repo_auth`, `snapshot_production`, `import_template` |
| **Per-team** | One team-wide operation at a time | template mutations that remount other environments' overlay filestores (`save_as_template`, `save_production_as_template`, `refresh_template`, `attach_filestore`), plus `delete_template` / `rename_template`, which must exclude a concurrent `create_environment` |
| **System/cluster** | Cross-environment infrastructure operation | startup initialization, `destroy`, cluster PITR via `/admin` or `oduflow restore-cluster` (excludes every production lock) |

Every template operation also takes that template's own key, so a publish and an import can never interleave on the same template name; only the ones listed above add the team lock on top. `attach_filestore` stages its source (an rsync or an archive unpack) before taking the team lock, so a large transfer does not hold the team; it is also the one operation whose team acquire *waits* (up to five minutes) instead of failing immediately, because by then the transfer is already done and discarding it would be far more expensive than queueing.

Thread-level locks also acquire advisory file locks under `{data_dir}/.locks`
when running the server or direct template CLI. Shared/exclusive team gates
exclude cron workers from UI/MCP mutations while allowing independent
environments to progress. Kernel locks release on process exit; lock files
are never unlinked, and a root process gives the ones it creates to the data
directory's owner. Scheduled template sync holds its template key throughout
transfer, then waits up to five minutes for the team gate during import and
environment refresh. Direct template CLI commands take the same locks as their
MCP tools: the template key, plus the team gate (waited for up to five
minutes) only when they remount other environments. Startup overlay
reconciliation respects the same gate; a team that is busy at startup is
repaired in the background as soon as it is free.

Operations on **different resources** run in parallel. If a lock cannot be acquired, the tool immediately returns `BusyError` (no queuing). Some tools take no `LockManager` lock at all: pure reads, the `odoo_*` tools (PostgreSQL arbitrates concurrent ORM calls), and the extra-repo tools (`extra_addons.py` serialises per repo itself).

## 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.

### PostgreSQL reports `no pg_hba.conf entry`

An error such as:

```text
FATAL: no pg_hba.conf entry for host "172.20.0.3", user "u_1_main",
database "postgres", no encryption
```

means Docker networking already works: the client reached PostgreSQL, but the
active HBA file has no matching host rule. Oduflow normally self-heals this on
startup by reading the actual Docker IPAM subnets and reconciling its marked
`ODUFLOW MANAGED NETWORKS` block in the active file. It then reloads PostgreSQL
and validates `pg_hba_file_rules`; a failed candidate is rolled back.

The generated rules use `md5` while any role still holds a pre-PostgreSQL-14
md5 verifier, and `scram-sha-256` once every role has migrated. `md5` is not a
downgrade: PostgreSQL performs a SCRAM exchange whenever the stored verifier is
SCRAM. Reset the affected passwords under `password_encryption =
scram-sha-256` to move an old cluster over.

Restart Oduflow and inspect its startup log first. If reconciliation fails, the
message names the invalid subnet, unsupported authentication method, existing
HBA parse error, or file operation that blocked it. These read-only commands
show the source state without guessing a Docker subnet:

```bash
docker exec oduflow-db psql -U odoo -d postgres -Atc \
  'SHOW hba_file; SHOW password_encryption;'
docker exec oduflow-db psql -U odoo -d postgres -P pager=off -c \
  'SELECT line_number, type, address, auth_method, error FROM pg_hba_file_rules ORDER BY line_number;'
docker network inspect oduflow-1-net --format '{{json .IPAM.Config}}'
```

Do not add a fixed `172.x` rule or replace the whole HBA manually. Docker may
allocate a different subnet after a network recreate, and replacing the file
can discard local or replication rules that PostgreSQL needs.

---

## 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. Two ways that happens:

- **Oduflow runs in Docker and was restarted.** The daemon lives in the Oduflow
  container's PID namespace and dies with it, while the mount itself lives in
  the host's mount namespace (it has to — Odoo containers bind-mount the merged
  path, which Docker resolves on the host) and survives. So *every* overlay
  environment goes stale at once on a restart. Running Oduflow directly on the
  host, the daemon is detached from the Oduflow process and a restart leaves it
  alone.
- **The daemon was killed** — an OOM kill, or the disk filling up.

Oduflow repairs this by itself: on every start it detects stale overlays,
detaches them, remounts each against its template's lower layer **keeping the
environment's own `filestore_upper` deltas**, and restarts the affected Odoo
containers (required — a running container's bind mount still points at the
dead mount). Recovery fails closed: Oduflow does not detach a mount unless the
container is confirmed stopped, does not restart the container unless the new
overlay is confirmed live, and always reuses the existing upper layer even if
the template's default mode was later changed to copy. The `/healthz` endpoint
and the dashboard's `OVERLAY` chip report stale or unexpectedly absent overlays;
full filesystem paths stay in server logs. An environment whose template is
gone is logged for manual recovery rather than touched.

Note that a stale mount reads as *absent*, not as *mounted*: `stat` on the
mountpoint fails with `ENOTCONN`, so `ls`, `os.path.ismount()` and
`os.path.isdir()` all behave as if nothing is there.

To detach one by hand — remount then happens through Oduflow (`restart` the
server, or `update_environment`):

```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
```

One direction is handled for you. Before the environment is reported ready,
`create_environment` compares each installed module's version in the cloned
database (`ir_module_module.latest_version`) with the `version` in your
checkout's `__manifest__.py`, and runs `odoo -u` for every module whose
checkout is newer — for any template, imported ones included. Without that
step Odoo would load the new models against the old tables, `/web/login`
would fail with `column ... does not exist`, and the environment would be
rolled back after the readiness timeout. The upgraded modules appear in the
setup log as `[TEMPLATE-UPGRADE]`; if the upgrade itself fails, the
environment is rolled back and the error carries Odoo's output. If the
comparison itself cannot run (an unreadable `odoo.conf`, a database that does
not answer), creation continues without it and the setup log says
`[TEMPLATE-UPGRADE] version check skipped` with the reason — so a readiness
timeout right after is explained by that line, not a mystery.

The comparison only sees version bumps. For everything else,
`create_environment` compares the template's snapshot 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: bumped modules are still upgraded at creation,
and the rule above still applies to the rest — 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`, `dashboard deploy`.

Production deploys and snapshots also report their current step, so a
long snapshot is distinguishable from a hung deploy:

```
Another operation on production 'erp' (dashboard deploy: pre-update snapshot (filestore 1200/12340 files, 3100 MiB) for 1m40s; running for 2m15s) is in progress.
```

---

## 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/oduflow/oduflow](https://github.com/oduflow/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

Starting with v1.84.0, Oduflow is source-available under
[PolyForm Noncommercial License 1.0.0](https://github.com/oduflow/oduflow/blob/main/LICENSE)
(`PolyForm-Noncommercial-1.0.0`).

- **Free public license:** noncommercial purposes, personal uses and the
  organizations covered by the standard license, including its education,
  research, public-sector and charitable-organization permissions.
- **Free internal evaluation:** a separate
  [evaluation permission](https://github.com/oduflow/oduflow/blob/main/EVALUATION-LICENSE.md)
  lets a business assess Oduflow in isolation before buying. It does not cover
  live business operations, commercial project development or paid client work.
- **Alternative commercial license:** Solo, Business, Integrator or Custom
  covers use outside those permissions, under the agreement and purchased scope.

The standard PolyForm text is unmodified; preserve its required notice from
`NOTICE` when distributing copies. Third-party components retain their own terms.
PolyForm releases have no automatic conversion to an open-source license.
Earlier releases retain their original terms. Existing perpetual commercial
grants remain in effect.

## Annual Commercial Plans

New purchases use the [Oduflow Commercial License Agreement](https://oduflow.dev/eula).
The public license shipped with each release and prior perpetual commercial
purchases retain their existing grants. Public source access and updates are
available to everyone. All plans have the same functions, including Production.

| Plan | Annual price (EUR, excluding tax) | License holder and scope |
|---|---|---|
| Free | Free | Public-license permissions and the separate internal evaluation grant |
| Solo | 149 | One named developer, including personal client development and administration |
| Business | 499 | Named company, internal use only |
| Integrator | 999 | Named Odoo integrator's team delivering client services |
| Custom | By agreement | Hosting, white label, or custom support requirements |

Solo, Business and Integrator renew annually through Paddle until canceled.
Choose a plan on [oduflow.dev/pricing](https://oduflow.dev/pricing); secure checkout,
key delivery and subscription checks are hosted on `license.oduist.com`.
Canceling before renewal leaves the remainder of the paid year intact. Only
completed payment extends the licensed term. Custom terms are agreed individually.
Existing perpetual keys have no expiration and remain recognized as perpetual.

## 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).

## License Details and Renewal

Click **Licensed to** in the dashboard header to see the holder, plan, status,
valid-from date, paid-through timestamp and a link to the
[Oduflow Commercial License Agreement](https://oduflow.dev/eula).
An expired license is shown in red
with the text **LICENSE EXPIRED**, while all functions and running systems remain
available. The dashboard refreshes this local status once a minute.

An expired annual license offers **Update license status**. This is a manual
network action: the authenticated dashboard posts the installed signed key to
`https://license.oduist.com/oduflow/check_license`. The server verifies its signature
and checks the completed payments for that license. If a newer paid term
exists, Oduflow verifies and atomically installs the renewed key. Otherwise it
keeps the existing key. Apart from the daily check for an expired
[Custom license](#custom-license-white-label), no startup or background job
contacts this service.

The same action is available as authenticated `POST /api/license/refresh`.
Shared environment links cannot read, activate or refresh the installation license.
A renewal key also arrives by email after each paid annual transaction.

Annual keys use the existing RSA-PSS/SHA-256 signature envelope, with signed
`plan`, `license_id`, `valid_from` and `expires` fields. `expires` is an exclusive
timezone-aware timestamp. The built-in public key remains unchanged; unsigned or
altered deadlines are rejected. How a period was paid is known only to the license
server: Oduflow ignores any payment-provider fields in older keys.


## Agreed migration and manually granted periods

A commercial license can have a manually granted period before a Paddle
subscription exists. For customers who agreed to migrate, the license server
records one calendar year from the original archived purchase timestamp. The
license has a stable identity independent of its later Paddle subscription.

After updating Oduflow, click the license banner and **Update license status** to
retrieve the agreed term. Only explicitly imported legacy holders can migrate;
other perpetual keys keep their existing status. The server verifies the old
signature and matches the holder, plan and issue date when the original archive
has no key copy. No periodic network check is added.

When **Update license status** reports that no subscription is active after a
manual period ends, **Subscribe annually** opens a short-lived checkout
link with the existing holder and plan. The customer accepts the annual terms
and pays through Paddle. The completed payment attaches the subscription to the
same license. **Update license status** then installs the paid extension. An
administrator can also extend an unlinked manual period from the license server's
Oduflow admin page. Expiration never disables product features.

## Custom License (White Label)

The Custom plan is for operators who run Oduflow for their own clients on servers
they control, for example as a hosted service under their own name. Terms, price
and allowed domains are agreed individually; you then receive a personal payment
link, and the license key arrives by email after payment and after every annual
renewal.

With an active Custom license:

- The dashboard, sign-in page, browser title and MCP server show your **brand
  name** instead of "Oduflow". Tool descriptions, instructions and messages use
  it too, as do the dashboard's agent guides viewer and error messages and the
  Odoo.sh import script.
- Vendor surfaces are not rendered: the "a product by Oduist" byline, **Docs**
  (also in the server settings console), **Feedback**, the release check behind
  the version number (the version stays visible), and the license banner and
  dialog. The `report_issue` and `submit_agent_feedback` MCP tools are hidden,
  and `/api/version` and `/api/feedback/link` answer `404`.
- Your clients see nothing about licensing, also after expiry.
- The agent's default commit identity uses the brand: `<brand>-coder` with an
  `@<brand>.local` address, also in existing checkouts, instead of
  `oduflow-coder` and `@oduflow.local`. The seed commit of a local extra repo is
  authored by the brand name.
- Requests to your clients' productions, git hosting APIs and GitHub downloads
  identify themselves with the brand (User-Agent `<brand>-production` and
  `<brand>`).

Technical names on your servers keep the product name: the `oduflow` package and
CLI, `oduflow.toml`, container, volume and database names, and paths. Avoid a
dashboard hostname like `oduflow.<base_domain>`: set an explicit `hostname` in each
`[team.*]` section.

### Installing on your servers

Copy the key and, optionally, your images into the config directory
(`/etc/oduflow` or `~/.oduflow/conf`) of every server:

```text
<config-dir>/license.key
<config-dir>/branding/logo.png   # header and sign-in logo, shown about 28–36 px high
<config-dir>/branding/icon.png   # square, at least 180×180: browser tab and home-screen icon
```

`icon.png` falls back to `logo.png`; without either, the stock images are used.
A custom logo keeps its colors in the light theme. Install and check with:

```bash
oduflow license install ./license.key
oduflow license status
```

Restart the service afterwards so MCP clients see the brand; the dashboard picks
up license and image changes within a minute.

### Domains

The key lists the domains it is valid for. White label is active only when the
`hostname` of every team equals one of these domains or is a subdomain of one, so
a copied key does not brand someone else's installation. `oduflow license status`
explains why white label is off.

### Renewal and expiry

The subscription renews annually and each renewal emails a new key. When the
installed key expires, Oduflow checks the license server once a day and installs
the renewed key automatically; `oduflow license refresh` does the same on demand.
White label stays active for **30 days** after expiry. After that the product
shows stock Oduflow branding and the expired license banner until a renewed key is
installed. Nothing is ever disabled. If the subscription was canceled,
`oduflow license subscribe` prints a new payment link.
