DOCUMENTATION · v0.6.0
Operator & API docs
Everything the appliance serves, what every field means, and how to run it —
the same surface an agent gets from GET /guide, written for humans.
Machine-readable version lives on the running instance itself.
5-minute quickstart
From tarball to a first routed decision to a certified lane installed — every step is self-contained.
# 1. get it running (~8MB static tarball, Linux x86_64, no deps) curl -LO https://github.com/javimosch/mtlm-router/releases/download/v0.6.0/mtlm-router-v0.6.0-linux-amd64.tar.gz tar xzf mtlm-router-v0.6.0-linux-amd64.tar.gz && cd mtlm-router-v0.6.0-linux-amd64 ./start.sh # serves on :8097 — readyz goes 200 when heads load # 2. first routed decision (the "hello world") curl -s localhost:8097/v1/route -H 'content-type: application/json' \ -d '{"state":"my server crashed, need a new VM","expert":"it_helpdesk"}' # {"action":"tool_call","route":"provision_vm","confidence":0.94,...} # 3. verify the whole surface (12 checks) ./smoke.sh http://localhost:8097 # 4. install a certified lane from the catalog — --apply patches start.sh python3 tools/lane_install.py clinc150 --appliance . --apply # verifies tarball+head sha256, writes models/, and adds an idempotent # managed env block to start.sh — no manual edits python3 tools/lane_install.py --appliance . --list # what's installed # 5. restart, confirm the lane is live curl -s localhost:8097/readyz # "experts":5 curl -s localhost:8097/v1/lanes # clinc: 151 routes + qhat
Install & verify
# ~8.2MB, static, no deps — Linux x86_64 tar xzf mtlm-router-v0.6.0-linux-amd64.tar.gz cd mtlm-router-v0.6.0-linux-amd64 ./start.sh # serves on :8097 ./smoke.sh # 12 checks: probes, route, conformal gate, abstain, systemone… python3 tools/monitor.py logs/decisions.jsonl # label-free drift check (exit 1 on drift) python3 tools/shadow_report.py logs/decisions.jsonl # canary verdict when ANVIL_SHADOW is set
Or mtlm-router.service for systemd (~/.config/systemd/user/). Day-2 operations (probes, maintenance loop, canary promotion, recalibration, incidents): ops/RUNBOOK.md inside the tarball.
Endpoints
Machine-readable contract: openapi.yaml — feed it to a client generator, gateway, or agent. Ships inside every appliance tarball.
| endpoint | what it does | body |
| POST /v1/route | the dispatcher: gate → expert head → noul/score → optional execute | {state|messages, context[], expert?, options[], min_conf?, min_gate_conf?, execute?} |
| POST /v1/decide | route head only | {state|messages, context[]} |
| POST /v1/noul | escalation yes/no head | {state} |
| POST /v1/score | complexity tier 1–4 | {state} |
| POST /v1/assess | decide+noul+score in one forward pass | {state} |
| POST /v1/systemone | Jev-compatible typed questions (noul|choice|score), no generation, no head files needed | {state, model?, questions:{qid:{type,instructions,criteria}}} |
| POST /v1/chat/completions | generative path, OpenAI-compatible, SSE stream | {messages, model?, stream?} |
| POST /v1/feedback | open submit — idempotent on id, 16KB cap, 30/min/ip | {message, kind?, context?, reporter?, id?} |
| GET /v1/feedback | admin read (valid ANVIL_KEYS bearer), ?limit=n | — |
| GET|POST /_qhat | live conformal recalibration — read or set per-lane qhat, no restart | POST {expert, qhat} |
| GET /metrics | Prometheus text: per-ep counters, per-lane requests/delegates/set_size, latency, uptime | — |
| GET /v1/whoami | masked key identity + tenant head + expert lanes | — |
| GET /v1/models | OpenAI-style model list | — |
| GET /guide · /llms.txt | the embedded operator manual (JSON) / agent plain-text summary | — |
| GET /_health · /health | liveness | — |
| GET /livez · /readyz | k8s liveness (always 200) / readiness (503 until a head or lane is loaded) | — |
| GET /v1/lanes | per-lane status: routes per head, qhat, minconf, gate domains | — |
| POST /_shutdown | token-gated (or loopback-only without keys) shutdown | — |
A routing decision, field by field
{
"action": "delegate",
"route": "collecte",
"confidence": 0.174,
"domain": "mairie_c",
"expert": "mairie_c",
"set_size": 0,
"qhat": 0.648,
"reason": "conformal_abstain",
"latency_ms": 34
}
action — tool_call | delegate | generate. delegate is a first-class answer, not a failure: route to a bigger model or a human.
reason — why: low_confidence · low_gate_confidence · route_escalate · route_oos · noul_escalate · conformal_abstain · head_trunk_disagree. A singleton oos delegates (route still says oos — treat it as a polite rejection, not a tool to call).
set_size/qhat — conformal fields, present on gated lanes. set_size=1 means the prediction set is a singleton (coverage guarantee applies); 0 or >1 means abstain. When 2 ≤ set_size ≤ 8 the response also carries candidates — the set members sorted by probability, so a client can ask a targeted clarification ("did you mean X or Y?") instead of treating the abstain as a dead end. When set_size = 0 (nothing clears the bar) candidates carries the top-2 argmax routes — best guesses for triage, with no coverage claim.
margin — top1 − top2 probability gap, always present. A small margin flags a torn decision even on a singleton set; clients can layer their own policy on top (e.g. only auto-execute when action=tool_call AND margin ≥ 0.3) without changing the lane's calibrated gate.
expert — the lane that answered. Pin it with {"expert":"mairie_c"} in the body to skip the gate (and noul/score — the lane's own escalate class is its abstention path).
Automation reality (Mondrian/per-class conformal, measured): on wide taxonomies the marginal auto rate is subsidized by easy classes — under a class-conditional guarantee bank77 automates 0.6% and clinc ~5% of traffic; on those lanes candidates clarification sets are the common interaction, not an edge case. Narrow lanes differ: civic sustains 46% per-class automation.
via/tier/call — present when execution or upstream delegation ran.
The clarification loop
How a client should drive a delegate response. Reference implementation: examples/clarify_loop.py.
tool_call + margin >= 0.3 -> execute route
tool_call + margin < 0.3 -> confirm with user, then execute
delegate + candidates[] -> "did you mean…" (order as returned —
already most-probable first), execute the pick
delegate + reason=route_oos -> polite rejection — the lane can't help
delegate, no candidates -> hand off to a human / bigger model
Loop discipline: ask at most one clarifying question, then hand off — an agent that keeps asking trains users to ignore it, and a wrong automated route is worse than a handoff. Candidates from a set_size=0 response are best guesses with no coverage claim: fine to show in a pick-list, never auto-execute them. Always offer a "none of these" option — that's the correct signal to hand off.
On wide-taxonomy lanes (bank77, clinc) this loop is the common interaction, not an edge case: measured 41–91% of real production traffic lands in delegate+candidates (see bench/dogfood_report.json).
Conformal gating
Per lane, a calibrated threshold qhat replaces the flat confidence floor: a decision only automates when the head's prediction set is a singleton — a finite-sample coverage guarantee, not a vibes threshold.
Set it at boot: ANVIL_EXPERT_QHAT=mairie_c:0.648 (the alpha=0.02 point on our municipal calibration split — recalibrate on your own data before trusting it, via tools/conformal_eval.py).
Move it at runtime — no restart:
# read current qhats curl localhost:8097/_qhat # tighten a lane live (ACI hook calls this) curl -X POST localhost:8097/_qhat \ -d '{"expert":"mairie_c","qhat":0.71}' # closed loop: grade outcomes -> ACI step -> _qhat python3 tools/aci_qhat.py feedback/labels.jsonl \ --expert mairie_c --alpha 0.02
/v1/systemone — Jev-compatible typed questions
Drop-in request/response surface for Jev clients. state accepts a string, object or array. Question types: noul, choice, score (2–10 ordered levels). Choice criteria as a map or "options":[...] shorthand. Multiple questions per call.
Errors: 400 missing fields · 422 bad type/criteria · 422 {"type":"max_tokens_exceeded"} on context overflow.
Honesty note: the contract is complete (19/19 conformance checks in bench/systemone/contract.sh); the raw 7M trunk's generic candidate semantics are shallow — trained taxonomy heads are the accurate path.
curl localhost:8097/v1/systemone -d '{
"state": "charged twice on my invoice",
"questions": {
"safe": {"type": "noul", "criteria": "safe to automate"},
"team": {"type": "choice", "options": ["billing","technical","returns"]},
"urgency": {"type": "score", "criteria": ["low","medium","high"]}
}
}'
# → {"answers":{"safe":{"noul":0.02},"team":{"choice":"billing",
# "probabilities":{...}},"urgency":{"score":1,"legend":[...]}},
# "usage":{...}}
For agents — MCP + embedded docs
mtlm-mcp is a pure-MFL stdio MCP server exposing two task-shaped tools — route_request and typed_questions — so MCP clients (Cursor, Claude Code, agent fleets) call calibrated routing as a native tool.
Env: ANVIL_URL (default http://127.0.0.1:8097), ANVIL_KEY (optional bearer). Tool descriptions carry the abstention contract: delegate/abstain means do not guess.
The server also self-documents: GET /guide (full JSON manual) and GET /llms.txt (one-screen agent summary) report what this instance actually loaded.
# build the MCP adapter machin encode tools/mcp.src > tools/mcp.mfl machin build tools/mcp.mfl -o mtlm-mcp # register with your MCP client, e.g. ANVIL_URL=http://127.0.0.1:8097 ./mtlm-mcp # tools/list → route_request, typed_questions
Observability
| signal | where | what it tells you |
| anvil_requests_total{ep,action} | /metrics | traffic split — tool_call vs delegate rate |
| anvil_set_size_sum/count{lane} | /metrics | rising set size = calibration aging, before labels arrive |
| anvil_decision_latency_ms_sum/count | /metrics | mean decision latency |
| decisions.jsonl | ANVIL_LOG | per-decision audit: state, action, conf, reason, set_size |
| tools/monitor.py | in the tarball | baseline-vs-recent window drift check, exit 1 on drift |
| feedback.jsonl | ANVIL_FEEDBACK_LOG | production outcomes → labels → ACI recalibration |
Recommended loop: Prometheus scrapes /metrics for live ops → monitor.py nightly for drift → grade outcomes into feedback.jsonl → aci_qhat.py re-tightens the gate. See the conformal abstention post for the math.
Measured latency
Disclosed run, not a marketing number: bench/route_latency.py --n 120 per lane, loopback on the loaded production box (shared LXC, load ~2–3 during the run), 6-text FR/EN mix, 2026-10-14. Raw output: bench/latency_2026-10-14.json in the repo.
| lane | p50 | p95 | p99 | mean |
| bank77 (51 routes) | 171ms | 265ms | 302ms | 177ms |
| clinc (151 routes) | 182ms | 272ms | 311ms | 178ms |
| civic_fr (12 routes) | 185ms | 264ms | 300ms | 177ms |
Lane width barely matters (~10ms across 12→151 routes) — the frozen trunk prefill dominates; each head is a ~1ms matmul. An idle box shows lower p50; this is the honest loaded number. Your deployment will differ — re-run bench/route_latency.py against your appliance.
Cost model (measured, same run): latency ≈ 25ms + ~0.45ms/char of input text. The 25ms floor is tokenize+head+HTTP overhead; the chat template and system prompt are prefix-cached so only user tokens forward (~2ms/token). Short commands like "lost my card" route in ~30–50ms — the ~180ms p50 above reflects a benchmark mix that includes 300-char complaints, not typical short inputs.
Environment
ANVIL_HEAD|NOUL|SCORE|GATE=file.head # the four core heads ANVIL_EXPERTS=dom:file.head,... # pinned expert lanes ANVIL_EXPERT_QHAT=dom:0.648,... # conformal gate per lane (replaces minconf) ANVIL_EXPERT_MINCONF=dom:0.5,... # flat floor per lane (legacy) ANVIL_KEYS=k1,k2 # bearer auth on /v1/* + feedback admin ANVIL_TENANTS=key:file.head,... # per-key tenant head override ANVIL_SHADOW=dom:file.head,... # canary: scored on same features, log-only "shadow" field ANVIL_LOG=logs/decisions.jsonl # decision audit log (shadow.agree feeds monitor.py) ANVIL_FEEDBACK_LOG=feedback.jsonl # submitted feedback ANVIL_DELEGATE_URL|KEY|MODEL|SYS # upstream for delegated requests ANVIL_S1_T[_CHOICE|_NOUL|_SCORE] # systemone candidate softmax temps ANVIL_S1_HEAD=file.head # trained scorer for systemone candidates ANVIL_BIND=127.0.0.1 # bind loopback-only (default 0.0.0.0) COLIBRI_THREADS=8 # saturates ~8 on the reference box COLIBRI_NOBATCH=1 # disable batched prefill
Lane manager — tools/lane_install.py
Package-manager lifecycle for heads from mtlm-heads: fetch → sha256-verify → install → activate → inspect → update-check → remove. JSON on stdout, exit codes 0/1.
# install latest non-yanked version (or pin with name@ver) python3 tools/lane_install.py helpdesk --apply --appliance . python3 tools/lane_install.py civic@0.2.1 --apply --appliance . # → fetches index.json, verifies tarball sha256 + head sha256 vs manifest, # writes models/*.head, patches start.sh's managed env block. JSON includes: # "warnings": ["newer version available: helpdesk@0.2.0 (installing 0.1.1)", # "replaces installed helpdesk@0.1.1 (upgrade|downgrade!)"] # inspect the managed block — name, head, qhat, version, head_present python3 tools/lane_install.py --list --appliance . # pip --outdated for lanes: current|outdated|unknown|not-in-catalog python3 tools/lane_install.py --check --appliance . # exit 1 if any outdated — cron-able # remove: drops the record, regenerates the block, deletes the head file python3 tools/lane_install.py --uninstall helpdesk --appliance . # flags: --dry-run (resolve+verify only) · --index URL (private catalog)
The managed block in start.sh (# >>> mtlm-lanes … # <<<) is regenerated from # lane records on every operation — installs accumulate, reinstalls update in place. Everything outside the markers is untouched.
Operating a lane — the lifecycle runbook
A lane pack is certified against its eval split, not your traffic. The guarantee holds only while your input distribution looks like the calibration set — this is the loop that keeps it true.
# day 0 — install + baseline python3 tools/lane_install.py mylane@1.0.0 --apply --appliance . python3 tools/lane_repro.py lanes/mylane-1.0.0.lanepack.tar.gz --url http://localhost:8097 # → replays the pack's eval_sample against YOUR instance; every published # number either reproduces or you know before clients do ./smoke.sh && python3 tools/release_gate.py --url http://localhost:8097 # enable the decision log — this is your recalibration corpus ANVIL_LOG=logs/decisions.jsonl # day 1+ — shadow first on risky lanes # run the new lane alongside prod traffic, grade a sample by hand, # THEN point tool calls at it. Delegations are safe by construction; # confident-wrong tool calls are the thing you're de-risking. # weekly — drift watch (5 min, cron-able) # prod runs this daily via cron: /root/mtlm/bin/drift_watch.sh, # appends to logs/drift_status.jsonl, latest in drift_status.json python3 tools/monitor.py logs/decisions.jsonl --baseline 1000 # → exit 1 when the recent window drifts from the first-1000-row # baseline: set_size climbing = coverage aging | delegate share # climbing = input mix shifting # when monitor fires — recalibrate, don't panic-retrain # 1. grade recent logged decisions (route-correct? y/n) into labels.jsonl python3 tools/aci_qhat.py --expert mylane --labels labels.jsonl --alpha 0.05 --dry-run # → adaptive conformal refits qhat on YOUR distribution; usually a # calibration fix, not a retrain — the head still separates fine, # the threshold aged. Drop --dry-run to apply. # 2. if graded accuracy itself collapsed → retrain the head on # logged data + phrasings; the trunk never changes, so a retrain # is hours on CPU, not a GPU project # rollback — lanes are files, not state python3 tools/lane_install.py mylane@0.9.0 --apply --appliance . # → previous version back in seconds; index keeps superseded packs # installable by exact pin for exactly this reason
The paid-lane version of this runbook is the product: a custom lane ships with its calibration already fit to your corpus, and the 12-month window exists because your drift, not our training set, decides when qhat needs refitting.
Hosted API — api.mtlm-router.intrane.fr
The same runtime, metered per decision on the peage rail (EUR). The hosted instance serves all catalog lanes; pin one with "expert".
# free metadata (no wallet) curl -s https://api.mtlm-router.intrane.fr/v1/lanes # installed expert lanes curl -s https://api.mtlm-router.intrane.fr/.well-known/api-catalog # RFC 9727 linkset curl -s https://api.mtlm-router.intrane.fr/pricing # price contract as JSON # free demo (rate-limited, CORS-locked to the landing) curl -s -X POST https://api.mtlm-router.intrane.fr/demo/route \ -d '{"state":"my-session","text":"my card was declined","expert":"bank77"}' # metered: 1 cent = 20 decisions, X-Peage-Wallet header curl -s -X POST https://api.mtlm-router.intrane.fr/v1/route \ -H 'X-Peage-Wallet: pw_…' -d '{"state":"status of ticket INC4821"}'
Wallets: POST https://peage.intrane.fr/v1/wallets (new wallets carry free starter credit). No wallet header → 402. Idempotent bundle charges; signed receipt on every charge. Agent-facing summary: api…/llms.txt.
Verify a deployment
Two self-checks ship with the product — run them against any instance (local, hosted, a client's):
# 12 endpoint+gate checks ./smoke.sh # HOST=... to point at another instance # 19/19 Jev-contract conformance checks HOST=http://localhost:8097 bench/systemone/contract.sh
Sources of truth while running: GET /guide (JSON manual), GET /llms.txt (agent summary), GET /v1/whoami (what this instance loaded).