Factorial Code — i18n
A workspace keeps one
locale per language it speaks: a YAML file of
translation keys under
, synced by the CLI like any other resource.
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
; CLI flow in
; form
embedding in
.
Gotchas
- Never alias — translations are only shipped to an execution
when 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 module names).
- The helper never fails. A key with no translation anywhere resolves to
the key itself — a raw 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 (including arrays) is ignored.
- Locale identifiers are case-sensitive ( ≠ ) 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
: overrides layer key by key, never file by file, so
keys you don't mention keep resolving to the parent's text.
- is a reserved name on form and webhook endpoints, like
and : it selects the language and is stripped before
the parameters are built. A form field or webhook body field named
never reaches the process — use another name for business data.
- Form-token arguments must be a flat object of scalars —
works,
{ "max": { "chars": "500" } }
does not; a token
with nested braces is left untouched in the served schema.
- A mistyped tag resolves against the current files silently —
it never blanks output. When released text looks un-frozen, check the pinned
tag before anything else.
- 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
. 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.
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 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
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"})
locale = fcode.i18n.locale
- The only option today is — a locale version tag or alias; see
versioning below.
- Interpolation is a single pass over own properties: a substituted value
containing is never rescanned (one argument can't reach another),
and resolves nothing.
- Missing key → the key itself; missing argument → placeholder left as
written. Nothing here ever throws while translations were shipped — the one
exception is calling through an alias (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
— in
titles, descriptions,
,
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
,
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. A missing key
resolves to the key itself, visible but never form-breaking.
The reader's locale comes from the embed:
in the embed options or the
attribute, sent to the platform as the
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
. (
in the embed options
plays no part here — it only selects the language of rjsf's built-in
validation messages when
isn't one it ships.)
How the execution locale is chosen
| Trigger | How to choose |
|---|
| Form | query parameter or header (parameter wins) |
| Webhook | query parameter or 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 |
A malformed locale on the public endpoints is a
; 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 —
in
(set it under Settings → Details, or edit the
file and
; field reference in
) — 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 / / 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.
- lives in and syncs with .
Local runs resolve
against the same
files, layered
exactly as the cloud does, so
behaves like production.
Pass
to run in a specific one:
sh
fcode run my-process --locale pt-BR
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:
- Agree scope with the user: which locales, and which is primary. If
unset, write in and .
- Inventory the user-facing strings. Translate: form-schema titles,
descriptions, placeholders and ; result
strings a form displays; 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.
- Name the keys in dotted namespaces:
<process-slug>.<area>.<name>
(, ), with strings shared
across processes under . Extract dynamic parts as
— never concatenate translated fragments.
- 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).
- Populate for every locale ( for
new ones). The primary locale must cover every key — it is the fallback all
the others lean on.
- Test per locale:
fcode run <slug> --locale <loc>
. A raw dotted key in
the output is a missing translation; a literal is a missing
argument.
- Push: (or aggregate ), plus
if changed.
Versioning locales
Locales are versioned like processes and modules (model in
): publishing snapshots the YAML under an immutable tag,
and aliases are movable pointers — but
per locale:
on
and
on
are two different aliases.
A pinned call (
) 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. Deleting a version sends the calls pinned to it back to the
current files; deleting a locale deletes its versions with it.
A workspace version freezes translations with the release. Creating one
(
fcode team:versions:create
, see
) publishes a version of every
owned locale — locales first, so the pins below have a target — and rewrites
the
published snapshots so bare
calls pin the tag, in process
code, module code, and form schemas:
javascript
// Working copy (never modified)
fcode.i18n("greetings.hello", { name });
fcode.i18n("legal.terms");
// Published v1.0.0 snapshot
fcode.i18n("greetings.hello", { name }, { version: "v1.0.0" });
fcode.i18n("legal.terms", null, { version: "v1.0.0" }); // None in Python
Calls already passing options are considered intentional and left untouched.
(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
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
:
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")
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
,
,
and
.
replaces the locale's content entirely — to
add keys,
first and write back the merged YAML.