Documentation

Localization workflow

Any field type can be marked translatable: true, which stores its value as a locale map such as { "en": "Welcome", "is": "Velkomin" }. Fields without the flag are stored once and shared by every locale. See Field types for the per-type detail.

Because values are per locale, so is publishing. Each publish names the locales it puts live, and the published snapshot keeps the locales that were already live. One entry can be live in English while Icelandic is still being written. See Publishing and releases.

Derived locale states

Blobify derives each locale's state from timestamps the entry already carries, so nothing has to be maintained by hand. There are two axes that are never collapsed into one value: a publication state, plus an outdated overlay that can ride on any of them.

For a target locale t and the source locale s (the organization's default locale):

text
code
U[x] = the entry's last update time for locale x
P[x] = the entry's last publish time for locale x
A    = the last update to a non-translatable (shared) field
L    = the most recent publish time across all locales

localeDirty(t) = U[t] exists AND P[t] exists AND U[t] > P[t]
sharedDirty    = A exists AND L exists AND A > L
State or flagExact ruleMeaning
Not startedP[t] absent and U[t] absentNo locale payload signal exists for t
In draftP[t] absent and U[t] presentLocale values were touched but never published
Published, changedP[t] present and (localeDirty(t) or sharedDirty)A live version exists, but the draft differs from it
Published, currentP[t] present and neither dirty predicate is trueThe live snapshot matches the current draft
Outdated to sourcet != s, U[t] and U[s] exist, and U[s] > U[t]Target work exists, but the source locale was edited later

A shared-field change is deliberately left out of the source-freshness comparison. It lands on the source and the target at the same moment, so it needs publishing, not translating. Two boundaries follow from the rules: "not started" means no update stamp, so an explicitly saved empty string counts as started, and changing the organization's default locale immediately changes every freshness comparison.

The manual locale status marker

Derived states answer "is this locale live and current". They cannot answer "who is working on it" or "is this locale deliberately skipped". That is the manual marker's job.

Stored statusDashboard labelMeaning
in_progressIn progressSomeone has claimed or started the locale work
needs_reviewNeeds reviewThe locale work is ready for editorial review
not_requiredDo not translateThis entry is intentionally excluded for the locale

The marker is editorial bookkeeping, not content, and it follows four rules:

  • Draft only. It is stored on the draft document and projected into draft summaries so lists can filter on it. It never appears in published.json, published summaries, or any other public artifact. Setting or clearing a marker lands on the draft document immediately, so the entry itself shows the new value on the next read. The summary shards behind the list filters are rewritten by the reconcile worker, so those lists catch up a few seconds later, exactly like every other draft save and publish.
  • Not a content edit. Setting it does not bump the entry's update timestamps, does not create a version snapshot, does not make the entry count as changed since its last publish, and emits no webhook.
  • Never a gate. It never blocks a save or a publish.
  • Cleared by publishing that locale. Publishing fr clears the fr marker, whichever of the three values it held, through every publish path (single, bulk, scheduled, and release). Publishing a different locale clears nothing, so not_required on fr survives an English publish. Source-locale edits, unpublishing, archiving, and restoring all leave markers alone.

In the dashboard

Open an entry and the locale switcher shows each locale's state, who set a marker, and when. The same menu sets or clears the marker for the locale you are viewing.

In the content list, select rows and use the bulk action to write one marker to one locale across the whole selection. Each entry is written conditionally on its own version, so two editors cannot silently overwrite each other's claim, and the page reports any per-entry failures instead of an all-or-nothing result.

The list's locale-status filter picks a locale and then narrows by marker, by derived state, or both. Two shortcuts matter for triage. Needs localization is the translator's work queue in one click: not started, or in progress, or behind the source locale, and never "do not translate". In progress older than N days surfaces claims that have gone stale.

REST routes

text
code
PUT    /v1/orgs/{orgId}/content/{spaceId}/{model}/{contentId}/locale-status/{locale}
DELETE /v1/orgs/{orgId}/content/{spaceId}/{model}/{contentId}/locale-status/{locale}

PUT takes { "status": "in_progress" } and DELETE clears the locale. Both require an If-Match header carrying the draft's current version: a missing header returns 428 and a stale one returns 412 with the current document. Markers are typically set from a list row where two editors can claim the same locale at once, so a blind write is refused by design. Both calls return the whole updated localeStatus map plus a new ETag, so several locales can be set in a row without re-reading.

Both need content:write and the editor role, and a scoped API key needs the model and the locale in its scope. There is no bulk marker endpoint: send one conditional request per entry with controlled concurrency.

Over MCP

findContent doubles as the triage tool. With state: 'draft' it accepts manualLocaleStatus (in_progress, needs_review, not_required, or unset), derivedLocaleStatus (not_started, draft, changed, published, plus the outdated overlay), and sourceLocale, which defaults to the workspace default locale. Every match reports both localeStatus and localeState, so nothing has to be guessed. A published-state filter is rejected, because published summaries carry no editorial metadata. Setting or clearing a marker lands on the draft document immediately, so getContent returns the new value and ETag on the next read. The summary shards behind findContent filters and dashboard lists are rewritten by the reconcile worker, so those lists catch up a few seconds later, exactly like every other draft save and publish.

So "which pages still need Icelandic" is one call:

json
code
{
  "model": "page",
  "state": "draft",
  "locale": "is",
  "derivedLocaleStatus": "not_started"
}

setLocaleStatus(model, id, locale, status, expectedEtag) writes the marker, with null to clear it. expectedEtag is required here too. Read the draft with getContent using state: 'draft', which returns the etag, or reuse the etag a previous setLocaleStatus returned.

For a translation integration

A sync should mark the locales it touches so editors can see what is in flight without opening the vendor's dashboard. Set in_progress on the target locale when the entry is exported, and needs_review on it when the translation comes back as a draft. Publishing that locale clears the marker, so the sync never has to clean up after a human. Leave not_required to editors: it is how they tell the sync which entries to skip.

Never derive a marker from content state. "This locale has no values, so it must be in progress" is already answered by the derived states. Write a marker only when a human or a sync event says so, and keep provider detail (job IDs, percent complete, per-field hashes) in your own system.

See Automation API for scoped keys, conditional writes, and the export and import loop, and MCP server for driving the same workflow from an AI host.