TESTED Microdesign — aligned with the code. Canonical document for the multilingual subsystem of Metnos. Covers the prompt-as-data + auto-bilingual alignment pattern and the three layers live in code: runtime/prompts/<lang>/<role>.j2 (MiniJinja, 32 roles), the message database i18n.sqlite, and the executor TOML manifests with [description].<lang> table + companion file manifest.lang_state.json.
← Documentation index Microdesign › multilang

Metnos

multilang — three layers, latest-wins source-of-truth, add-language
Microdesign — aligned with the code.
Covers the prompt-as-data pattern and the modules
runtime/prompt_loader.py, runtime/i18n.py, runtime/i18n_translator.py, runtime/admin/prompts_cli.py.

Audience: those adding a new language to Metnos,
those reviewing translation candidates, those auditing cross-lang coherence.
Reading time: 14 minutes.

Table of contents

  1. Scope and boundaries
  2. The three multilingual layers
  3. Source-of-truth: latest-wins
  4. add-language workflow
  5. METNOS_LANG vs dynamic source-of-truth
  6. Edge case: manual retro-translate
  7. Future extensions
  8. CLI reference

1. Scope and boundaries

This document defines how Metnos speaks more than one language: where strings live, who can edit them, how a change in one language propagates to the others, how a new language is added. Italian and English are both present; the system must accept a third language (French, Spanish, German) without code changes.

