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 --system creates).

  • 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 (--local is 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 like jq, ripgrep, or hyperfine. 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-file for 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
Note 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 --env)

mim install main.loc

view — declare which installed modules answer on which adapter

mim view add <module> --as mcp,api

serve — launch one front-end over the declared views

mim start

freeze — turn the environment into a self-contained image

mim freeze --tag <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

--auth-token <tok> / MORLOC_MCP_TOKEN

Require Authorization: Bearer <tok> on every /mcp and /call request (/health stays open). The env var keeps the token off the command line.

--expose

Publish on 0.0.0.0 (all interfaces) instead of loopback. Requires a token and --allow-plaintext.

--allow-plaintext

Acknowledge that there is no TLS — the token and traffic travel in cleartext.

--allow-no-auth

Permit an off-box endpoint with no token (an open server; strongly discouraged).

--unsafe

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.

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