Languages
This page explains how Covel handles several languages: which language the interface, plugins, world packs, and the prompts sent to the model each use, which file the text is written in, and what is shown when a translation is missing. You need it if you add a language to the app, or if you translate a plugin or a world pack.
First, remember one type. In Covel, every multilingual field that people read uses I18nText:
type I18nText = string | Record<string, string>;It is either a string, or a table of "language → text", for example { "en": "Codex", "zh": "知识图鉴" }.
Rules for locale codes:
- You can use any Unicode language tag that
Intl.getCanonicalLocales()normalizes and that works as a file name, for exampleru,ru-RU,sr-Latn,zh-Hant-TW, anden-u-ca-gregory. - Private or legacy BCP 47 tags (for example
x-elvishandi-klingon) cannot be used in saved data or in files named after a language. - Human-readable text should be given in the target language and in English. English is used as the fallback. Chinese is not required.
- Stable IDs, paths, URLs, icon names, and status values are not translated.
Built-in languages and the extension boundary
The repository ships three interface languages: zh-CN, en-US, and ru-RU.
Interface languages are discovered automatically. The web app scans apps/web/src/i18n/locales/*.json, and the file name is the canonical locale code. The settings validation and options, browser language detection, the language switcher, and the i18next resources are all derived from this scan. So adding a complete interface dictionary (called a catalog below) does not require changes to a TypeScript registry or to components.
LocaleDefinition/defineLocaleRegistry()in@covel/sharedstill provide the framework defaults, the fallback language, and the entry point for host composition.- For a catalog that is not built in, the web app uses
Intl.DisplayNamesto generate its name in each interface language. It uses the uppercase form of the primary language as a short label, and English as the fallback. - Catalogs are loaded dynamically: only the current language and its fallback language are loaded. Not every dictionary is put into the entry bundle.
Automatic discovery happens at build time. Put the JSON into the repository or the release package, then rebuild.
- Plugins installed at runtime do not run code in the browser or in the Electron main process. So they cannot override a core interface catalog that is already built.
- Plugin and world content can use the same safe, normalizable language tags.
- The desktop web interface picks up the new catalog. Electron native menus and startup errors for a language that is not built in fall back to English.
Interface catalogs and prompts sent to the model are two different things. Adding a JSON file translates only the web interface. It does not machine-translate the English or Chinese prompts into the target language. Prompts are chosen by these rules:
- Framework prompts. The file is
prompts/server/<name>.<locale>.md, or a short-name file for the same language and script, for examplegenerate-world.ru-RU.md/generate-world.ru.md. If none is found, the English template is used, and if that is not found either, the main file without a language is used. The English template still clearly tells the model to answer in the language of the current session. - Plugin prompts are instructions, not what the player sees. The body of the main file
PLUGIN.md/RUNTIME.mdmust be in English. Only Simplified Chinese can have another version:PLUGIN.zh.md/RUNTIME.zh.md(a more specific file name such aszh-CNorzh-Hansalso works).- A Simplified Chinese session (
zh,zh-CN,zh-Hans) reads the Chinese file. - Every other language reads the English main file, including
ru-RUand Traditional Chinese (zh-Hant/zh-TW). The model still writes the text the player sees in the session's language. - A Traditional Chinese session does not read the Simplified instructions, because they would push the output toward Simplified characters.
- A file such as
PLUGIN.ru.mdis not read. The loader logs a warning, andpnpm check:promptsreports an error.
- A Simplified Chinese session (
- The Chinese file only needs the body and the
contentofcontributes.prompt. The frontmatter can be empty (---directly followed by---). Omitted fields are inherited from the main file. The structural contract always comes from the main file. ThedisplayName/description/labelin the manifest are labels the player sees. Write them in English in the main file, and put the translation inlocales/<locale>.yaml, not in the Chinese prompt file. - The Chinese file is a translation of the English one. Both files must mention the same set of tool names, injection block tags, and
runtime-inputs.<name>.plugins/prompt-variants.lock.jsonrecords a content fingerprint for each pair: if you change only the English and not the Chinese,pnpm check:promptsfails. After you change both, runpnpm prompts:lockto record them again. - Built-in plugins must have both an English and a Chinese version. Every prompt the model reads (the body of an agent runtime, and the fixed sections of
contributes.prompt) needs a Chinese file, andpnpm check:promptsenforces this. The body of a function runtime is only a note for the author, so it is written in English only. Community plugins need only English. - Templates that plugin code loads (
createPromptLoader(root)from@covel/plugin-handlers-utils/prompts) are instructions too, and the same rule applies:<name>.mdis English and<name>.zh.mdis the Simplified Chinese one. Pass the session's locale; the loader chooses withinstructionLocaleFor(). Files in other languages (such as<name>.ru.md) are never read, andpnpm check:promptsreports them. Existing English/Simplified Chinese pairs under a plugin’sprompts/directory, including subdirectories, participate in the same fingerprint lock. Unpaired standalone templates do not acquire a new translation requirement. Standalone templates are read as complete bodies without parsing manifest frontmatter.PLUGIN.mdandRUNTIME.mdkeep their existing manifest reader. - Plugin templates in
templates/follow the same rules (English body, no prompt files in other languages), but they do not include a Chinese file. A plugin generated withpnpm create-pluginstarts in English, and the Chinese labels are written inlocales/zh.yaml. - Instruction sentences that the framework itself puts into a prompt (the runtime, output language rule, and completion convention in the preamble, correction hints, and the opening user message) follow the same rules. The whole preamble uses only one language, chosen by
instructionLocaleFor()in@covel/sharedas Chinese or English. Other fixed text the code adds to prompts (data block headings and notes, truncation and trimming markers, retry hints) is also instruction text. The rules for it are in Text the code puts into prompts. COVEL_INSTRUCTION_LOCALE=en|zhforces all sessions to use the same instruction language. It is used to compare the effect of the two languages, or to fix one language when a model clearly suits it better. It does not change the content language of a session.- A world's narrative text uses
WORLD.<locale>.md, where<locale>is the full form of that edition of the world (for exampleWORLD.ru-RU.md). If none is found, the world's main fileWORLD.mdis used. - Advanced deployments can point
COVEL_PROMPTS_DIRat a directory of framework prompts that they maintain themselves. It replaces the entire prompt root directory, so it must include every main file the application actually uses.
Plugins installed at runtime can bring and change their own prompts, but they cannot override the framework's core prompts. There are only two ways to override core prompts: add the files to the source or to the release package, or have the deployer explicitly set COVEL_PROMPTS_DIR.
Text the code puts into prompts
Besides the body of the prompt, the framework and plugin code also write some fixed text into prompts: a sentence before and after a data block, a heading, or a correction or retry hint.
The problem: when a session is in Simplified Chinese, the body is in Chinese. If this fixed text were in English, the same prompt would switch languages partway through. Small models are most likely to write their output in English in exactly this situation.
So each piece of text is handled according to what it is:
| What this text is | How it is handled |
|---|---|
| An instruction, or a sentence the model must understand (a note, heading, hint, or a sentence inside a marker) | English and Simplified Chinese versions preserve the same rules, limits and references with natural phrasing. Pick one by the session's instruction language |
Machine markers: XML tag names, bracketed tags such as [RUNTIME] / [retry N], enum values, IDs | Not translated |
| Text that the prompt body quotes literally | Not translated. It is a marker that the bodies in both languages refer to together |
| Tool definitions, and fixed protocol messages, rejection reasons, and parameter validation errors returned to the model in the tool loop | English. The field names and enum values they refer to come from the English tool definitions, and they are the same in every session |
The field names of a tool result (summary, data, staged, error, code, message, hint) and its error codes are not translated. Story content, character names and player-facing text inside data still use the session’s content language. See the tool result envelope.
Framework code chooses the language with instructionLocaleFor(locale). The existing fixed text:
packages/context:- the heading of a world rule,
[World Rule: …]/[世界规则:…]; - the event-emission note at the end of
<available-events>; - the note sentence after a history summary block;
- the opening sentence of the recent pictures message, and the
图片 N/Picture Nlabel before each picture; - the placeholder marker for trimmed history messages;
(none)/(无)and the count note when a block of the runtime's own data is empty;- the sentence in
<runtime-inputs>that "points to the conversation" in place of the turn's body; - the list of character names in
characters.npcsthat have no archive listed.
- the heading of a world rule,
packages/ai-provider: the JSON Schema instruction for text/object requests and Anthropic’s omitted-history opener. Agent and function runtimes selectinstructionLocalewithinstructionLocaleForfor each session;COVEL_INSTRUCTION_LOCALEstill overrides it.packages/shared: the line "some dimensions are not listed" in the dimension projection.packages/runtime:- the note sentence used when a plugin review rejects a draft;
- retry hints;
- markers used when a tool result or a compacted history is truncated;
- the "return only JSON that matches the JSON Schema" rule of an
output.schemaruntime. The model adapter adds this to the system prompt, so every model request carries the session'slocale.
packages/plugin-handlers-utils:- the narrative-person (point of view) requirement and the three correction sentences that a narrative review (
createNarrativeReview) adds to its request; - the list of characters without archives in
characterSheetSegments. It has no session context, so the caller passeslocale: ctx.locale.
- the narrative-person (point of view) requirement and the three correction sentences that a narrative review (
Plugin code uses pickLocaleText(ctx.locale, zh, en) from the SDK. It chooses by instructionLocaleFor(ctx.locale): the Chinese sentence when the result is zh, the English one otherwise.
The hook context has ctx.locale (the session's content language). A hook that adds text to a request chooses the language with instructionLocaleFor(ctx.locale), and adds the Chinese text when the result is zh. The character-read restriction of char-creator works this way. See Hooks.
Bundled plugins that handle text this way include:
- the extraction prompt of
memoryand the heading of its "earlier facts" block; - the "Known NPC relationships" heading of
npc-graph; - the template, default summary sections, summary request and language rule of
history-compaction(the language rule still asks for the summary in the session's language); - the notes and the "truncated" headings of the two dimension blocks in
world-init; - the event notes that
story-eventshands to the narrative; - the music mood note of
soundtrack; - the check receipt note of
tabletop-rules; - the on-stage character block of
scene-stage; - the prompt that
branch-replyuses to regenerate candidates.
Two pieces of text stay in English, because the body quotes them literally:
No hidden story event this turn., whichstory-eventsgives for a turn with no events;Settled tabletop check, which opens the receipt fromtabletop-rules.
The bodies of narrator and chat-mode-narrator, in both languages, use these two pieces of text to recognize the input. Changing them is the same as changing the output of story-event-cue@1 and tabletop-check@1.
NOTE
COVEL_INSTRUCTION_LOCALE applies to instructions that plugin code writes as well: pickLocaleText, instructionLocaleFor and the plugin template loader all read it.
When you add a new piece of text that code writes into a prompt, first decide which row of the table above it belongs to. The Chinese lines in framework source are counted file by file by pnpm check:i18n (FRAMEWORK_CHINESE_LINES in scripts/check-plugin-i18n.mjs). When you add a Chinese version, update this table too.
Language tiers
| Tier | Languages | What it includes |
|---|---|---|
native | English, Simplified Chinese | The interface, the labels and text of built-in plugins, the language versions of built-in worlds, and the instructions the model reads. |
extended | Other languages | The interface, and the part of the text that plugins and worlds provide themselves. The instructions are in English. |
- In a session of an
extendedlanguage, the model reads English instructions and writes the output in that language. Story quality depends on the model. The language selector labels these languages "experimental". - The tier is given by
localeTier()in@covel/shared. It only checks whether the language has its own instruction set. It is not a list. - Traditional Chinese is
extended: it reads English instructions, not Simplified Chinese instructions.
So adding a new language needs only an interface catalog, plus a language version of the content. The number of instruction sets does not grow.
The session language is locked at creation
A session has three languages, and each one controls only one thing:
| Language | When it is set | What it decides |
|---|---|---|
| Interface language | Every render, following the player's current setting | Labels such as buttons, panel titles, and plugin names |
| Content language | When the session is created (session.locale) | World content, the story text and data the model writes, and text in session state |
| Instruction language | Derived from the content language, see the rules in the previous section | Whether the framework instruction sentences and plugin prompts use English or Simplified Chinese |
The content language must be a language version the world actually has. When a session is created, the client passes the player's interface language:
- If the world has a version in that language (a
world.<locale>.yamlorWORLD.<locale>.mdat its root), the session uses it. - If not, the session uses the world's own language (
defaultLocale). The interface stays in the player's language, and the client shows a notice before entering.
This way, you never get a mix where the setting is in one language, the instructions are in a second, and the model is asked to write in a third. Small models go wrong first in exactly this kind of mix. To play a world that has no version in your own language, first use pnpm i18n translate to add one.
The preparation page's world name, description, and world info card use the edition the session will play, following the same content-language rule as session creation. The session's world name, description, and window title select the edition using session.locale; switching the interface language does not change them. Web components read edition text through worldDisplayText(world, locale).
The content language does not change after the session is created.
- Multilingual content in a world pack is resolved to this one language when the session is imported. The session state stores only plain strings.
POST /api/actionsdoes not acceptlocale. Every turn, manual runtime, and background job usessession.locale.- If the player switches the interface language during play, only the labels change. The story keeps being written in the original language.
- To play in another language, create a new session.
An AI-generated new world produces only one language. It generates one body of text, in the requested content language. defaultLocale is the normalized requested language, and the package has no language files. To have other language versions, you must generate matching translations separately. Natural-language values use that content language; YAML/JSON keys, schema keywords, contract IDs, enum/const machine values, and delimiter markers stay unchanged. Revision works the same way: a revised world has only the world's own language, and its other language versions are removed with the whole-package replacement, so they must be translated again. See the revise-world API.
One resolution rule
When the framework and the web interface display text, both must use resolveI18nText(value, locale) from @covel/shared to resolve it. A component must not write its own language check such as startsWith("en"), split("-"), or Object.values()[0].
Resolution order:
- An exact match on the current locale. Case is ignored, and
_and-are treated the same, soen_UScan matchen-US. - The short key for the current primary language, then other region keys of the same language and script. For example,
en-USmatchesen. Languages with different scripts do not fall back to each other. For example,zh-Hantdoes not matchzh/zh-CN. - Fall back in turn through the
fallbackLocalesdeclared in the language registry. The last fallback of the built-in registry is English. - The first usable string in the object.
When no locale is passed, the first usable string in the object is taken directly. This keeps the result deterministic for the server and for old data. Web components should always pass the current i18n.language.
Related functions:
localeLanguage(locale)only extracts the primary language.- To find the candidate names of resource files, you must use
localeLookupCandidates(). - To check whether two locales are compatible, you must use
localesShareLanguageAndScript()or the registry'smatch(). You must not treat two languages as one just because their primary languages are the same, if they use different scripts.
Nested data.
- World dimension values are not translated when they are read. An
x-i18nnode is resolved to a plain string when the session is imported, using the session's content language (resolveWorldDimensionsLocale()). The session state has no "language → text" table. - Other nested data that still carries such a table (for example, panel labels) is resolved uniformly with
resolveI18nDeep(value, locale). It only walks arrays and plain objects recursively. It keeps the input's own data keys, and it does not treat JSON keys such as__proto__as JavaScript prototype operations. When it reaches a "language → text" table, it still hands the table toresolveI18nText, so the rules are exactly the same as in the interface: exact match, same-language match, and English fallback.
Web boundary
Put new interface text in the catalog and display it through t(). pnpm check counts hard-coded JSX text per file; existing counts can only decrease, and a decrease requires lowering the baseline. See check commands for the scope and update command.
Builtin theme names, descriptions, and group labels are Web UI text under appearance.builtinThemes.<id>. Translate them with the rest of a new catalog. Custom themes still supply their own I18nText in the theme package.
Theme validation errors carry a stable code and interpolation parameters. The interface resolves settings.themeImportErrors.<code> instead of exposing parser exceptions or Zod JSON. Unknown import failures use a localized generic message.
API and plugin JSON are untrusted input. The web interface displays them with resolveDisplayText in apps/web/src/lib/i18n-text.ts. It first drops fields that are not strings, and then calls the shared resolution function. This way a nested object is never rendered as a React child.
The existing resolveI18n, text, and textValue are only compatibility entry points. They must also go through this same path.
Keep complete sentences, units, and punctuation in the catalog, interpolating numbers and names. When a noun changes with a count, supply _one / _other in English and _one / _few / _many / _other in Russian, or use a count-invariant label. Pass a numeric count instead of choosing a plural in the component. Format numbers, prices, and dates with the current i18n.language, rather than the operating system default.
Add a language
The example below uses Japanese, ja-JP. A file named after a language can use the full ja-JP, or the short name ja for the same script.
- If you only want to translate a world pack: you do not need to copy the app's catalog. Go straight to step 3.
- If you want players to pick
ja-JPdirectly: add a complete JSON catalog to the Web or release package.
1. Register the app language
Create the catalog. Run
pnpm i18n:add ja-JPto generate a complete template. You can also copyapps/web/src/i18n/locales/en-US.jsonby hand toapps/web/src/i18n/locales/ja-JP.json.- The scaffold uses the target language's
Intl.PluralRulesto fill in the plural forms that English entries marked as plural need. - The file name must be a safe, canonical BCP 47 locale, and it must not duplicate a built-in code or alias (such as
zh,zh-Hans,en, orru). - You do not need to change
i18n/index.ts, the settings schema, or the language switcher.
- The scaffold uses the target language's
Translate only the values. All base keys, JSON types, array lengths, and
{{ interpolation }}placeholders stay as they are. Any base text that contains{{count}}can have the i18next plural keys the target language needs, for example_few/_manyfor Russian. The plural forms the scaffold generates are only English placeholder values, so translate them too.Run
pnpm check:i18n. The checker verifies:- the file name, and whether it conflicts with a built-in alias;
- whether its base structure matches
en-US; - whether all plural forms exist in the target language for the entries that English marks as plural;
- the interpolation variables in all plural variants;
- the keys the code actually uses;
- whether the fallback text written in code as
t("key", "English")/defaultValueis identical, character for character, to the text of that key in theen-UScatalog. When you change English wording, change both places (apps/web/scripts/check-fallback-text.mjs).
Test and build. Run
pnpm --filter @covel/web testandpnpm --filter @covel/web build. After the build,ja-JPappears automatically in Settings, the home top bar, and the onboarding. It is matched automatically when the browser language isja/ja-JP.Other translations, optional.
- Framework prompts:
<name>.ja-JP.md/<name>.ja.md. - World narrative text:
WORLD.ja-JP.md. World language files are named with the full locale of that edition. When the world has aja-JPedition, a language-only name such asWORLD.ja.mdis not accepted. - Translations for the Electron native menu.
If any of these are missing, they fall back by the rules above. This does not stop the Web interface catalog from working. Plugin prompts do not need a Japanese version. A Japanese session reads the English body, and writes its output in Japanese.
- Framework prompts:
Framework content that cannot be translated yet must fall back clearly to English. It must not fall back to Chinese, and you must not change stable fields, enum values, or business contracts to make it work.
2. Localize a plugin
The text in a plugin falls into four kinds:
| Text | Who reads it | Where it is written | Supported languages |
|---|---|---|---|
Labels: displayName, description, the label / description of commands and settings, title, summary, the about of author information, the label of links and the placeholder of action inputs | The player, following the interface language | The main manifest is written in English. The translation is in locales/<locale>.yaml | Any |
Interface text: panel names, buttons, and hints in a UI spec (ui/*.json), and badges used only for display in the interface | The player, following the interface language | The spec and code are written in English. The translation is in messages in locales/<locale>.yaml | Any |
| Session text: hints and natural-language values that a handler or tool writes into state or into the model's context | The player and the model, following the session's content language | The code is written in English. translate(ctx, ...) resolves it to one language | Any |
Instructions: the prompt body and the fixed sections of contributes.prompt | The model | The main file is written in English. Simplified Chinese is in PLUGIN.zh.md / RUNTIME.zh.md | Only English and Simplified Chinese |
So to add Japanese to a plugin, you need only one file: locales/ja.yaml.
plugins/dice-check/
├── PLUGIN.md # English: contract, labels, prompt
├── PLUGIN.zh.md # Simplified Chinese prompt (optional; a built-in plugin's agent must have one)
├── locales/
│ ├── zh.yaml # Chinese labels
│ └── ja.yaml # Japanese labels
└── runtimes/check/
├── RUNTIME.md
└── RUNTIME.zh.mdThis directory tree only shows where each file goes. *.zh.md is needed only when the model reads that body.
locales/ja.yaml is divided into sections by manifest file. Each section holds only the translations, and its keys are the same as in the main manifest:
PLUGIN.md:
displayName: ダイス判定
description: 危険を伴う行動を判定します。
contributes:
commands:
- name: roll # the list is matched by id / name / key
description: ダイスを振る。
runtimes/check/RUNTIME.md:
description: 危険な行動を一つ判定します。
messages: # UI spec text: English source → translation
Checks: 判定
No checks yet.: まだ判定はありません。The result:
- The loader compiles these translations into the manifest and the UI spec, and the interface shows them in the player's current language. A label with no translation shows English.
- A Japanese session reads the English prompt, and writes its output in Japanese. A Japanese prompt is not needed, and must not be provided.
A label file cannot touch the contract. It can only translate the label fields listed in the table above. If it contains runtime.schedule, the tool list, prompt content, or an entry that is not in the main manifest, that entry is ignored with a warning, and pnpm validate:plugin reports it as an error.
Do not write inline multilingual tables in the main manifest or the UI spec (displayName: { zh: …, en: … }). Validation reports this as an error too.
How text in code chooses a language. It is resolved one entry at a time, using the session's content language, with the same rules as interface labels: exact locale → same language and same script → the English original. If a dictionary for an exact locale is incomplete, a missing entry still looks for a translation in the same language. An incomplete dictionary does not cause other usable translations to be lost.
How text in code is written. Text that handlers, guards, tools, RPC commands, and extension-point handlers produce is also written in English only, and its translations also go into messages. The host passes the translations to the code as ctx.messages. @covel/plugin-handlers-utils provides two functions that read them:
import { labelText, translate } from "@covel/plugin-handlers-utils";
// One text in the session language: a hint for the player, a heading placed in a prompt, or data injected into a prompt
translate(ctx, "World time: {display}", { display: clock.display });
// One copy per language the plugin carries: badges and status names drawn only by the interface. The interface picks by the player's interface language
labelText(ctx, "Critical success"); // { en: "Critical success", zh: "大成功" }- The English must be a constant string. The parts that change are passed in as arguments, using
{name}placeholders. The validator finds "source text that no longer appears in the code" by reading the source. It cannot read strings assembled at runtime, and it reports an error for them. - Data injected into a prompt (
io.selfData) usestranslate, notlabelText.labelTextcontains all languages, and the model would read all of them. - When there is no translation, the English original is returned. This also holds when the context has no
messages(an old host, or a context written by hand in a test). Both functions behave this way. - When the interface and the code use the same English sentence but translate it differently, add an attribute-name prefix to the interface entry (
label|Completed: 已完成). The entry without a prefix belongs to the code (Completed: 完成). pickLocaleText(locale, zh, en)is used only for instructions that code writes for the model (it chooses by instruction language, see above), for example the check rules that the dice plugin injects into the narrative prompt, or a note sentence at the end of a prompt section (see Text the code puts into prompts). Like prompt bodies, instructions come in only two versions: English and Simplified Chinese.- In tests, use
loadPluginMessages(pluginRoot, locale)from@covel/plugin-test-utilsto get thectx.messagesthat the host would pass in.
What happens when the source text changes. messages uses the English original as its key. When the original changes, the old translation no longer matches: the interface falls back to English, and pnpm validate:plugin reports that translation as an error. When the same sentence needs two translations, add an attribute name before the key, such as shortLabel|Codex. For the full rules, see UI panels · Plugin UI text rules.
3. Localize a world pack
The main file is written in one language only, the defaultLocale of world.yaml:
defaultLocale: en-US
name: My World
summary: An example world.Then, next to each file you want to translate, add a language file that holds only the translation. Once the root has world.ja-JP.yaml or WORLD.ja-JP.md, the world has a ja-JP edition; you do not register it anywhere else:
worlds/my-world/
├── world.yaml
├── world.ja-JP.yaml # Japanese for name / summary / attribute names and other labels
├── WORLD.md
├── WORLD.ja-JP.md # replaces the whole body
├── characters/
│ ├── characters.json
│ └── characters.ja-JP.json # matched by id, holds only the fields that were translated
└── data/
├── dimensions.yaml
├── dimensions.ja-JP.yaml
└── rules/
├── core.yaml
└── core.ja-JP.yaml# world.ja-JP.yaml
name: 私の世界
summary: サンプル世界です。Merge rules:
- A source points only at the main file, for example
characters/characters.json; a language file is not a source of its own. - When a
ja-JPsession is created, the importer merges the translation incharacters.ja-JP.jsononto the main file. - A field that was not translated keeps the main file's value. Structure, IDs, and numbers always come from the main file. So you can translate a little at a time, and copying a whole file will not make it drift from the main file.
- For a nested list made of objects, the source can declare stable keys (such as
label) withlocaleArrayKeys. Once a translation keeps this key, it is not affected by insertion or reordering. - Structural fields such as media hashes and paths should be written only in the main file.
For the full merge rules, see World Data.
When a plugin lacks a language
A missing language does not stop a plugin from being installed or enabled. Each layer falls back separately:
| Layer | What happens when the plugin lacks this language |
|---|---|
| Labels and interface text | The same language in another region, then a translation outside the package (see below), and finally English. |
| Text that code writes into state and context | The same as above. |
| Instructions (prompt body) | Uses the version the plugin has: a Simplified Chinese session reads *.zh.md, and everything else reads English. |
| Content the model generates during play | Not affected. It is decided by the framework's output language instruction. |
The languages a plugin has are decided by its files. They do not need to be declared:
- Each extra
locales/<locale>.yamladds one text language. - Each
*.zh.mdnext to the manifest adds Chinese instructions.
This summary appears in three places:
The output of
pnpm validate:plugin <dir>, for example:textlanguages: text en, zh; instructions en, zhThe
languagesfield of the plugin list endpoint. The session-prep screen uses it to show "Not translated" next to plugins that have no text in the current interface language.The response of
POST /api/sessions:pluginsWithoutLocalelists the plugins that are enabled but have no text in the session's language. The session is still created as normal.
Translations outside the package
Translations do not have to live in the plugin package. The translation directory (COVEL_USER_TRANSLATIONS_DIR, by default $COVEL_HOME/translations) can hold plugins/<pluginId>/<locale>.yaml. Its format is exactly the same as a plugin's own locales/<locale>.yaml:
~/.covel/translations/
└── plugins/
└── dice-check/
└── ja.yamlBoth sources use the same directory:
- Translation packs: translations that someone else made for several plugins. Put them in the translation directory using the structure above.
- Local machine translation:
pnpm i18n translate <plugin-dir> --locale ja --to ~/.covel/translations. The translation is written to the translation directory, not into the plugin package.
Rules:
- Priority: translations the author ships with the plugin → the translation directory → English. The translation directory only fills in entries the author did not translate. When an entry is in both places, the author's version is used.
- Labels and
messagesonly, never instructions. An instruction translation that nobody has reviewed is the same as an unreviewed change in behavior. So only the*.zh.mdinside the plugin package is accepted. - When translations go stale.
messagesuses the English original as its key. When a plugin update changes the original, the old translation no longer matches on its own. Labels match by position, and the translation directory has nolock.json. So after a plugin changes the original of a label, the old translation keeps showing. Runtranslate --toagain, or update it by hand. - Restart after a change. The server reads the translation directory at startup.
WARNING
Install only translation packs you trust. Text in the translation directory is the same kind of input as text in a world pack: labels are shown to the player, and code text in messages enters the model's context.
4. Translation tools
Plugin translation tools share the loader’s plain YAML reader. They reject engine directives such as ---js and ---javascript before parsing and cannot execute code through manifests. Runtime directories follow the package layout.
pnpm i18n works on a plugin directory (one with PLUGIN.md) or a world directory (one with world.yaml):
pnpm i18n status plugins/dice-check # how much each language is translated
pnpm i18n status worlds/emberback --locale zh-CN --missing # list each entry that is still missing
pnpm i18n extract plugins/dice-check --locale ja # print a YAML file to fill in
pnpm i18n translate worlds/emberback --locale zh-CN # use the configured model to fill in the missing parts
pnpm i18n lock plugins/dice-check # confirm that label translations match the current English
pnpm i18n translate plugins/dice-check --locale ja --to ~/.covel/translations # write translations outside the packageThe output of the first command looks like this:
plugins/dice-check [zh] labels 6/6 messages 14/14For a world directory, status must be given --locale <tag>.
| Command | What it does |
|---|---|
status | Counts coverage for each language. A plugin has two parts: labels (the name and description in the manifest) and messages (interface and code text). A world is listed by file. --missing lists each missing or stale entry. |
extract | Prints the entries a plugin lacks in one language as YAML. The values are TODO: <English>. After you fill them in, merge them into locales/<locale>.yaml. |
translate | Asks the model to translate missing and stale entries, and writes them into the language file. It reads llm.toml and the keys in the environment the same way the server does. --slot picks the slot, and --dry-run only lists what would be translated. Each translation is checked to make sure its placeholders are kept as they are. A translation that fails is asked once more, and then reported if it still fails. |
lock | Records in locales/lock.json the "English source that each label translation corresponds to". |
How staleness is detected.
messagesuses the English original as its key. When the original changes, the old translation no longer matches, and it shows as "missing" naturally. Nothing else needs to be recorded.- Manifest labels match by position (
displayName,contributes.commands[name=bag].description). When the English changes, the old translation is still "in place". Solocales/lock.jsonrecords which English sentence each translation was made from.statusandpnpm check:i18nuse it to find stale translations. - After you edit a translation by hand, run
pnpm i18n lock.translateupdates it automatically when it writes.
Which world data counts as text. World data has no schema that marks which strings are text, so the tool judges by appearance:
- In a world written in Chinese, Japanese, or Korean: a string that contains those characters counts as text.
- In a world written in other languages: a string that is not under an identifier field (
id,type,kind, ...) and that contains a space or starts with a capital letter counts as text. - Trigger-word lists named
keysalways count as text, including lowercase words such as[echo, signal]. - For other plain text lists, such as
aliases, if one item is text, the whole list is translated.
This is a reference for translators, not a gate. The language files that are written are checked by pnpm validate:world. It reports translations that have no place to go, and changes to values that are not text.
When a rule or lorebook entry translates its text but omits its trigger words, validation gives a locale-trigger-keys warning. Add keys to that entry in the language file, using words a player would write in that language. Translating other text in the file again preserves existing trigger-word lists, even when their length differs from the source.
The author name (author.name) and the license (license) in world.yaml are exceptions. They are shown unchanged in every language, and they do not count as text to translate. If you write translations for them in a language file, manifest validation fails.
Dimension files are also an exception. Their schema says which values are text:
- a dimension's
name,description, andupdateRule; - a node's
titleandx-enumLabels; - values on schema nodes marked
x-i18n: true.
IMPORTANT
A value without x-i18n is not localized, and it appears in its original form in a session of any language. status reports how many values read like text but cannot be translated. To make narrative initial values such as geography, factions, or history translatable, first add x-i18n: true to the matching natural-language string leaf. Mark these leaves even for single-language creation; the mark does not itself generate multilingual values. Do not mark stable IDs, enum/const machine values, code, numbers, or booleans.
One name has only one translation.
translatetranslates identical source text only once within an ordinary batch; lore with hidden boundaries is processed one fragment at a time.- When translating a world, names are translated first (short text in fields such as
name,title, andaliases). Then the translated names are carried as a glossary into every later batch. - Translations that the language already has are reused as terms too. So when you fill in an incomplete version, existing names are not changed.
Translating hidden lore. The program retains markers and requests visible and hidden fragments separately. Hidden lore is excluded from shared glossary extraction. Fragments still undergo placeholder checks, and model-inserted boundary markers are rejected. If a fragment ultimately fails, no partial translation of that document is written. See World Data for lore boundaries. This does not automatically verify facts, references, quantities, or the semantics of hidden information.
Translating a world in the app. When a world has no version in the player's interface language, the world detail page offers "Translate to {{language}}". After the player confirms, the server runs the same process as pnpm i18n translate (POST /api/worlds/:id/translate). It writes the language files into the world package, and the world then has an edition in that language. This only works for world packages in the user's world directory. The player must confirm first, because it makes model calls.
Model translations are drafts. Personal names, place names, worldbuilding terms, and tone need a person to review them. The Chinese in bundled plugins is enforced by pnpm check:i18n to be complete and up to date. World packs have no such enforcement.
5. Validation
pnpm check:i18n
pnpm --filter @covel/shared test -- i18n
pnpm --filter @covel/plugin-world-init test
pnpm --filter @covel/web test -- i18n-text execution-timelineYou should also actually switch to the new language and check these places: the Settings page, the world selection page, session creation, the plugin panel, the Agent's prompts, and the WorldData import result.
pnpm check:i18n finds these kinds of problems within its scope: Chinese, Japanese, or Korean text that is not wrapped, an I18nText that lacks an English fallback, and a key missing from any Web catalog. It does not check translation quality.
Plugin example
The main manifest is written in English only:
# PLUGIN.md
displayName: Dice Check
description: Resolves risky actions with dice checks.The Chinese goes in a language file:
# locales/zh.yaml
PLUGIN.md:
displayName: 骰子判定
description: 为有失败风险的行动提供骰子判定。id is the stable plugin ID. The plugin list, the settings navigation, and the execution timeline all show displayName resolved for the current language. They show id only when displayName is missing.