npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@purposeinplay/payload-ai-translate

v0.2.4

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. 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-translate

The 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 ai

Quick 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,
  },
  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 "Translate…" button in the sidebar of every posts document
  • POST /api/posts/ai-translate and POST /api/posts/ai-translate/estimate endpoints
  • A live progress stream at GET /api/posts/ai-translate/progress

For automation, 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 } | No | always allowed | Guard the translate endpoints. | | 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 | Inject a translate button next to each localized field. | | quality | QualityConfig | No | — | Sampling, canary, and output validation. | | usageTracking | UsageTrackingConfig | No | { enabled: false } | Persist a row per translation job. See Usage tracking. | | 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. | | 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. | | settings | TranslationSettingsConfig | No | — | Override defaults for the auto-registered translation-settings global (only meaningful with providers). |

CostLimits

All three fields are required.

| Field | Type | Description | |---|---|---| | perCallCharLimit | number | Maximum characters in a single provider call. Requests over this throw CostGuardError with code 'PER_CALL_LIMIT'. | | perDocCharCeiling | number | Maximum total characters extracted from one document. Document is aborted with a 'translation.cost-guard-abort' alert. | | bulkConfirmUsdThreshold | number | USD threshold above which the admin UI requires explicit confirmation before starting bulk translation. |

ConcurrencyLimits

| Field | Type | Default | Description | |---|---|---|---| | perDocument | number | 3 | Maximum target locales translated in parallel for a single doc. | | perProvider | number | 5 | Requests-per-minute cap (token-bucket) across all in-flight translations. |

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 | 01. 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.


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 _status transitions to 'published'. Requires versions.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
  • perDocCharCeiling exceeded

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.

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.

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

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*, the aborted status) and register new sidecar collections (ai-translate-meta, ai-translate-unresolved, translation-alerts, ai-translate-jobs, bulk batches/units). After every plugin upgrade, run pnpm payload migrate:create and 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.)

translation-settings (global)

Registered when providers (the map) is passed. Exposes an "Active provider" select sourced from the map's keys. The admin's choice is read on every job — admin updates take effect on the next translation, not mid-job.


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.

Translation Hub endpoints (root-level)

