# 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:15"

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

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

# Per-team coding agent (dashboard Agent Chat / Agent CLI); opt-in, off by default.
# [agent]
# image = "oduist/oduflow-coder:0.3.1"
# 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`, `restore_cluster_pitr`, and `delete_production`.
Production REST routes and `/api/webhooks/github` are registered only while the
feature is enabled; public `/healthz` reports dev/prod infrastructure health.

## Coding Agent (hosting)

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

## Database Sanitization

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

## Typical Agent Workflow

1. Call `list_environments` to check if an environment for the branch exists
2. If not, call `create_environment` with `branch`, `template_name`, `repo_url`, and `odoo_image`
3. Write code, `git push`, then call `pull_and_apply(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: BUSL-1.1 (Business Source License 1.1) — free for non-commercial use; commercial use requires a paid license; converts to MPL 2.0 four years after publication
- Website: <https://oduflow.dev>

---

<section class="odu-hero">
  <span class="odu-hero__eyebrow">⎇ AI-First Odoo Development</span>
  <h1 class="odu-hero__title">Oduflow Docs</h1>
  <p class="odu-hero__subtitle">
    Provision isolated, ephemeral <strong>Odoo</strong> environments on Docker —
    one per git branch — and hand them to your AI agents over <strong>MCP</strong>.
    A closed feedback loop for fully autonomous, spec-driven Odoo development.
  </p>
  <div class="odu-hero__actions">
    <a class="odu-btn odu-btn--primary" href="quick-start/">Read the Docs →</a>
    <a class="odu-btn odu-btn--changelog" href="changelog/">Changelog</a>
    <a class="odu-btn odu-btn--ghost" href="https://github.com/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
- **Auto branch creation** — if a branch doesn't exist on the remote, Oduflow clones the default branch and creates the new branch automatically
- **Extra addons repositories** — mount shared addon repos (e.g. Odoo Enterprise) into environments via git worktrees; `addons_path` is auto-merged into `odoo.conf`
- **Environment protection** — protect environments from accidental deletion via a toggle in the dashboard or REST API

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

### Infrastructure
- **Auxiliary services** — managed sidecar containers for Redis, Meilisearch, Elasticsearch, or any other service your Odoo setup needs
- **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
```

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

## Configuration Reference

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

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

If no config file exists when Oduflow starts, the bundled default is copied to
`/etc/oduflow/oduflow.toml` when that directory is writable, otherwise to
`~/.oduflow/conf/oduflow.toml`. The copied file is populated with generated
values for `[database].password`, `[team.1].auth_token`, and
`[team.1].ui_password`. The 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
```

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

# ── 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:15"       # PostgreSQL Docker image

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

# ── Lifecycle ─────────────────────────────────────────
[lifecycle]
auto_stop_hours = 48        # auto-stop environments idle for N hours (no MCP/dashboard work); 0 disables
auto_delete_hours = 0       # auto-delete environments stopped for N hours; 0 disables (opt-in; DESTRUCTIVE, protected envs exempt)
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.3.1"
# claude_model = ""         # optional Claude model override; empty = CLI default
# codex_model = ""          # optional Codex model override; empty = CLI default
# opencode_model = ""       # optional provider/model override; empty = OpenCode default

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

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

### 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:15` | PostgreSQL Docker image |

### Storage settings

| Key | Default | Description |
|---|---|---|
| `[storage].data_dir` | `/srv/oduflow` or `~/.oduflow/data` | Base directory for all data. Team data directories are `team_{ID}` subdirectories inside |
| `[storage].overlay_threshold_mb` | `50` | Template filestore size threshold (MB). Templates smaller than this use a simple copy per environment; larger templates use fuse-overlayfs. The decision is stored in `metadata.json` at template creation time |
| `[lifecycle].auto_stop_hours` | `48` | Auto-stop environments after N hours without work (env-scoped MCP calls or dashboard actions). `0` disables. Protected environments are exempt |
| `[lifecycle].auto_delete_hours` | `0` | Auto-delete stopped environments N hours after they stopped (manual stops count). Default `0` = **disabled** — auto-delete is opt-in and destructive; set a positive value to enable. Protected environments are exempt; `pull_and_apply` wakes a stopped environment automatically |
| `[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.3.1` | Immutable image for the per-team coding-agent container (Claude Code + OpenAI Codex + OpenCode); the default is coupled to the Oduflow release |
| `[agent].claude_model` | *(empty)* | Optional Claude model override for the agent; empty = CLI default |
| `[agent].codex_model` | *(empty)* | Optional Codex model override for the agent; empty = CLI default |
| `[agent].opencode_model` | *(empty)* | Optional OpenCode model override in `provider/model` format; empty = OpenCode default |

