Internationalization
A workspace keeps one locale per language it speaks. Each locale is a YAML file of translation
keys, and fcode.i18n("key") resolves a key against the locale the current execution or form
render is using.
Locales live under Internationalization in the sidebar. Adding one asks only for its
identifier — en, es, pt-BR, whatever you already call that language — and drops you into the
editor to write the translations.
Writing translations
Section titled “Writing translations”A locale is a YAML mapping of keys to text. Nesting is a convenience for whoever writes the file, not a data model: nested keys are addressed with dots, so these two are the same locale.
greetings: hello: "Hi %{name}" farewell: "See you"requests: approved: "Your request was approved"# identical to the file above"greetings.hello": "Hi %{name}""greetings.farewell": "See you""requests.approved": "Your request was approved"That means a workspace can override a single key from a parent without repeating its structure.
%{name} placeholders are filled in with the arguments you pass to the helper.
Using translations in a process
Section titled “Using translations in a process”const greeting = fcode.i18n("greetings.hello", { name: "Ada" });// "Hi Ada" when running in `en`async function main() { fcode.log(fcode.i18n("greetings.hello", { name: fcode.context.parameters.name })); fcode.log(fcode.i18n("greetings.farewell"));}def main(): logger.info(fcode.i18n("greetings.hello", {"name": fcode.context.parameters.name})) logger.info(fcode.i18n("greetings.farewell"))The helper never fails. A key with no translation anywhere resolves to the key itself, so a
missing translation shows up as greetings.hello in your output instead of breaking the run. A
placeholder you pass no argument for is left exactly as written.
A third argument holds options: locale reads the key in another locale (see
overriding the locale per call), and version reads it from a
published version of the locale (see versioning translations).
Modules can call fcode.i18n too — a process that only reaches it through one of its modules still
gets its translations.
Overriding the locale per call
Section titled “Overriding the locale per call”The execution’s locale is decided before the run starts — see choosing the locale. A call can override it for that lookup only by naming a locale in the options, and the value can be computed at runtime, so one execution can speak several languages:
for (const employee of employees) { fcode.log(fcode.i18n("greetings.hello", { name: employee.name }, { locale: employee.locale }));}for employee in employees: logger.info(fcode.i18n("greetings.hello", {"name": employee["name"]}, {"locale": employee["locale"]}))The fallback chain never gets worse than without the option: a key the named locale has not
translated resolves as the call would have without it — the execution’s locale first, then the
primary one — and a locale that does not exist behaves as if none was named. fcode.i18n.locale
keeps reporting the execution’s locale; the option is per lookup and changes nothing else.
The name is matched the same way the request’s own locale is (see regional
variants): { locale: "en-gb" } reads your en locale when you have no
en-gb one, so passing a viewer’s locale straight through works without a file per region.
The platform reads your source to decide whether the other locales’ translations ship with the
execution at all. Writing the option inline in the options object, as in the examples above, is
what it looks for — and when a call’s options are built elsewhere and passed as a variable, it
ships the locales anyway, since it cannot see inside. Either way the option works; inline just
keeps the payload smaller for scripts that never override the locale. version is stricter: its
value decides which snapshot ships, so a version passed as a variable cannot be resolved and
that call reads the current files.
Using translations in a form
Section titled “Using translations in a form”Form schemas are rendered by the browser, so there is no runtime to resolve tokens in. Write the same call directly in the schema and the platform substitutes it before serving the schema:
{ "type": "object", "properties": { "reason": { "type": "string", "title": "fcode.i18n(\"form.reason.label\")", "description": "fcode.i18n(\"form.reason.help\", { max: \"500\" })" } }}The browser receives a schema already written in one language; translations never reach the client.
Choosing the locale
Section titled “Choosing the locale”Which locale an execution or render uses depends on how it was triggered:
| Trigger | How to choose |
|---|---|
| Form | Fcode-Locale header or ?locale= query parameter |
| Webhook | Fcode-Locale header or ?locale= query parameter |
| Run now | Locale selector in the run dialog |
| Schedule | Locale selector when creating or editing the schedule |
| Rerun | Reuses the original execution’s locale |
| Per call | locale in the helper’s options — wins for that lookup only |
When nothing names a locale, the workspace’s primary locale is used — and when the workspace has not chosen one, its first locale (alphabetically) is. So translations work as soon as you create a locale, without having to configure anything. The query parameter wins over the header, so a caller that can only set a URL can override a header a proxy added.
Regional variants
Section titled “Regional variants”A caller usually names the viewer’s locale, which is a regional variant more often than not:
en-gb, pt-BR, fr-CA. You do not need a file for each one. An identifier that no locale matches
is shortened one segment at a time until one does, so en-gb reads your en locale, and
zh-Hans-CN tries zh-Hans before zh. Only when nothing it shortens to exists does the primary
locale take over.
Every step that matches contributes its keys, closest first — so you can publish an en-gb locale
holding just the handful of words that differ, and leave the rest to en.
Shortening also ignores case as a last resort: if nothing matches en-GB by name, a locale called
en-gb answers it. It never goes the other way, though — asking for pt does not reach a
pt-BR locale, because there is no way to tell which region you meant.
The same shortening applies wherever a locale is named: the header, the query parameter, the run
and schedule selectors, fcode run --locale, and the per-call option.
curl "https://code.factorialhr.com/platform/api/my-team/webhooks/my-process?locale=pt-BR"# orcurl -H "Fcode-Locale: pt-BR" "https://code.factorialhr.com/platform/api/my-team/webhooks/my-process"The primary locale
Section titled “The primary locale”Set the workspace’s main language under Settings → Details → Primary locale. It does two things:
- it is the locale used when a caller names none;
- it is the fallback for keys the chosen locale has not translated yet.
Leaving it unset is fine: the workspace’s first locale, alphabetically, is used for both. Choosing one explicitly matters once you have more than one locale and the alphabetically-first is not the language you want to default to.
So a partially translated es still resolves every key: what es translates comes from es, and
the rest comes from the primary locale. Only a key missing from every locale in play resolves to its
own name.
Inheriting locales
Section titled “Inheriting locales”Locales inherit from parent workspaces like modules and team variables do. A locale a parent defines is listed here as read-only, with the parent’s name next to it.
Writing a locale whose identifier a parent owns creates an override in this workspace, and the override is layered key by key — not file by file. Keys the override does not mention keep resolving to the parent’s text, so a child workspace can change one greeting without copying the parent’s whole file.
Because both files stay live, both stay in the list: an overridden locale appears twice, once as this workspace’s file and once as the parent’s, and clicking either opens that file. Deleting the override leaves the parent’s text resolving on its own again.
The CLI keeps both files too. Your own locale is i18n/<locale>.yaml, and what you inherit is
i18n/<locale>.inherited.yaml, read-only and kept out of git — one file, holding the merge of every
parent workspace that defines the locale. A local run layers them exactly as the cloud does.
Versioning translations
Section titled “Versioning translations”Locales are versioned like processes and modules are. Publishing a version snapshots the YAML file under a tag, and a snapshot is immutable: the same tag can never be published twice, so a call pinned to it always resolves the same text.
Publish one by creating a workspace version, which snapshots every locale along with the processes and modules. Select that version in the sidebar to read the file as it was published, read-only.
Pinning a version in the helper
Section titled “Pinning a version in the helper”Pass the version in the helper’s third argument, the options object:
fcode.i18n("legal.terms", null, { version: "v1.0.0" });fcode.i18n("greetings.hello", { name: "Ada" }, { version: "production" });fcode.i18n("legal.terms", None, {"version": "v1.0.0"})fcode.i18n("greetings.hello", {"name": "Ada"}, {"version": "production"})The same options work in a form schema, where they are resolved at render time like the rest of the token:
{ "title": "fcode.i18n(\"form.reason.label\", null, { version: \"v1.0.0\" })" }A version name is a tag or an alias. A workspace alias
such as production is propagated to every locale that has the target tag, and repointing it changes
what every call pinned to that name resolves, without touching any code.
Both options combine: { version: "v1.0.0", locale: "es" } reads the es text as it was published
at v1.0.0, with the same fallback chain a pinned call has for keys that locale’s snapshot does not
translate.
What a pinned call resolves
Section titled “What a pinned call resolves”Resolution is per file. Each locale file in the inheritance chain answers with its snapshot at that tag, or with its current content when it has no snapshot at that tag, and the result is layered key by key as usual. So:
- a locale created after the version was cut still contributes its keys;
- a parent workspace that never published the tag still contributes its text;
- a mistyped tag resolves everything against the current files, rather than blanking your output.
Deleting a version sends the calls pinned to it back to the current files. Deleting a locale deletes the versions you published manually with it. The versions a workspace version contains survive: the locale is kept as deleted, out of the list and of the current resolution but still answering the calls pinned to one of those versions, until the last workspace version containing it is deleted. Saving the same locale again revives it, with those versions as its history.
Freezing a release
Section titled “Freezing a release”Creating a workspace version publishes a version of every
locale and rewrites the snapshots it publishes so that fcode.i18n calls pin that tag — in process
code, in module code and in form schemas. A release therefore ships with its translations frozen,
exactly as it ships with its modules frozen, and fixing a released typo means publishing again.
A call that names a locale is frozen too: its options get the version spliced in, while the locale
stays exactly as written — { locale: employee.locale } becomes
{ version: "v1.0.0", locale: employee.locale }, still dynamic, now reading the release’s
snapshots. Only a call that already names a version (or whose options are not written as an
object literal) is left alone.
Your working copy is never rewritten: calls there keep resolving against the current files.
Syncing with the CLI
Section titled “Syncing with the CLI”Locales are a CLI resource like any other. They live in i18n/<locale>.yaml, with what the
workspace inherits alongside them in i18n/<locale>.inherited.yaml:
fcode i18n:pull # fetch every locale, inherited ones includedfcode i18n:status # what changed locally vs the cloudfcode i18n:add pt-BR # track a new local filefcode i18n:push # create or update in the cloudfcode i18n:remove pt-BR # stop tracking it locallyfcode i18n:reset # discard local changesfcode pull and fcode push include locales, so the usual whole-workspace commands already cover
them. The primary locale lives in settings.json as primaryLocale and syncs with fcode settings:push.
Local runs resolve fcode.i18n against the same files, so fcode run my-process behaves like the
cloud does. Pass --locale to run in a specific one:
fcode run my-process --locale pt-BRManaging locales from the API
Section titled “Managing locales from the API”Locales are part of the public API and both SDKs. They are addressed by their identifier, and PUT
creates or replaces, so a sync does not have to know whether the workspace already had the locale.
GET /{team}/rest/localesGET /{team}/rest/locales/{locale}PUT /{team}/rest/locales/{locale}DELETE /{team}/rest/locales/{locale}import { FcodeI18n } from "@factorialco/fcode-sdk";
const i18n = new FcodeI18n();
await i18n.list();await i18n.set("pt-BR", 'greetings:\n hello: "Olá %{name}"\n');await i18n.delete("pt-BR");from fcode_sdk import FcodeI18n
i18n = FcodeI18n()
i18n.list()i18n.set("pt-BR", 'greetings:\n hello: "Olá %{name}"\n')i18n.delete("pt-BR")