Bulk translation runs on /api/translation-hub/bulk-translate/*: preflight (cost estimate + checks), enqueue (start, 2FA-gated), status, list, active, :id/cancel, :id/retry-failed, :id/revert (24h window, 2FA), :id/force-reset, :id/failures, and :id/review — the batch-scoped Review Drawer feed (same item anatomy as the per-doc review, each item tagged with its owning document).

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

Pass access.translate to gate the translate and estimate endpoints:

aiTranslatePlugin({
  // …
  access: {
    translate: ({ req }) => req.user?.role === 'editor' || req.user?.role === 'admin',
  },
})

The progress endpoint inherits the same gate.


Admin UI

Sidebar Translate button

Auto-injected on every configured collection (and global) at the edit view. Triggers a full-doc translation against the configured targetLocales. The component is at @purposeinplay/payload-ai-translate/client#TranslateButton.

Per-field button

Set perFieldButton: true to inject a button next to each localized text, textarea, or richText input. Clicking it re-translates that field only. Recurses into group, array, row, collapsible, tabs, and blocks.

In-edit progress bar

Mounts beneath the action bar and connects to the SSE doc-stream the moment the edit view loads. When a translation job for that doc starts, the bar renders within ~50ms — no polling delay. Showed every per-locale tick as it lands.

Translation Settings global (when providers map is configured)

Admin chooses an active provider from a dropdown sourced from the providers map keys. Changes apply on the next translation.

Translation Usage collection (when usageTracking.enabled)

Standard Payload collection list view, admin-gated. Filterable by status, model, slug, date.


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. Set canary: true when emitted from the canary locale.

onAlert

| Type | When | |---|---| | 'translation.persistent-failure' | A field has failed after all retry attempts. | | 'translation.cost-guard-abort' | A document exceeded perDocCharCeiling. | | 'translation.provider-outage' | Provider unreachable for multiple consecutive requests. |


Quality and monitoring

Sampling

quality: {
  sampling: {
    rate: 0.05,
    onSample: async (sample) => {
      // sample: { documentId, collection, fieldPath, sourceLocale, targetLocale,
      //           sourceText, translatedText, model, timestamp }
      await db.translationSamples.insert(sample)
    },
  },
}

Canary locale

The canary locale is translated on every job but the result is not written back. Instead, the result fires via onEvent with canary: true. Use it to run automated quality scoring against a synthetic locale without affecting production content.

quality: { canaryLocale: 'en-qa' }

The synthetic locale must exist in localization.locales.

Output validation

Every translation is validated before write:

  • Length ratio within [minLengthRatio, maxLengthRatio]
  • Doesn't match a known refusal pattern ("I cannot translate…")
  • Doesn't match an injection pattern (attempted prompt break-out)
  • Not a verbatim echo of the source (special case: marked 'skipped' instead of failed; target field left untouched)

Failed validations produce status: 'failed' and the source field is left unchanged in the target.


Cost controls

Character limits

| Limit | Config | Scope | Behavior | |---|---|---|---| | Per-call | perCallCharLimit | Single LLM request | CostGuardError with code 'PER_CALL_LIMIT' | | Per-document | perDocCharCeiling | All fields in one doc | Document aborted; 'translation.cost-guard-abort' alert |

Both count raw characters (not tokens). The character-to-token ratio used for estimates is approximately 3.5 chars/token.

Rate limiting

Internal token-bucket rate limiter enforces concurrency.perProvider requests per minute. Excess requests queue internally — none dropped.

Cost estimation

Providers that implement estimate return token and cost estimates. The /estimate endpoint and the admin "Estimate" preview both use this. When cost exceeds bulkConfirmUsdThreshold, the UI requires explicit confirmation.

For custom providers without pricing, or unknown model IDs, estimatedCostUsd is undefined — not an error.


Lexical rich text

Lexical fields are translated at the node level, not as serialized JSON. Text nodes are extracted, sent to the LLM as discrete items, and patched back into the tree. Formatting, inline marks, and nested block structure are preserved.

Inline nodes (links, inline code) are passed to the model as opaque <ph id="..."> placeholders — the LLM sees only translatable text and is instructed to keep placeholders in order.

Custom Lexical node registration

aiTranslatePlugin({
  lexicalNodes: [
    {
      type: 'callout',
      translatable: true,
      translatableAttributes: ['title', 'body'],
    },
  ],
})

| Field | Type | Description | |---|---|---| | type | string | Lexical node type value. | | translatable | boolean | Whether the node contains translatable text. | | translatableAttributes | string[] | Attribute names containing translatable text. Defaults to ['text']. |


Security

API keys

Read from process.env at runtime. The plugin never serializes them to the client config bundle.

Anti-injection system prompt

All providers include a system prompt instructing the model to translate only and ignore embedded instructions. The output validator additionally scans translated output for injection patterns before writing.

fallbackLocale: null on source reads

The plugin reads source documents with fallbackLocale: null so it gets only the explicit source-locale value. This prevents fields that fall back to another locale from being double-translated or miscounted.


Escape hatches

context.skipAutoTranslate

Set on the Payload request context to suppress automation hooks. Use during bulk imports / seed scripts.

await payload.create({
  collection: 'posts',
  data: { title: 'Seed' },
  context: { skipAutoTranslate: true },
})

context.aiTranslateInternal

Set automatically by the plugin on its own writes to prevent re-trigger loops. Don't propagate this flag from one operation to another — if you copy req.context into another write, you'll suppress translation.

excludeUrlFields: false

Default is true, which auto-excludes **.url paths. Override to false if you genuinely need locale-specific URLs (rare — usually localized deep links).

writeMode: 'minimal'

Per-call override on translateDocument / translateGlobal. Writes only translated fields, not the full localized field set. Caller accepts the risk that Payload may reject the write if other required localized fields are empty in the target locale.


Cross-references

  • Integration walkthrough — adding the plugin to a project from scratch.
  • Architecture — how the components fit together.
  • Lifecycle diagrams — GitHub-rendered Mermaid flowcharts (lifecycle, status states, review loop, runId correlation) + the full Excalidraw system map.
  • Recipes — copy-pasteable solutions.