SKILL.md
---
name: fcode-i18n
description: Workspace locales and translations for Factorial Code — i18n/<locale>.yaml files, the fcode.i18n helper in code and in form schemas, execution-locale selection, per-call locale overrides, primary-locale fallback, locale versioning, and the i18n:* CLI commands. Use when adding a locale, internationalizing process code or form text, rendering several languages in one execution, or testing and syncing translations.
license: MIT
metadata:
category: factorial-code
---
# Factorial Code — i18n
A workspace keeps one **locale** per language it speaks: a YAML file of
translation keys under `i18n/`, synced by the CLI like any other resource.
`fcode.i18n("key")` resolves a key against the locale the current execution is
using — in process code, in module code, and (substituted server-side) in form
schemas. Platform model in `fcode-core-concepts`; CLI flow in `fcode-cli`; form
embedding in `fcode-forms`.
## Gotchas
- **Never alias `fcode.i18n`** — translations are only shipped to an execution
when `fcode.i18n(` is statically detected in the source, so
`const t = fcode.i18n; t("k")` throws **"i18n is disabled"** at runtime.
Always call it literally, with the key as a hardcoded string (same class of
rule as `fcode.import` module names).
- **The helper never fails.** A key with no translation anywhere resolves to
**the key itself** — a raw `greetings.hello` in output means a missing
translation, never a broken run. A placeholder you pass no argument for is
left exactly as written; a non-object `args` (including arrays) is ignored.
- **Locale identifiers are case-sensitive** (`pt-BR` ≠ `pt-br`) and this is
permanent platform-wide. Valid: up to 20 letters, numbers, `-` or `_`,
starting with a letter or number (`^[A-Za-z0-9][A-Za-z0-9_-]{0,19}$`) — no
dots. On a case-folding filesystem (macOS/Windows defaults) the CLI refuses
to pull two locales differing only in case, since their files would collapse
into one.
- **Never edit `i18n/<locale>.inherited.yaml`** — read-only, gitignored,
regenerated on pull. To override an inherited key, write it into your own
`i18n/<locale>.yaml`: overrides layer **key by key**, never file by file, so
keys you don't mention keep resolving to the parent's text.
- **`locale` is a reserved name** on form and webhook endpoints, like
`version_tag` and `async`: it selects the language and is stripped before
the parameters are built. A form field or webhook body field named `locale`
never reaches the process — use another name for business data.
- **Form-token arguments must be a flat object of scalars** —
`{ "max": "500" }` works, `{ "max": { "chars": "500" } }` does not; a token
with nested braces is left untouched in the served schema.
- **A mistyped `version` tag resolves against the current files silently** —
it never blanks output. When released text looks un-frozen, check the pinned
tag before anything else. Same shape for a per-call `{ locale }` naming a
locale that doesn't exist: the lookup behaves as if no locale was named.
- **A pinned `version` must be an inline literal string** — the snapshot to
ship is read from the source, so `{ version: chosenTag }` resolves against
the current files. The `locale` option has no such rule: its value may be any
runtime expression, and options built elsewhere still work.
- **A YAML key written without a value counts as untranslated** — it falls
through to the fallback locale rather than resolving to an empty string.
## Locale files
One YAML mapping of keys to text per locale, at `i18n/<locale>.yaml`. Nesting
is a convenience for whoever writes the file, not a data model: nested keys are
addressed with dots, so these two files are the same locale —
```yaml
# i18n/en.yaml
greetings:
hello: "Hi %{name}"
farewell: "See you"
```
```yaml
# identical to the file above
"greetings.hello": "Hi %{name}"
"greetings.farewell": "See you"
```
— which is what lets a child workspace override a single key without repeating
the parent's structure. `%{name}` placeholders are filled from the arguments
passed to the helper. A locale file is capped at 256 KB (the whole merged set
travels with each execution).
What the workspace **inherits** sits alongside what it owns, in
`i18n/<locale>.inherited.yaml` — read-only, gitignored (the CLI adds the
entry). When several parent workspaces define the same locale, the inherited
file holds their merge in the platform's resolution order, rewritten as a flat
mapping of dotted keys under a generated header; with a single parent the file
is kept verbatim, comments included. A local run layers your own file over it
exactly as the cloud does.
## The `fcode.i18n` helper
`fcode.i18n(key, args, options)` — same name and semantics in JavaScript and
Python, available in processes **and** modules (a process reaching it only
through a module still gets its translations):
```javascript
const greeting = fcode.i18n("greetings.hello", { name: "Ada" }); // "Hi Ada" in `en`
fcode.i18n("greetings.farewell"); // no placeholders → no args
fcode.i18n("legal.terms", null, { version: "v1.0.0" }); // pinned to a published version
fcode.i18n("greetings.hello", { name: "Ada" }, { locale: "es" }); // another locale, this lookup only
const recipient = { name: "Ada", locale: "pt-BR" }; // ...and the value may be dynamic,
fcode.i18n("greetings.hello", { name: recipient.name }, { locale: recipient.locale }); // per recipient
fcode.i18n("legal.terms", null, { version: "v1.0.0", locale: "es" }); // both combine
const locale = fcode.i18n.locale; // the execution's locale
```
```python
greeting = fcode.i18n("greetings.hello", {"name": "Ada"})
fcode.i18n("greetings.farewell")
fcode.i18n("legal.terms", None, {"version": "v1.0.0"})
fcode.i18n("greetings.hello", {"name": "Ada"}, {"locale": "es"})
recipient = {"name": "Ada", "locale": "pt-BR"}
fcode.i18n("greetings.hello", {"name": recipient["name"]}, {"locale": recipient["locale"]})
fcode.i18n("legal.terms", None, {"version": "v1.0.0", "locale": "es"})
locale = fcode.i18n.locale
```
- `version` — a locale version tag or alias, as an **inline literal string**
(see Gotchas); versioning below.
- `locale` — reads **that lookup** in another locale, and the value may be
dynamic (an employee's language), so one execution can speak several
languages. A key the named locale hasn't translated falls back to the
execution's locale, then the primary; a locale that doesn't exist behaves as
if none was named — never worse than without the option. `fcode.i18n.locale`
keeps reporting the execution's locale. Form-schema tokens do **not** take
this option — a render is already in the language the request chose.
- Interpolation is a **single pass over own properties**: a substituted value
containing `%{...}` is never rescanned (one argument can't reach another),
and `%{constructor}` resolves nothing. Missing keys and arguments never
throw (see Gotchas).
## Translating form schemas
Form schemas are rendered by the browser, so there is no runtime to resolve
keys in. Write the same call **as a string** in `parametersSchema.json` — in
titles, descriptions, `ui:placeholder`, `embedFormOptions.loadingOverlayContent`,
any visible text — and the platform substitutes it **before serving the
schema**. The browser receives a schema already written in one language;
translations never reach the client. Substitution runs after `preRenderProcess`,
so text a pre-render injects is translated too.
```json
{
"type": "object",
"properties": {
"reason": {
"type": "string",
"title": "fcode.i18n(\"form.reason.label\")",
"description": "fcode.i18n(\"form.reason.help\", { max: \"500\" })"
}
}
}
```
Arguments follow relaxed JavaScript syntax (single quotes, unquoted field
names, trailing commas all accepted) but must stay a **flat object of
scalars** — a nested value leaves the whole token unsubstituted (see Gotchas).
The reader's locale comes from the embed: `locale` in the embed options or the
`data-fcode-form-locale` attribute, sent to the platform as the `Fcode-Locale`
header. Changing it refetches the schema, and the submit carries the same
header, so the execution runs in the language the form was rendered in.
Embedding mechanics in `fcode-forms`. (`fallbackLocale` in the embed options
plays no part here — it only selects the language of rjsf's built-in
validation messages when `locale` isn't one it ships.)
## How the execution locale is chosen
| Trigger | How to choose |
|---|---|
| Form | `?locale=` query parameter or `Fcode-Locale` header (parameter wins) |
| Webhook | `?locale=` query parameter or `Fcode-Locale` header (parameter wins) |
| Run now | Locale selector in the run dialog |
| Schedule | Locale selector when creating or editing the schedule |
| Rerun | Reuses the original execution's stored locale |
| Per call | `locale` in the helper's options — that lookup only, value may be dynamic |
A malformed locale on the public endpoints is a `400`; an unknown-but-valid
one merely falls back. The chosen locale is stored on the execution, which is
why a rerun reproduces the original run's language even if the workspace's
default has moved since.
When nothing names a locale, the workspace's **primary locale** is used —
`primaryLocale` in `settings.json` (set it under Settings → Details, or edit the
file and `fcode settings:push`; field reference in `fcode-cli`) — and when none is
chosen, the first locale alphabetically. The primary locale is **also the
key-level fallback**: a key the chosen locale hasn't translated resolves from
the primary, and only a key missing from both resolves to its own name. So a
partially translated locale still resolves every key — but keep the primary
complete.
## CLI: syncing and testing locales
Locales are a CLI resource like any other:
```sh
fcode i18n:pull # fetch every locale, inherited ones included
fcode i18n:status # what changed locally vs the cloud
fcode i18n:add pt-BR # track a new local file
fcode i18n:push # create or update in the cloud
fcode i18n:remove pt-BR # stop tracking it locally
fcode i18n:reset # discard local changes
```
- Aggregate `fcode pull` / `push` / `status` include locales, so the usual
whole-workspace commands already cover them.
- **There is no extract command** — moving hardcoded strings into locale files
is the agent's job (next section); the CLI only syncs the files.
- **Pushing an identifier a parent workspace owns creates an override here**,
layered key by key — it never edits the parent's file.
- `primaryLocale` lives in `settings.json` and syncs with `fcode settings:push`.
Local runs resolve `fcode.i18n` against the same `i18n/` files, layered
exactly as the cloud does, so `fcode run my-process` behaves like production.
Pass `--locale` to run in a specific one:
```sh
fcode run my-process --locale pt-BR
```
`--locale` is not format-validated locally: a typo silently matches no file
and every key resolves to itself. If a local run shows raw keys, check the
flag's spelling (and case) first.
## Internationalizing existing code
There is no automated extraction — internationalizing a workspace is a code
transformation you perform, with the CLI as the sync vehicle:
1. **Agree scope with the user**: which locales, and which is primary. If
unset, write `primaryLocale` in `settings.json` and `fcode settings:push`.
2. **Inventory the user-facing strings.** Translate: form-schema titles,
descriptions, placeholders and `loadingOverlayContent`; result `message`
strings a form displays (these are markdown — keep any formatting like
`**bold**`, links or table syntax intact in every locale); email subjects
and bodies; webhook response bodies end users see. Do **not** translate:
log messages, developer-facing errors, datastore keys, variable names,
slugs and identifiers.
3. **Name the keys** in dotted namespaces: `<process-slug>.<area>.<name>`
(`order-sync.form.title`, `order-sync.email.subject`), with strings shared
across processes under `common.*`. Extract dynamic parts as
`%{placeholders}` — never concatenate translated fragments.
4. **Replace each string**: in code with a literal
`fcode.i18n("key", { args })` call (never aliased, key hardcoded); in
schemas with the token string (flat scalar args only).
5. **Populate `i18n/<locale>.yaml` for every locale** (`fcode i18n:add` for
new ones). The primary locale must cover every key — it is the fallback all
the others lean on.
6. **Test per locale**: `fcode run <slug> --locale <loc>`. A raw dotted key in
the output is a missing translation; a literal `%{name}` is a missing
argument.
7. **Push**: `fcode i18n:push` (or aggregate `fcode push`), plus
`fcode settings:push` if `primaryLocale` changed.
## Versioning locales
Locales are versioned like processes and modules (model in
`fcode-core-concepts`): publishing snapshots the YAML under an immutable tag,
and aliases are movable pointers — but **per locale**: `production` on `en`
and `production` on `es` are two different aliases.
A pinned call (`{ version: "v1.0.0" }`) resolves **per file** in the
inheritance chain: each locale file answers with its snapshot at that tag, or
with its current content when it has no snapshot at that tag, layered key by
key as usual. So a locale created after the version was cut still contributes
its keys, a parent that never published the tag still contributes its text,
and a mistyped tag resolves everything against the current files rather than
blanking output. `{ version: "v1.0.0", locale: "es" }` combine: the snapshot is
read in the requested locale, with the same fallbacks. Deleting a version sends
the calls pinned to it back to the current files; deleting a locale deletes its
versions with it.
One layer resolves differently: a **parent workspace pinned to one of its
versions** answers with its snapshot at the pin, and falls back to that snapshot
rather than to its current file when the call names a tag it never published.
Its live translations never reach the child — see `fcode-core-concepts`.
**A workspace version freezes translations with the release.** Creating one
(`fcode settings:versions:create`, see `fcode-cli`) publishes a version of every
owned locale — locales first, so the pins below have a target — and rewrites
the **published snapshots** so bare `fcode.i18n` calls pin the tag, in process
code, module code, and form schemas:
```javascript
// Working copy (never modified)
fcode.i18n("greetings.hello", { name: "Ada" });
fcode.i18n("welcome", { name: "Ada" }, { locale: "es" });
// Published v1.0.0 snapshot
fcode.i18n("greetings.hello", { name: "Ada" }, { version: "v1.0.0" });
fcode.i18n("welcome", { name: "Ada" }, { version: "v1.0.0", locale: "es" });
```
A call whose options already name a `version` — even a dynamic one — is
considered intentional and left untouched; options naming none (only a
`locale`, an empty object, an explicit `null`/`None`) get the tag spliced in,
so a localized call freezes with the release while its locale stays as
written — a dynamic value (`{ locale: employee.locale }`) is preserved
verbatim too.
(Note the asymmetry with module imports, which are pinned in the string form —
`fcode.import("m", "v1.0.0")` — for compatibility with older executors; don't
"fix" one to look like the other.) Fixing a released typo means publishing
again — snapshots are immutable.
## REST API, SDKs & MCP tools
Locales are addressed by identifier, and `PUT` upserts, so a sync never needs
to know whether the workspace already had the locale:
```
GET /{team}/rest/locales
GET /{team}/rest/locales/{locale}
PUT /{team}/rest/locales/{locale} body: { "content": "<yaml>" }
DELETE /{team}/rest/locales/{locale}
```
Both SDKs expose the same surface as `FcodeI18n`:
```javascript
import { FcodeI18n } from "@factorialco/fcode-sdk";
const i18n = new FcodeI18n();
await i18n.list(); // inherited included
await i18n.set("pt-BR", 'greetings:\n hello: "Olá %{name}"\n');
await i18n.delete("pt-BR");
```
```python
from fcode_sdk import FcodeI18n
i18n = FcodeI18n()
i18n.list()
i18n.set("pt-BR", 'greetings:\n hello: "Olá %{name}"\n')
i18n.delete("pt-BR")
```
`set()` on an identifier a parent workspace owns creates an override here —
the same key-by-key layering the CLI push does.
The MCP server exposes `get_locales`, `get_locale`, `save_locale` and
`delete_locale`. **`save_locale` replaces the locale's content entirely** — to
add keys, `get_locale` first and write back the merged YAML.