---
name: run-local
description: "The warm Docker-free dev loop, `mxcli run --local` — model changes live in about a second, with watch, screenshots, metrics, tracing and external preview. Use for the fastest edit-to-running-app cycle, especially when driving the model programmatically with MDL."
---

# Warm Local Dev Loop — `mxcli run --local`

## Overview

`mxcli run --local` runs a Mendix app **without Docker**, keeping a
`mxbuild --serve` process and a standalone runtime hot so model changes apply in
~1 s instead of a ~30–60 s rebuild-and-restart. Use it as the fast inner loop when
iterating on an app; use `mxcli docker run` when you need the fully-rendered browser
client (see the limitation below).

## When to Use This Skill

Use this when:
- You want the fastest edit → running-app loop for a Mendix 11.x project.
- You're driving the model programmatically (`mxcli exec`/MDL) and want each change
  live immediately.
- You're iterating on **page design** (the app renders in a real browser) or doing
  runtime/model/API/headless verification.

Prefer `mxcli docker run` when:
- The project is Mendix 9/10 (JDK 11/17 — not yet supported by `run --local`).
- You want a container-parity deployment rather than a standalone runtime.

## Usage

```bash
# boot once and keep serving (Ctrl-C to stop)
mxcli run --local -p app.mpr

# boot and hot-apply on every project change
mxcli run --local -p app.mpr --watch
```

## How apply is chosen

Every warm rebuild reports whether a restart is required; `run --local` applies the
cheapest action automatically:

| Change | Apply | Cost |
|--------|-------|------|
| page / microflow / nanoflow / text | hot `reload_model` (no restart) | ~1 s |
| entity / view entity / association | runtime restart + DDL | ~9 s |

Structural changes need a restart because the runtime reconciles its entity/
association catalog only at startup; behavioural changes are hot-reloaded.

## Prerequisites

- **Mendix 11.x** project (runtime launches under **JDK 21**).
- A **PostgreSQL** database (defaults: `127.0.0.1:5432`, user `mendix`, db derived
  from the project name; override with `--db-host/--db-name/--db-user/--db-password`).
  - **`--ensure-db`** provisions it for a fresh session: starts local Postgres if the
    port is down and creates the role + database if missing. It uses a service
    manager, or a user-owned `initdb`/`pg_ctl` cluster under `~/.mxcli/postgres`
    when no service becomes ready (e.g. Arch) — the latter needing no `postgres`
    OS account or `sudo`. Remote hosts are only checked, not provisioned.
    In a **non-root devcontainer** the service start is elevated with `sudo -n`
    (Debian's init script aborts on a permission denial before it reaches any
    cluster), and the superuser is reached through root where the sudoers policy
    permits only that target — the devcontainer default. Both are non-interactive,
    so a run never blocks on a password prompt; where sudo is unavailable the
    user-owned cluster still carries it.
    The user-owned cluster persists across sessions; its server log is
    `~/.mxcli/postgres/server.log`. Stop it with
    `pg_ctl -D "$HOME/.mxcli/postgres/data" stop`. To remove it, stop it first and
    then delete `~/.mxcli/postgres` (this permanently deletes its databases).
  - Without `--ensure-db`, create it once and the command errors if it's unreachable:

    ```bash
    createdb -h 127.0.0.1 -U mendix "$(basename app.mpr .mpr | tr '[:upper:]' '[:lower:]')"
    ```

### Which mxbuild the loop uses

- **Linux** — the CDN download cached at `~/.mxcli/mxbuild/<version>/`, as before.
- **macOS / Windows** — **Studio Pro's bundled mxbuild**, resolved before the cache.
  The Mendix CDN publishes **Linux archives only** (the URL varies by architecture,
  not by OS), so a cached download on a Mac is a Linux `aarch64` ELF — the arch
  matches, which is why it looks fine until exec.
