@purposeinplay/payload-ai-translate
v0.3.10
Published
AI translation plugin for Payload CMS 3 — multi-provider (OpenAI, Anthropic, Gemini, custom), bulk translation, Lexical-aware, with an admin Translation Hub.
Readme
AI Translate Plugin
Automatic LLM translation of localized Payload CMS 3.x content. Translation can be triggered on save, on publish, manually via the admin UI, or programmatically via the Node.js API.
This is the reference doc — every config knob, type, endpoint, and provider. New to the plugin? Start with INTEGRATION.md. Upgrading an existing install? Read UPGRADING.md first. Running it in production? OPERATIONS.md. Want to understand how it works under the hood? See ARCHITECTURE.md and the lifecycle diagrams (GitHub-rendered Mermaid + the full Excalidraw system map). Looking for copy-pasteable solutions? See USAGE.md.
Installation
pnpm add @purposeinplay/payload-ai-translateThe providers entrypoint hard-imports all four AI SDK adapters at module load. Install all of them as peer deps:
pnpm add @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai-compatible aiQuick start
// payload.config.ts
import { buildConfig } from 'payload'
import { aiTranslatePlugin } from '@purposeinplay/payload-ai-translate'
import { createAnthropicProvider } from '@purposeinplay/payload-ai-translate/providers'
export default buildConfig({
localization: {
locales: ['en', 'de', 'es', 'fr'],
defaultLocale: 'en',
fallback: true,
},
// Keep aiTranslatePlugin LAST in this array: it appends the Translations tab
// by rewriting each tracked collection's `fields`, and a plugin after it that
// also rewrites `fields` can misplace or drop that tab silently.
plugins: [
aiTranslatePlugin({
collections: ['posts'],
sourceLocale: 'en',
targetLocales: ['de', 'es', 'fr'],
provider: createAnthropicProvider({
apiKey: process.env.ANTHROPIC_API_KEY!,
}),
costLimits: {
perCallCharLimit: 10_000,
perDocCharCeiling: 50_000,
bulkConfirmUsdThreshold: 1.0,
},
}),
],
})This wires:
- A Translations tab inside every
postsdocument — status per language, the Translate CTA, per-field detail, and the Review Drawer - A
translation-settingsglobal with the runtime kill switches POST /api/posts/ai-translate,/estimate,/cancel,/doc-status, and the rest of the per-document endpoint set- A live progress stream at
GET /api/posts/ai-translate/progress
Plugin order matters. Register
aiTranslatePluginafter every field-injecting plugin (seoPlugin, form builder, …). The Translations tab wraps the surface's existing fields into a tab set at registration time, so fields injected later land outside the wrap.
For automation, the Translation Hub, audit-log integration, and cost tracking, see INTEGRATION.md.
Configuration reference
AITranslatePluginConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| collections | string[] | No | [] | Collection slugs to enable translation on. |
| globals | string[] | No | [] | Global slugs to enable translation on. |
| sourceLocale | string | Yes | — | The locale to translate from. Must match a locale in localization.locales. |
| targetLocales | string[] | Yes | — | Locales to translate into. sourceLocale is silently filtered if it appears here. |
| provider | TranslationProvider | Yes | — | Default provider. See Providers. |
| providers | Record<string, TranslationProvider> | No | — | Optional named providers for runtime switching. See Runtime provider switching. |
| costLimits | CostLimits | Yes | — | Hard limits that guard against runaway spend. |
| excludeFields | string[] | No | [] | Field paths or globs to skip. E.g. ['slug', 'meta.*', '**.internalNote']. |
| excludeUrlFields | boolean | No | true | When true, appends **.url to excludeFields so URL strings never reach the LLM. |
| concurrency | Partial<ConcurrencyLimits> | No | see below | Parallelism controls. |
| retry | Partial<RetryConfig> | No | see below | Retry behavior on provider errors. |
| access | { translate?: AccessFn; maxRequestsPerMinute?: number } | No | admin/editor role, 60 req/min | Authorization for the whole /ai-translate/* namespace, plus a per-user rate limit on the two endpoints that call the provider. The engine overrides Payload's own access control, so this is the entire boundary — see Access control. |
| onEvent | (event: TranslationEvent) => void \| Promise<void> | No | — | Receive translation lifecycle events. |
| onAlert | (alert: TranslationAlert) => void \| Promise<void> | No | — | Receive operational alerts. |
| lexicalNodes | LexicalNodeRegistration[] | No | [] | Register custom Lexical node types for extraction. |
| enabled | boolean | No | true | Disable the plugin without removing it from config. |
| automation | AutomationConfig | No | — | Hook-based automatic translation. See Automation. |
| perFieldButton | boolean | No | false | Deprecated and ignored — logs a warning at boot. Per-field re-translate lives in the Translations tab. |
| quality | QualityConfig | No | — | Sampling, canary, and output validation. |
| preserveManualEdits | boolean | No | false | Fingerprint every write (auto-registered ai-translate-meta collection) so hand-edited translations are detected and never overwritten ("Protected"). Also enables per-leaf unresolved tracking + the Review Drawer's retry bypass. |
| manualEditCollectionSlug | string | No | 'ai-translate-meta' | Override the sidecar slug used by preserveManualEdits. |
| providerOutage | { threshold?: number; dedupeMs?: number } | No | { threshold: 3, dedupeMs: 900000 } | Emit one deduplicated translation.provider-outage alert after N consecutive transient provider failures; a success resets the streak. |
| excludeTechnicalFields | boolean | No | true | Never send fields whose name reads technical (*_href, *_url, *_key, icon, slug, camelCase variants) or whose whole value is shaped like a URL / absolute path / asset key (/terms, promo/hero.svg). These used to burn tokens and come back as soft-skip noise. Set false for pre-2026-07 behavior. |
| syncBlockNames | boolean | No | true | Mirror each block's blockName from the source locale into every locale. A blockName-only source edit fires a structural sync — no LLM call, no token spend — that propagates the name while leaving translations and manual edits untouched. Set false to let each locale keep its own. |
| adminRoles | string[] | No | ['admin'] | Role names that count as "admin" for the plugin's field-level gates (_aiTranslateOptOut, _aiTranslateAutoLocales) and admin-only endpoints. Matched case-insensitively against a roles array or a singular role select. [] means everyone (not recommended). Independent of access.translate. |
| usageTracking | UsageTrackingConfig | No | { enabled: false } | Persist a row per translation job. See UsageTrackingConfig. |
| alertsCollectionSlug | string | No | 'translation-alerts' | Override the alerts collection slug. Registered alongside usageTracking.enabled. |
| persistJobs | boolean | No | false | Mirror the in-memory progress store to an auto-registered ai-translate-jobs collection and route async work through Payload's job queue. Not a full work-queue — see Persist async work. |
| jobsCollectionSlug | string | No | 'ai-translate-jobs' | Override the sidecar slug used by persistJobs. |
| bulk | BulkTranslateConfig | No | — | Enables the bulk-translate engine and the Translation Hub's fleet-wide runs. Presence of the key turns it on. See BulkTranslateConfig. |
| settings | TranslationSettingsConfig | No | — | Override defaults for the auto-registered translation-settings global. See translation-settings. |
CostLimits
The first three fields are required.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| perCallCharLimit | number | Yes | — | Maximum characters in a single provider call. Requests over this throw CostGuardError with code 'PER_CALL_LIMIT'. |
| perDocCharCeiling | number | Yes | — | Maximum total characters extracted from one document. Document is aborted with a 'translation.cost-guard-abort' alert. |
| bulkConfirmUsdThreshold | number | Yes | — | USD threshold above which the admin UI requires explicit confirmation before starting bulk translation. |
| perCallItemLimit | number | No | 250 | Maximum translation units per provider call, independent of the character cap. Some models' structured-output mode stalls when the response array grows past ~800–1000 entries — HTTP 200 returns fast, the body never finishes streaming. Short strings (nav labels, JSON keys) pack many units into a small character count, so the char cap doesn't catch it. Measured: a 250-item batch succeeded 100% of the time on the provider that motivated this; a 500-item batch on the same provider timed out roughly 3 attempts in 4. Raise it only for larger-context models you've verified. |
| providerCallTimeoutMs | number | No | 120000 | Hard plugin-level deadline for one provider call. A backstop for the same failure mode: when the provider's own timeout doesn't fire, the plugin used to await forever, freezing the run's heartbeat and stranding the job as running. The call is now raced against this deadline and throws a transient.provider timeout on expiry. Set it longer than your provider's own timeout so it only fires when that fails. |
ConcurrencyLimits
| Field | Type | Default | Description |
|---|---|---|---|
| perDocument | number | 3 | Maximum target locales translated in parallel for a single doc. |
| perProvider | number | 5 | Requests-per-minute ceiling (token-bucket) across all in-flight translations. The limiter starts here, halves on every rate-limit / edge refusal (floor 1 RPM) and recovers towards it on sustained success — see Rate limiting. |
RetryConfig
| Field | Type | Default | Description |
|---|---|---|---|
| attempts | number | 2 | Retry attempts after the initial failure. 2 = up to 3 total attempts. |
| backoffMs | number | 1000 | Base delay in ms. Doubles per attempt. |
AutomationConfig
| Field | Type | Default | Description |
|---|---|---|---|
| trigger | 'on-change' \| 'on-publish' | 'on-change' | When to fire automatic translation. Use 'on-publish' for collections with drafts. |
| mode | 'async' \| 'inline' | 'async' | async queues with coalescing; inline fire-and-forgets. Save returns immediately in both. |
| targetPolicy | TargetPolicy | 'mirror' | 'mirror' overwrites existing translations; 'preserve' skips fields with non-empty target content. |
| diffOnly | boolean | true | Only retranslate fields whose content hash changed since the previous version. |
| coalescingWindowMs | number | 12000 | async mode only — saves within this window collapse into one job. |
QualityConfig
| Field | Type | Description |
|---|---|---|
| validation | ValidationConfig | Tune length bounds and refusal/injection patterns. |
| sampling | SamplingConfig | Sample a percentage of translations for review. |
| canaryLocale | string | Translate this locale on every job but don't write the result. Emitted via onEvent with canary: true. |
ValidationConfig
| Field | Type | Default | Description |
|---|---|---|---|
| minLengthRatio | number | 0.3 | Minimum output/input length ratio. CJK locales (ja/ko/zh) typically need 0.15. |
| maxLengthRatio | number | 3.0 | Maximum output/input length ratio. |
| minSourceLength | number | 10 | Source strings shorter than this bypass length validation. |
| extraRefusalPatterns | RegExp[] | [] | Additional patterns to detect LLM refusals in output. |
| extraInjectionPatterns | RegExp[] | [] | Additional patterns to detect prompt injection in output. |
SamplingConfig
| Field | Type | Description |
|---|---|---|
| rate | number | 0–1. 0.1 samples 10% of translations. |
| onSample | (sample: TranslationSample) => void \| Promise<void> | Callback per sampled translation. |
UsageTrackingConfig
| Field | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | false | Master switch. When true, the plugin auto-registers a collection that persists one row per translation job. |
| collectionSlug | string | 'translation-usage' | Override the auto-registered collection slug. |
| access | { read?: Access } | admin-only | Override the default admin-gated read access. |
When enabled, run pnpm payload migrate:create to generate the schema migration. Commit and deploy. See the Auto-registered surfaces section for the full schema.
BulkTranslateConfig
Passing a bulk block turns on the bulk-translate engine: fleet-wide runs from the Translation Hub, backed by Payload's job queue. Presence of the key is the switch — enabled: false opts out at the runtime layer without removing the config.
| Field | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | true when bulk is present | Runtime opt-out. The real cost guards are dailyUsdCap + requireTotp, not this flag. |
| excludeCollections | string[] | [] | Slugs to keep out of bulk runs even though they're in collections. Recommended for users where bios carry personal content. |
| dailyUsdCap | number | 50 | Ceiling on aggregate estimated USD per UTC day. Charged for bulk runs at enqueue/retry and, inside the engine, for every manual (per-doc-retry) and automation (coalesce) run — one budget for all paths. Read it as an admission check, not a spend meter: the estimate is charged when a run is attempted, before the provider is called and before per-locale hash-skip drops unchanged fields, and it is never reconciled against what was actually spent or refunded when a run fails. A re-translate of an up-to-date document therefore charges its full estimate and spends ~$0. Two enqueue-side undercounts in the other direction: the batch estimator stops enumerating at 500 documents per collection, and mode: 'canary' estimates $0 (its units are charged individually at the engine instead). A run with nothing billable — a structural-only sync, or a provider whose estimator returns no USD figure — is not charged and not refused. BULK_TRANSLATE_DAILY_USD_CAP overrides the option when set to a valid number. 0 is a kill switch: every run with a non-zero estimate is refused. Programmatic callers of translateDocument / translateGlobal get a DailyCapError (code: 'DAILY_CAP') on refusal — narrow it with isDailyCapError and map it with dailyCapRefusalHttp, both exported from the package root. |
| requireTotp | boolean | false | Require a TOTP code on the enqueue endpoint. Friction, not the security boundary — the daily cap is the enforcement. Set it explicitly; there is no auto-detection. Needs @purposeinplay/payload-totp >= 0.5.1 installed (declared as an optional peer). With the flag on but no TOTP plugin resolvable, enqueue proceeds without a code (fail-open, Decision #13 v2) while batch revert refuses — the plugin warns about exactly that at boot. |
| canaryDefaultSize | number | 10 | Sample size for mode: 'canary' runs without an explicit limit. Random-stratified across configured collections. |
| onBatchComplete | (e: BulkTranslateBatchEvent) => void \| Promise<void> | — | Fires on every terminal batch transition. Best-effort: exceptions are caught and logged, never block completion. |
| onBatchFailed | (e: BulkTranslateBatchEvent) => void \| Promise<void> | — | Fires only when a batch ends failed (zero successes), so you can page differently. |
| onCapExceeded | (info) => void \| Promise<void> | — | Fires when the daily cap rejects a request because the budget is gone — never for a storage outage or a missing estimate, which are not budget decisions and would page on-call with numbers that aren't real — { todaySpentUsd, capUsd, rejectedEstimateUsd, requestPath } where requestPath is 'bulk-endpoint' \| 'per-doc-retry' \| 'coalesce'. Page on this before the cap silently locks out a day's work. |
| unitsCollectionSlug | string | 'bulk-translate-units' | Slug override for the units collection. |
| janitorIntervalMs | number | 300000 | Interval between janitor sweeps that reclaim stuck running units. 0 disables the periodic sweep (the boot sweep still runs). The janitor only resets rows older than max(10min, 2 × p99 LLM latency), so slow translations are never interrupted. Do not set below 30000 — the sweep walks the units table. |
| janitorProcess | 'auto' \| 'always' | 'auto' | Which processes run the periodic sweep. 'auto' skips the interval only in a process we can prove doesn't run jobs (jobs.autoRun is an explicit empty array and this isn't a payload jobs:run worker) — so a web server stops sweeping once jobs move to a dedicated worker. Any ambiguous case keeps the interval; reclamation is never silently disabled. 'always' is the escape hatch. |
The janitor must have a driver. Payload's queue has no job lease and no
reaper, so this plugin's janitor is the only reclamation there is — and its
default drivers (a boot sweep plus a setInterval) do not reach a dedicated
payload jobs:run worker (one-shot CLI; the interval is unref()'d) or a
Cloudflare Worker (setInterval does not survive a request-scoped isolate).
The plugin awaits its boot sweep in a jobs:run process, and exports two
entry points for the rest:
import { queueJanitor, runJanitorOnce } from '@purposeinplay/payload-ai-translate'
await runJanitorOnce(payload) // awaited sweep — worker loops, scheduled()
await queueJanitor(payload) // enqueue task `bulk-translate-janitor` insteadBoth are idempotent and cheap when nothing is stale. The task carries no
Payload schedule on purpose: declaring one turns jobs.scheduling on for
the host and needs a payload_jobs_stats table not every consumer has. See
USAGE — Make sure the bulk janitor actually runs.
Registering bulk also:
- auto-registers four sidecar collections —
bulk-translate-batches,bulk-translate-units,translation-daily-spend,translation-rate-limits; - registers three Payload tasks —
bulk-translate-coordinator,bulk-translate-doc,bulk-translate-janitor; - mounts the
/api/translation-hub/bulk-translate/*endpoints; - wraps your
onInitto runensureBulkTranslateSchema(idempotentCREATE UNIQUE INDEX IF NOT EXISTSbacking the dedup invariant) andmigrateBatchStatusVocabulary.
Bulk throughput and per-document serialization
Enqueue ordering is locale-major. Bulk enumerates one job per (collection, documentId, locale). Within each enumeration page the coordinator emits them as
for (locale of scope.locales)
for (doc of page) → (l1,d1) (l1,d2) … (l1,dN) (l2,d1) …not document-major ((d1,l1) (d1,l2) …). This is deliberate and load-bearing.
Payload's runner selects the first limit eligible jobs by createdAt and runs them together, and the plugin admits at most one locale per document at a time (below). Under document-major ordering a window of 100 jobs held only floor(100 / locales) distinct documents — 7 for a 13-locale batch — so 7 units ran and ~93 deferred to the FIFO tail in the same order, tick after tick. A measured 1,157-unit run took 19 h 45 m. Locale-major ordering puts min(limit, page size) distinct documents in that same window, restoring the throughput of a single-locale batch: ~13× for a 13-locale run.
The page is the ordering unit, not the batch — enumeration trampolines per page, and the default page size (100) already exceeds a typical runner window. Nothing else changes: preRunSnapshot is captured by the worker per locale, and revert restores each unit into its own snapshotLocale, so both are independent of enqueue order.
Per-document serialization. Payload's runner executes a tick's jobs with Promise.all, so sibling locales of one document would otherwise run at once, and their writes must be serialized (a per-locale payload.update() rewrites the document's whole <table>_locales row set). Three guards, in order of strength:
The per-document unit claim (Postgres). A short transaction takes
SELECT … FOR UPDATEover the document's non-terminal unit rows and admits exactly one locale; the rest defer and re-queue. This is a real lock — an earlier autocommitNOT EXISTSvariant was write-skew-prone under READ COMMITTED and did leak in production. The transaction spans two statements and commits before any translation, so it does not hold a pool connection.Payload job concurrency (every adapter). The worker task declares
concurrency: { key, exclusive: true }keyed on the document, but only when the host setsjobs.enableConcurrencyControl: true— declaring it while that flag is off makes Payload throw at boot. Turning the flag on adds an indexed field to the jobs collection, i.e. a migration on most adapters, so it's the host's call:// payload.config.ts jobs: { enableConcurrencyControl: true, // opt in — adds an indexed field (migration) tasks: [], }The in-process document write lock, which covers sibling units picked up by one jobs-runner invocation.
On @payloadcms/db-d1-sqlite the claim degrades to a single conditional NOT EXISTS UPDATE. That is best-effort, not a lock: D1 has no advisory locks, no SELECT … FOR UPDATE and no interactive transactions, so it narrows the race window without closing it. On D1, guard 2 is the real cross-process guard — enable enableConcurrencyControl.
D1 consumers: keep the jobs-runner
limitat or below ~80.Locale-major ordering means a window of 100 jobs holds 100 distinct documents, therefore 100 distinct concurrency keys. Payload's job-selection query excludes running keys with a
NOT IN (…)list of bound parameters, and D1 caps a statement at 100 bound parameters — so a full window of distinct documents overflows the cap and the selection query fails. Either run withlimit≤ ~80 (payload jobs:run --limit 80, orjobs.autoRun[].limit), or leaveenableConcurrencyControloff on D1 and rely on the plugin claim plus the in-process write lock, accepting that the cross-isolate case stays best-effort. Postgres hosts are unaffected.
Automation
Automation wires translation into Payload's afterChange hook so content is translated on every qualifying save without manual action.
Triggers
on-change— fires after every save. If the collection has drafts, every autosave fires translation. The plugin emits a startup warning when this combination is detected.on-publish— fires only when_statustransitions to'published'. Requiresversions.drafts: true.
Modes
| | async (default) | inline |
|---|---|---|
| Save response time | Not blocked | Not blocked |
| Coalescing | Yes (12s window default) | No |
| Use case | High-frequency edits, autosaves | One-translation-per-publish, low autosave noise |
| Error visibility | onEvent / onAlert | onEvent / onAlert (errors during inline never reach the save response — fire-and-forget) |
diffOnly
When true (default), the hook passes previousDoc to translateDocument. Fields whose content hash matches the previous version are not retranslated. The diff is per field path, including paths inside arrays/blocks, so a typo fix in one block doesn't retranslate an entire page.
The first publish of a new doc has identical previousDoc/doc (autosave already mirrored) — the plugin detects this and force-translates everything. You don't need to handle the empty-diff edge case.
Target policy
'mirror'(default) — overwrite whatever's in the target locale. Source publishes are authoritative.'preserve'— skip fields whose target value is non-empty. Useful when editors hand-edit translations and you want manual edits left alone.
Programmatic API
All exports are from @purposeinplay/payload-ai-translate.
translateDocument(payload, options)
Reads the source document, extracts translatable content, fans out to the LLM per locale, and writes results back.
import { translateDocument } from '@purposeinplay/payload-ai-translate'
const result = await translateDocument(payload, {
collection: 'posts',
id: 'abc123',
})TranslateDocumentOptions
| Field | Type | Required | Description |
|---|---|---|---|
| collection | string | Yes | Collection slug. |
| id | string \| number | Yes | Document ID. |
| targetLocales | string[] | No | Override plugin config. |
| targetPolicy | TargetPolicy | No | Override automation.targetPolicy or default 'mirror'. |
| fields | string[] | No | Restrict translation to specific paths. |
| previousDoc | Record<string, unknown> | No | When provided, only fields that differ are translated. |
| scope | { topFields: string[] } | No | Restrict the run to specific localized top-level field groups. Validated against the schema — unknown names are an error. Multi-group docs auto-chunk per group server-side either way. |
| force | boolean | No | Bypass the fingerprint skip AND the manual-edit protect guard — every field re-translates and overwrites, then re-baselines. |
| signal | AbortSignal | No | Abort in-flight translation. |
| writeMode | 'full' \| 'minimal' | No ('full') | 'full' writes all top-level localized fields together (passes Payload's required-field validation on a fresh target row); 'minimal' writes only translated fields (caller accepts validation risk). |
| jobId | string | No | Reuse an existing progress-store job (used internally by hooks). |
| req | PayloadRequest | No | Originating request — used for audit-log attribution. The plugin always uses a fresh transactionID per locale write regardless. |
| draft | boolean | No (true) | Read latest draft when versioning is enabled. |
TranslateDocumentResult
{
jobId?: string
documentId: string | number
collection: string
sourceLocale: string
succeeded: FieldLocaleResult[]
failed: FieldLocaleResult[]
usage: TranslationUsage
}FieldLocaleResult.status: 'success' | 'failed' | 'skipped'. 'skipped' means verbatim-echo (the LLM returned the source unchanged — typical for brand names, code identifiers, URLs); the target field is left untouched.
Throws when:
- Plugin not configured
- Document not found
- Collection not in Payload config
perDocCharCeilingexceeded
translateGlobal(payload, options)
Globals counterpart of translateDocument. Same shape minus id.
import { translateGlobal } from '@purposeinplay/payload-ai-translate'
await translateGlobal(payload, {
global: 'site-config',
targetLocales: ['de', 'fr'],
})TranslateGlobalOptions
| Field | Type | Required | Description |
|---|---|---|---|
| global | string | Yes | Global slug. |
| targetLocales | string[] | No | Override plugin config. |
| targetPolicy | TargetPolicy | No | Override default 'mirror'. |
| fields | string[] | No | Restrict to paths. |
| previousDoc | Record<string, unknown> | No | Diff-only equivalent for globals. |
| signal | AbortSignal | No | Abort. |
| writeMode | 'full' \| 'minimal' | No ('full') | See translateDocument. |
| jobId | string | No | Reuse a progress-store job. |
| req | PayloadRequest | No | For audit-log attribution. |
Exported utilities
import { isFieldEmpty, diffFields } from '@purposeinplay/payload-ai-translate'
isFieldEmpty('', 'text') // true
isFieldEmpty('Hi', 'text') // false
isFieldEmpty([], 'array') // true
isFieldEmpty(null, 'richText') // true
diffFields(prevDoc, currDoc, ['title', 'body']) // ['title'] (paths that changed)Providers
All built-in providers live at @purposeinplay/payload-ai-translate/providers. They share an internal AI-SDK-based adapter, so option shapes are uniform: apiKey (required), model (with provider-specific default), temperature, maxTokens, baseURL.
Response-container repair. Structured output is only schema-enforced server-side on some endpoints; on OpenAI-compatible endpoints (OpenRouter included) the shape is merely prompted, and models drift. Two container deviations each used to discard a whole batch of perfectly good translations and leave the locale on its source text: a bare top-level array with no items wrapper, and the exactly-correct object wrapped in a markdown code fence.
The adapter now repairs the container — never the content — through the AI SDK's own repair hook, so a repaired call stays on the normal success path with real usage and telemetry. Truncated JSON, prose, and wrong-shaped payloads still fail with unchanged diagnostics. A repaired response is marked repaired on TranslateResponse and logged as translation.response.container-repaired, so a model drifting out of shape is visible rather than a silent success.
Anthropic
import { createAnthropicProvider } from '@purposeinplay/payload-ai-translate/providers'
createAnthropicProvider({
apiKey: process.env.ANTHROPIC_API_KEY!,
model: 'claude-sonnet-4-6', // default
temperature: 0.3, // default
maxTokens: 4096, // default
})Cost estimates available for claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5.
OpenAI
import { createOpenAIProvider } from '@purposeinplay/payload-ai-translate/providers'
createOpenAIProvider({
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-4o', // default
temperature: 0.3,
baseURL: undefined, // override for Azure OpenAI
})Cost estimates available for gpt-4o, gpt-4o-mini, gpt-4.1, gpt-4.1-mini, gpt-4.1-nano.
Google Gemini
import { createGeminiProvider } from '@purposeinplay/payload-ai-translate/providers'
createGeminiProvider({
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY!,
model: 'gemini-2.5-flash', // default
temperature: 0.3,
})Cost estimates available for gemini-2.5-pro, gemini-2.5-flash, gemini-2.0-flash.
Custom (OpenAI-compatible API)
For Grok, Kimi, Together, self-hosted, or any provider that implements the OpenAI chat completions API:
import { createCustomProvider } from '@purposeinplay/payload-ai-translate/providers'
createCustomProvider({
apiKey: process.env.XAI_API_KEY!,
baseURL: 'https://api.x.ai/v1',
model: 'grok-3-mini',
pricing: { input: 0.3 / 1_000_000, output: 0.5 / 1_000_000 }, // optional
})Without pricing, estimatedCostUsd is undefined (not an error) — the plugin still translates. Per-document, automation and programmatic runs proceed with a logged warning and no daily-cap charge. Bulk enqueue is the exception: it refuses with cost_unavailable rather than start a whole batch at an unknown cost.
Mock (for testing/dev)
import { createMockProvider } from '@purposeinplay/payload-ai-translate/providers'
createMockProvider()Prepends [{locale}] to each input string. No network calls, no costs, deterministic. Use in CI and local dev to exercise wiring without spending tokens.
Runtime provider switching
When you pass a providers map, the plugin auto-registers a translation-settings global so admins can switch providers without redeploying:
aiTranslatePlugin({
// …
provider: createMockProvider(), // fallback when no admin choice exists
providers: {
'anthropic-sonnet': createAnthropicProvider({ apiKey, model: 'claude-sonnet-4-6' }),
'anthropic-haiku': createAnthropicProvider({ apiKey, model: 'claude-haiku-4-5' }),
'openai-mini': createOpenAIProvider({ apiKey: oaKey, model: 'gpt-4o-mini' }),
},
})Each map key becomes an option in the admin's "Active provider" dropdown. The default provider is always available as a fallback.
Implementing your own provider
import type { TranslationProvider } from '@purposeinplay/payload-ai-translate'
const myProvider: TranslationProvider = {
async translate(request) {
// request.items: text units to translate
// request.sourceLocale, request.targetLocale, request.context
// return: { items, usage, model, latencyMs }
},
// estimate is optional — without it, cost previews are unavailable
async estimate(request) {
return { inputTokens: 0, estimatedCostUsd: 0 }
},
}The items array in the response must preserve the same id values as the request — the plugin matches translated text back to field positions by id.
Auto-registered surfaces
What the plugin adds to your Payload config, and what turns each one on:
| Surface | Kind | Registered when | Purpose |
|---|---|---|---|
| translation-settings | global | targetLocales non-empty (unless settings.enabled: false) | Runtime control panel — kill switches, per-surface overrides, active provider. |
| translation-usage | collection | usageTracking.enabled: true | One row per run: tokens, cost, per-locale outcomes, the field receipt. |
| translation-alerts | collection | usageTracking.enabled: true | Cost-guard aborts, persistent failures, provider outages — so the Hub can show them in-app. |
| ai-translate-meta | collection | preserveManualEdits: true | Per-(collection, doc, locale, field) fingerprints. lastWrittenHash = the value the plugin last wrote (backs "Protected"); lastSourceHash = the translatable text of the source it translated (backs "Out of date" and the hash-skip) — see Hash semantics. |
| ai-translate-unresolved | collection | preserveManualEdits: true | Per-leaf problem book — the flag → retry/acknowledge → clear loop behind the Review Drawer. |
| ai-translate-jobs | collection | persistJobs: true | Mirrors the in-memory progress store so in-flight runs survive a restart visibly. |
| bulk-translate-batches | collection | bulk present | One row per bulk run. |
| bulk-translate-units | collection | bulk present | One row per (collection, document, locale) work item. |
| translation-daily-spend | collection | bulk present | UTC-day spend counter backing dailyUsdCap. |
| translation-rate-limits | collection | bulk present | A DB-backed token bucket, registered but not consulted. The live per-provider RPM limit is an in-process limiter built from concurrency.perProvider; acquireToken and getBucketStatus are exported and called from nowhere. Editing rows in this collection, or setting the two TRANSLATION_RATE_LIMIT_* variables, changes nothing about throughput today. Kept registered because dropping a collection is a migration for every consumer — the decision to wire it up or remove it is open. |
Every one of these is a real Payload collection, so each needs a migration. Only the bulk engine's own index/vocabulary bootstrap self-heals at boot.
The plugin also injects fields into every tracked collection and global: _aiTranslateTabRouter and _aiTranslateReviewPanel (UI-only, no storage), plus _aiTranslateOptOut (checkbox) and _aiTranslateAutoLocales (select, hasMany) which do persist — those two are admin-editable and read-only for everyone else, gated by adminRoles.
translation-usage (collection)
Registered when usageTracking.enabled: true.
| Field | Type | Description |
|---|---|---|
| kind | 'collection' \| 'global' | What was translated. |
| jobId | string | Matches the SSE jobId. |
| slug | string | Collection or global slug. |
| documentId | string \| null | Null for globals. |
| status | 'succeeded' \| 'failed' \| 'aborted' | Job-level outcome. Skipped fields don't make a job fail; aborted = the cost guard stopped the run before writing anything (a safety stop, not a failure). |
| sourceLocale | string | |
| succeededCount | number | Per-locale successes. |
| failedCount | number | Per-locale real failures (excludes skipped). |
| inputTokens / outputTokens | number | Provider-reported. |
| estimatedCostUsd | number \| null | Null for providers without pricing. |
| model | string | Provider model id. |
| durationMs | number | Wall-clock total. |
| error | string \| null | Short error text on failure. |
| targetLocales | array | Per-locale outcome rows: locale, status, error, errorCode, failedFields[], hashRecordPartial. |
| fieldsTranslated / fieldsHashSkipped / fieldsPreserved / fieldsSoftSkipped / fieldsNonTranslatable | number | The run receipt breakdown — what actually happened per field. |
| softSkippedFields | array | Per-field skip entries: path, locale, reasonCode, sourceValue, acknowledged, acknowledgedNote, acknowledgedBy. Feeds the Review Drawer. |
| preservedFields | array | Fields the manual-edit guard refused to overwrite (path, locale). |
| triggeredByUserId / triggeredByEmail | string | Who caused the run — the "By" column everywhere. |
| createdAt / updatedAt | Date | |
Reads are admin-gated by default. Override with usageTracking.access.read.
Migration: run pnpm payload migrate:create after first enabling. The plugin doesn't ship migrations for its Payload-registered collections — your schema management owns them.
Upgrading? Releases regularly ADD columns to
translation-usage(e.g. the per-field receipt counts,softSkippedFields.acknowledged*,triggeredBy*, theabortedstatus) and register new sidecar collections (ai-translate-meta,ai-translate-unresolved,translation-alerts,ai-translate-jobs, bulk batches/units). After every plugin upgrade, runpnpm payload migrate:createand review the generated migration before deploying — the release notes call out schema-affecting versions. (The bulk job-queue tables self-migrate idempotently at boot; the Payload collections above do not.)
selectfields need a CONDITIONAL migration.migrate:createreads your migration snapshot, not the live database, and for a Payloadselectthe two routinely disagree: how aselectis stored changed across Payload and plugin versions, so an installation created before 0.3.4 has a realenum_…type while a newer one has a plainvarcharthat already accepts any string. Both shapes are in production. When a release adds an option to an existingselect—bulk_translate_units.failure_codegainedpermanent.writeandtransient.storagein 0.3.9 — the generatedALTER TYPE "public"."enum_bulk_translate_units_failure_code" ADD VALUE …fails at deploy withtype "…" does not existon any host whose column is a varchar. Check the live schema first (\d+ bulk_translate_units), then wrap the statement so it is correct in both shapes and idempotent on re-run:await db.execute(sql` DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_bulk_translate_units_failure_code') THEN ALTER TYPE "public"."enum_bulk_translate_units_failure_code" ADD VALUE IF NOT EXISTS 'permanent.write'; ALTER TYPE "public"."enum_bulk_translate_units_failure_code" ADD VALUE IF NOT EXISTS 'transient.storage'; END IF; END $$; `);Leave
downempty — PostgreSQL cannot remove an enum value.
translation-settings (global)
Registered whenever targetLocales is non-empty — not only when the providers map is passed. Pass settings: { enabled: false } to suppress it.
This global is the runtime control panel: every value is read on each job, so admin changes take effect on the next translation, never mid-job.
| Field | Type | Default | Description |
|---|---|---|---|
| activeProvider | select | — | Options come from the providers map keys. Only meaningful when that map is configured; otherwise the config's provider always runs. |
| enabledTargetLocales | select (hasMany) | all configured | Which of targetLocales actually fan out. |
| globalAutoTranslateEnabled | checkbox | true | 🛑 Kill switch — automation. Unchecked, the afterChange hook does nothing on every save. Manual and bulk are unaffected. |
| globalManualTranslateEnabled | checkbox | true | 🛑 Kill switch — manual translate. Unchecked, the Translate dialog hides and the endpoint returns 403. Automation and bulk are unaffected. |
| globalBulkTranslateEnabled | checkbox | true | 🛑 Kill switch — new bulk runs. Unchecked, enqueue is rejected and the Hub's trigger renders disabled. In-flight runs continue — this does not cancel running work. |
| perCollection[] | array | [] | Per-surface overrides. A missing row inherits the site-wide values above. |
Each perCollection row carries:
| Field | Type | Default | Description |
|---|---|---|---|
| slug | select | — | The collection or global this row configures. |
| enabled | checkbox | true | Kill switch for this surface — disables automation and manual translate. |
| autoOnPublish | checkbox | true | Automation only. Uncheck to make this surface manual-only. No effect when enabled is off. |
| targetLocalesOverride | select (hasMany) | [] = inherit | Empty means inherit, not "none". A locale must be enabled site-wide and here to fan out. Clearing the list does not opt the surface out — uncheck enabled for that. |
| translateSlug | checkbox | false | Include the slug field in runs for this surface. Requires localized: true on the slug field, otherwise it's a no-op. Applies to automation and manual. |
| excludedFieldPaths | text (hasMany) | [] | Fields excluded for this surface, rendered as a checkbox list driven by the row's slug. Applies to automation and manual — unlike the two flags above. Stale paths (after a field rename) are harmless: the filter is a set-membership check. |
The three kill switches compose inside each other rather than overriding: per-surface config still applies within a global switch that's on, and flipping a global switch back on resumes whatever per-surface config was already in place.
REST endpoints
All endpoints are auto-registered on every configured collection and global at /api/<slug>.
POST /ai-translate
Translates the doc (or global). Request body fields are all optional; defaults come from plugin config.
{
"id": "doc-id", // collections only — required for collections
"targetLocales": ["fr", "de"], // optional
"fields": ["title", "body"], // optional
"writeMode": "full", // optional ('full' | 'minimal')
"async": true, // optional — enqueue and return { jobId } immediately; poll doc-status/progress
"force": false, // optional — bypass skip + protect guards (overwrites hand-edits)
"scope": { "topFields": ["hero"] }, // optional — translate only these top-level groups (validated)
"confirmCost": true // optional — acknowledge a 402 cost-confirmation response and run anyway
}Use async: true behind proxies/load balancers — the synchronous mode can outlive
a gateway timeout on large documents. The admin UI always uses async + polling.
Response:
{
"jobId": "j_abc123",
"documentId": "doc-id",
"collection": "posts",
"sourceLocale": "en",
"succeeded": [
{ "fieldPath": "title", "locale": "fr", "status": "success", "characterCount": 42, "durationMs": 310 }
],
"failed": [],
"usage": { "inputTokens": 120, "outputTokens": 135, "estimatedCostUsd": 0.00034, "model": "claude-sonnet-4-6" }
}POST /ai-translate/estimate
Token + cost preview without making a translation call. Same body as /ai-translate.
{
"inputTokens": 120,
"estimatedCostUsd": 0.00034,
"billableCharacters": 480,
"skippedCharacters": 0
}billableCharacters / skippedCharacters lets you see how much will actually be translated vs. how much is excluded (URLs, exact-match exclude paths, empty fields).
POST /ai-translate/cancel
Requests cancellation of the document's running job ({ "id": "doc-id" } for collections). Honored at the next per-group chunk boundary — languages and groups already written stay saved; the job ends as cancelled with its per-group breakdown intact. Works cross-instance via the shared jobs mirror.
GET /ai-translate/review
The Review Drawer's data source: everything a human still needs to look at for one document, merged from the per-leaf unresolved book (incl. self-heal detections), the latest run's failed fields, and unacknowledged attention-grade soft-skips — deduped per (locale, path) with breadcrumb labels and current source/target text.
Query params: ?id= (collections), ?locale= (scope the read to one language), ?runId= (scope to one run's receipt — the Hub's "This run (N) | Everything (M)" toggle). The response includes targetLocales (the surface's full configured list) so the drawer can render a chip for every language.
GET /ai-translate/doc-status · POST /ai-translate/status-batch
doc-status is the per-document status projection (per-language state, stale groups, active job) — derived through the one canonical resolveLocaleStatus. status-batch (root-level) answers the same question for many documents in ONE request: { docs: [{ slug, documentId }] } → a live verdict per doc. The Hub's "Now" column uses it.
POST /ai-translate/skips/acknowledge
Mark-as-OK: { id?, locale, paths, note? }. Persists the acknowledgment durably (with acknowledgedBy from the session and the optional note) so detection never re-flags a deliberate keep. A later source change re-opens the question by design.
POST /ai-translate/keep-current
{ id?, locale, paths }. The resolution path for an Out of date entry when the editor has looked and decided the existing translation is still fine — no AI call, no change to the translation. Staleness isn't a stored flag: doc-status recomputes it each render by comparing the current source hash against the lastSourceHash from the last run. So the only way to clear it is to make that comparison match again — either re-translate, or re-baseline against the current source while keeping the existing target, which is what this does. Only meaningful with preserveManualEdits: true (that's what maintains the hash book).
Hash semantics and the source baseline
lastSourceHash fingerprints the translatable projection of a top-level field — the ordered list of (leafPath, text) pairs the unit extractor yields for it, sorted by path, stored as p1:<sha256>. Both the write (recordHashesAfterWrite) and the read (applyHashSkip, computeStaleForLocale, the Hub preflight) call the same function over the same field set, which is the whole point: the fingerprint has to mean "the text we translated", not "the object we happened to read".
Consequences worth knowing:
- Adding a non-translatable key to a shared block does not make every document using it out of date. TASKS-25582 appended one relationship field to a
visibilitygroup; Payload returns it as"rule": null, and the old raw-object fingerprint moved on 436 documents across five collections with zero text change. - Neither do
nullvs absent, key order, relationship population depth, blockidvalues,blockName, or lexical node-shape drift across a Payload upgrade. - Changing a non-translatable value (a reward amount, an href) still moves it — the projection keeps those leaves so the mirror can propagate them.
- Reordering rich-text blocks or array items still moves it. That is a content change.
Old rows migrate themselves, on read. Nothing to run.
- A pre-0.3.9 raw-object hash is recognised by the absence of the
p1:marker, compared the old way, and rewritten in the new format the first time a run proves both sides unchanged. - An orphaned baseline — one that matches nothing under any algorithm, because the recorder fingerprinted an in-memory shape the database never held — is rescued by version history. If the source-locale snapshot at
lastWrittenAtprojects to the same text the source has now, and the target passes its own gate, the field is skipped and the row re-baselined instead of being re-translated. On the 2026-09-02 production copy 1376 of 4419 rows (31%) were in that state, re-billing $18.80 on every 13-locale "changed" pass. The rescue needs document versions to be enabled and retained; where they are not, the row simply keeps behaving as it does today.
lastWrittenHash is unchanged — it fingerprints the whole target value, because the manual-edit guard compares values, not text.
GET /ai-translate/locks · POST /ai-translate/locks/unlock
The Protected edits inventory and its unlock verb. A "lock" is a field the manual-edit guard preserved because the target value diverged from the plugin's last write — the machine will keep refusing to touch it, run after run.
The inventory derives per (document, locale) from the newest usage row whose run covered that locale, so a hand-edit only appears once a run has observed it. That's the same observation window the per-document tab reports, which is why the two always agree.
Unlock deletes the field's meta-hash rows rather than re-hashing the current value. No stored hash → no divergence → the field reads Out of date and the next run re-translates and re-baselines it. That's deliberate: the intent is "hand this field back to the machine", not "bless the manual edit".
Both are open to any authenticated panel user — not admin-only. The per-document Translations tab points editors here to resolve protected fields on their own content, and an admin gate made that instruction a dead end.
POST /ai-translate/policy
{ id?, optOut?, autoLocales? }. Admin-gated via adminRoles. Updates the two per-document policy fields (_aiTranslateOptOut, _aiTranslateAutoLocales) from the Translations tab. The write carries the re-entry flag so a policy toggle never fires an auto-translate, and it's draft-aware: on collections with drafts it saves as a draft, taking effect on the next publish — which is exactly when the policy is consulted.
POST /ai-translate/alerts/dismiss
Admin-gated. Dismisses a blocking alert (cost-guard abort, persistent failure, provider outage) from the document's alert zone.
GET /ai-translate/client-config (root-level)
The JSON-safe subset of plugin config the admin client needs at render time — target locales, effective exclude patterns, kill-switch state. It exists because Payload's admin client strips function references from config.custom during client serialization, so useConfig() can't read the plugin's config directly. Any authenticated user; the response carries no secrets.
GET /translation-hub/editor-overview (root-level)
One request backing the editor's Hub Overview: the "needs your attention" work queue (failed locales plus pending attention-grade skip reviews), the read-only "running now" list, and a 14-day activity feed. Editor-or-admin auth, and sanitized — no cost, token, or model data.
GET /translation-hub/usage-summary (root-level)
Server-side aggregation for the Hub's Overview and Audit KPIs. Replaces the earlier /translation-usage?limit=1000 pattern, which silently truncated past 1000 rows.
Translation Hub endpoints (root-level)
Registered when bulk is configured, all under /api/translation-hub/bulk-translate:
| Method | Path | What it does |
|---|---|---|
| POST | / | Enqueue a run. Admin-only, TOTP-gated when requireTotp, rejected past dailyUsdCap. |
| GET | / | List runs. |
| GET | /active | Currently queued/running runs. |
| GET | /preflight | Cost estimate + scope checks before enqueueing. documents.perCollection[].changed is a real per-document diff, computed with the same projection hashes the run's hash-skip uses. The scan runs against an 8s wall-clock budget rather than a scope-size ceiling; when it runs out, documents.scan.partial is true, scannedDocs / totalDocs say how far it got, and a collection it never reached carries changed: null. |
| GET | /:id/status | One run's live status and per-unit breakdown. |
| GET | /:id/failures | The run's failed units. |
| GET | /:id/review | Batch-scoped Review Drawer feed — same item anatomy as the per-document review, each item tagged with its owning document. |
| POST | /:id/cancel | Stop a run. Units already written stay written. |
| POST | /:id/retry-failed (alias /:id/retry) | Re-queue the failed units. |
| POST | /:id/revert | Roll back the run's writes. 24-hour window, admin + TOTP. |
| POST | /:id/force-reset | Break a wedged run out of a non-terminal state. |
Bulk is admin-only across the board — enqueue, cancel, retry, revert. Editors translate per-document from the Translations tab; fleet-wide runs are an admin operation.
GET /ai-translate/progress (SSE)
Live progress stream for a translation job. Subscribed via Server-Sent Events.
Two modes:
?jobId=X— follow a specific job.?docId=X— follow any active job for this doc, or wait until one starts. The in-edit progress bar uses this mode so the bar appears instantly when a job begins.
Each event has shape:
{
"jobId": "j_abc123",
"completed": ["de", "es"],
"failed": [{ "locale": "fr", "reason": "1 field(s) failed" }],
"total": 9,
"status": "running"
}status is one of 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'.
Note: many proxy/load-balancer chains buffer or kill SSE. The admin UI does NOT rely on this stream — it polls
doc-status(2.5s) instead. Prefer polling for anything that must work behind an arbitrary gateway.
Access control
The /ai-translate/* namespace requires the admin or editor role throughout. On top of that, access.translate is your own rule for the endpoints that spend money or change state — translate, estimate, cancel, unlock, skips/acknowledge and keep-current. Reads (doc-status, review, status-batch, progress, lock inventory) are decided by the role check alone and never reach your callback; see below for why:
aiTranslatePlugin({
// …
access: {
translate: ({ req }) => {
const roles = [
...(Array.isArray(req.user?.roles) ? req.user.roles : []),
...(typeof req.user?.role === 'string' ? [req.user.role] : []),
].map((r) => r.toLowerCase())
return roles.includes('editor') || roles.includes('admin')
},
},
})The default is the admin or editor role, read from a roles array or a singular role select, case-insensitively. Two things follow:
- If your project names those roles differently, write
access.translateor your editors get a 403. The plugin logs a boot warning whenever the option is unset, naming the default. - Until 0.3.6 the default was "any authenticated user" and it covered only two endpoints. The document-scoped endpoints checked for a session alone and then read admin-only collections with access control overridden, so any logged-in user could read another document's spend and model data, its verbatim source and target strings, and every locked field in the site.
Your callback is consulted only for verbs that spend money or change state. Reads — status, review, progress, lock inventory — are decided by the role check alone and never reach it. That is deliberate: a consumer is entitled to implement access.translate as a rate limiter (wild does, 30 requests per 60 seconds per user), and the Translations tab polls doc-status every 2.5 seconds during a run, so a second document started inside that window would saturate the bucket and 403 every later poll. The role requirement still applies to reads, so a member with no role cannot reach them.
The callback receives kind alongside req — 'spend' for the provider-calling endpoints, 'mutate' for state changes like unlock, acknowledge and cancel — so you can rule on each verb deliberately:
import { readUserRoles } from '@purposeinplay/payload-ai-translate'
access: {
translate: ({ req, kind }) => {
const roles = readUserRoles(req.user)
if (kind === 'spend') return roles.includes('admin')
return roles.includes('editor') || roles.includes('admin')
},
}Two things are enforced independently of this gate:
- Cost, model and duration are admin-only in the
doc-statusresponse, matching the usage collection's own access control. - Unlock requires update access to the target document, run through that collection's own
access.update. It deletes the bookkeeping that stops the next run from overwriting a human's edits, so it is a write against that document by proxy.
access.maxRequestsPerMinute (default 60, 0 disables) bounds per-user requests to the two endpoints that call the provider. The counter is per process, so on a multi-instance deployment the effective ceiling is that number times the instance count — it bounds a runaway client, while bulk.dailyUsdCap remains the real spend boundary.
Note that adminRoles (the plugin's own gates) reads the same two role shapes. Your own access.translate callback is your code, so it has to handle whichever shape your Users collection uses.
Admin UI
Everything translation-related lives in two places: a Translations tab inside each document, and the Translation Hub views. Nothing renders outside them.
The Translations tab (per document)
Plugin-registered on every tracked collection and global — you wire nothing. It appends as a field tab to the surface's existing tabs (Hero | Content | SEO | Translations); a flat surface gets wrapped into a Content | Translations pair. Switching tabs never leaves the form, and the active tab syncs to #translations in the URL so it's deep-linkable.
It contains:
- Action bar — the primary Translate CTA, language scoping, an ambient cost estimate, and last-run metadata. The CTA always opens one confirmation dialog naming languages, field counts, and cost.
- Per-language status rows — one per target locale, each with its state badge, a Translate/Retry action, and expandable per-field detail (failed fields, soft skips with reasons and source values, protected manual edits, out-of-date fields). Field paths resolve to breadcrumb labels from the field config — "Hero › Terms Link", never
content.0.content.hero.terms_href. - Quick-select pills — Everything not up to date / Not translated yet / Out of date / Failed / All set the run scope; clicking a language row toggles it in or out.
- Force re-translate — an always-visible guarded toggle beside the CTA ("Also replace manual edits"). The confirmation names every hand-edited field it would overwrite. Force lives nowhere else.
- Alert zone — blocking alerts (cost-guard abort, persistent failure, provider outage), dismissable by admins.
- Policy zone (collapsed) — the per-document opt-out and locale narrowing. Live inputs for admins, read-only for editors.
Manual translation from the tab runs in async mode (202 + jobId, progress driven by doc-status polling). The earlier synchronous POST held the response open for a whole multi-language run, so any proxy timeout returned a 504 to the editor while the server kept translating.
Per-language states
The vocabulary editors see. One canonical resolver (resolveLocaleStatus) derives all of them; labels live in one place, so every surface agrees.
| State | Label | Meaning |
|---|---|---|
| synced | Up to date | Every field for this language matches the current source and passed checks. |
| stale | Out of date | The source changed since this language was last translated. |
| failed | Failed | Some fields couldn't be translated on the last run. |
| needs-review | Needs review | Some fields came back unchanged or need a human to confirm. |
| preserved | Protected | A human edited this by hand; the machine won't overwrite it without Force or an unlock. |
| never-ran | Not translated yet | No run has ever covered this language. |
| reverted | Reverted | The last translation for this language was rolled back. |
Two tiers answer these questions at different costs. verified re-hashes live documents on the request (the Translations tab, the Review Drawer). recorded consults only persisted rows — cheaper, used by Hub list views, but blind to source edits since the last run. The recorded tier reports problems only: a clean result reads "No open issues", never "Up to date", because without hashing it cannot honestly claim sync.
The Review Drawer
One non-modal, resizable drawer, reused across the Content tab, the Translations tab, and the Bulk Runs Hub. It persists across tab switches and opens from the in-form flag badge.
It merges everything still needing a human for one document: the per-leaf unresolved book (including self-heal detections), the latest run's failed fields, and unacknowledged attention-grade soft skips — deduped per (locale, path), with breadcrumb labels and the current source/target text.
Each entry resolves one of three ways: Retry (re-translate), Keep current (re-baseline the hash, no AI call), or Mark as OK (acknowledge a deliberate keep). Entries in a running job show "Translating…"; every other Retry is disabled with a tooltip, because a document runs one job at a time.
Soft skips are triaged server-side into attention (the model refused, or sentence-shaped prose came back untranslated) and benign (URLs, icon keys, brand terms correctly kept as-is — collapsed into a "kept in the original language on purpose" count). Needs review fires only on attention-grade skips, so a language whose skips are all benign reads Up to date.
Translation Hub views
Two server-rendered admin views. These are not auto-registered — you wire them into admin.components.views yourself. See INTEGRATION.md.
- Translation Hub (
/admin/translation) — tabs for Overview, Configuration, Protected edits, Audit & Cost, and Advanced. The admin Overview carries bulk runs and cost; the editor Overview is a sanitized work queue with no cost, token, or model data. - Bulk Translate Runs (
/admin/translation-runs) — the run list with inline drill-down into batches and units. Admin-only.
A TranslationNavGroup component gives you a collapsible "Translation" sidebar group linking just those two. Every other registered surface (meta hashes, jobs, batches, units, spend, rate limits, usage, settings) stays reachable by direct URL for debugging but is deliberately kept out of the sidebar — their polished form lives inside the Hub's tabs.
Deprecated surfaces
| Removed | Replacement |
|---|---|
| Sidebar "Translate…" button, translate modal | The Translations tab's action bar |
| perFieldButton: true | Per-field re-translate in the tab's expanded language rows. The option is ignored and logs a warning at boot. |
| Floating review navigator, docked sidebar panel | The single Review Drawer |
TranslateButton, TranslateModal and FieldTranslateButton were removed in 0.3.6 — deprecated in 0.2.0, kept "one release", and shipped for sixteen without anything injecting them. Run payload generate:importmap after upgrading: an import-map entry still naming one of them stops the admin panel from loading. ReviewSidebarPanel and DockedReviewPanel remain exported as aliases of ReviewDrawerHost. Also drop any hand-rolled per-document status panels.
Events and alerts
onEvent
| Type | When | Additional fields |
|---|---|---|
| 'translation.started' | Before LLM calls | targetLocales |
| 'translation.field' | After each individual field translation | fields (single entry) |
| 'translation.succeeded' | All locales completed with no real failures (skipped is OK) | fields, usage |
| 'translation.failed' | At least one locale had a real failure | fields, usage, error |
All events include documentId, collection, sourceLocale, targetLocales, timestamp. canary: true marks an event emitted from the canary locale.
Events also carry skippedFields[] — the per-field soft skips. A document-level translation.succeeded can still carry skips: soft skips don't drive document status to failed, but the editor needs
