Warm Local Dev Loop — mxcli run --local
SkillCloud & infraLets your agent run claude skill mxcli run --local so app changes go live in about a second with screenshots and tracing.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Warm Local Dev Loop — mxcli run --local skill
About this capability
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.
What this skill tells your AI
The instructions your AI receives, as published by mendixlabs/mxcli in .claude/skills/mendix/run-local/SKILL.md and read by ahel’s review.
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
# 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, 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. 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:]')"
-
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.
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
# 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 —
// 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. 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:
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
| --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).
--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.- 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
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 122
- Forks
- 49
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
run-local- Source
- github.com/mendixlabs/mxcli