Translations everywhere: one capability, every surface
Everything you can do with translations in Forumulate, and where you do it: the REST API, the JavaScript SDK (client.translations), the CLI (forumulate translations, alias forumulate i18n), the agent (MCP) server and the console. Every row works on every surface. A test enforces this, so a capability can't ship on one surface and skip another.
All machine translation uses your organization's own provider: your DeepL key, or your connected AI provider for content-library entries. There is no platform key. Until you connect one, every machine-translate call answers 409 ai.unavailable. forumulate translations auto on a space checks for a key before it translates anything, so you get that answer once rather than once per piece.
The four states
Every translatable piece is reported in the same four states, whatever kind of piece it is:
State | Meaning |
|---|---|
| No translation exists in that language yet. |
| A translation exists, but readers aren't served it. |
| Readers are served it, and it was made from the current original. |
| Readers are served it, but the original changed after it was made. |
Where the translation lives depends on the kind of piece:
Docs page (also legal, FAQ and other page-backed spaces): a language twin, which is a page of its own. Declaring a language seeds an unpublished twin of every page. The twin's id is the
idin its cell, and you read and write its text through…/items/{id}. A legal edition's translation is its twin too, and it keeps the original's edition and acceptance record.Blog post, forum topic, changelog release: stored under the original's id at
…/{blog|forum|changelog}/{id}/translations/{locale}.Status incident: stored at
…/status/incidents/{id}/translations/{locale}. Incident translations have no draft state. One is either published or outdated.Content-library entry: a locale twin holding the entry's localized fields.
The SDK, the CLI and the MCP tools work out the kind from the space's type, so you address the original piece and the language, and never the door.
The matrix
SP is /orgs/{orgId}/spaces/{spaceId} and LIB is /orgs/{orgId}/content/libraries/{libraryId}.
Capability | REST (canonical) | SDK | CLI | MCP tool | Console |
|---|---|---|---|---|---|
Declare, rename and remove languages (space or library) |
|
|
|
| Studio → Settings → Translations; Content → library → Languages |
Coverage (piece × language → state) |
|
|
|
| Studio → Settings → Translations |
Read one translation | page: its twin via |
|
|
| The piece's editor → Translations |
Write or correct one by hand | page: |
|
|
| The piece's editor → Translations |
Remove one | page: |
|
|
| The piece's editor → Translations |
Machine-translate (your key) |
|
|
|
| Studio → Settings → Translations → Translate (one piece, or every missing or outdated one) |
Correct a piece's declared language |
|
|
|
| The piece's inspector |
A library entry's locale twin |
|
|
|
| Content → entry → Languages |
Space and collection names |
|
|
|
| Studio inspector / Collection settings |
Chrome words (labels, strings, per-locale |
|
|
|
| Studio → Settings → Translations; Sites |
Interface string overrides and the public catalog |
|
|
|
| Organization settings → Text customization |
Reader language switcher |
|
|
|
| Organization settings; a space's Look |
Your translation provider key |
|
|
|
| A docs space's studio → Settings → For developers and agents |
The agent server has a named tool for all the translation work. For the settings rows it uses call_api: the agent server doesn't mirror every settings route as its own tool, and call_api reaches each of them with the same permissions.
Two reader-side features aren't authoring capabilities, so they aren't rows here. A forum reader can translate a topic or post on the fly with POST /public/orgs/{orgId}/spaces/{spaceId}/forum/t/{slug}/translate, using the organization's provider. The docs editor's helper, POST /orgs/{orgId}/spaces/{spaceId}/pages/{pageId}/ai/translate, translates a document you send it and writes nothing.
Every route by name
Coverage: GET /orgs/{orgId}/spaces/{spaceId}/translations
Languages: GET /orgs/{orgId}/spaces/{spaceId}/docs/locales, POST /orgs/{orgId}/spaces/{spaceId}/docs/locales, GET /orgs/{orgId}/content/libraries/{libraryId}/locales
A page: GET /orgs/{orgId}/spaces/{spaceId}/pages/{pageId}/translations
A post, topic or release: GET /orgs/{orgId}/spaces/{spaceId}/blog/{topicId}/translations, PUT /orgs/{orgId}/spaces/{spaceId}/blog/{topicId}/translations/{locale} (and the same under
forum/{topicId}andchangelog/{releaseId})An incident: GET /orgs/{orgId}/spaces/{spaceId}/status/incidents/{incidentId}/translations
A piece's own text and language: GET /orgs/{orgId}/spaces/{spaceId}/items/{itemId}, PATCH /orgs/{orgId}/spaces/{spaceId}/items/{itemId}
An entry's twin: GET /orgs/{orgId}/content/libraries/{libraryId}/entries/{entryId}/locales
Names: GET /orgs/{orgId}/spaces/{spaceId}
Chrome: GET /orgs/{orgId}/sites/{siteId}/i18n/strings
Overrides: GET /orgs/{orgId}/translation-overrides
Switcher: GET /orgs/{orgId}/language-switcher
Provider: GET /orgs/{orgId}/deepl
Machine translation: one address
Machine translation used to have three names: …/auto on posts and releases, …/machine on incidents, and …/ai/translate on content entries. Pages had no machine-translate route at all. The canonical address is now the same for every kind:
Kind | Canonical | Older address, kept as an alias |
|---|---|---|
Docs page | POST /orgs/{orgId}/spaces/{spaceId}/pages/{pageId}/translations/{locale}/auto | none (this route is new) |
Blog post | POST /orgs/{orgId}/spaces/{spaceId}/blog/{topicId}/translations/{locale}/auto | none (already canonical) |
Forum topic | POST /orgs/{orgId}/spaces/{spaceId}/forum/{topicId}/translations/{locale}/auto | none |
Changelog release | POST /orgs/{orgId}/spaces/{spaceId}/changelog/{releaseId}/translations/{locale}/auto | none |
Status incident | POST /orgs/{orgId}/spaces/{spaceId}/status/incidents/{incidentId}/translations/{locale}/auto | POST /orgs/{orgId}/spaces/{spaceId}/status/incidents/{incidentId}/translations/{locale}/machine |
Library entry | POST /orgs/{orgId}/content/libraries/{libraryId}/entries/{entryId}/translations/{locale}/auto | POST /orgs/{orgId}/content/libraries/{libraryId}/ai/translate (batch of up to 25) |
Every canonical route takes the same body:
force: truereplaces a translation a person wrote. Without it, that request is refused with a 409.published: truepublishes the result at once. By default the result is a draft that a person reads first.
There are two exceptions. An incident has no draft state, so its route does not take published: sending it is a 422. A library entry's machine translation is always a draft, so published: true is a 422 there too, and force changes nothing: the result is written into the entry's draft twin either way. The aliases keep their own bodies (replace on …/machine), and the incident's canonical route accepts replace too. Nothing that worked before has stopped working.
From the CLI
forumulate login
# Declare exactly these languages (add, rename; --prune also removes)
forumulate translations locales set es=Español fr --space docs
forumulate translations locales add de --library site # labelled "Deutsch"
forumulate translations locales relabel es "Español (LatAm)" --space docs
forumulate translations locales remove de --library site --yes
forumulate translations locales list --space docs
# Coverage, and a CI gate: exit 1 if anything Spanish is missing or outdated
forumulate translations status --space docs
forumulate translations status --space docs --locale es --fail-on missing,outdated
# Round trip through files (one Markdown file per piece per language)
forumulate translations pull --space docs --out translations --include-missing
forumulate translations push translations --space docs --dry-run # the diff
forumulate translations push translations --space docs # idempotent
# Machine-translate what is missing or outdated, with your own DeepL key
DEEPL_API_KEY=… forumulate translations provider connect
forumulate translations auto --space docs --locale es,fr --state missing,outdated
forumulate translations auto --library site --locale de --entry <entryId>
# Chrome words and interface overrides, as JSON or XLIFF for your TMS
forumulate translations labels pull --space docs --format xliff --out labels.xlf
forumulate translations labels push labels.xlf --space docs
forumulate translations overrides pull --format xliff
forumulate translations overrides push overrides.xlf --prune
# The rest
forumulate translations remove <pieceId> es --space docs --yes
forumulate translations item-locale <itemId> es --space docs
forumulate translations entry <entryId> es --library site --fields es.json
forumulate translations name es "Guías" --space docs
forumulate translations switcher get
forumulate translations provider statusEvery command takes --json and --org, and the defaults come from FORUMULATE_ORG, FORUMULATE_SPACE and FORUMULATE_LIBRARY.
A pulled file's front matter says which space, piece and language it is, so a file your TMS renames still lands on the right piece. A file pulled with --include-missing holds the original's words. push skips such a file until someone has changed it, so English is never published under a Spanish address.
From the SDK
import { ForumulateClient } from '@forumulate/sdk';
const client = ForumulateClient.fromEnv();
const cov = await client.translations.status(orgId, spaceId);
for (const item of cov.items)
if (item.locales.es?.state === 'missing')
await client.translations.auto(orgId, spaceId, item.id, 'es');
const es = await client.translations.get(orgId, spaceId, pageId, 'es'); // { title, markdown, state }
await client.translations.put(orgId, spaceId, pageId, 'es', { markdown: es.markdown + '\n\nActualizado.' });The Python and Go SDKs are generated from the same OpenAPI document and already carry these operations, the canonical /auto routes included.
From an agent (MCP)
Connect to the authenticated agent server with a token. Then:
translation_statusfinds what needs work.get_translationandput_translationread and write one piece in one language, as Markdown. An agent that writes a translation counts as a person, so automatic translation never overwrites it.auto_translatemachine-translates with the organization's own provider. If no provider is connected, it answersai.unavailable, and the agent should ask the user to connect one.list_translation_localesandset_translation_localesread and declare the languages.remove_translation,set_item_localeandset_name_translationcover the rest of the translation work.translate_entrywrites a content-library entry's localized fields.
What readers and search engines see
The language is in the address, on every type
A translation is read at its own address: the language code is a path segment after the space's mount, the same way on every type with reader pages.
Type | Original | Spanish |
|---|---|---|
Docs, FAQ, careers, custom |
|
|
Blog |
|
|
Community spaces |
|
|
Status |
|
|
Legal |
|
|
Any type's root |
|
|
(The mounts above are examples; yours are whatever you mounted each space at.)
Only a declared language is a segment.
/docs/es/…is read as Spanish only when the space declareses; otherwiseesis an ordinary slug, so a page called "es" can never be taken over by a language nobody declared. The original language never has a segment.A status page's languages come from its incidents. The languages it translates into automatically, and every language an incident has a translation in — including one written by hand with no automatic translation — each get a path (
/status/es). You do not have to declare them a second time.theme.i18n.urlStyle: "query"keeps the older?locale=esform for a space that relied on it. Unset ispath, the form search engines index as a separate page. The space's value wins over its site's.<html lang>is the language the page actually served.The reader never picks a language from the browser. Reader pages are cached at the edge by address, so a language chosen from a request header would be served to the next reader who asked for that address. The reader's language comes from the address alone; the language switcher (and its
remembersuggestion) links to the other addresses.
Canonical and hreflang
Every type's index, item and hub pages name their canonical and alternates the same way:
A translation that is published canonicalises to its own address (
/blog/es/launch-week), never to the original, so a search engine indexes it as the Spanish page rather than dropping it as a duplicate.An address in a language the piece has not been published in shows the original as a fallback, and names the original as its canonical.
The
hreflangalternates list only the languages that hold the piece (an index or hub lists every declared language), plus the original under both its own language andx-default.Page 2 and later of a list keep their own canonical and name no alternates, because each language's page 2 holds different items.
Sitemaps
A space's sitemap submits each translation at its own address: the root and the hubs (archive, tags, /c/<slug> collections) in every declared language, each translated post, topic and legal document, and every translated docs page, each entry naming its twins. Status incidents are submitted as items.
Events and courses have no translated content (their interface words translate, their text does not), so their language addresses are not submitted: they would be the original text claiming to be another language. Spaces created from a template preview are never in a sitemap.
updates.xml: what changed, as RSS
Every space, of any type, has /<mount>/updates.xml: its 30 most recently changed pieces — pages, posts, topics, releases, incidents, events, legal documents by kind, the contact page — each linked at the address readers use. /<mount>/<lang>/updates.xml is the same feed in a declared language: each piece's published translation at its language address, and an untranslated piece under its original title and address.
curl -sS https://docs.acme.com/docs/es/updates.xml
# the same feed from the API
curl -sS 'https://forumulate.com/api/public/orgs/'$ORG'/spaces/'$SPACE'/updates.xml?locale=es'The API route is in the public reference. A language the space does not declare gets the original feed rather than an error. A docs-family root (docs, FAQ, careers, custom) names the feed as its RSS alternate, so a feed reader finds it from the page.
The header search, per type
The search box in a space's header asks that type's own search, and every hit links to the address the type really reads it at (an incident at incidents/<id>, a policy at its kind, a contact topic at ?topic=):
Type | Search route under | Reference |
|---|---|---|
Docs, FAQ, careers, custom, courses |
| |
Blog |
| |
Community spaces |
| |
Roadmap |
| |
Changelog |
| |
Events |
| |
Status, legal, contact |
|
Each answers { hits: [{ title, snippet?, path }] }, where path is the hit's address under the space's mount.
In the API
Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.
Blog Post Translation
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List translations in a space (blog) |
| |
| Get the translation (spaces blog translations) |
| |
| Replace the translation (blog) |
| |
| Delete the translation (blog) |
| |
| Create the auto in a space (blog) |
|
Content Ai
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Whether AI for content is available |
| |
| Translate entries with the organization’s AI provider |
| |
| Machine-translate an entry |
| |
| Suggest an SEO title and description |
| |
| Draft a blocks field from a brief |
|
Content Entry Locales
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Read an entry's fields in a locale |
| |
| Write an entry's localized fields in a locale |
|
Content Locales
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List locales in an organization |
| |
| Create a locale in an organization |
| |
| Update locales in an organization |
| |
| Update the locale (content libraries) |
| |
| Delete the locale (content libraries) |
|
Deepl
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the deepl in an organization |
| |
| Replace the deepl in an organization |
| |
| Delete the deepl in an organization |
|
Forum Topic Translation
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List translations in a space (forum) |
| |
| Get the translation (spaces forum translations) |
| |
| Replace the translation (forum) |
| |
| Delete the translation (forum) |
| |
| Create the auto in a space (forum) |
|
Forum Translate
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Create the translate in a space (public reader forum t) |
| |
| Create the translate in a space (public reader forum t posts) |
|
Org Language Switcher
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the language switcher in an organization |
| |
| Replace the language switcher in an organization |
|
Page Translation
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List a page’s translations |
| |
| Machine-translate a page |
|
Release Translation
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List translations in a space (changelog) |
| |
| Get the translation (spaces changelog translations) |
| |
| Replace the translation (changelog) |
| |
| Delete the translation (changelog) |
| |
| Create the auto in a space (changelog) |
|
Status Translations
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List an incident’s translations |
| |
| Write an incident’s translation |
| |
| Remove an incident’s translation |
| |
| Machine-translate an incident |
| |
| Machine-translate an incident (canonical) |
|
Translation Coverage
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get a space’s translation coverage |
|
Translation Override
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List translation overrides in an organization |
| |
| Replace translation overrides in an organization |
| |
| Delete translation overrides in an organization |
|