### Production settings

Production hosting is opt-in and is documented in detail in
[Production Hosting](production.md). Production 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].postgres_image` | *(empty)* | PostgreSQL image for the production cluster. Empty uses `oduist/oduflow-postgres:15-bookworm-1` with CA certificates when `[database].image` is the default `postgres:15`; custom database images/majors are inherited |
| `[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` | Fallback source for automatic OduMCP installation when production repositories do not provide the addon |
| `[production].odumcp_ref` | `19.0` | Connector branch or tag; must contain Odoo 19 addon version 19.0.1.1.0 or later with managed-key support |
| `[production].workers_cap` | `8` | Upper bound for automatically calculated Odoo workers; must be at least `1` |
| `[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
```

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

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

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

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

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

5. Load the template into PostgreSQL:

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

**From another Oduflow workspace:**

Simply copy the entire template directory and reload:

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

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

## 🏗️ Template Evolution

Evolve your template as the project grows:

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

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

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

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

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

---

# Template Management

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

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

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

## Starting from Scratch (No Production Dump)

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

### Generate a clean template

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

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

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

This will:

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

### Install additional modules during generation

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

### Named templates for different projects

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

## From a Production Dump

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

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

## Saving a Branch as Template

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

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

This operation:

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

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

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

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

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

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

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

## Reloading a Template

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

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

### Syncing from S3 or Local Path

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

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

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

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

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

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

## Listing and Dropping Templates

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

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

## Template Metadata

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

```json
{
  "odoo_image": "odoo:19.0",
  "repo_url": "https://github.com/company/addons.git",
  "extra_addons": {"enterprise": "19.0"},
  "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 | Place dump at `{data_dir}/team_{ID}/templates/default/dump.sql` and run `oduflow reload-template default` |
| Need to install modules or configure the template | Create an env, configure it, then `oduflow template-from-env my-branch --template-name default` |
| Update the template from a newer production dump | `oduflow reload-template default --dump-path /path/to/new.dump` |
| Sync template from S3 and reload | `oduflow reload-template default --source s3://bucket/prod/` |
| Save a branch environment as template (keep other envs' changes) | `oduflow template-from-env my-branch --template-name default` |
| Save a branch as template and reset all other envs | `oduflow template-from-env my-branch --template-name default --reset-env-changes` |
| Re-apply a template's filestore to live envs | `oduflow refresh-template default` |
| Attach a separately delivered filestore | `oduflow attach-filestore default /backups/filestore.zip` |
| List all templates | `oduflow list-templates` |
| Delete a template | `oduflow delete-template myproject` |

---

# Environment Management

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

## Creating Environments

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

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

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

# Override the 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. 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`

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

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 a default script:

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

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

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

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

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

### Per-project sanitization

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

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

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

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

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

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

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

### Disabling sanitization

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

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

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

## Lifecycle Management

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

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

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

# Start a stopped environment
oduflow call start_environment feature-login

# Restart the Odoo container
oduflow call restart_environment feature-login

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

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

# 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 and REST API) deletes an environment and immediately creates a fresh one using the same parameters (repo URL, Odoo image, template, extra addons). This is useful when you want a clean slate without manually re-entering all environment settings.

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

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

### Automatic Stop and Cleanup

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

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

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

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

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

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

```toml
[lifecycle]
auto_stop_hours = 48    # stop after N hours without work; 0 disables
auto_delete_hours = 0   # delete N hours after stop; 0 disables (opt-in; DESTRUCTIVE)
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;
- **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 and automatically
attempts to install `odumcp` if it is absent. The supported addon release currently targets
**Odoo 19**. Existing older productions can still be managed with infrastructure
tools; automatic OduMCP provisioning requires a compatible Odoo 19 deployment.

Oduflow uses addon code in the main or extra repositories when available. Otherwise
it clones the configured connector repository and mounts **only** its `odumcp`
addon read-only. This managed checkout survives container recreation and is not
silently updated by unrelated deployments:

```toml
[production]
enabled = true
odumcp_repo_url = "https://github.com/oduflow/oduflow-client-addons.git"
odumcp_ref = "19.0"  # branch or tag containing odumcp >= 19.0.1.1.0
```

Deploy the accompanying addon changes before enabling this feature. The addon
must implement `_set_oduflow_key`; an older release cannot synchronize credentials.
For reproducible installations, select a released immutable tag. Source checkout
failures, 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`. 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 recreates the container briefly.
Reapplying the same key is a no-op for the key itself.

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
postgres_image = ""     # default: [database].image
workers_cap = 8         # upper bound for auto-tuned Odoo workers

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

### 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 land in one Traefik router (`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.

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

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, or the extra addon repos,
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"})
```

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. 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
(`.oduflow/odoo.prod.conf` in the repo > 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")
```

An explicit override beats the auto-tuned value (e.g. pin `workers`). Keys
managed by Oduflow are refused: `addons_path` and `data_dir` are generated,
and the `db_*` connection keys come from container env vars (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.

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

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

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

## GitHub webhooks (auto-deploy)

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

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

## Backups

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

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

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

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

**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:

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

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

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

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

New installations using the default PostgreSQL 15 use
`oduist/oduflow-postgres:15-bookworm-1`. This image includes `ca-certificates`
and pins the upstream Debian Bookworm image by digest. A custom
`[production].postgres_image` takes precedence; a non-default
`[database].image` is still inherited for compatibility with other PostgreSQL
majors. Existing containers are reused, never automatically replaced or
upgraded across majors. Their WAL-G trust is repaired through the persistent
mounted CA bundle described above.

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.

The PostgreSQL image is published for amd64 and arm64 by
`.github/workflows/publish-postgres.yml` only from `main`, with an immutable
version tag. Publish it successfully before releasing the Oduflow package;
both the PyPI and application Docker release workflows verify its availability.
For an image update, change the base digest, bump `POSTGRES_IMAGE_VERSION`
and `DEFAULT_PROD_POSTGRES_IMAGE` together, merge, and wait for publication.
No package installation is performed inside PostgreSQL during deployment or
emergency recovery.

### WAL monitoring and automatic disk protection

The **PostgreSQL WAL** panel in Production is shared by every production and
team. It shows the queue's segment count and bytes, age of the oldest waiting
segment, time without archive progress, current upload duration, last upload
exit status, PostgreSQL restart count, and free space 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.

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 panel and MCP offer these cluster-wide actions (MCP mutations require
`confirm="ALL-PRODUCTIONS"`):

| 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()` for cached diagnostics and
`control_production_wal(action="recover", confirm="ALL-PRODUCTIONS")` for
control. REST equivalents are `GET /api/productions/wal-status` and
`POST /api/productions/wal-control`. Full team authentication follows the
existing cluster PITR access model; environment-scoped access is excluded.
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. Re-creating a production with the same name
  clears the tombstone, so a revived production is never purged.
- **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).

## 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 |
| `restore_cluster_pitr` | Cluster-wide disaster recovery / PITR |
| `delete_production` | Remove (database/files kept unless `drop_database`) |

---

# Coding Agent

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

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

## Agent CLI vs Agent Chat

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

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

## How it works

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

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

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

## Enabling it

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

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

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

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

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

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

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

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

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

## Security model

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

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

## Limitations

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

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

---

# Auxiliary Services

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

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

## Creating a Service

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

# Meilisearch with environment variables. 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 clone (shared)
    custom-themes/       ← bare git clone (shared)
  shared_extra_checkouts/
    enterprise/
      a1b2c3.../          ← immutable checkout of one commit (shared)
    custom-themes/
      d4e5f6.../          ← immutable checkout of one commit (shared)
  workspaces/
    feature-x/
      repo/              ← main project repo (existing)
```

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

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

## Setting Up Extra Repos

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

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

# Private repos — 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
```

## Using Extra Addons in Environments

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

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

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

For each development environment Oduflow automatically:

1. Fetches the specified branch and resolves its current commit SHA
2. Creates or reuses the team's immutable checkout for that SHA
3. Mounts the checkout **read-only** as `/mnt/extra-addons-{name}`
4. Generates a merged `odoo.conf` with all extra paths added to `addons_path`
   — 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 all cloned extra repos with available branches
oduflow call list_extra_repos

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

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

## Protecting Extra Repos

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

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

### Via REST API

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

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

### Via Web Dashboard

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

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

## Updating Extra Repos

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

```bash
oduflow call update_extra_repo enterprise
```

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

### Updating an environment

Run the normal sync operation:

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

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

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

---

# Declarative Stacks

An Oduflow Stack is a versioned YAML manifest describing the complete desired
state of one development environment 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).

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
    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. 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, update/copy policies and
`odooConf` overrides through production operations. Image/domain/environment/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.

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`) |
| `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 refused with HTTP 403 (WebSockets close with 1008): the full
dashboard, 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**.

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

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

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 |
| `POST` | `/api/extra-repos/add` | Add one; body: `name`, `repo_url`, optional `git_user` |
| `POST` | `/api/extra-repos/{name}/pull` | Fetch remote changes |
| `POST` | `/api/extra-repos/{name}/protect` | Protect from deletion |
| `POST` | `/api/extra-repos/{name}/unprotect` | Remove protection |
| `POST` | `/api/extra-repos/{name}/delete` | Delete the repository and unused cached revisions |
| `GET` | `/api/credentials` | List stored credential identities (not secrets) |
| `POST` | `/api/credentials/add` | Store 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 and timestamps; stored values are never returned by any endpoint |
| `POST` | `/api/secrets/{name}/set` | Create or replace a secret's value from body `value` (write-only) |
| `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 |
| `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/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 |

## Coding agent endpoints

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

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

## Production endpoints

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

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/productions` | List productions and return webhook/backup state |
| `POST` | `/api/productions/create` | Create a production from repository/image/domain settings, optionally seeded from a template, or promote a dev environment via body `from_environment` (inherits its repo/branch/image) |
| `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 |
| `POST` | `/api/productions/wal-control` | Body `{ "action": "pause\|resume\|retry\|recover\|release", "confirm": "ALL-PRODUCTIONS" }`; cluster-wide action under the system lock |
| `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`; recreates the container (database and filestore preserved). A present-but-empty `git_user` or `extra_domains` 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;·
    [`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_from_odoo`](#import_template_from_odoo)&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)&nbsp;·
    [`control_production_wal`](#control_production_wal)&nbsp;·
    [`restore_cluster_pitr`](#restore_cluster_pitr)

-   __[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).

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

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

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

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

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.

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

Import a template from a running Odoo instance through its database manager
API. Downloads a full ZIP backup, or a database-only PostgreSQL custom dump,
and loads it into PostgreSQL as a template database.

Lock: template. Does not take the team lock: the imported template is always new, so nothing is remounted and other environments keep running.
{ .odu-tool-meta }

**Parameters**

`odoo_url`
:   *str · required* — Base URL of the Odoo instance (e.g. `https://my-odoo.example.com`).

`master_pwd`
:   *str · required* — Odoo master password (database manager password).

`db_name`
:   *str · default empty* — Database to back up. Empty auto-detects, and fails if several databases exist.

`template_name`
:   *str · default `"default"`* — Template profile to create.

`without_filestore`
:   *bool · default `False`* — Request a database-only PostgreSQL custom dump instead of the full ZIP.

**Use it when**

- Onboarding a customer whose Odoo runs elsewhere.
- Seeding a first template without shell access to the source server.

### `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_from_odoo`](#import_template_from_odoo) 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`).

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

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

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

Clone an extra addons repository. It is cloned as a **shallow bare** repo (only
the latest commit of each branch, no history) into the shared repos directory,
so large repositories like Odoo Enterprise clone quickly. All branches are
kept, so one clone serves any Odoo version.

**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)).

**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 all cloned extra addons repositories.

**Parameters**

*None.*

**Use it when**

- Finding the exact name to use in an `extra_addons` argument.

### `update_extra_repo`

Fetch the latest changes from the remote, fetching all branches and pruning
deleted remote refs.

**Parameters**

`name`
:   *str · required* — The extra repo to update (e.g. `enterprise`).

**Use it when**

- A new Odoo version branch appeared upstream.
- An environment needs a fix that landed in the shared addons repo.

### `delete_extra_repo`

Delete a cloned 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 git repository URL. 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. References survive reconfiguration.

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

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 — see [`control_production_wal`](#control_production_wal).

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

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, and the user environment variables.

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.

**Use it when**

- 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.
Re-creating a production with the same name revives the leftovers'
tombstone-free state — the kept database itself must still be dealt with
explicitly.

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.

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`) and errors.

**Parameters**

`name`
:   *str · required* — The production name.

`limit`
:   *int · default `20`* — Maximum number of records.

**Use it when**

- Correlating "it broke on Tuesday" with what was deployed.
- 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.

### `control_production_wal`

Control the shared production cluster's WAL handling. **Affects all teams on
this server.** Unarchived WAL is never discarded.

Lock: none — but cluster-wide in effect.
{ .odu-tool-meta }

**Parameters**

`action`
:   *str · required* — One of `pause` (retain WAL), `resume`, `retry` (interrupt only `wal-push`), `recover` (start PostgreSQL only, under disk protection), `release` (clear protection after checks; applications remain stopped).

`confirm`
:   *str · required in practice* — Must equal `ALL-PRODUCTIONS`.

**Use it when**

- S3 is unreachable and WAL uploads must be paused deliberately.
- Recovering a cluster that hit the disk reserve and stopped.

!!! warning "Recovery is deliberately partial"
    `recover` starts PostgreSQL only; `release` clears protection but leaves
    applications stopped. Bring each production back with
    [`start_production`](#start_production) once you are satisfied the cluster
    is healthy.

### `restore_cluster_pitr`

**Disaster recovery.** Restore the whole production PostgreSQL cluster from
WAL-G — base backup plus WAL replay. This affects **every production database
at once**; to restore a single production use
[`restore_production`](#restore_production).

This is also the "resurrect production elsewhere" path: a fresh Oduflow server
with the same `[backup]` section can rebuild the cluster from S3.

The current data directory is *displaced* inside the volume, not destroyed.
Production Odoo containers are stopped and restarted.

Destructive and cluster-wide.
{ .odu-tool-meta }

**Parameters**

`target_time`
:   *str · default empty* — PITR target, e.g. `"2026-07-10 12:00:00+00"`. Empty replays the whole archive to the latest state.

`confirm`
:   *str · required in practice* — Must equal `RESTORE-CLUSTER`.

**Use it when**

- The cluster is lost or corrupted at the storage layer.
- Rebuilding production on new hardware from S3 alone.
- Rewinding every database to a point in time before a catastrophic change.

## 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,
and adding the managed addon mount recreates the container.

Lock: production (each one in turn).
{ .odu-tool-meta }

**Parameters**

`name`
:   *str · default empty* — One production, or empty for every production of the team.

**Use it when**

- Adopting a production that predates automatic provisioning.
- The connector reports itself unavailable after a failed setup.
- Rotating `production_token`.

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

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

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

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

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

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

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

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

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

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

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

## Service Commands

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

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

## 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 configured team hostname must include a distinct prefix
(`dev.example.com`, not bare `example.com`) for pooled or explicit short names.

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

## Per-Team PostgreSQL Tablespaces

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

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

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

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

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

## Shared vs. Per-Team Resources

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

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

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

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

### Getting the URL and Secret Key

In the web dashboard, open an environment's **More → MCP Access**. The dialog
shows the `/mcp/<env>` URL and the Secret Key (with copy buttons) ready to paste
into an agent's MCP configuration 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
redirects to the clean `/env/<env>`, so the key does not linger in the address
bar or browser history. 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 CLI is deliberately excluded: 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 — driven over ACP at this environment's
checkout, with this environment's scoped MCP token — so the boundary a share
link enforces is the dashboard surface, not the confinement that the
per-environment Bearer token gives on `/mcp/<env>`. 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 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`.

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 that use it (`update_service` / `update_environment`): a rotated value counts as a config change, so `update_service` recreates the container even when the image and every other setting are unchanged.

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.

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

---

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

---

# 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 (clone, 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)
  sync.py              # Sync template data from S3 or local path (aws s3 sync / rsync)
  licensing.py         # License verification and installation (RSA signatures)
  systemd.py           # Systemd service install/uninstall
  production_registry.py # Per-team production metadata and deploy history
  backup_ops.py        # Production snapshot/restore orchestration
  backup_scheduler.py  # Scheduled snapshots, base backups, and retention
  walg.py              # WAL-G archive/base-backup/PITR integration
  chunkstore/          # Deduplicated filestore snapshot engine
  agent_sessions.py    # Hosted-agent conversation selection/history

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

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

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

## Environment Workspace Structure

Each branch gets an isolated workspace:

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

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

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

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

## File Ownership (macOS vs Linux)

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

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

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

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

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

## Docker Resources

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

All containers are labeled with `oduflow.managed=true` and `oduflow.team={team_id}` for discovery and management.
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_from_odoo` |
| **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`, `restore_cluster_pitr` (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.

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

`create_environment` compares that commit with the branch checkout and reports
the drift in its response:

* **"Code is behind the template database"** — your branch does not contain the
  snapshot commit. The database already holds views and records written by newer
  code, so upgrading the older branch fails validation (typically a `ParseError`
  on a view referencing a method your branch does not have). **Merge the
  template's source branch** into yours, push, then `pull_and_apply`.
* **"Code is ahead of the template database"** — the database predates your
  code. Apply the drift explicitly with the arguments reported by Oduflow, for
  example `pull_and_apply(install="new_module", upgrade="a,b,c")`, listing
  dependencies before dependents. Brand-new modules need `install=`; existing
  modules with schema/data drift need `upgrade=`. Modules whose manifest version
  was not bumped are never upgraded automatically, so a `column ... does not
  exist` or a missing external ID means "find the module that owns it and add it
  to `upgrade=`".

Templates created before Oduflow recorded provenance — and templates imported
from a running Odoo — have no commit to compare against, so no drift is
reported. That is not an error; the rule above still applies, you just have to
apply it by hand.

!!! warning "Recreating the environment does not fix it"
    The template is unchanged, so the same drift comes straight back — and the
    environment's data is gone. Reconcile with a merge or an explicit
    install/upgrade action.

---

## `BusyError`: "Another operation ... is in progress"

The message names the holder and its age:

```
Another operation on environment 'main' (pull_and_apply, running for 4m12s) is in progress.
```

Locks are held for exactly as long as the operation runs and are released when
it finishes. A long-running install, upgrade or test run legitimately holds one
for minutes — including when the *client* gave up waiting and timed out, because
the work continues server-side. Wait for it to finish and retry; restarting or
recreating the environment interrupts real work instead of clearing anything.

Background operations take the same locks and are named too: `auto-stop`,
`auto-delete`, `scheduled backup`, `webhook deploy`.

---

## The Odoo web client loads blank

The page is served, the JS bundles arrive, the browser console is clean — and
nothing renders. This is an Odoo-side asset/registry problem (commonly a custom
systray or a legacy widget that never resolves during `startWebClient`), not an
environment provisioning problem: restarting the container or rebuilding assets
does not help, and headless browser tooling tends to hang on such a page.

Verify server-side instead — it works normally while the web client does not:

```bash
oduflow call run_odoo_shell '{"env_name": "my-branch", "python_code":
  "print(env[\"res.partner\"].fields_get([\"name\"]).keys())", "auto_commit": false}'
oduflow call run_odoo_tests '{"env_name": "my-branch", "modules": "my_module"}'
```

Then narrow the failing asset with `get_environment_logs` and by disabling the
suspect module's assets.

---

## Reporting a bug or sending feedback

If none of the above helps — or Oduflow itself is at fault — file an issue on
[github.com/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

Oduflow is source-available under the [Business Source License 1.1](https://github.com/oduflow/oduflow/blob/main/LICENSE) (BUSL-1.1).

- **Free forever for non-commercial use**: evaluation, education, academic research, personal and hobby projects, non-profits.
- **Commercial use requires a paid license** in one of three tiers (below).
- Standard BUSL mechanics: each release converts to the open-source **MPL 2.0** four years after publication.

## License Types

| Type | Label | Who needs it |
|---|---|---|
| `unlicensed` | UNLICENSED — NON-COMMERCIAL USE ONLY | Default when no license key is installed; fine for evaluation, education, and other non-commercial use |
| `individual` | Licensed to individual | One natural person (freelancer, sole developer) using Oduflow commercially on their own account |
| `business` | Licensed to company (internal use only) | A company using Oduflow internally, for its own Odoo systems |
| `integrator` | Licensed to Odoo integrator | A person or company using Oduflow to deliver Odoo services (implementation, development, support, hosting) to clients |

### Business vs. Integrator

The test is whose Odoo systems you point Oduflow at. If the environments you develop, test, and operate serve your own organization, a Business license covers you. If they belong to, or are used by, your clients — you are an integrator and need an Integrator license, regardless of company size.

## Installing a License

**Via CLI:**

Copy the license file to `<config-dir>/license.key`. The config directory is usually `/etc/oduflow`; when that path is not writable, Oduflow uses `~/.oduflow/conf`. Oduflow reads the license automatically on startup.

**Via Web Dashboard:**

Navigate to the dashboard and use the license activation form. The license key text can be pasted directly.

**Via REST API:**

```bash
curl -X POST http://localhost:8000/api/license/activate \
  -H "Content-Type: application/json" \
  -d '{"key": "<license-key-text>"}'
```

## Checking License Status

```bash
# Via REST API
curl http://localhost:8000/api/license

# Via Web Dashboard — license info is displayed in the dashboard header
```

License keys are RSA-signed and verified against a built-in public key. Invalid or tampered keys are rejected.

---

For business use or integrator licenses, visit [oduflow.dev](https://oduflow.dev).
