9.3. mim (Morloc Installation Manager)
9.3.1. System-scope environments
mim, the Morloc Installation Manager, keeps environments in two parallel
scopes: a per-user local scope (no privileges required) and a machine-wide
system scope (root required). The --system flag selects the system scope on
the subcommands that mutate it:
-
mim new <name> --system— build a new environment in the system scope so it is shared across users (its--engine, if given, also sets the system-scope default engine for later--systemcreates). -
mim rm <name> --system— remove an environment from the system scope. -
mim nuke --system— remove all system-scope environments (and, with--images, their backing container images). -
mim modify --env <name> --set-default --system— write the default-environment tag to the system config rather than the user’s local config.
Read-only subcommands also accept --system for discovery rather than
mutation:
-
mim ls --system— list only system-scope environments (--localis the symmetric filter). -
mim info [<name>] --system— describe a system-scope environment, useful when a local environment of the same name shadows it.
A regular (non-root) user can therefore find out whether a system-scope
environment exists — and what it is configured with — without elevated
privileges, by running mim ls --system or
mim info <name> --system. Mutating subcommands will refuse to
run without root and print a hint to re-invoke under sudo.
9.3.2. Rebuilding and reconfiguring environments
Two verbs cover the environment lifecycle after creation, split by what they touch:
update rebuilds an environment. With no flags it re-solves and rebuilds at the
environment’s current morloc version; it never moves the version implicitly.
$ mim update --env myenv # rebuild, same version
$ mim update --env myenv --latest # move to the newest release
$ mim update --env myenv --morloc-version 0.105.2 # move to a specific version
$ mim update --env myenv --force # force a fresh re-solve (repair)
modify changes an environment’s settings without moving its morloc version.
Setting the default and copying dotfiles are instant; changing languages or
packages triggers a rebuild at the current version. modify validates the whole
request before making any change, so an invalid combination never leaves an
environment half-modified.
$ mim modify --env myenv --set-default # make it your default (no rebuild)
$ mim modify --env myenv --env-name newname # rename it; a default follows (no rebuild)
$ mim modify --env myenv --dotfiles ~/dotfiles # copy dotfiles (no rebuild)
$ mim modify --env myenv --lang py@3.13 # re-pin a language, then rebuild
$ mim modify --env myenv --conda-packages-file tools.conda # set conda packages
$ mim modify --env myenv --system-packages-file tools.apt # set apt packages
9.3.3. Packages
Beyond language runtimes, an environment can carry extra packages from two
sources, each supplied as a file — one package per line, with # comments and
blank lines ignored:
-
--conda-packages-file <file>adds conda packages from conda-forge, such as command-line utilities likejq,ripgrep, orhyperfine. Each line is a conda match-spec: a bare name takes the latest compatible version, or pin one (hyperfine>=1.18,numpy=1.26,cmake<4). Conda packages work on every backend, native included. -
--system-packages-file <file>adds OS packages installed with the base image’s package manager (apt), for things conda-forge does not carry —locales,linux-tools-generic, kernel-matched tooling. These are baked into the image, so they apply to container backends only; requesting them on a native environment is an error. Prefer--conda-packages-filefor anything on conda-forge.
Both flags are accepted by new and modify. The file is the whole list for
that source: passing it replaces the environment’s stored packages for that
source, so to add or remove one package you edit the file and re-apply. Changing
either list triggers a rebuild at the current morloc version, and the stored list
is updated only after the rebuild succeeds — a package that breaks the build
leaves the previous list intact, so a typo never wedges the environment.
9.3.4. Language toolchains
--lang provisions a language runtime into an environment, at new or modify
time. Each value is a language name or a lang@version pin, and the flag is
repeatable or comma-separated — these are equivalent:
$ mim new myenv --lang py,r,cpp
$ mim new myenv --lang py --lang r --lang cpp
$ mim new myenv --lang py@3.12,r@4.3 # with version pins
Most languages (python, R, C++, Rust) are provisioned from conda-forge and their
versions are solved together with the rest of the environment. A language whose
toolchain is not on conda-forge — currently futhark — is instead installed by
a script when the container image is built, so it is supported only on the
docker/podman backends; requesting it on the native or apptainer backends is an
error. --lang futhark installs a fixed, tested futhark release.
9.3.5. Development environments
A development environment mounts a morloc source tree and provisions the
tooling to build it, giving contributors (and coding agents) a uniform,
reproducible place to build, edit, and test morloc itself. Point --dev at a
morloc source checkout:
$ mim new --dev /path/to/morloc --engine podman
This builds a container (docker or podman only) that bakes the Haskell toolchain
via ghcup and provisions the language runtimes with pixi — but it does not
build morloc. That is deliberate: an in-development source may not compile, and
that must never block creating the environment. You build the compiler and
runtime yourself, in the dev shell, where the toolchain (stack, ghc, cargo)
is on PATH. The dev shell does not mount the source at a fixed path: it opens
in your host working directory (bind-mounted at /work), so cd into your morloc
checkout on the host first, then open the shell and build from there:
$ cd /path/to/morloc # on the host: your source checkout
$ mim shell --env dev
# inside the container, now in /work (your checkout):
$ stack install morloc:exe:morloc morloc:exe:morloc-codegen-generic \
--local-bin-path /opt/morloc-runtime
$ MORLOC_RUST_DIR="$PWD/data/rust" morloc init -f
Because the source is reached through the working-directory mount rather than a
dedicated one, point morloc init/morloc make at the Rust workspace with
MORLOC_RUST_DIR (its data/rust subdirectory) when you rebuild the runtime. The
compiler you build and the build caches live in host-mounted directories, so
stack, ghc, and cargo sit alongside the language runtimes and edits persist
across shells. A dev environment is local-scope and docker/podman only, and its
default name is dev. --dev is compatible with --morloc-version, which sets
the stdlib base the environment tracks; the compiler itself is whatever you
build from the source. freeze is refused, since a dev environment is not
reproducible, and info reports the source path.
To round out a development environment with the tools you work with — linters,
profilers, language servers — add them from conda-forge with
--conda-packages-file (see Packages), which behaves the same here as on any
other environment:
$ mim new --dev /path/to/morloc --engine podman \
--conda-packages-file dev-tools.conda # e.g. jq, ripgrep, hyperfine, nodejs
9.3.6. Inspecting an environment
mim info <name> describes an environment as four groups: its
identity and materialization status, the folders it occupies on the host, the
environment variables a run/serve process sees, and its dependencies — the language runtimes and package count from the solved world (pixi.lock):
$ mim info myenv
Name: myenv
Scope: local
Default: yes
Backend: native
Morloc: 0.98.2
Status: materialized
Folders (host):
Data dir: .../environments/myenv
Runtime: .../environments/myenv (shared with state)
Pixi: .../environments/myenv/pixi
Requirements: .../environments/myenv/requirements
Cache: .../environments/myenv/cache
Config: .../environments/myenv/env.yaml
Environment (exported into run/serve):
MORLOC_HOME=.../environments/myenv
MORLOC_ENV=myenv
MORLOC_PIXI=.../bin/pixi
Dependencies (locked):
Languages: python 3.12.4, rust 1.83.0
203 packages in the solved world (`info myenv --packages` for the full list)
mim info myenv --packages lists every package in the solved world
at its locked version; --json prints the whole description (packages included)
as machine-readable JSON, and --packages --json prints just the package array.
9.3.7. Checking environment health
mim doctor [<name>] runs read-only health checks against an
environment and exits non-zero if any check fails, so it fits a setup script or
CI gate. On the native backend it verifies that the runtime is materialized and
its conda toolchain is present, that the captured activation exports the compiler
tools a build needs ($CC/$CXX/$AR/…), that each provisioned language
stack and libmorloc are in place and resolve their libraries, that the
compiler/manager/agent versions are in step, and that the dependency world is
solved and the data dir is writable:
$ mim doctor --env myenv
...
9 passed, 0 warnings, 0 errors
Add --deep for slower checks (installed program launchers), --json for a
machine-readable report, and --strict to treat warnings as failures.
9.3.8. The environment home and dotfiles
Under the Docker and Podman backends a container runs as your host user but
does not mount your host $HOME. Each such environment instead owns a
private home directory on the host, bind-mounted as $HOME inside the
container. It is writable from both sides and survives runs, update, and
clean. mim info prints its path under Folders:
$ mim info myenv
...
Folders (host):
...
Home: .../environments/myenv/home (shell $HOME; drop dotfiles here)
Anything you place there behaves like a normal home file. An interactive shell
reads .bashrc, so a custom prompt and aliases go there (a .vimrc,
.gitconfig, or nested .config/… work the same way):
# .../environments/myenv/home/.bashrc
export PS1='(myenv) \w $ '
alias grep='grep --color=auto'
alias ls='ls --color=auto'
$ mim shell # the shell picks up the new .bashrc
shell spawns a fresh interactive shell (exit with exit) and tags its
prompt with the environment name so it is clear which environment you are in. It
does this without editing your dotfiles — for bash and zsh it sources your real
init first, then prepends the (myenv) tag — so your own prompt still applies.
To seed a whole set of dotfiles at once, new and modify accept
--dotfiles <dir>, which recursively copies a directory into the environment
home (overwriting like cp -rf; symlinked directories are skipped):
$ mim new myenv --dotfiles ~/dotfiles
$ mim modify --env myenv --dotfiles ~/dotfiles # re-copy after edits
|
|
This is a Docker/Podman feature. An Apptainer environment mounts your
real host $HOME (so it already sees your host dotfiles) and the native
backend runs against your real home, so --dotfiles is rejected on both. A
.bashrc affects interactive shells only — it is not read by a
non-interactive morloc make, so build-time settings belong elsewhere.
|
9.3.9. Serving installed programs
Beyond managing environments, mim runs the serving lifecycle
that presents compiled programs to AI assistants (over MCP) and to HTTP clients
(over a JSON API). The first three steps are distinct on purpose — installing
a program does not make it reachable, and declaring that it should be does not
serve it until you ask.
The fourth turns the environment you have been working in into something you
can hand to someone else:
| Step | Command |
|---|---|
install — build a module into an environment (the default, or |
|
view — declare which installed modules answer on which adapter |
|
serve — launch one front-end over the declared views |
|
freeze — turn the environment into a self-contained image |
|
An environment is pliable while you work in it: you install programs, add dependencies, rebuild, and try things. Freezing takes that environment as it stands and produces an image that runs the same programs with nothing mounted in from outside.
install
mim install <src> is shorthand for morloc make --install <src>
run inside the target environment (the default, or the one named with --env).
The installed program is identified by its
module name — the module <name> declaration in the source — never by the
file name or any -o. By convention the source lives in main.loc, so a
program declaring module dna installs as dna regardless of the file name.
view
A view is what an adapter shows of an environment. The MCP view and the API
view are independent sets, and eval is a separate capability. view edits a
per-environment file recording that declared intent; nothing is served until
start realizes it.
$ mim view # show this environment's views
$ mim view add dna --as mcp,api # dna answers on both /mcp and /call
$ mim view add util --as api # util answers on the JSON API only
$ mim view eval --allow dna # enable sandboxed eval (allow: dna)
$ mim view rm dna # remove dna from every view
Installing a module makes it importable; adding it to a view makes it callable
over the network. The two are independent, so a program can be installed and
unreachable, which is the default. A module must be installed before it can be
added (view add checks for the launcher and errors otherwise). --as takes a
comma-separated list of mcp and/or api. view eval turns on the sandboxed
eval capability with an import allow-list; --off disables it.
start
start launches a single serving front-end — one HTTP listener that answers
/mcp (MCP), /call/<module>/<command> (JSON API), /discover (API
discovery), and /health (liveness) — forwarding each call to that module’s
own worker process.
$ mim start # serve the environment's views
$ mim start --mcp dna # ad-hoc: serve just `dna` over MCP
$ mim start -p 9090:9090 # pin the host:container port
With no --mcp, start serves the environment’s views; it errors if there are
none rather than serving nothing. --mcp <module> is a quick
one-off that ignores the declared views and serves that one module over MCP.
By default the endpoint binds the host’s 127.0.0.1 (loopback, reachable
only from the host), so no token is required. Making the endpoint reachable
elsewhere is a deliberate opt-in:
| Flag | Effect |
|---|---|
|
Require |
|
Publish on |
|
Acknowledge that there is no TLS — the token and traffic travel in cleartext. |
|
Permit an off-box endpoint with no token (an open server; strongly discouraged). |
|
On a VM-backed engine (Docker Desktop / podman machine), where a loopback bind cannot be confined to the host, serve unauthenticated anyway. Dangerous; trusted hosts only. |
For AI assistants, start also prints an mcpServers client-config entry as
pure JSON on stdout (so mim start … > dna.mcp.json is ready to
drop into a client); human status goes to stderr.
status, stop, logs
$ mim status # list running serve containers (mode, modules, URL)
$ mim logs [-f] # stream the serve container's logs
$ mim stop # stop the running serve container
status reads a runtime record written by start, so it reports the mode,
served modules, and URL even under host networking where the engine shows no
published port.
eval
mim eval '<expr>' evaluates a Morloc expression against a running
serve container’s sandboxed eval endpoint (enabled with view eval):
$ mim eval 'import dna (revcomp); revcomp [("s","ACGTA")]'
$ mim eval -p 9090 'map (add 1) [1,2,3]'
mim eval reaches the serve on the port it recorded when it started, so you
do not have to remember which one it picked; --port overrides that. If the
serve requires a bearer token, pass it with --auth-token or set
MORLOC_MCP_TOKEN — the token is never written to disk, so mim can tell you
that one is needed but cannot supply it for you.
freeze
Freezing turns the environment you have been working in into a container image that carries everything it needs.
$ mim freeze --tag dna-service:v1
The image is the environment with its mounted halves baked in. The morloc
runtime is copied: the nexus, the shared library, the language bindings, and a
launcher for every installed program, all of which were built after the
environment’s image was. The language toolchain is installed from the
environment’s own pixi.lock — a solved toolchain is not a directory you can
copy, so what travels is the lock that reproduces it. The programs and the
module sources behind them are copied too. Anything required and missing is
reported by name, so freezing an environment that was never provisioned fails
instead of building an image with holes in it.
Each program travels as the mirror of its project directory that install
made, since the pools import sourced files from it. Whatever else the project
held goes with it — which is fine for a data file the program reads and
unfortunate for a build tree. So before anything expensive runs, freeze
walks every installed program and prints what it will carry:
$ mim freeze --tag dna-service:v1
Installed programs to freeze:
dna 0.4 MB
atlas 310.2 MB
large file: atlas/data/genome.fa (299.0 MB)
tool state: dna/target (not sized)
A tool-state directory — .git, a cargo target/, node_modules,
pycache, .venv, an editor’s cache, and the like — is a refusal: nothing
a program runs reads one, and the fix belongs in the project. Add it to the
project’s .morlocignore (one pattern per line, target/ for a directory),
reinstall the program, and freeze again; include: in package.yaml is the
allowlist form of the same control. A program over 100 MB, or a file over
50 MB, is a question rather than a refusal, because only you know whether that
is data the program needs: on a terminal freeze shows the sizes and asks;
from a script, where nobody can answer, it stops. An environment with no
programs installed is asked about the same way, since freezing one is usually
a mistake but sometimes the point — a base to run programs in later. --force
answers all of these.
Nothing appears in your working directory. The artifact is a tag in the engine’s image store, which is what makes it portable: you move it by pushing it to a registry, or as a file.
$ docker push ghcr.io/you/dna-service:v1
$ mim freeze --tag dna-service:v1 --save ./dna-service-v1.tar
$ docker load -i ./dna-service-v1.tar # on the far side
--save is the engine’s own docker save, so the tarball carries every layer
including the base and needs nothing else to restore. There is no
morloc-specific archive format.
The image above is the environment whole — the compiler, the Rust and C toolchains, pixi — which is what lets it evaluate expressions and rebuild a pool, and is also most of its size. When the programs are the point and nothing in the image will ever compile, freeze it slim:
$ mim freeze --slim --tag dna-service:v1-slim
A slim image keeps every interpreter and package the environment declared and
removes what only built things: the compilers and their sysroot, rustc, make,
git, the morloc compiler, pixi, and the runtime’s headers. The cut is computed
from the environment’s own package records, so it also reaches the compiler
chain that conda’s r-base drags in for install.packages — about half of a
typical environment goes. What runs is exactly what the full image runs, at the
same paths, and the image is checked after the build: every program’s launcher
starts, and the nexus and every compiled pool resolve their shared libraries.
The default tag ends in -slim, so the two flavours of one environment do not
overwrite each other, and the morloc.flavor label says which one you hold.
A slim image cannot eval, so an environment that exposes eval is refused; drop
the view or freeze without --slim. The same holds for anything a program
compiles at run time — Cython at import, numba, R packages installed from
source — which needs the full image.
An image that has left mim has nothing tracking it, so it carries its own
provenance as labels — the morloc version, the environment it came from, the
programs inside, and which of them answer on each adapter. docker inspect
reads them back.
The image serves the environment’s views, and nothing else. Declare no views and the image has no default command — it is still complete, and you run programs in it by name:
$ docker run -e MORLOC_MCP_TOKEN=$TOKEN -p 8080:8080 dna-service:v1
$ docker run dna-service:v1 dna revcomp ACGTA
The first form serves; the second runs a program through the command line the compiler generated for it. One image, both interfaces, from the same declarations.
Inside the container the service binds every interface, because a container’s
loopback belongs to the container and a published port never reaches
127.0.0.1 in there. That address says nothing about who can reach the
service, so the image does not treat it as if it did: it serves without a
token, and says so once at startup. What can reach it is your decision, made
outside the container — publish to loopback with -p 127.0.0.1:8080:8080,
keep it on an internal network, or put a gateway in front. Set
MORLOC_MCP_TOKEN to require a bearer token as well.
Eval is the exception. It runs expressions the caller writes rather than the functions you exported, and a single call can rebuild a pool, so it asks for a token even where the rest of the endpoint does not:
$ docker run -e MORLOC_MCP_TOKEN=$TOK -p 8080:8080 dna-service:v1
$ docker run -e MORLOC_EVAL_ALLOW_NO_AUTH=1 -p 8080:8080 dna-service:v1
The first serves eval to holders of the token; the second waives the
requirement, for a deployment where something in front already gates it.
Without either, eval is locked: it is not advertised in the tool list,
/discover reports it as not callable, and a request to it is refused with the
reason. Everything else serves normally.
mim start waives the requirement by itself when the endpoint cannot leave the
host, which is the case eval mostly exists for; --eval-allow-no-auth waives
it when serving off-box.
mim manages environments, not images. It builds a deployment image and has no
further relationship with it: status, logs and stop act on serves started
from environments, so an image you run yourself is not something mim tracks,
and needs neither mim nor the environment it came from.
|
|
Experimental Feature
The image is built where the environment lives, because it is that environment’s image with the runtime and toolchain added. Freezing on one machine to build on another is not supported; move the built image instead. |
The wire protocols behind these endpoints — the MCP handshake, the JSON API call/discover shapes, sessions, and status codes — are documented in the Model Context Protocol (MCP) and Building APIs chapters.