Skip to content
Local environment Preproduction — not production data

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.

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.

i18n/en.yaml
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.

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"));
}

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.

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 }));
}

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.

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.

Which locale an execution or render uses depends on how it was triggered:

TriggerHow to choose
FormFcode-Locale header or ?locale= query parameter
WebhookFcode-Locale header or ?locale= query parameter
Run nowLocale selector in the run dialog
ScheduleLocale selector when creating or editing the schedule
RerunReuses the original execution’s locale
Per calllocale 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.

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.

Terminal window
curl "https://code.factorialhr.com/platform/api/my-team/webhooks/my-process?locale=pt-BR"
# or
curl -H "Fcode-Locale: pt-BR" "https://code.factorialhr.com/platform/api/my-team/webhooks/my-process"

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.

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.

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.

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" });

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.

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.

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.

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:

Terminal window
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

fcode 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:

Terminal window
fcode run my-process --locale pt-BR

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/locales
GET /{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");