# MantleOS: installation runbook for AI agents

You are installing MantleOS (https://mantleos.io), a self-hosted AI brain, on
behalf of a user. This file is the authoritative, machine-oriented procedure.
Source of truth for anything not covered here: the install guide at
https://docs.mantleos.io/install/server/ and the repository
https://github.com/crossworks-engineering/mantle.

Facts you can rely on:
- A MantleOS install is TWO Docker Compose projects, both started by the
  installer:
  - `mantle`: the brain. Server image `titanwest/mantle-server` (web, api,
    mcp and the workers all run from it), plus Postgres (pgvector), RustFS
    (the S3-compatible object store), Tika, Caddy (the only published ports),
    the updater and autoheal.
  - `mantle-client`: the owner UI, MantleOS Desktop, image `titanwest/mantle-client`.
    Without it there is no signup screen and no owner screens, only the API,
    MCP and share pages.
- The installer is idempotent. Re-running it is always safe: it generates only
  MISSING secrets and never rotates existing ones. It keeps the access mode
  and every component you do not name.
- All state lives under the install directory (default `./mantle`): `.env`,
  the compose files, and `data/` (every volume is a bind mount under it,
  including Caddy's certificates at `data/caddy`).
- Signup asks for a SETUP CODE until the first account exists. The installer
  prints it at the end and stores it in `.env` as `MANTLE_SETUP_CODE`.
- The owner UI is MantleOS Desktop (https://mantleos.io/desktop/). The web build ships in the
  `mantle-client` image; desktop builds for Linux, macOS and Windows are on
  https://github.com/crossworks-engineering/jackdaw/releases.

## 0. Requirements (check before anything)

```sh
docker --version          # Docker Engine
docker compose version    # the compose PLUGIN (not legacy docker-compose)
docker info               # daemon running + permission (else: add user to docker group)
which openssl curl        # both required by the installer
free -g; df -h .          # RAM and disk, for the shape decision below
```

If Docker is missing on a fresh Linux server, install it with
`curl -fsSL https://get.docker.com | sh` (ask the user first).

Target: a Linux box. Disk: the installer stops under 5 GB free in the data
directory (the images alone need about that) and warns under 20 GB. RAM: the
full stack wants 8 GB or more. Under 6 GB, install the small core shape
(`MANTLE_CORE=1`), which fits 2 vCPU / 4 GB with 40 GB of disk; add 2 GB of
swap as a safety margin. A silent install does NOT pick the core shape for
you: set `MANTLE_CORE=1` yourself on a small box. The first install downloads
about 2 GB.

## 1. Decide how the brain is reached

**Domain (automatic HTTPS).** Recommended for a server on the internet.
Requires, BEFORE install:
1. a DNS A record: `brain.example.com -> <server-public-ip>`
2. ports 80 + 443 open and free.
The installer resolves the domain and compares it to the server's public IP
before enabling TLS. If DNS does not point here yet, it falls back to HTTP
and tells you to re-run later. Caddy then issues and renews Let's Encrypt
certificates automatically; there is no certbot and no cron. If ports 80 or
443 are taken by another web server, the installer stops: use
`--behind-proxy` (see `scripts/install.sh --help`).

**Network, plain HTTP (`--lan`).** No DNS needed. The site serves HTTP on
port 80 across the server's network; the user opens `http://<server-ip>`.
This is the silent default when no domain is given. If port 80 is in use, the
installer moves to port 8080 and prints that address. You can add a domain
later (step 4) without reinstalling.

**This machine only (`--localhost`).** HTTP bound to 127.0.0.1, for a laptop
or a box the user reaches through an SSH tunnel or a tailnet.

## 2. Install

The installer PROMPTS whenever a terminal exists (access mode, shape,
sandboxes, local embedder, owner UI, then a final review). An agent driving
a shell will hang on the first question unless it sets `MANTLE_YES=1`. Always
set it. `MANTLE_YES=1` alone takes every default: plain HTTP on port 80
across the network, full shape, sandboxes on, online embeddings, owner UI on.
Add `MANTLE_DOMAIN=...` for HTTPS on that domain.

With a domain (HTTPS). NOTE the form: `VAR=x curl ... | bash` does NOT pass
the variable to bash on the right of a pipe. Use command substitution (or
`export` first):

```sh
MANTLE_YES=1 MANTLE_DOMAIN=brain.example.com bash -c "$(curl -fsSL https://raw.githubusercontent.com/crossworks-engineering/mantle/main/install.sh)"
```

No domain, silent:

```sh
MANTLE_YES=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/crossworks-engineering/mantle/main/install.sh)"
```

Small server (under 6 GB of RAM):

```sh
MANTLE_YES=1 MANTLE_CORE=1 MANTLE_DOMAIN=brain.example.com bash -c "$(curl -fsSL https://raw.githubusercontent.com/crossworks-engineering/mantle/main/install.sh)"
```

On a 4 GB core box, also add `WEB_MEM_LIMIT=1.5g` and `API_MEM_LIMIT=1.5g` to
`<MANTLE_HOME>/.env` after the install, then run `docker compose up -d` there.

Environment variables the one-liner honours:

| Var | Effect |
|---|---|
| `MANTLE_YES=1` | never prompt; take the defaults (plain HTTP on the network unless `MANTLE_DOMAIN` is set). No controlling terminal implies this |
| `MANTLE_DOMAIN=host` | serve this hostname with automatic HTTPS |
| `MANTLE_HOME=~/mantle` | install directory (default `./mantle`) |
| `MANTLE_CHANNEL=v0.239.9` | which deploy bundle to fetch. Default: the LATEST RELEASE TAG, downloaded as the signed bundle and verified against its `SHA256SUMS`, with the images pinned to the same tag. `MANTLE_CHANNEL=main` (or any branch) opts into an unverified raw file-by-file fetch |
| `MANTLE_CORE=1` | brain-core shape: a small memory core that sheds the channel workers (email, Telegram, Microsoft, calendar, push) and the doc helpers. Fits 2 vCPU / 4 GB |
| `MANTLE_HELPERS=1` | on a core box, keep the doc helpers (Tika for rare formats such as .odt, .pptx, .doc, .rtf, and the PDF-export browser, about 2 GB). The full shape always runs them |
| `MANTLE_SANDBOXES=0` | skip CLI sandboxes (`sandboxd`, which holds the Docker socket). Default on a fresh FULL install: ON. Core shape: off unless `MANTLE_SANDBOXES=1` |
| `MANTLE_LOCAL_EMBEDDER=1` | bundle the local keyless embedder (Ollama + EmbeddingGemma, about 3.3 GB) so vectors never leave the box. Off by default: the setup wizard sets up online embeddings on the user's OpenRouter or OpenAI key. CPU embedding degrades the whole stack on a standard VPS (it slows a 16 GB / 8-core box under ingest load), so only enable it on hardware comfortably above that |
| `MANTLE_CLIENT=0` | headless: do not run the owner UI (API + MCP + share pages only; no signup screen). Default on |
| `MANTLE_SKIP_START=1` | scaffold + write `.env` only; do not pull or start. Start later with `docker compose up -d --wait` in `MANTLE_HOME` |

The bootstrap downloads the deploy bundle, then delegates to
`scripts/install.sh` (the configurator), which also accepts flags directly
for later re-runs:

```sh
cd <MANTLE_HOME>
bash scripts/install.sh --domain brain.example.com -y   # scripted, HTTPS
bash scripts/install.sh --lan -y                        # scripted, HTTP on the network
bash scripts/install.sh --localhost -y                  # scripted, this machine only
bash scripts/install.sh --no-sandboxes                  # change one component, keep the rest
bash scripts/install.sh --help                          # every flag
```

On a running brain, Settings > Services in the MantleOS app switches sandboxes, media,
the local embedder and the doc helpers without the shell.

### Optional after install: media (video ingest, CAD)

Off unless you turn it on. The `media` profile runs the `titanwest/mantle-media`
sidecar (yt-dlp, ffmpeg, DWG tools) and needs a shared token. The installer
sets neither. Prefer Settings > Services in the MantleOS app. By hand: in
`<MANTLE_HOME>/.env` add `media` to `COMPOSE_PROFILES`, keeping what is
already there (for example `COMPOSE_PROFILES=sandboxes,media`); if there is no
`MEDIA_SIDECAR_TOKEN`, add one made with `openssl rand -hex 32`; then run
`docker compose --profile media up -d --wait`. Full procedure:
https://docs.mantleos.io/install/options/

## 3. Verify

The installer ends with **Installation complete**, the address to open and
the setup code. **Installation incomplete** means a check failed: read what it
flags, fix it, and run the installer again.

```sh
cd <MANTLE_HOME>
bash scripts/install.sh --check                           # built-in health check, both projects
bash scripts/install.sh --setup-code                      # print the setup code again
docker compose ps                                         # the brain: every service Up/healthy
docker compose -f docker-compose.client.yml ps            # the owner UI
docker compose logs --tail 50 web caddy                   # when a check fails
curl -s <address>/api/health                              # the address the installer printed
```

`{"error":"unauthorized"}` from `/api/health` is SUCCESS: the app is up and
correctly refusing an unauthenticated caller. Then give the user the address
and the setup code, and tell them to open the address and create the first
account with the code. The in-app setup wizard handles model keys, the
embedding model, persona and identity. The user pastes their OpenRouter key
there, not into the shell or the chat. Nothing more to do on the shell.

With a domain, the certificate is issued on the first request, so the first
load can take a few seconds.

Headless (`MANTLE_CLIENT=0`): there is no signup screen on the server. The
user connects the MantleOS desktop app to the address and signs up with the
setup code, or runs `bash scripts/onboard.sh` in `<MANTLE_HOME>`.

For a desktop client, point the user at the MantleOS Desktop releases page
(https://github.com/crossworks-engineering/jackdaw/releases); the app asks for
the brain URL on first launch.

## 4. Adding or changing a domain later

Preferred (does the DNS pre-check and .env writes for you):

```sh
cd <MANTLE_HOME> && bash scripts/install.sh --domain brain.example.com -y
```

Later runs keep the domain, so you do not pass `--domain` again to change a
component.

Manual equivalent (what that actually does), for when you must edit by hand:
Caddy's vhost address comes from `.env`, consumed by `infra/caddy/Caddyfile`:

```sh
# 1. DNS first: A record  brain.example.com -> <server-public-ip>
# 2. in <MANTLE_HOME>/.env set:
#      MANTLE_SITE_ADDRESS=brain.example.com
#      MANTLE_PUBLIC_URL=https://brain.example.com
#      MANTLE_SERVER_ORIGIN=https://brain.example.com
# 3. apply:
docker compose up -d --wait
```

`MANTLE_SITE_ADDRESS=:80` is the no-domain form (plain HTTP on the IP).
Never point a domain at the box before the A record resolves, or Caddy will
burn Let's Encrypt rate limits retrying.

## 5. Update, backup, uninstall

```sh
cd <MANTLE_HOME>
docker compose pull && docker compose up -d --wait   # update the brain (or Settings > Updates in-app)
docker compose down                                  # stop; data/ persists on disk
bash scripts/uninstall.sh                            # remove containers; data/ and .env stay (--purge deletes them)
```

The in-app updater also rolls the owner UI to the client release the server
was paired with. Backups: TWO things matter. The `data/` directory (it IS the
brain: DB, object store, files, certificates) and the `.env` file
(`MANTLE_MASTER_KEY` decrypts the stored API keys and mail passwords; lose it
and the vault is unrecoverable). Scheduled DB backups are built into the app
at Settings > Backups. Details: https://docs.mantleos.io/admin/backups/

## Hard rules for agents

- **NEVER rotate or regenerate `MANTLE_MASTER_KEY`** in an existing `.env`.
  Sealed secrets become permanently unreadable. The installer already
  guarantees this on re-runs; do not "fix" the .env by regenerating it.
- Never ask the user for their OpenRouter key, any other API key or a
  password in the chat. Keys go into the setup wizard in the app.
- Show the user each command before you run it.
- Do not delete `data/caddy` (Let's Encrypt state; deleting it re-hits
  issuance rate limits).
- Do not edit the compose files to publish the web container's port; Caddy
  is the only intended ingress. Box-local changes go in `.env`,
  `docker-compose.override.yml` or `infra/caddy/conf.d/`.
- Do not run the installer without `MANTLE_YES=1` from a shell you cannot
  answer prompts in.
- If the user asks for team access, MCP connectors, or provider keys: those
  are configured inside the app (Settings), not in `.env`.
