UNDER APPROVAL Microdesign v1.1 — 6 May 2026. Canonical document for the multilingual subsystem of Metnos. Aligned with ADR 0092 (prompt-as-data + auto-bilingual alignment) and the three layers already live in code: runtime/prompts/<lang>/<role>.j2 (MiniJinja, 26 roles), runtime/i18n.sqlite (118 keys), 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 v1.1 — 6 May 2026
Aligned with ADR 0092 (prompt-as-data) 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 have been present from day one; from September 2026 the system must accept a third language (French, Spanish, German) without code changes.

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 26 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. ~52 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). 118 standard keys + 79 descriptions = 197 entries × 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
    … (26 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"). If the requested language is missing, the loader falls back to the source language (typically it) and logs a prompt.lang_fallback event.

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:gemma4-26b",
      "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, fallback to IT with logged event.

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/myclaw/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. This prevents half-translated fragments from showing up in production while human review is still in progress.

Mandatory human review. The nightly daemon writes candidates in _pending/, it does not promote. metnos-prompts review <role> --lang=fr shows the diff; metnos-prompts mark-synced <role> --lang=fr promotes the candidate to runtime, but only if validation passes (placeholder match, MiniJinja syntax, length ratio within [0.6, 2.0]).

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 (Gemma 4 26B 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