@braze/sanity-plugin-ai-translation
A Sanity Studio plugin that adds an AI-powered Translate document action for internationalized content.
- Default provider: Sanity Agent Actions (
client.agent.action.translate()) - Optional provider: OpenAI (through a Sanity function) for large documents / long style guides
- Output: Translations are created as versioned documents inside Content Releases (staged publishing)
- Extras: translate referenced documents, glossary terms, protected phrases, debug logging, field exclusion, and field transforms (Portable Text + captions)
For editors: see the standalone user guide at docs/user-guide.md.
Table of contents
- What this plugin does
- Prerequisites
- Install
- Configure (Studio)
- Optional: OpenAI mode
- User guide (for editors)
- Content configuration (settings + glossary)
- Advanced configuration
- Debugging & troubleshooting
- Development (this repo)
- License
What this plugin does
When you open a document in Sanity Studio (for a configured schema type), the plugin adds a Translate action. You can:
- Select one or more target languages
- Choose:
- whether to translate from the draft or the published version
- which Content Release the translations go into (new or existing)
- whether to translate referenced documents as well (with depth control)
- Run translation using:
- Sanity Agent Actions (default), or
- OpenAI (optional)
Existing translations are always re-translated in full; there is no partial or diff-only mode.
Translations are written as version documents in a Content Release, so you can review and publish them on your schedule.
Prerequisites
Your Studio must already support internationalized documents.
Required:
- Sanity Studio v6.12 or newer with
@sanity/uiv4, React 19.2+, and Node.js 22.12+ (the plugin invokes Sanity Functions through Studio's client, which needs@sanity/client8.3+; Studio 6.12 is the first release that bundles it) @sanity/document-internationalizationconfiguredsanity-plugin-internationalized-arrayinstalled and configured (used for glossary term translations)@sanity/client7.1.0+ (required for Agent Actions)
Also required for Agent Actions:
- Your schema must be deployed to Sanity so Agent Actions can access it (see Deploy schema)
- Important API version requirement: the client must use
apiVersion: 'vX'(not date-based versions). Date-based versions can cause Agent Actions to fail.
Install
Use your Studio package manager (examples use pnpm):
pnpm add @braze/sanity-plugin-ai-translation
You’ll also need the prerequisites if you haven’t installed them yet:
pnpm add @sanity/document-internationalization sanity-plugin-internationalized-array
pnpm add @sanity/client@^7.1.0
Configure (Studio)
1) Configure document internationalization
This plugin is designed to work with @sanity/document-internationalization. Configure it first.
You’ll typically define your supported languages as:
const SUPPORTED_LANGUAGES = [
{ id: "en-us", title: "English" },
{ id: "es", title: "Spanish" },
{ id: "fr", title: "French" },
];
Then enable internationalization for the schema types you want to translate:
documentInternationalization({
supportedLanguages: SUPPORTED_LANGUAGES,
schemaTypes: ["post", "page"],
languageField: "language", // your language field name (example)
});
2) Add the AI translation plugin
Add aiTranslationPlugin() after documentInternationalization():
import { defineConfig } from "sanity";
import { documentInternationalization } from "@sanity/document-internationalization";
import { aiTranslationPlugin } from "@braze/sanity-plugin-ai-translation";
const SUPPORTED_LANGUAGES = [
{ id: "en", title: "English" },
{ id: "es", title: "Spanish" },
{ id: "fr", title: "French" },
];
export default defineConfig({
// ...your config...
plugins: [
documentInternationalization({
supportedLanguages: SUPPORTED_LANGUAGES,
schemaTypes: ["post", "page"],
languageField: "language",
}),
aiTranslationPlugin({
supportedLanguages: SUPPORTED_LANGUAGES,
translatableSchemaTypes: ["post", "page"],
languageFieldName: "language",
schemaId: "_.schemas.default",
maxConcurrentTranslationJobs: 5,
maxReferenceDepth: 3,
}),
],
});
Notes
schemaIdis usually'_.schemas.default'for most projects.languageFieldNamemust match the field your documents use (oftenlanguage).glossaryDataset(optional) reads glossary terms from another dataset in the same project. SeetranslationGlossaryTerm.
3) Add translation history metadata
This plugin can store translation history via a metadata schema you attach to documentInternationalization.
Add translationHistorySchema:
import { translationHistorySchema } from "@braze/sanity-plugin-ai-translation";
documentInternationalization({
supportedLanguages: SUPPORTED_LANGUAGES,
schemaTypes: ["post", "page"],
languageField: "language",
metadataFields: [translationHistorySchema],
});
4) Deploy schema for Agent Actions
Agent Actions need access to your deployed schema.
From your Studio project directory:
npx sanity deploy
If Agent Actions fail with schema-related errors, re-check:
- schema deployment
schemaId- client
apiVersionis set to'vX'(not date-based)
Optional: OpenAI mode
OpenAI mode is optional. If enabled, users can choose OpenAI as the translation provider inside the translation dialog.
Studio never calls OpenAI or holds an OpenAI API key. It sends the assembled prompt to a deployed Sanity function with client.functions.invoke(..., { sync: true }), using the editor's Studio session. The function makes the OpenAI call with the server entry and returns the translated text.
Example configuration:
aiTranslationPlugin({
supportedLanguages: SUPPORTED_LANGUAGES,
translatableSchemaTypes: ["post", "page"],
languageFieldName: "language",
schemaId: "_.schemas.default",
openai: {
enabled: true,
stackId: "ST-xxxxxxxx", // blueprint stack that contains the function (required)
functionName: "ai-translation",
model: "gpt-6.1-sol", // one of OPENAI_MODELS; set it explicitly in production
reasoningEffort: "medium", // 'low' | 'medium' | 'high'
defaultMode: "openai-only", // 'sanity-only' | 'openai-only'
},
});
Requirements
- Studio's Sanity client must be
@sanity/client8.1 or newer, which addsclient.functions.invoke. If it is older, the dialog shows OpenAI mode as unavailable. - A pubsub function with the configured name must be deployed in the stack. It reads
OPENAI_API_KEYfrom its own environment, allowlists models, and returns the result ofrequestOpenAICompletion. - The function timeout must be long enough for a reasoning call. Use at least 120 seconds; Sanity allows up to 900. The plugin waits up to 900 seconds for the invocation.
Security
- Do not put an OpenAI API key in the Studio config or bundle. It belongs only in the function environment.
- The payload sent to the function contains only the messages, model, and reasoning effort. It is client-supplied, so the function must validate it and must not treat any field in it as an identity check.
Why use OpenAI mode?
- Larger effective context window for long documents and long style guides
- Deterministic serialization/deserialization to preserve structure
- Strict structure validation to prevent data corruption
Costs
- OpenAI calls incur API costs based on token usage.
- The stable parts of every request (global prompt, locale prompt, glossary, protected phrases, instructions) are sent before the document, so OpenAI's cached input pricing applies to them on every translation into the same language.
Models
openai.model must be one of OPENAI_MODELS (exported from the server entry). The list is also the only spend guard, because any Studio user can invoke the translation function: no pro tiers, o-series or 3.5 models are allowed.
| Model | Intended use |
|---|---|
gpt-6.1-sol | Default. Everyday marketing and documentation pages. |
gpt-6.1-terra | Budget option for bulk runs or low-stakes pages. |
gpt-6-luna | Drafts and testing. |
gpt-6-astra | Opt-in premium for legal or flagship pages. |
gpt-5.1, gpt-5, gpt-5-mini | Previous generation, still supported for Studios that already use them. |
DEFAULT_OPENAI_MODEL (gpt-6.1-sol) is used when model is omitted. Set model explicitly in production so a plugin upgrade cannot silently change which model, and which price, a Studio uses.
Server entry
The plugin exposes a server-only entry at @braze/sanity-plugin-ai-translation/server for running the OpenAI call outside the browser (for example in a Sanity function). It does not load React or Studio, it is not part of the Studio bundle, and it does not read the API key from the environment, so the caller passes it in.
import {
DEFAULT_OPENAI_MODEL,
isOpenAIModel,
OPENAI_MODELS,
requestOpenAICompletion,
} from "@braze/sanity-plugin-ai-translation/server";
// payload: { model, messages, reasoningEffort?, temperature? } assembled in Studio
if (!isOpenAIModel(payload.model)) {
throw new Error(`model must be one of ${OPENAI_MODELS.join(", ")}`);
}
const { content, tokensUsed } = await requestOpenAICompletion(payload, {
apiKey: process.env.OPENAI_API_KEY!,
});
The first argument is the client-supplied request and is validated at runtime. The second holds server-side configuration such as the API key, and is never read from the payload.
- Requests with more than 20 messages, or more than 1,000,000 characters of message content in total, are rejected before anything is sent to OpenAI.
- Only
roleandcontentare sent for each message. Any other fields in the payload are dropped. - Logs never include prompt or document text, even with plugin logging turned on. They include only the model, message roles, and content lengths.
Serialization, prompt assembly, structure validation, and the Content Release write stay in Studio. The entry does not allowlist models on its own. Do that in the caller before invoking it, with isOpenAIModel (or OPENAI_MODELS): it is the same list the openai.model plugin option accepts, so Studio and the function never disagree about which models are allowed. GPT-5 and GPT-6 models are sent to the Responses API; other model families fall back to Chat Completions.
Pruning translation metadata
The plugin records each translation in translation.metadata as soon as it creates the version in a Content Release. When that release is deleted or the version is discarded, the entry still points at the removed document. pruneTranslationMetadata removes translations and translationHistory entries whose target no longer exists. A target that still exists as a published document, a draft, or a version in any release is kept. So is a target whose release is archived: archiving deletes a release's versions and unarchiving restores them, so their entries must survive.
The plugin already prunes the metadata of every document it is about to translate. To prune as soon as documents are deleted, deploy a delete-event Sanity function. functions/ in this repo is a working example:
// sanity.blueprint.ts
defineDocumentFunction({
name: "prune-translation-metadata",
event: {
on: ["delete"],
filter: "_type in ['course', 'lesson']", // your translatableSchemaTypes
includeAllVersions: true,
projection: "{_id}",
},
});
// functions/prune-translation-metadata/index.ts
import { pruneTranslationMetadata } from "@braze/sanity-plugin-ai-translation/server";
import { createClient } from "@sanity/client";
import { documentEventHandler } from "@sanity/functions";
export const handler = documentEventHandler(async ({ context, event }) => {
const client = createClient({ ...context.clientOptions, apiVersion: "vX", useCdn: false });
await pruneTranslationMetadata({ client, documentIds: [event.data._id] });
});
Pass dryRun: true to get the entries that would be removed without writing anything.
To clean up an existing dataset, run functions/scripts/fix-translation-metadata.mjs. It prunes dangling entries, then merges duplicate metadata documents for the same source into the oldest one. If two duplicates point the same language at different live documents, the script reports that group and doesn't change it. It's a dry run unless you pass --apply. Build the plugin first (pnpm build), then run it while no translations are in progress:
SANITY_PROJECT_ID=... SANITY_DATASET=... SANITY_WRITE_TOKEN=... pnpm --filter ./functions fix-metadata
SANITY_PROJECT_ID=... SANITY_DATASET=... SANITY_WRITE_TOKEN=... pnpm --filter ./functions fix-metadata -- --apply
SANITY_WRITE_TOKEN needs the Editor role or higher on the dataset.
User guide (for editors)
Translate a document
- Open a document in Studio whose type is included in
translatableSchemaTypes. - Open the document actions menu and click Translate.
- Select one or more target languages.
- Configure translation options (see below).
- Click Translate to run the pre-flight checks, then Translate again to start.
While translation runs, you’ll see job statuses for each target language (and for any referenced documents, if enabled). Each job is labelled with the document's schema preview title, the same label Studio lists use. View opens the finished translation in a new pane with the release pinned, so the header shows the real title and references inside the release resolve. Pinning the release switches the whole Studio to it, not only the new pane: until you switch back to drafts, edits to any document go into that release.
Choose a Content Release
Translations are always created inside a Content Release.
In the dialog you can typically:
- create a new release, or
- add the translations to an existing active release
Use releases when you want:
- staged review
- coordinated publishing of multiple translations at once
Translation options
The dialog offers:
- Translate from drafts / Translate from published
- Which version of the source document (and of its references) is translated.
- Content Release
- Create a new release, or pick an existing active release to add the translations to.
- Translate references
- Also translate referenced documents and update references to point at the localized versions.
- Max reference depth
- How far to traverse reference graphs (depth
0means no traversal).
- How far to traverse reference graphs (depth
- Provider (only when OpenAI mode is enabled)
- Sanity Agent Actions or OpenAI.
Existing translations are always re-translated in full. There is no overwrite toggle and no diff-only mode: the release holds the complete new translation, and you review it before publishing.
Pre-flight checks
Clicking Translate first runs pre-flight checks. Translation only starts once they pass:
- Validation: every document to be translated must pass its schema validation. Documents with errors are listed with a link to open and fix them, then Rescan.
- Unpublished changes on references: referenced documents with drafts are listed so you can confirm the draft or published source is the one you want.
- Stale drafts on existing translations: if a translation that is about to be replaced has an unpublished draft, the dialog warns that the draft will be out of date. Publish or discard each draft from the links shown, or click Continue Anyway to proceed regardless.
- Release conflicts: when adding to an existing release that already contains a version of the same document and language, the dialog shows the conflicting translations and asks you to choose:
- Cancel to go back and pick another release,
- Replace All to delete the existing versions in the release and create new ones, or
- Skip Conflicts to translate only the non-conflicting document/language pairs.
Translate referenced documents
If you enable reference translation:
- The plugin traverses references (up to the configured depth).
- It translates referenced documents that are eligible (type is included in
translatableSchemaTypes). - It updates references in the translated document to point to localized versions.
This is useful for content like:
- posts referencing authors
- pages referencing shared sections
- documents referencing related content
What gets created
For each selected target language, the plugin creates a versioned document inside the selected Content Release.
When references are translated, the references in the translated version are rewritten to the translated targets the way Studio itself links unpublished documents: _weak: true with _strengthenOnPublish: { type }. They become strong references when the release is published, so the release publishes without validation errors, and Studio can render the field outside the release.
You can then:
- review and edit translated versions
- publish the release when ready
Content configuration (settings + glossary)
The plugin introduces two document types for configuration:
translationSettings
Create a document with _id == "translation-settings" to configure:
- Global prompt (applies to all translations)
- Locale prompts (per-target-language instructions)
- Protected phrases (never translate these terms; e.g. brand names)
Example shape:
{
_type: 'translationSettings',
_id: 'translation-settings',
globalPrompt: 'Maintain a professional tone. Preserve formatting and links.',
localePrompts: [
{_key: 'es', value: 'Use formal Spanish (usted).'},
{_key: 'fr', value: 'Use formal French.'},
],
protectedPhrases: ['Braze', 'iOS', 'Android', 'SDK']
}
Notes:
localePromptsuses_keyfor the locale id (must match yoursupportedLanguages[].id).- Protected phrases may be filtered to only those that appear in the source document (to keep prompts smaller).
translationGlossaryTerm
Create glossary term documents to enforce consistent translations of key terms.
This schema uses internationalizedArrayString so you can provide one value per locale.
Conceptual example:
{
_type: 'translationGlossaryTerm',
translations: [
{_key: 'en', value: 'dashboard'},
{_key: 'es', value: 'panel de control'},
{_key: 'fr', value: 'tableau de bord'},
]
}
Glossary terms are used to build locale-specific translation instructions.
By default, glossary terms are read from the dataset of the document being translated. To share one glossary across datasets, set glossaryDataset to another dataset in the same project:
aiTranslationPlugin({
// ...
glossaryDataset: "production",
});
- Terms are read with the editor's existing Studio session, so editors need read access to that dataset.
- Only glossary terms come from
glossaryDataset.translationSettings(global prompt, locale prompts, protected phrases) is still read from the document's dataset. - Nothing is written to either dataset's glossary.
Advanced configuration
Exclude fields from translation
Use excludeFields to prevent certain fields from being translated (slugs, enums, flags, etc.).
Supports:
- field name matches anywhere (e.g.
'status') - exact paths (e.g.
'seo.title') - array paths / wildcards (e.g.
'body[*].imageType')
Example:
aiTranslationPlugin({
// ...
excludeFields: ["slug", "status", "seo.canonicalUrl", "body[*].variant"],
});
Important behavior:
- The plugin will not allow the
languageFieldNameto be excluded. If you include it, it will be removed to prevent translation errors. - Sanity system fields (
_id,_type,_key,_rev,_createdAt,_updatedAt, and any other_-prefixed field) never need excluding. Neither provider translates them: Agent Actions reads the document from the dataset by id, and the OpenAI serializer skips_-prefixed keys. Patterns that target them (such as_idorbody[*]._key) are ignored with a warning, because stripping them would remove the id and keys the pipeline relies on.
Field transforms
Some fields (like Portable Text or caption formats) benefit from transformation before translation.
Transforms run in this sequence:
- Filter excluded fields
- Apply transforms (to produce a translatable representation)
- Translate (Sanity Agent or OpenAI)
- Restore transforms (back into original structure)
- Restore excluded fields (except language)
- Create version document in the release
Built-in transforms include:
richTextTransformfor Portable TextvttCaptionTransformfor VTT captionswistiaCaptionTransformfor Wistia captions (if used by your schema)
Example setup:
import {
aiTranslationPlugin,
richTextTransform,
vttCaptionTransform,
wistiaCaptionTransform,
} from "@braze/sanity-plugin-ai-translation";
aiTranslationPlugin({
// ...
fieldTransforms: [
{
...vttCaptionTransform,
matcher: { schemaType: "video", fieldName: "captions" },
},
{
...wistiaCaptionTransform,
matcher: { schemaType: "videoDoc", fieldName: "wistiaCaptions" },
},
richTextTransform,
],
});
Order matters: first match wins.
Concurrency and depth
maxConcurrentTranslationJobscontrols how many translations run at the same time (default behavior is conservative).maxReferenceDepthcontrols how deeply references are traversed (set to0to disable traversal).
Debugging & troubleshooting
Enable debug logging
This plugin uses a centralized debug logger. Output is off by default. To turn it on, set this environment variable in the host Studio, then restart the dev server or rebuild (the value is inlined at build time):
# Standalone Sanity Studio
SANITY_STUDIO_AI_TRANSLATION_PLUGIN_LOGGING=true
# Studio embedded in Next.js
NEXT_PUBLIC_SANITY_STUDIO_AI_TRANSLATION_PLUGIN_LOGGING=true
To turn it off, remove the variable or set it to any other value.
Common issues
Agent Actions fail immediately
- Confirm
@sanity/clientis 7.1.0+ - Confirm your client API version is
apiVersion: 'vX'(not date-based) - Confirm schema is deployed:
npx sanity deploy - Confirm
schemaIdmatches your deployed schema (commonly'_.schemas.default')
Translation completes but content didn’t change
- Ensure the document type is included in
translatableSchemaTypes - Check whether excluded fields or transforms are preventing updates
- If using OpenAI mode, structure validation can reject translations that modify non-text fields
Reference translation is slow
- Reduce
maxReferenceDepth - Reduce
maxConcurrentTranslationJobs - Ensure you’re not translating large reference graphs unnecessarily
Development (this repo)
This repository is a pnpm monorepo with:
package/— the plugin packagedemo-nextjs/— demo Next.js app with embedded Studio
Common commands:
pnpm dev
pnpm build
cd package
pnpm test
pnpm typecheck
pnpm lint
CI (.github/workflows/ci.yml) runs biome ci, the plugin typecheck, the test suite, and the plugin build on every pull request and on pushes to main.
To check the server entry against the real OpenAI API, run pnpm check:openai-server from the repo root or from package/. It reads OPENAI_API_KEY from the environment or from a .env file. See package/scripts/README.md for details.
License
MIT