LLM prompts prompts/<lang>/*.j2 executor descriptions manifest + JSON user messages i18n.sqlite
Figure 1 — The three multilingual layers: LLM prompts, executor descriptions and user-facing messages; no canonical language, the latest editor wins.

The document covers:

It does not cover, deferring elsewhere:

2. The three multilingual layers

Every string Metnos utters, or that an LLM reads as part of its instructions, lives in one and only one of the three layers below. The separation is by nature of the string, not by convenience: no string inhabits two layers.

Layer What it contains Storage Volume
Layer 1 — LLM prompts The text that instructs the planner, vaglio, describe, classify, the synt 5-stage pipeline. MiniJinja templates with variables ({{ tools }}, {{ history }}). runtime/prompts/<lang>/<role>.j2 32 roles × N langs
Layer 2 — manifest description Executor descriptions and affinity keywords. They are the tool prompt for the planner (CLAUDE.md §2.5). [description].<lang> table inside the TOML manifest; companion file manifest.lang_state.json for version_hash + source_hash per language. ~79 executors × N langs
Layer 3 — user-facing messages ERR_*, WARN_*, MSG_*, LOG_* (errors, warnings, confirmations, channel notifications). Executor descriptions migrate here at runtime read. SQLite ~/.local/share/metnos/i18n.sqlite; table i18n(key, lang, text, needs_translation, source_lang, version_hash). standard keys + migrated descriptions × N langs

Layer 1 — LLM prompts

The runtime/prompts/ directory has a sub-tree per language. Each role (planner, vaglio_judge, describe_entries, …) is a .j2 file with the same structure in every language: same variables, same section skeleton, only localised text.

runtime/prompts/
 it/
 planner.j2
 vaglio_judge.j2
 describe_entries.j2
 … (32 files)
 en/
 planner.j2 # same skeleton, EN text
 …
 _pending/
 planner.j2.candidate # candidate awaiting review
 fr/ # optional, after metnos-prompts add-language fr
 …

The loader runtime/prompt_loader.py is called by every runtime component as prompt_loader.get(role, lang, **vars). Language resolved as os.environ["METNOS_LANG"], fallback config.DEFAULT_LANG (currently "it"). Per-file resolution chain (§K, ADR 0173): live → _pending candidate (used in-vivo) → EN. If the requested language lacks a file, the loader uses the daemon's candidate if present, otherwise falls back to EN (the meantime default) — never a crash. No manual promotion required.

Layer 2 — executor manifest descriptions

The TOML manifest of an executor (executors/<name>/manifest.toml) has the [description] section as a per-language table, plus a companion JSON manifest.lang_state.json in the same folder tracking who is source and when.

# manifest.toml
[description]
it = """
Cerca file su filesystem per pattern e finestra temporale.
…
"""
en = """
Find files on the filesystem by pattern and time window.
…
"""

[affinity]
it = ["cerca", "trova", "file", "ricerca", "…"]
en = ["find", "search", "files", "lookup", "…"]
// manifest.lang_state.json (companion file)
{
 "lang_state": {
 "it": {
 "version_hash": "a3f9...",
 "source_hash": null, // it = current source
 "source_lang": null,
 "translated_by": null,
 "reviewed_at": "2026-04-22T10:30Z"
 },
 "en": {
 "version_hash": "7c1b...",
 "source_hash": "a3f9...", // EN derived from IT hash a3f9
 "source_lang": "it",
 "translated_by": "wise:qwen3.6-35b-a3b",
 "reviewed_at": "2026-04-22T11:05Z"
 }
 }
}

At run time the planner reads the description in the current language (METNOS_LANG) via loader.describe(name, lang); if the language is missing, explicit fallback to EN (§K, ADR 0173), otherwise the first available language.

Layer 3 — user-facing messages in i18n.sqlite

The runtime/i18n.py module exposes i18n.get(key, lang=None, **vars) with {name}-style interpolation. Keys follow the convention SCOPE_DOMAIN_DETAIL: ERR_FILE_NOT_FOUND, WARN_CAP_REACHED, MSG_APPROVAL_GRANTED, LOG_TURN_ENDED.

Executor descriptions are all migrated to the DB with prefix EX_DESCRIPTION_<name> and EX_AFFINITY_<name>; the manifest loader reads them from the DB, not the TOML. The TOML remains authoritative for new executors; the DB imports them on first load.

3. Source-of-truth: latest-wins

No language is canonical by construction. Whoever edits last (in any language) becomes source for the others.

For each multilingual resource — a prompt, a description, an i18n key — we keep two hashes:

Re-alignment trigger

When an editor (human or batch) changes the text of language L1 of a resource, version_hash for L1 changes. This implicitly invalidates the other languages L2, L3, … whose source_hash equals the old version_hash of L1: they are no longer in sync. The nightly daemon i18n_translator.run_loop picks them up as pending and generates a new candidate in _pending/.

EditedImmediate effectFollowing night
prompts/it/planner.j2 New version_hash(it). source_hash(en) is now stale. The translator regenerates prompts/en/_pending/planner.j2.candidate from the new IT. metnos-prompts review planner --lang=en shows the diff.
prompts/en/planner.j2 (direct EN edit) New version_hash(en). EN becomes current source: source_lang=en for IT. The translator now considers EN as source. If source_hash(it) is stale, it regenerates prompts/it/_pending/planner.j2.candidate.
i18n.set("MSG_APPROVAL_GRANTED", "fr", "…") Only the fr row of that key changes. No trigger on other languages: the change is local to fr.
Why latest-wins and not IT-canonical? By discipline: the first draft is in Italian because Metnos was born in IT, but the editorial quality of a string can come from any language. We do not want a small EN refactor overwritten the next night by a stale IT. The source is always the latest editor, always.

Deterministic comparison (no LLM in the critical path)

The version_hash vs source_hash comparison is deterministic (CLAUDE.md §7.9). The LLM intervenes only to generate the translation candidate; when to regenerate and which is the source is pure code, no prompt.

4. add-language workflow

The full flow to add a new language is a single command:

$ metnos-prompts add-language fr
Lingua 'fr' bootstrap completato.
 - prompts/fr/ creata (vuota, daemon notturno generera' candidate)
 - i18n.sqlite: fr bootstrap rows pending
 - Manifest description: il daemon scansionera' al prossimo cycle

Per triggerare manualmente la traduzione subito:
 /opt/metnos/deploy/run_prompts_translator.sh

Per attivare la lingua: METNOS_LANG=fr nei systemd unit + restart

Four effects, all idempotent:

  1. Layer 1: creates an empty runtime/prompts/fr/. The nightly daemon will fill it with candidates from the source language (default it; can be overridden with --source-lang en if EN has become the most up-to-date language).
  2. Layer 2: no immediate action. On each cycle the daemon scans TOML manifests with missing [description].fr table and generates candidates inside the manifest.lang_state.json (fr key) marked needs_review=true.
  3. Layer 3: i18n_cli.add-lang fr populates the i18n table with a placeholder for every existing key, needs_translation=1, source_lang=it. The daemon translates them in batches.
  4. Audit: writes an entry in ~/.local/share/metnos/multilang/audit.jsonl with timestamp, ISO code, chosen source_lang.

The language stays inactive until METNOS_LANG=fr is set in the runtime systemd units and the daemon is restarted. Until a string is translated, the runtime falls back to EN (the meantime default), never to broken fragments.

No approval gate (§K, ADR 0173). The nightly daemon writes candidates in _pending/ and the loader uses them in-vivo: nobody reviews hundreds of strings by hand. Review is opt-inmetnos-prompts review <role> --lang=fr shows the diff, metnos-prompts mark-synced <role> --lang=fr canonicalizes the candidate to <role>.j2 (re-enabling the prescriptive linter §6.1) — but it is not a prerequisite for the translation to take effect.

5. METNOS_LANG vs dynamic source-of-truth

METNOS_LANG is the process read language: which language the planner sees as prompts, which language appears in user messages. It is a static variable, resolved when the systemd units boot, constant for the entire process lifetime.

The latest-wins source-of-truth (§3) is a per-resource dynamic attribute: for resource A it may be IT, for B EN, for C FR — it depends on who edited last. The translator daemon uses the source-of-truth to decide what-to-translate-from-what, independently of METNOS_LANG.

AspectMETNOS_LANGLatest-wins source-of-truth
GranularityProcess-globalPer resource (prompt / description / key)
When resolvedAt systemd unit bootOn every edit, on every daemon cycle
EffectLanguage shown to user / fed to LLMLanguage to regenerate candidates from
ChangeEdit unit + restartAutomatic: every edit triggers hash recalc

6. Edge case: manual retro-translate

It happens that an editor prefers to write a new version of a prompt directly in the secondary language (e.g. EN), then wants to bring the change back to IT. Latest-wins does this automatically: when the EN edit lands, version_hash(en) becomes new, EN becomes the current source, and on the following night the IT candidate is regenerated in prompts/it/_pending/<role>.j2.candidate.

The same review/mark-synced commands work symmetrically: metnos-prompts review planner --lang=it shows the diff, mark-synced planner --lang=it promotes. Translation direction is encoded in the companion JSON's source_lang, not in the command verb.

Retro-translate is opt-in in the sense that it requires a direct edit of the secondary-language file. There is no separate retro-translate command: the symmetry of the mechanism makes it implicit. The constraint is do not edit both languages simultaneously with divergent changes: the last save wins and the other loses (with log multilang.simultaneous_edit_warning).

7. Future extensions

Layer 4 — chat HTML rendering templates

Today the Jinja2 templates in channels/templates/ (approval card, dialog form, turn summary) are IT only. Layer 4 will replicate the layer 1 discipline with a sub-tree channels/templates/<lang>/ and the same latest-wins mechanics. Estimate: 8-10 templates, 2-3 hours of porting once the third language arrives.

Opt-in frontier translation

The --quality {wise,frontier} flag of metnos-prompts translate and translate-all lets you choose the LLM tier. wise is the default (Qwen 3.6 35B-A3B local, free); frontier uses Anthropic Opus 4.7 at ~$0.015/call. The difference shows on long prompts and on roles (e.g. vaglio_judge) where semantic nuance is high. Documented in the CLI man page as conscious opt-in: no silent use of an external provider.

Non-LTR and RTL languages

Arabic, Hebrew and Persian require RTL in channel HTML rendering (layer 4 when it lands) and care with dir="rtl" markup in Jinja2 templates. Layers 1-3 are RTL-agnostic: strings are just text, the channel is responsible for rendering.

8. CLI reference

CommandEffect
metnos-prompts listPrompt × lang table + size + last commit.
metnos-prompts show <role> [--lang=it]Final prompt render with stub vars.
metnos-prompts validateMiniJinja syntax lint + invariant check.
metnos-prompts translate <role> [--to=en] [--quality=wise|frontier]One-shot: translate IT → --to; saves candidate in _pending/.
metnos-prompts translate-all [--to=en] [--quality=wise|frontier]Batch: translate every missing.j2.
metnos-prompts sync-statusMtime/lag/candidate table per role.
metnos-prompts review <role> [--lang=en]Shows diff + validation of a candidate. Does not promote.
metnos-prompts mark-synced <role> [--lang=en]Promotes _pending to runtime if validation OK.
metnos-prompts validate-cross-langVerifies placeholder + syntax + length ratio cross-lang.
metnos-prompts add-language <code> [--source-lang=it]Bootstrap a new language (layer 1+3, layer 2 on next cycle).

The central install manifest (install/manifest.toml) does not list languages: they are persisted data, not capabilities. Adding FR does not modify systemd units nor the manifest, only METNOS_LANG=fr when activating it.

References