Lazy loading defers command handler imports until Milo needs that command's schema or handler. Root help, group help, and another selected command do not resolve lazy siblings, so large command trees keep cheap interactions cheap.
lazy_command
Register a command with a dotted import path instead of a function reference:
from milo import CLI
cli = CLI(name="myapp")
cli.lazy_command(
"deploy",
"myapp.commands.deploy:deploy_handler",
description="Deploy to an environment",
)
The module myapp.commands.deployis not imported while Milo renders root or
group help, or while another command runs. Leaf help and execution resolve only
the selected leaf.
Pre-computed schemas
By default, the schema is generated by importing the handler and inspecting its signature. To avoid even that import, provide the schema upfront:
cli.lazy_command(
"deploy",
"myapp.commands.deploy:deploy_handler",
description="Deploy to an environment",
schema={
"type": "object",
"properties": {
"target": {"type": "string"},
"dry_run": {"type": "boolean"},
},
"required": ["target"],
},
)
With a pre-computed schema, --llms-txt and --mcp tools/listwork without importing any handler modules.
Theschema=value is ordinary JSON Schema and is the stable cache format. An
application may keep it in Python as above or load a persisted.jsonfile:
import json
from importlib.resources import files
deploy_schema = json.loads(
files("myapp.schemas").joinpath("deploy.json").read_text(encoding="utf-8")
)
cli.lazy_command(
"deploy",
"myapp.commands.deploy:deploy_handler",
schema=deploy_schema,
)
Milo retains the supplied object on LazyCommandDef; no second schema source
or Milo-specific cache encoding is involved. Keep the cached schema beside the
annotated handler and test it againstfunction_to_schema()when the handler's
contract changes.
CLI presentation can also be pre-computed with the same schema extension:
{
"type": "object",
"properties": {
"target": {
"type": "string",
"x-milo-cli": {"kind": "positional", "metavar": "TARGET"}
}
},
"required": ["target"]
}
Lazy commands in groups
Groups support lazy loading too:
site = cli.group("site", description="Site operations")
site.lazy_command(
"build",
"myapp.commands.site:build_handler",
description="Build the site",
)
How it works
LazyCommandDefstores the import path and defers resolution:
- On registration, only the name, description, and optional schema are stored
- On first invocation,
resolve()imports the module and extracts the handler - The result is cached as a full
CommandDef— subsequent calls skip the import
Resolution is thread-safe (uses a lock with double-check pattern).
Ifschema=is omitted, root and group help remain metadata-only, but selected
leaf help/parser construction imports that leaf to derive its schema. Full-tree
discovery (--llms-txt, completions, and MCP tools/list) likewise resolves
each lazy command that has no pre-computed schema. Supplying schemas makes that
fallback explicit and removes those imports.
If the module or attribute cannot be imported, terminal invocation exits1
withM-CMD-004. call() and call_raw()raise the same structured
MiloError; MCP returns its reason, command, importPath, and suggestion
inerrorData.
When to use lazy loading
- CLIs with many commands where only one runs per invocation
- Commands that import heavy dependencies (cloud SDKs, ML libraries)
- Plugin systems where third-party command modules may not be installed
For small CLIs with lightweight imports, the@cli.commanddecorator is simpler and equally fast.
Tip
Combine pre-computed schemas with--llms-txt and --mcpto let AI agents discover all your commands without triggering any imports.