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.
multilang — three layers, latest-wins source-of-truth, add-languageThis 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:
metnos-prompts add-language <code> and its effects;METNOS_LANG (process boot resolution) and source-of-truth (latest-wins, dynamic);It does not cover, deferring elsewhere:
runtime/i18n.py;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 |
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.
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.
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.
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:
version_hash — SHA-256 of the current text in its language;source_hash — the version_hash of the source language at the time of the last alignment. null if the language is itself the current source.
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/.
| Edited | Immediate effect | Following 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. |
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.
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:
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).[description].fr table and generates candidates inside the manifest.lang_state.json (fr key) marked needs_review=true.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.~/.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.
_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]).
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.
| Aspect | METNOS_LANG | Latest-wins source-of-truth |
|---|---|---|
| Granularity | Process-global | Per resource (prompt / description / key) |
| When resolved | At systemd unit boot | On every edit, on every daemon cycle |
| Effect | Language shown to user / fed to LLM | Language to regenerate candidates from |
| Change | Edit unit + restart | Automatic: every edit triggers hash recalc |
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 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).
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.
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.
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.
| Command | Effect |
|---|---|
metnos-prompts list | Prompt × lang table + size + last commit. |
metnos-prompts show <role> [--lang=it] | Final prompt render with stub vars. |
metnos-prompts validate | MiniJinja 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-status | Mtime/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-lang | Verifies 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.
indices_image pattern: the same instrument-object + domain-qualifier scheme applies to indices_text for multilingual semantic search (future).get_inputs: dialogues require process-language LLM prompts for the final_message_hint.get_inputs + on_complete: same prompt loader, same languages.runtime/prompt_loader.py, runtime/i18n.py, runtime/i18n_translator.py, runtime/admin/prompts_cli.py, runtime/admin/i18n_cli.py.