run-local

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

SKILL.md

GitHub
<!-- skill-index: generated by scripts/skill-index.py; make check-skill-index -->

Find it fast

sed -n '<from>,<to>p' SKILL.md one section rather than reading the file; ./mxcli syntax <topic> answers how do I write this for a fraction of either.

lines section
40-47 Overview
48-60 When to Use This Skill
61-118 Usage
119-131 How apply is chosen
132-201 Prerequisites
202-219 The intended loop
220-247 Editing themes (SCSS) — rebuild, never clear caches
248-274 --watch waits for a write to finish before it builds
275-303 "My edit didn't show up" — stale process, not stale cache
304-334 Flags
335-388 Metrics and OpenTelemetry
389-446 Pages render in the browser
447-464 Pixel-perfect page loop
465-485 Debugging a server-side error
486-506 "Sign in failed" that is not about the password
507-539 The same licence also stops the app after a few hours
540-619 External browser preview (--hub)
620-628 Validation checklist
629-668 Constant values come from a configuration
669-672 Warm local dev loop (mxcli run --local [--watch] [--screenshot])
673-676 External browser preview (mxcli run --hub <url> + mxcli tunnel-hub)
677-680 Tunnel-hub GitHub authentication (opt-in, gated on --github-oauth-client-id; absent = today's open hub)
681-683 Runtime metrics + settings passthrough (mxcli run --local --metrics / --runtime-setting Key=Value)
<!-- /skill-index -->

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

# an agent / a tool call: start in the background, return once it serves
mxcli run --local --watch --detach -p app.mpr
#   running: http://127.0.0.1:8080/ (pid 4711, watch, log .mxcli/run.log)

mxcli exec change.mdl -p app.mpr && mxcli run wait -p app.mpr
#   applied: build #2 via reload in 4.9s

mxcli run status -p app.mpr     # one line: url, uptime, last build, pending change
mxcli run stop -p app.mpr       # runtime + mxbuild + bundler, nothing left behind
mxcli run restart -p app.mpr    # stop, then detach again with the same arguments

# a human at a terminal: foreground, Ctrl-C to stop
mxcli run --local --watch -p app.mpr

Never hand-roll the lifecycle

These were a quarter of all tool calls in measured agent sessions, and each one re-reads the whole context. Every one has a command that answers in one call:

Don't Do
nohup mxcli run --local … > log 2>&1 & mxcli run --local --watch --detach -p app.mpr
until grep -q 'App is running' log; do sleep 2; done, curl loops on 8080 nothing — --detach returns when the app serves (or run wait --ready)
sleep 45; tail log, grep -c applied counting loops after an edit mxcli run wait -p app.mpr
pkill -f 'mxcli run', pgrep loops, kill <runtimelauncher pid> mxcli run stop -p app.mpr
ps/tail to learn whether it is up mxcli run status -p app.mpr
a supervisor script that restarts it mxcli run restart -p app.mpr

Exit codes are the same across them, so a && chain is honest: 0 running / applied / stopped, 1 failed (the change or the boot — the errors are printed), 2 timeout, 3 not running, 4 (status only) still starting.

What each one does, precisely:

  • --detach writes .mxcli/run-state.json (pid, ports, URL, log, args) and sends the loop's own output to .mxcli/run.log. On a failed boot it prints the reason and the last log lines and exits 1. A second --detach for the same project is refused and points at status/stop — so is a foreground run while a detached one is up.
  • run wait is race-free: it reads the model's source time when it starts and returns the first build made from that source or newer. A change the loop applied before you called wait is reported at once — it never waits for a "next" change that already happened. With nothing changed it reports the last build. A failure prints the CE codes / SCSS error lines, never a hang. Where mxbuild writes the client itself (11.12.5, 11.14) its serve process only builds completely once, so the loop restarts it per change (one line says so; ~25–30 s instead of ~5 s, still a hot reload). A run started without --watch never applies a change: wait says so and exits 1.
  • run stop asks the run to shut down (it removes --test-endpoint, turns the debugger off), waits until it is gone, then sweeps the run's process session, so an orphaned runtimelauncher or mxbuild JVM is stopped too — including the leftovers of a run that was killed with -9. Stopping when nothing runs is a no-op (exit 0).
  • run restart reuses the arguments recorded in the state file; to change a flag, run stop and --detach again.

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. On Windows the installer always gives postgres a password and there is no sudo: pass --db-admin-user postgres --db-admin-password <pw> (or set PGPASSWORD for postgres). The admin login is used only to create the role and database, and is refused without --ensure-db. 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:

      createdb -h 127.0.0.1 -U mendix "$(basename app.mpr .mpr | tr '[:upper:]' '[:lower:]')"
      
  • No PostgreSQL available? --db-type hsqldb boots on the runtime's built-in file database instead — no server, no --ensure-db. Reach for it when --ensure-db stops with no local PostgreSQL superuser available to create the role/database. Data: <project>/deployment/data/database/hsqldb/.

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. test --local takes the same flag (#1086), and MXCLI_MXBUILD_PATH sets the override for both from the environment; the flag wins when both are set.

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

# once: keep the app hot, in the background
mxcli run --local --watch --detach -p app.mpr

# per change: edit the model, then wait for that change to be live
mxcli exec add-page.mdl -p app.mpr && mxcli run wait -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. The one stale state is the serve process's own: after a Sass error it reports [CE6093] with that first error on every later build, even once fixed. --watch restarts mxbuild --serve when a theme failure follows another (one line says so), so the error you see after that is the current one.

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 —

// 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. For a --detach run, mxcli run stop -p app.mpr reaps those leftovers (they stay in the run's process session), and run status reports them. Do not pkill -f 'mxcli run': that pattern also matches the shell you type it in.

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
--web-client-timeout $MXCLI_WEB_CLIENT_TIMEOUT, else 5m Limit for one web client bundle build; on timeout the tail of deployment/log/web-client-build.log is printed
--ensure-db off Provision local Postgres + app database if missing
--db-admin-user / --db-admin-password postgres / — Superuser login for --ensure-db when it has a password (Windows); only with --ensure-db
--app-port / --admin-port / --serve-port 8080 / 8090 / 6543 Ports
--listen-addr 127.0.0.1 IP the app binds to. 0.0.0.0 (or ::) makes it reachable from other machines; the network URLs are printed once it runs. If the app builds absolute URLs (SSO redirects, email links), pair it with --runtime-setting ApplicationRootUrl=http://<host>:<port>/ (or a real host in the configuration): the runtime does not derive one from the bind address — a warning says so. The M2EE admin API always stays on loopback
--db-host / --db-name / --db-user / --db-password 127.0.0.1:5432 / derived / mendix / mendix Database; bracket IPv6 endpoints ([::1]:5432)
--db-type postgresql hsqldb runs on the runtime's built-in file database: no database server, no --ensure-db, works offline. Data lives in <project>/deployment/data/database/hsqldb/. Refused together with --db-host/--db-user/--db-password/--ensure-db. Local development only
--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.
(scheduled events) ALL run --local boots with ScheduledEventExecution=ALL (the runtime's own default is NONE, which silently never fires an event) and prints Scheduled events: ALL (N defined) at boot. --runtime-setting ScheduledEventExecution=NONE (or SPECIFIED plus MyScheduledEvents) wins. The test runner keeps NONE

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:

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:

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:

export OTEL_TRACES_EXPORTER=otlp OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
mxcli run --local -p app.mpr --trace

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. The bundler is supervised: one that exits is restarted on the next change (web client bundler exited unexpectedly; restarting it..., with backoff when it cannot start), and an incremental rebuild that fails is retried once with a fresh bundler before the change is reported as failed. A recovery re-bundle (missing page, dangling chunk, /dist/index.js gone after a restart) replaces the bundler — two rollups never write web/dist at once.
  • A structural change restarts the runtime and drops every browser session. The loop prints runtime restarted for this change — browser sessions were dropped; log in again; a browser test that sees it must log in before the next step.
  • 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):

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:

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.

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

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

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.