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.

endpointwhat it doesbody
POST /v1/routethe dispatcher: gate → expert head → noul/score → optional execute{state|messages, context[], expert?, options[], min_conf?, min_gate_conf?, execute?}
POST /v1/decideroute head only{state|messages, context[]}
POST /v1/noulescalation yes/no head{state}
POST /v1/scorecomplexity tier 1–4{state}
POST /v1/assessdecide+noul+score in one forward pass{state}
POST /v1/systemoneJev-compatible typed questions (noul|choice|score), no generation, no head files needed{state, model?, questions:{qid:{type,instructions,criteria}}}
POST /v1/chat/completionsgenerative path, OpenAI-compatible, SSE stream{messages, model?, stream?}
POST /v1/feedbackopen submit — idempotent on id, 16KB cap, 30/min/ip{message, kind?, context?, reporter?, id?}
GET /v1/feedbackadmin read (valid ANVIL_KEYS bearer), ?limit=n—
GET|POST /_qhatlive conformal recalibration — read or set per-lane qhat, no restartPOST {expert, qhat}
GET /metricsPrometheus text: per-ep counters, per-lane requests/delegates/set_size, latency, uptime—
GET /v1/whoamimasked key identity + tenant head + expert lanes—
GET /v1/modelsOpenAI-style model list—
GET /guide · /llms.txtthe embedded operator manual (JSON) / agent plain-text summary—
GET /_health · /healthliveness—
GET /livez · /readyzk8s liveness (always 200) / readiness (503 until a head or lane is loaded)—
GET /v1/lanesper-lane status: routes per head, qhat, minconf, gate domains—
POST /_shutdowntoken-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

signalwherewhat it tells you
anvil_requests_total{ep,action}/metricstraffic split — tool_call vs delegate rate
anvil_set_size_sum/count{lane}/metricsrising set size = calibration aging, before labels arrive
anvil_decision_latency_ms_sum/count/metricsmean decision latency
decisions.jsonlANVIL_LOGper-decision audit: state, action, conf, reason, set_size
tools/monitor.pyin the tarballbaseline-vs-recent window drift check, exit 1 on drift
feedback.jsonlANVIL_FEEDBACK_LOGproduction 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.

lanep50p95p99mean
bank77 (51 routes)171ms265ms302ms177ms
clinc (151 routes)182ms272ms311ms178ms
civic_fr (12 routes)185ms264ms300ms177ms

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