- `--mxbuild-path` overrides both. It is honoured by the local loop (#916) *and*
  accepted by `run --local` (#1125) — between those two fixes the skill said the
  first and the command rejected the flag, so the advertised workaround did not
  exist on the platform that needed it.

If nothing runnable is found, the command says so up front instead of failing with
`fork/exec …: exec format error`:

```
mxbuild from the Mendix CDN is a Linux binary and cannot run natively on darwin
  Install Mendix Studio Pro 11.12.0 and use its bundled mx …
  Or point mxcli at it explicitly with --mxbuild-path.
```

### Windows and macOS toolchain

- **JDK 21** — Mendix Studio Pro does not bundle one; its installer puts **Eclipse
  Temurin JDK 21** in the usual place, which is where mxcli looks (`Eclipse
  Adoptium` / `Java` / `Microsoft` under both Program Files, plus the per-user
  `%LOCALAPPDATA%\Programs\…` installs winget produces). `JAVA_HOME` wins over all
  of them. When nothing is found the error now lists every location it searched.
- **Gradle** — bundled inside mxbuild (`modeler/tools/gradle`) and invoked by
  mxbuild, not by mxcli. Studio Pro extracts its own copy to the parent of its
  install directory (usually `C:\Program Files\Mendix`). A "Gradle not found"
  from a local run therefore points at an incomplete or foreign mxbuild bundle —
  check which mxbuild was resolved before looking for a system Gradle.

## The intended loop

```bash
# terminal 1: keep the app hot
mxcli run --local -p app.mpr --watch

# terminal 2 (or an agent): edit the model — the change hot-applies automatically
mxcli exec add-page.mdl -p app.mpr
```

`--watch` observes two source trees and rebuilds when either changes: the **model
source** (`.mpr` + `mprcontents/`, v1 and v2 layouts) and the **theme source**
(`theme/` and `themesource/<module>/web/` — SCSS/CSS/JS). It ignores build output
(`deployment/`, `theme-cache/`, `.mendix-cache/`, `.mxcli/`). Both signals are mtime
polling, so they work on container filesystems where inotify is silent. Each apply is
logged with a build-generation counter (`build #2`, …) so you can confirm a change
landed.

## Editing themes (SCSS) — rebuild, never clear caches

A theme edit (`theme/web/main.scss`, module SCSS) needs a **rebuild**, not a
cache-clear. With `--watch`, just save the `.scss` — the theme source is watched and
the loop rebuilds and hot-applies. Without `--watch`, nothing is watched, so the save
does nothing until you restart `run --local` (or re-run with `--watch`). `mxbuild
--serve` recompiles the theme on its next build and correctly picks up SCSS content
changes, so **never** `rm -rf theme-cache/ .mendix-cache/ deployment/` — clearing
caches is a red herring.

A **theme compile error fails the build step, before the runtime starts** — the
error text is in the serve `/build` output (printed by `run --local`), not a
runtime problem. A common Dart Sass trap: `rgba()` needs a real color, so a
comma-list variable is rejected —

```scss
// WRONG: Dart Sass reports "$color: 168, 50, 30 is not a color"
$cf-over: 168, 50, 30;
background-color: rgba($cf-over, 0.1);

// RIGHT: make the variable a color, then adjust alpha
$cf-over: rgb(168, 50, 30);
background-color: rgba($cf-over, 0.1);
```

## `--watch` waits for a write to finish before it builds

The watcher polls the model source and rebuilds when it changes — but it does
**not** rebuild the instant it sees the first change. It waits for the source to
stop moving first.

That matters because an `mxcli exec` of a real script rewrites the `.mpr` and
many `mprcontents/*.mxunit` files over several seconds. Building on the first
change deploys whatever is on disk at that instant: a **half-applied model**,
which looks like an ordinary stale build until you notice the app is missing
things the script definitely created.

The escape hatch that used to cover this — "just run the script again" — stopped
working when `exec` became byte-idempotent. A re-run of an already-applied script
writes nothing, so nothing re-triggers the watcher, and the stale build is what
you are left with. Waiting for the write to settle is what makes that
unreachable rather than merely unlikely.

Practical consequences:

- A rebuild starts a couple of poll intervals after your last change, not
  immediately. A single editor save is unaffected in practice.
- A long `exec` produces **one** build, of the finished model, instead of a
  build of the first file it happened to touch.
- If you ever do need to force a rebuild without changing anything, `touch` the
  `.mpr` — the watcher keys on mtime, so that re-triggers it.

## "My edit didn't show up" — stale process, not stale cache

`run --local` refuses to boot when its ports (8080/8090/6543) are already answering,
because a leftover `run --local` / `mxbuild --serve` / runtime would otherwise be
silently adopted and keep serving old output (it looks like a cache but is a stale
**process**). If a background `run --local` died while its serve+runtime kept serving,
**the refusal names the pid** — on Linux it resolves the listener through `/proc`, so
recovery is one command:

```
port 8080 (app) is already in use.
  Held by pid 11893: /root/.mxcli/mxbuild/11.13.0/modeler/mxbuild --serve …
  That is a leftover from an earlier run that did not shut down cleanly
  (a kill -9 or a reaped container skips mxcli's own teardown).
    kill 11893
```

It also distinguishes the two cases, which need opposite remedies: a leftover of a
previous run is safe to kill, while a **foreign** listener (someone else's server on
8080) is not — for that it says so and points at `--app-port`.

Note that a *graceful* stop already reaps everything: `run --local` puts each child
(mxbuild's JVM, the runtime, the rollup bundler) in its own process group and kills the
group on Ctrl-C/SIGTERM. Reaching this error means the previous run was killed with
`kill -9`, crashed, or had its container reaped — none of which run any handler. Do
**not** `pkill -f 'mxcli run'`: that pattern also matches the shell you type it in.

Launch `run --local` as the **sole** command in its invocation (don't chain a trailing
`sleep`/`curl` whose non-zero exit can kill the backgrounded run); poll separately.

## Flags

| Flag | Default | Purpose |
|------|---------|---------|
| `--local` | — | Required; run without Docker (implied by `--hub`) |
| `--hub` | — | Expose the app in a browser at a tunnel-hub URL (see below) |
| `--hub-secret` | — | Shared auth (`user:pass`) matching an **open** hub's `--secret` |
| *(hub API key)* | — | For an **authenticated** hub: get one from `https://<hub>/cli`, set `MXCLI_HUB_KEY` (see below) |
| `--watch` | off | Rebuild + hot-apply on each change |
| `--ensure-db` | off | Provision local Postgres + app database if missing |
| `--setup` | off | Cache MxBuild+runtime + ensure DB, then exit (SessionStart bring-up) |
| `--screenshot` | off | Playwright PNG after boot + each change |
| `--screenshot-path` / `--screenshot-url` | `.mxcli/run-local.png` / app root | Screenshot output / page (URL or `/path`) |
| `--screenshot-user` / `--screenshot-password` | — | Log in once, reuse session (pages behind login) |
| `--runtime-log` | `.mxcli/runtime.log` | Runtime log file: JVM stdout/stderr **and** the application log (microflow `LOG` output + server stack traces, via an attached file log subscriber). `-` disables. |
| `--test-endpoint` | off | Host mxcli's token-guarded test endpoint so `mxcli test … --attach` can run a suite against this app with no boot of its own. Installed **before** the boot (the handler registers from after-startup), your own after-startup microflow is chained not displaced, and both are removed on exit. See `test-microflows`. |
| `--debug` | off | Enable the microflow debugger at boot + start a session, so `mxcli debug break/paused/…` works from another terminal (see `debug-microflows`). No breakpoints = no behaviour change; disabled on shutdown. |
| `--debug-pass` | `mxdebug` | Debugger password when `--debug` is set |
| `--metrics` | off | Register a Prometheus meter registry at boot; the runtime serves metrics at `http://127.0.0.1:<admin-port>/prometheus` |
| `--trace` | off | Enable OpenTelemetry tracing (bundled agent, console exporter → the runtime log) with default span filters |
| `--trace-service` | `.mpr` name | `OTEL_SERVICE_NAME` under `--trace` |
| `--trace-otlp` | off (console) | Export traces to this OTLP collector endpoint (e.g. `http://127.0.0.1:4318`) instead of the console. Implies `--trace`. Needed for flame charts |
| `--runtime-setting Key=Value` | — | Merge an extra runtime setting into the boot config (Value parsed as JSON when possible). Repeatable. |

## Metrics and OpenTelemetry

**Metrics (`--metrics`):** the Mendix runtime ships Micrometer registries but starts
with none. `--metrics` registers a **Prometheus** registry at boot, so
`http://127.0.0.1:8090/prometheus` (the admin port) serves ~70+ metric families
(`connectionbus_*`, `handler_requests_total`, `sessions_*`, `taskqueue_*`, …). For a
different registry use `--runtime-setting`, e.g.
`--runtime-setting 'Metrics.Registries=[{"type":"otlp","settings":{"step":"PT10S"}}]'`
(also `influx`, `statsd`, `jmx`).

**Why this is a flag, not a post-boot API call:** the admin `update_configuration`
action **replaces** the whole config (there's no read-back), so a separate call to add
metrics would wipe the DB/BasePath settings. `--metrics`/`--runtime-setting` merge into
mxcli's single boot `update_configuration`, which is the only safe way.

**Traces (`--trace`):** the runtime bundles the OpenTelemetry Java agent;
`--trace` attaches it to the runtime JVM and applies the default span filters:

```bash
mxcli run --local -p app.mpr --trace                 # spans -> runtime.log
mxcli run --local -p app.mpr --trace --runtime-log - # spans to console only
```

Spans (tracer `com.mendix.runtime`, attrs `mx.microflow.name`/`mx.microflow.depth`) go
to the console exporter → `runtime.log` (so `tail -f .mxcli/runtime.log` shows them).
`--trace` sets `OTEL_SERVICE_NAME` (default the `.mpr` name, override with
`--trace-service`) and, unless you set them yourself, `OTEL_TRACES_EXPORTER=console`
with metrics/logs exporters off.

**Why the default span filters matter:** unfiltered per-activity tracing is
**~10× slower**, so `--trace` ships `OpenTelemetry._RuntimeSpanFilters` =
`["CreateOrChangeVariable","Loop","Gateway","RetrieveFromCache"]` by default (keeping
the microflow-level spans). Override with
`--runtime-setting 'OpenTelemetry._RuntimeSpanFilters=[…]'`.

**Export to a collector (OTLP) instead of the console:** the console exporter
omits start/end timestamps and parent span IDs, so call trees and durations can't
be reconstructed from it — for flame charts, export to an OTLP collector. Pass
`--trace-otlp <endpoint>` (implies `--trace`); mxcli sets
`OTEL_TRACES_EXPORTER=otlp`, `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`, and
`OTEL_EXPORTER_OTLP_ENDPOINT` for you:

```bash
mxcli run --local -p app.mpr --trace-otlp http://127.0.0.1:4318
```

You can still set the `OTEL_*` env yourself for full control — `--trace` /
`--trace-otlp` never override an exporter you've already set:

```bash
export OTEL_TRACES_EXPORTER=otlp OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
mxcli run --local -p app.mpr --trace
```
| `--app-port` / `--admin-port` / `--serve-port` | 8080 / 8090 / 6543 | Ports |
| `--db-host` / `--db-name` / `--db-user` / `--db-password` | 127.0.0.1:5432 / derived / mendix / mendix | Database; bracket IPv6 endpoints (`[::1]:5432`) |

## Pages render in the browser

`run --local` bundles the browser client (`web/dist/`) with mxbuild's rollup tooling
after the deploy build, so the app renders in a real browser (verified with
Playwright + the devcontainer's Chromium).

- **`--watch`** keeps a long-lived incremental bundler hot (the client-side mirror of
  `mxbuild --serve`): a page/widget edit re-bundles in ~3–4 s; a microflow/entity edit
  skips the bundle and just hot-reloads. It uses `CHOKIDAR_USEPOLLING` because inotify
  is silent on container filesystems.
- Without `--watch`, a single one-shot bundle (~7 s) runs before boot.
- **The bundle is re-checked after the boot**, because bundling before it is not
  enough: the runtime's boot runs Gradle `clean-custom-classes compile package`,
  and when Gradle has work to do (a new Java action, a full recompile) its package
  pass repopulates `deployment/web` and deletes `dist/` — the bundle written
  seconds earlier by the same command. If that happened, `run --local` says
  `re-bundling` and rebuilds it. When Gradle had nothing to do the check is a
  `stat` and costs nothing.

**If you ever see a black page:** that is this failure, and nothing else reports
it — `mxcli check` passes, the build succeeds, the runtime log is quiet, `curl /`
returns **200** with a valid HTML shell, and the OData services all answer. Only a
browser sees it. Confirm with `curl -o /dev/null -w '%{http_code}' <app>/dist/index.js`;
a 404 there is the whole diagnosis.

`mxcli test --local` boots the same way and destroys the bundle too. Tests are
headless so it is not rebuilt for them (that would cost ~30 s on a loop whose point
is two seconds) — the run prints a note instead, and a subsequent `run --local`
restores it.

### Screenshots when the app has an https root URL (`--hub`)

Under `--hub` the runtime boots with the public **https** root URL, so it marks
its session cookies `Secure` and prefixes them `__Host-`. The screenshot login
therefore declares the real scheme with `X-Forwarded-Proto: http`, which on
Mendix 10.24+ takes precedence over `ApplicationRootUrl` and drops both — so the
captured session is usable over http.

Measured on 11.12.1, `__Host-XASSESSIONID (secure)` becomes `XASSESSIONID`
(not secure). Real users still arrive over https through the hub without that
header and still get `Secure` cookies.

One correction to a common assumption: this is **not** needed for `127.0.0.1`.
Loopback is a *trustworthy origin*, so Chromium accepts `Secure` cookies there
and an app with an https root URL renders and logs in fine over
`http://127.0.0.1:8080`. It matters when the browser reaches the app from a
non-loopback host — a container name, a LAN address — where the origin is not
trustworthy and the session cannot be held at all.

## Pixel-perfect page loop

`--screenshot` captures a PNG (default `<projectDir>/.mxcli/run-local.png`) after boot
and after each applied change, via Playwright's built-in `screenshot` command
(Chromium from `PLAYWRIGHT_BROWSERS_PATH`):

```bash
mxcli run --local -p app.mpr --watch --screenshot
# edit a page -> auto rebuild -> re-bundle -> reload -> fresh screenshot
```

- `--screenshot-url /p/customers` shoots a specific page (bare path resolved against
  the app root; a full URL is used as-is). Repeat it for a multi-page set — each page
  gets its own PNG (`run-local-p-customers.png`, `run-local-home.png`).
- `--screenshot-user`/`--screenshot-password` log in once (Mendix form auth) and
  reuse the session, so pages behind login render authenticated. Best-effort: an
  anonymous app with no login form proceeds unauthenticated.

## Debugging a server-side error

When a page action throws, the browser shows the generic Mendix error dialog with no
detail. The runtime log — server stack traces and your microflow `LOG ERROR`/
`LOG INFO` output — is written to `<projectDir>/.mxcli/runtime.log` (the path is printed
at boot). `tail -f .mxcli/runtime.log` while you reproduce the action to see the stack
and correlate it.

Two things feed that file, because a standalone runtime attaches **no** log subscriber
by default (a Studio Pro / m2ee run does):

- mxcli tees the runtime **JVM's** stdout/stderr to it (startup output, JVM-level crashes).
- After start, mxcli attaches a Mendix **file log subscriber** so the **application** log
  — microflow `LOG` output and server-side exception stack traces — lands there too.
  Without this, application logs go nowhere and the file is nearly empty.

The file is appended across restarts (each boot writes a `=== runtime start … ===`
marker); the subscriber is re-attached on every restart and never rotates the file (so
the JVM tee's handle stays valid). Override the path with `--runtime-log <path>`, or
pass `--runtime-log -` to disable the file (and the subscriber) entirely.

## "Sign in failed" that is not about the password

The local runtime is **unlicensed**, and an unlicensed runtime caps concurrent
sessions at a handful. Past the cap it refuses the sign-in, and the login page
reports that as a plain **"Sign in failed"** — exactly what a wrong password
looks like. The real reason is written only to the runtime log:

```
Maximum number of sessions exceeded! (You are currently using a trial license)
```

So: when a login you know is correct starts failing, `grep -c "Maximum number of
sessions" .mxcli/runtime.log` before touching the credentials or the user's
password in the model. `--screenshot-user` does this for you — a rejected sign-in
now reads the log and says so instead of quietly screenshotting the login page.

Sessions are held until they expire; restarting `run --local` clears them all.
A script that drives the app through a browser should **sign out at the end**,
otherwise each run leaks a slot and the fifth or sixth run is the one that fails —
which makes it look like a change you just made broke authentication.

## The same licence also stops the app after a few hours

The development licence has a **maximum run time**, and the runtime enforces it by
shutting *itself* down. It warns at every boot, which reads as boilerplate until
the day it matters:

```
WARNING - LicenseService: The runtime has been started using a trial licence,
          the framework will be terminated when the maximum time is exceeded!
...
LicenseService: Maximum run time exceeded, framework is now terminating
```

Measured on one machine: **3h52m** and **5h07m** — not a fixed number, and shorter
than a working session. Budget for a restart on anything long-running.

`mxcli run` now **exits** when the runtime stops, with a non-zero status and the
reason, so a shell loop or systemd can restart it:

```bash
while ! mxcli run --local -p app.mpr; do echo "restarting"; done
```

Two traps this closes, both of which cost hours before it did:

- **A 200 from a tunnelled URL is not evidence the app is alive.** Under `--hub`
  the tunnel outlives the runtime, so the preview URL keeps answering over a dead
  app. Health-check the app itself, and check something the *app* serves —
  `/dist/index.js`, not `/`, which returns the SPA shell either way.
- **`mxcli run` at several hundred percent CPU is not working hard.** It used to
  keep supervising a runtime it had neither reaped nor noticed the death of; a
  `Z` (zombie) process underneath it is the tell.

## External browser preview (`--hub`)

> **Linux builds only.** `--hub` and `mxcli tunnel-hub` ship in the **Linux** build
> only. The tunnel embeds a general-purpose tunnelling tool that gets the Windows
> and macOS binaries flagged by Defender and enterprise EDR for a capability they
> can never use, so it is left out of them. On Windows/macOS the commands exist and
> show help, but fail with an explanatory message — run mxcli inside the project's
> devcontainer (where the warm loop already runs) to use `--hub`. See
> [ADR-0009](https://github.com/mendixlabs/mxcli/blob/main/docs/13-decisions/0009-tunnel-is-linux-only.md).

`--hub <url>` exposes the running app in a **browser at a public URL** without the app
leaving this machine and without committing — for reviewing work-in-progress from a
phone/tablet, or from an egress-only environment like Claude Code on the web. The app
stays here; a **reverse tunnel** dials *out* to a hub over 443 and the hub proxies
browser requests back down it. Nothing is pushed — only live HTTP — and everything rides
one 443 connection, so it works through an egress-only proxy.

**You run your own hub — there is no hosted service.** Stand up `mxcli tunnel-hub` once on
a host you control (a small VPS with a domain), then point apps at it.

```bash
# on your VPS: *.example.com + hub.example.com -> this host, inbound 80+443 open
mxcli tunnel-hub --domain example.com --secret alice:s3cret

# where the app runs:
mxcli run --hub https://hub.example.com --hub-secret alice:s3cret -p app.mpr
#   -> registers and prints e.g. "Preview available at https://app.example.com"
```

The hub is **multi-tenant**: it fronts many previews at per-preview subdomains
(`<project>-<branch>.example.com`; `main`/`master` collapses to `<project>`) with a
sortable overview at `https://hub.example.com/`. Each `run --hub` self-registers:

- **Project** and **branch** auto-detect (`.mpr` name + git); override with
  `--hub-project` / `--hub-branch`. `--hub-worktree` distinguishes worktrees of one branch.
- **`--hub-prefix`** namespaces the hostname (org/solution/team/env) →
  `<prefix>-<project>-<branch>`; **`--hub-solution`** groups a solution's apps in the overview.
- The overview shows availability (a reaped/idle container goes **stale**), sortable by
  last-used / registered / project. Re-registering keeps a **stable URL**.
- `--hub` **implies `--local`**, boots the runtime with `ApplicationRootUrl` set to the
  assigned URL (so the SPA/`originURI` work), and the tunnel reconnects forever. Combine
  with `--watch` for the full loop: edit here → hot-apply → refresh the browser.

**Hub setup:** wildcard `*.example.com` A record (+ `hub.example.com`) → the VPS; inbound
80+443 open (per-subdomain Let's Encrypt on demand).

### Authenticated hub (GitHub)

A self-hosted hub started **without** the GitHub flags is open — the shared `--hub-secret`
is all `run --hub` needs. A hub started **with** `--github-oauth-client-id` (see
`mxcli tunnel-hub --help`) adds per-user isolation: browsers sign in with GitHub and see
only their own previews, and registration needs a **per-user hub API key** instead of the
shared secret.

**Get a key from the hub's browser page** (works from any device — desktop, or Claude
Code web/mobile, whose container can't reach GitHub's device endpoints):

1. Open `https://hub.mxcli.org/cli` in a browser and sign in with GitHub.
2. Click **Create a hub key** and copy it.
3. Set it as an environment/repo secret in your Claude Code environment — it's picked up
   automatically and survives container reaping:

```bash
export MXCLI_HUB_KEY=<key>
mxcli run --hub https://hub.mxcli.org -p app.mpr   # registers previews as you
```

The key is **durable** (no expiry, survives hub restarts) — you set it once. It stays valid
until you revoke it (sign out on the hub, or `mxcli auth hub logout`).

Headless alternative (CI, or a machine with a GitHub token): `mxcli auth hub login --token
<github-pat>` mints and caches a key in `~/.mxcli/auth.json` (mode 0600). The GitHub token
is used once for the mint and never stored.

**Registration failure is non-fatal:** if the hub is unreachable or the key is stale/rejected,
`run --hub` prints a warning and continues as a normal local run (the app boots on localhost;
only the public preview URL is lost). On an authenticated hub the shared `--hub-secret` still
works as a fallback registration credential — a valid key stamps the preview as yours, a valid
secret registers it owner-less.

## Validation checklist

- [ ] Project is Mendix 11.x.
- [ ] Postgres is running and the target database exists.
- [ ] `curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/` returns `200`,
      and `.../dist/index.js` also returns `200` (client bundle served).
- [ ] With `--watch`, editing a microflow logs `applied via reload`; adding an entity
      logs `applied via restart` and creates the table in Postgres.

## Constant values come from a configuration

`mxcli run --local` applies the constant values of the project configuration it
is running, merged over each constant's default:

```text
Applying 1 constant value(s):
  Encryption.EncryptionKey  configuration "Default"
```

Before this they were ignored: mxbuild writes each constant's **default** into
`deployment/model/config.json`, and that map is what the runtime is handed — so
`alter settings constant … in configuration 'Default'` executed, round-tripped
through `describe settings`, and did nothing. An app ran for hours with an empty
encryption key while the model said otherwise.

- `--configuration <name>` picks one. With several configurations and none named
  `Default`, mxcli applies **none** and says so rather than guessing which
  environment this run means.
- A **private** override has no value in the model at all (the value lives on the
  developer's workstation), so the default is used and the constant is named.
- The line prints in every case, including "no overrides" — silence used to mean
  "your override is in effect" when it was not.
- `--constant Module.Name=value` (repeatable) sets a value for **this run only**.
  It wins over the configuration, is never written to the project, and is
  reported as coming from `--constant` so the output says which layer won. A
  constant the project does not declare is refused before the app boots: the
  runtime ignores a value for a constant that does not exist, so a typo would
  otherwise be reported as applied and do nothing.

Setting a constant on an app that is *already running* is a different mechanism
again: `MicroflowConstants` over the M2EE admin port, which is how Mendix Cloud
injects per-environment values. Measured on 11.12.1, `update_configuration` is
**staged rather than applied** — the running app keeps the old value until the
next `reload_model`, while the call answers `result:0` — and the admin API has no
read-back to check against. Do not reach for
`--runtime-setting 'MicroflowConstants={…}'`: it replaces the map mxcli built
rather than adding to it, and at boot there is nothing to fall back on for
`BasePath`/`DatabaseName`. Use `--constant`.

## Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`)

Docker-free `mxbuild --serve` + standalone runtime, hot `reload_model` for behavioural changes and restart+DDL for structural ones (chosen from the serve build's `restartRequired`). Bundles the browser client (`web/dist/` via mxbuild's rollup runner, which the serve Deploy target skips) so Mendix 11.x apps render in a browser. `--watch` keeps an incremental rollup bundler hot (CHOKIDAR_USEPOLLING for container fs; ~3-4s page re-bundle, skipped for model-only edits) and watches only model source (`.mpr`+`mprcontents/`). `--ensure-db` provisions the local Postgres + app database if missing; `--setup` does the non-blocking prerequisites (cache mxbuild+runtime, ensure DB) and exits — `mxcli init` wires it into a Claude Code SessionStart hook so a fresh/reaped web session self-bootstraps, and `docs-site/src/tools/bootstrap-prompt.md` is the empty-repo seed prompt. `--screenshot` captures a Playwright PNG each change (pixel-perfect page loop), with `--screenshot-url` deep links (repeatable for multi-page sets, one PNG per page) and `--screenshot-user`/`--screenshot-password` form login (session saved as Playwright storage state, reused via `screenshot --load-storage`). See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md`

## External browser preview (`mxcli run --hub <url>` + `mxcli tunnel-hub`)

the app stays local and reverse-tunnels out over a single 443 connection (embedded chisel) to a static relay, so it is reachable in a browser at a public URL — works from egress-only environments (Claude Code web), verified live through the session's MITM egress proxy. `run --hub` implies `--local`, boots the runtime with `ApplicationRootUrl` set to the assigned URL (so the SPA/`originURI` work under the public origin), resolves the control proxy honouring `NO_PROXY`, and retries forever. `mxcli tunnel-hub --domain <base>` is the **multi-tenant** relay: a registry keyed by prefix/project/solution/branch/worktree (stable URLs on reconnect) fronts many previews at per-subdomain hosts (`[prefix-]project[-branch].<base>`; main collapses to the project) over one 443 with per-subdomain autocert, a registration API (`/api/register|status|deregister|backends|sessions`), and an availability overview at `hub.<base>/` **grouped by Claude Code session** (`/api/sessions`): each session lists the endpoints it exposed and links back to its `claude.ai/code` conversation. Client identity flags: `--hub-prefix`/`--hub-project`/`--hub-solution`/`--hub-branch`/`--hub-worktree` (project + branch auto-detected); `--hub-session` groups a session's endpoints (auto-detected from `CLAUDE_CODE_REMOTE_SESSION_ID`). Past sessions are retained: a durable per-session endpoint history (`--sessions-file`, default `~/.mxcli/hub-sessions.json`) survives restarts and reaping, and is pruned after `--session-retention` (default 30d) — so the overview shows offline sessions too (`SessionLog` in `cmd/mxcli/tunnelhub/sessions.go`). Package: `cmd/mxcli/tunnelhub/`. See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md` (slices 3–4)

## Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub)

**viewer plane** — GitHub OAuth web flow + HMAC-signed SSO session cookie (`Domain=.<domain>`), owner-checked previews (`--require-auth` default on → 302 to login / 403 non-owner; soft mode filters the listing only), `/api/backends` filtered to the viewer (unauthenticated → 401), admin "signed in as" via `/api/whoami`. **Registration plane** — durable, hashed hub API keys (`--keys-file`, default `~/.mxcli/hub-keys.json`, survive restarts) presented as `X-Hub-Key` → stamps `Backend.Owner`; shared `X-Hub-Secret` still works as an owner-less fallback. **Key issuance** — the hub's `/cli` browser page mints a key from the session cookie (no PAT; the device flow was removed as Claude Code containers block GitHub's device endpoints), rotate-by-default + count + revoke-all; `mxcli auth hub login --token <pat>` is the headless path; `run --hub` reads `MXCLI_HUB_KEY` (env → `~/.mxcli/auth.json`) and degrades to local-only if registration fails. Append-only JSONL audit trail (`--audit-log`, no secrets). Packages: `cmd/mxcli/tunnelhub/` (+`audit/`), `cmd/mxcli/hubauth/`. See `docs/11-proposals/PROPOSAL_hub_authentication.md`

## Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`)

`--metrics` registers a Prometheus Micrometer registry at boot (served at `http://127.0.0.1:<admin-port>/prometheus`); `--runtime-setting` merges arbitrary runtime config (e.g. `Metrics.Registries` for otlp/influx/statsd, or `OpenTelemetry._RuntimeSpanFilters`) into mxcli's **single** boot `update_configuration` call — the admin action replaces rather than merges, so folding settings into the one boot call is the only safe way. OTel traces via `--trace` attach the bundled `opentelemetry-javaagent` to the runtime JVM (console exporter → the tee'd runtime log) and ship default `OpenTelemetry._RuntimeSpanFilters` (unfiltered per-activity tracing is ~10× slower); `--trace-service` sets `OTEL_SERVICE_NAME`. The console exporter omits timestamps/parent span IDs (no flame charts), so `--trace-otlp <endpoint>` (implies `--trace`) switches to the OTLP exporter (protocol `http/protobuf`) pointed at a collector; user-set `OTEL_*` env still takes precedence.
