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
Files2
View on GitHubSKILL.md.claude/skills/mendix/run-local/SKILL.md
GitHub<!-- skill-index: generated by scripts/skill-index.py; make check-skill-index -->
<!-- /skill-index -->
Warm Local Dev Loop —
Screenshots when the app has an https root URL (
External browser preview (
Warm local dev loop (
External browser preview (
Tunnel-hub GitHub authentication (opt-in, gated on
Runtime metrics + settings passthrough (
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) |
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:
--detachwrites.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--detachfor the same project is refused and points atstatus/stop— so is a foreground run while a detached one is up.run waitis 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 calledwaitis 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--watchnever applies a change:waitsays so and exits 1.run stopasks 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 orphanedruntimelauncheror 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 restartreuses the arguments recorded in the state file; to change a flag,run stopand--detachagain.
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, usermendix, db derived from the project name; override with--db-host/--db-name/--db-user/--db-password).--ensure-dbprovisions 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-ownedinitdb/pg_ctlcluster under~/.mxcli/postgreswhen no service becomes ready (e.g. Arch) — the latter needing nopostgresOS account orsudo. Remote hosts are only checked, not provisioned. In a non-root devcontainer the service start is elevated withsudo -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 givespostgresa password and there is no sudo: pass--db-admin-user postgres --db-admin-password <pw>(or setPGPASSWORDfor 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 withpg_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 hsqldbboots on the runtime's built-in file database instead — no server, no--ensure-db. Reach for it when--ensure-dbstops 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
aarch64ELF — the arch matches, which is why it looks fine until exec. --mxbuild-pathoverrides both. It is honoured by the local loop (#916) and accepted byrun --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 --localtakes the same flag (#1086), andMXCLI_MXBUILD_PATHsets 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/Microsoftunder both Program Files, plus the per-user%LOCALAPPDATA%\Programs\…installs winget produces).JAVA_HOMEwins 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 (usuallyC:\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
execproduces 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,
touchthe.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).
--watchkeeps a long-lived incremental bundler hot (the client-side mirror ofmxbuild --serve): a page/widget edit re-bundles in ~3–4 s; a microflow/entity edit skips the bundle and just hot-reloads. It usesCHOKIDAR_USEPOLLINGbecause 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.jsgone after a restart) replaces the bundler — two rollups never writeweb/distat 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 repopulatesdeployment/weband deletesdist/— the bundle written seconds earlier by the same command. If that happened,run --localsaysre-bundlingand rebuilds it. When Gradle had nothing to do the check is astatand 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/customersshoots 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-passwordlog 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
LOGoutput 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
--hubthe 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 runat several hundred percent CPU is not working hard. It used to keep supervising a runtime it had neither reaped nor noticed the death of; aZ(zombie) process underneath it is the tell.
External browser preview (--hub)
Linux builds only.
--hubandmxcli tunnel-hubship 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 (
.mprname + git); override with--hub-project/--hub-branch.--hub-worktreedistinguishes worktrees of one branch. --hub-prefixnamespaces the hostname (org/solution/team/env) →<prefix>-<project>-<branch>;--hub-solutiongroups 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.
--hubimplies--local, boots the runtime withApplicationRootUrlset to the assigned URL (so the SPA/originURIwork), and the tunnel reconnects forever. Combine with--watchfor 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):
- Open
https://hub.mxcli.org/cliin a browser and sign in with GitHub. - Click Create a hub key and copy it.
- 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/returns200, and.../dist/index.jsalso returns200(client bundle served). - With
--watch, editing a microflow logsapplied via reload; adding an entity logsapplied via restartand 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 namedDefault, 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--constantso 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.